diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a934d07e..9a883369 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,10 @@ "vscode": { "extensions": [ "harmoniqs.amicode" - ] + ], + "settings": { + "amicode.opencodePort": 43117 + } } } } diff --git a/docs/adr/0008-server-url-push-on-restart.md b/docs/adr/0008-server-url-push-on-restart.md new file mode 100644 index 00000000..6240c02e --- /dev/null +++ b/docs/adr/0008-server-url-push-on-restart.md @@ -0,0 +1,65 @@ +# ADR 0008: Server URL Push on Restart + +## Status + +Accepted + +## Context + +The Amicode extension embeds the opencode web app in a WebviewPanel iframe. The +iframe's `location.origin` IS the server URL (e.g., `http://127.0.0.1:43117`). +When the server restarts, two scenarios exist: + +1. **Same port** (the default, `amicode.opencodePort = 43117`): the iframe's origin + is still valid. The SSE reconnect loop retries every 250 ms and reconnects once + the server is back. The server's `server.connected` event carries a `bootId` that + the web app uses to detect the restart and trigger a full state refresh. + +2. **Different port** (ephemeral mode, `amicode.opencodePort = 0`): the iframe's + origin points to a dead port. The SSE loop fails indefinitely. The webview's + localStorage may hold a stale URL from the previous port, compounding the issue. + +Previously, there was no mechanism for the extension host to notify the webview of +a server restart or URL change. The webview relied entirely on the SSE reconnect +loop and localStorage, both of which fail when the port changes. + +## Decision + +### Extension host (this repo) + +1. **`ChatPanel.notifyServerUrlChanged(url)`**: a static method that checks whether + the new URL's origin differs from the panel's recorded origin. + - If same origin: posts a `server-url-changed` message (Lane 2) to all live + panels as a "restart happened" signal. + - If different origin: returns `true`, signaling the caller to dispose and + recreate the panel. + +2. **`ChatPanel.disposeCurrent()`**: disposes the underlying `vscode.WebviewPanel`, + which triggers cleanup and allows `openOrReveal` to create a fresh panel with the + new iframe `src`. + +3. **`serverManager.onReady` hook**: after every successful server start (including + restarts), calls `notifyServerUrlChanged`. If recreation is needed, disposes the + panel before `openOrReveal` creates a fresh one. + +4. **Lane 2 allowlist**: `"server-url-changed"` added to the relay script's Lane 2 + filter in both `chat_panel.ts` and `deck/shell.ts`. + +### Web app (opencode repo) + +5. **`AmicodeServerBridge` component**: listens for `server-url-changed` messages. + If the URL in the message differs from `location.origin` (unexpected — the panel + should have been recreated), redirects to the new URL as a safety net. If same + origin, does nothing (the SSE loop handles it). + +## Consequences + +- Same-port restarts (the common case) are seamless: the SSE loop reconnects and + the boot-ID mismatch triggers a full refresh — no panel recreation needed. +- Port-change restarts (ephemeral mode) cause a brief panel flicker as the old + panel is disposed and a new one is created with the correct origin. +- The `onReady` approach covers ALL server-ready events, not just explicit restarts + — including the initial boot, solver-mode switches, and vault respawns. +- Future improvement: self-healing via `postMessage` (documented in + `plans/followup-self-healing-reconnect.md` in the workspace) to avoid panel + recreation entirely. diff --git a/docs/devcontainers.md b/docs/devcontainers.md new file mode 100644 index 00000000..fe294c25 --- /dev/null +++ b/docs/devcontainers.md @@ -0,0 +1,193 @@ +# Devcontainer Configuration + +This document covers how the Amicode extension's runtime invariants (server port, +storage paths, etc.) are configured in devcontainer environments, including the +Dockerfile-based build case. + +--- + +## Why port stability matters + +The Amicode webview panel embeds the opencode web app in an iframe served by a +local HTTP server. The webview's localStorage (which persists session tabs, project +paths, and connection state) lives on the **host machine** — it survives container +rebuilds. The server port, however, is ephemeral unless explicitly pinned. + +If the server starts on a different port after a container rebuild, the persisted +connection state in localStorage is stale. The webview's SSE event stream connects +to the dead port, and the user sees no responses until localStorage is cleared. + +**The fix:** pin the server port to a stable value (default: `43117`) so that the +persisted URL remains valid across container rebuilds. + +--- + +## Use cases + +### A. Pre-built extension (marketplace install) + +The devcontainer installs Amicode from the VS Code Marketplace. No build-time +dependencies are needed. + +```jsonc +// .devcontainer/devcontainer.json +{ + "name": "Amicode", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "customizations": { + "vscode": { + "extensions": ["harmoniqs.amicode"], + "settings": { + "amicode.opencodePort": 43117 + } + } + } +} +``` + +The `amicode.opencodePort` setting is read by the extension at startup. It passes +`--port 43117` to the spawned `opencode serve` process. This is the simplest +configuration and covers most users. + +### B. Dockerfile-based build (extension under development) + +The devcontainer uses a Dockerfile that installs build-time dependencies (Node, +pnpm, Bun, etc.). The extension is NOT installed from the marketplace — it runs +via F5 ("Run Extension") or is manually installed from a locally-built `.vsix`. + +```jsonc +// .devcontainer/devcontainer.json +{ + "name": "Amicode Dev", + "build": { + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "settings": { + "amicode.opencodePort": 43117 + } + } + } +} +``` + +**Key point:** `customizations.vscode.settings` is applied to the VS Code instance +regardless of how extensions are installed. Even when the extension is launched via +F5 (Extension Development Host), VS Code resolves `amicode.opencodePort` from the +workspace/container settings. You do NOT need the extension to be listed in +`"extensions"` for the setting to be available. + +If the Dockerfile needs to set a default port for cases where VS Code is not +involved (e.g., running `opencode serve` directly in a terminal during +development): + +```dockerfile +# Dockerfile +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +# ... build-time dependencies ... + +# Default server port for opencode (read via OPENCODE_CONFIG_CONTENT) +ENV OPENCODE_CONFIG_CONTENT='{"server":{"port":43117}}' +``` + +This env var is read by the opencode binary directly, bypassing VS Code settings. +It works in all contexts — terminal, scripts, CI — but note that when the Amicode +extension IS running, it builds its own `OPENCODE_CONFIG_CONTENT` (merging +instructions, permissions, telemetry, etc.) and passes it to the server process. +The Dockerfile's `ENV` value is therefore only effective when running the binary +manually outside the extension. + +### C. CI / headless (no VS Code) + +For test harnesses, build pipelines, or headless environments where VS Code is not +present: + +**Option 1 — `opencode.json` in the project root:** +```json +{ + "server": { + "port": 43117 + } +} +``` + +This is the most portable option. The opencode binary reads it from the working +directory (or any ancestor). It works in all contexts and requires no environment +variable management. + +**Option 2 — `OPENCODE_CONFIG_CONTENT` env var:** +```bash +export OPENCODE_CONFIG_CONTENT='{"server":{"port":43117}}' +opencode serve +``` + +Or in `docker-compose.yml`: +```yaml +services: + opencode: + environment: + OPENCODE_CONFIG_CONTENT: '{"server":{"port":43117}}' +``` + +**Option 3 — CLI flag:** +```bash +opencode serve --port 43117 +``` + +--- + +## Port resolution priority (highest wins) + +| Priority | Mechanism | Who sets it | +|----------|-----------|-------------| +| 1 | `--port` CLI flag | The extension (internally) or manual invocation | +| 2 | `OPENCODE_CONFIG_CONTENT` env var | The extension (builds merged config) or Dockerfile `ENV` | +| 3 | `opencode.json` `server.port` field | Developer, committed to repo | +| 4 | Default: `0` → try 4096, then OS-assigned | Built-in fallback | + +When the Amicode extension is running: +- It reads `amicode.opencodePort` from VS Code settings (default: `43117`) +- It passes this as `--port` to the spawned server (priority 1) +- All other mechanisms are fallbacks for when the extension is not present + +--- + +## Other configurable paths + +The extension also supports overriding storage locations via VS Code settings +(added in #378): + +| Setting | Env var injected | Default (XDG) | +|---------|-----------------|---------------| +| `amicode.sessionDatabase` | `OPENCODE_DB` | `~/.local/share/opencode/opencode.db` | +| `amicode.configDir` | `OPENCODE_CONFIG_DIR` | `~/.config/opencode` | + +These can also be set in `customizations.vscode.settings` in `devcontainer.json` +for container-specific overrides (e.g., placing the database on a mounted volume). + +--- + +## Caveats for Dockerfile-based builds + +1. **The extension is not installed at image build time.** `customizations.vscode` + is processed by VS Code/Codespaces at container start, not during `docker build`. + Do not rely on extension presence in Dockerfile `RUN` steps. + +2. **`OPENCODE_CONFIG_CONTENT` conflicts.** If both the Dockerfile sets this env + var AND the extension is running, the extension's value wins (it spawns the + server with its own merged config in the process env, overriding the container + env). The Dockerfile value is only effective for manual `opencode serve` calls. + +3. **Port forwarding.** If VS Code auto-forwards port 43117 (which it does by + default for detected listening ports), the server is accessible from the host at + `localhost:43117`. This is expected behavior and does not interfere with the + webview (which connects to the container-internal `127.0.0.1:43117`). + +4. **Multiple containers on the same host.** If two devcontainers both use port + 43117, VS Code handles port forwarding conflicts (it maps to different host + ports). The webview inside each container connects to its own `127.0.0.1:43117` + without conflict. The localStorage isolation concern (multiple webviews sharing + one localStorage scope) is separate and addressed by the boot-ID mechanism + (opencode ADR 0005). diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index ed6bf236..8dd2dd28 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -47,6 +47,8 @@ export class ChatPanel { * only when the report-a-bug skill is there to answer it. */ private static bugReportAvailable = false; private readonly disposables: vscode.Disposable[] = []; + /** The origin the iframe was built with — used to detect port changes on restart. */ + readonly origin: string; private constructor( private readonly panel: vscode.WebviewPanel, @@ -55,6 +57,7 @@ export class ChatPanel { authToken?: string, hideProjectDir?: string, ) { + this.origin = opencodeUrl.origin; this.panel.webview.html = this.renderHtml(opencodeUrl, authToken, hideProjectDir); ChatPanel.live.add(this); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); @@ -108,6 +111,30 @@ export class ChatPanel { setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); } + /** Notify all live panels that the server URL changed (or that the server + * restarted on the same port). If the port changed, the panel's iframe is + * stale and must be recreated — returns true if recreation is needed. */ + static notifyServerUrlChanged(url: URL): boolean { + const current = ChatPanel.current; + if (!current) return false; + if (current.origin !== url.origin) return true + // Same origin — just inform the webview the server restarted. + for (const panel of ChatPanel.live) { + void panel.panel.webview.postMessage({ + source: "amicode", + kind: "server-url-changed", + url: url.href, + }); + } + return false; + } + + /** Dispose the current primary panel (closes the VS Code tab). Used when the + * server port changed and the iframe needs to be rebuilt with a new origin. */ + static disposeCurrent(): void { + ChatPanel.current?.panel.dispose(); + } + /** AC5's gate setter — called after each session prep with * bugReportSkillStaged(project.skillPaths). */ static setBugReportAvailable(available: boolean): void { @@ -273,7 +300,7 @@ export class ChatPanel { // (webview-internal origin, never the opencode origin). Forward only // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "server-url-changed" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } diff --git a/packages/extension/src/deck/shell.ts b/packages/extension/src/deck/shell.ts index 5b05e1e8..b4fe82ab 100644 --- a/packages/extension/src/deck/shell.ts +++ b/packages/extension/src/deck/shell.ts @@ -407,6 +407,10 @@ window.addEventListener("message", (e) => { if ((d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status") && typeof d.tab === "string") { frameByTab.get(d.tab)?.contentWindow?.postMessage(d, boot.origin); } + // Server URL push: broadcast to all panes so the SSE loop can reconnect. + if (d.kind === "server-url-changed") { + for (const f of frameByTab.values()) f.contentWindow?.postMessage(d, boot.origin); + } // #351: inspector fan-out — broadcast to every live pane (no tab routing; // the app's Work Column tabs buffer per-run/per-device themselves). if (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index b2f0fe77..fe62e094 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -88,6 +88,9 @@ let statusBar: StatusBarManager | undefined; let sseClient: OpencodeEventClient | undefined; let runsManager: RunsManager | undefined; let opencodeReadyUrl: URL | undefined; +/** Host-accessible URL for webview contexts — resolved via vscode.env.asExternalUri + * to account for devcontainer port forwarding (container:43117 may forward to host:43118). */ +let opencodeExternalUrl: URL | undefined; /** Set once the binary + vault are known; the watcher's onRunFinished closure * and the distillNow command read it lazily (undefined = distiller disabled). */ let distillerSetup: DistillerSetup | undefined; @@ -829,14 +832,28 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); ctx.subscriptions.push(sseClient); - serverManager.onReady((url) => { + serverManager.onReady(async (url) => { opencodeReadyUrl = url; + // Resolve the host-accessible URL for webview contexts: in a devcontainer, + // container port 43117 may be forwarded to a different host port. The webview + // iframe renders on the HOST, so it needs the forwarded URL. + const extUri = await vscode.env.asExternalUri(vscode.Uri.parse(url.toString())); + opencodeExternalUrl = new URL(extUri.toString()); + statusBar?.setServerReady(true); - sseClient?.connect(url); + sseClient?.connect(url); // SSE runs in-container — use container-internal URL + // If the server restarted on a different port (ephemeral mode), the + // existing panel's iframe is stale — dispose it so openOrReveal creates a + // fresh one with the correct origin. If same port, push a notification so + // the web app's SSE loop knows the server restarted (boot-ID detection + // handles the rest). + if (ChatPanel.notifyServerUrlChanged(opencodeExternalUrl)) { + ChatPanel.disposeCurrent(); + } // Open the chat as soon as the server is up (amicode.chat.autoOpen, // default on) — the chat IS the product's front door. if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { - ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + ChatPanel.openOrReveal(ctx, opencodeExternalUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); } // Surface ONE explicit LLM-provider signal at boot, read from opencode's // OWN resolution (its live /config/providers) — not a silent hang at the @@ -1321,12 +1338,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); serverManager = freshManager; ctx.subscriptions.push({ dispose: () => void freshManager.stop() }); - freshManager.onReady((url) => { + freshManager.onReady(async (url) => { opencodeReadyUrl = url; + const extUri = await vscode.env.asExternalUri(vscode.Uri.parse(url.toString())); + opencodeExternalUrl = new URL(extUri.toString()); + statusBar?.setServerReady(true); - sseClient?.connect(url); + sseClient?.connect(url); // container-internal if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { - ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + ChatPanel.openOrReveal(ctx, opencodeExternalUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); } }); await freshManager.start(); @@ -1404,7 +1424,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // only as the fallback when no ready server exists to host the panel. ctx.subscriptions.push( vscode.commands.registerCommand("amicode.setCloudKey", () => { - const readyUrl = opencodeReadyUrl; + const readyUrl = opencodeExternalUrl; if (readyUrl) { ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir).postComputeConnect(); return; @@ -1420,7 +1440,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // opencodeReadyUrl, so a restart racing this handler would otherwise reach // openOrReveal as undefined (or reveal a panel bound to a stale server). const readyUrl = opencodeReadyUrl; - if (!readyUrl) { + const externalUrl = opencodeExternalUrl; + if (!readyUrl || !externalUrl) { vscode.window.showWarningMessage( "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", ); @@ -1436,7 +1457,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); return; } - ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + ChatPanel.openOrReveal(ctx, externalUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); }), // Side-by-side sessions: ALWAYS a fresh editor tab (ViewColumn.Beside, so // it splits next to whatever is focused) pinned to the app's /new-session @@ -1445,7 +1466,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // than a named warning. vscode.commands.registerCommand("amicode.newChat", async () => { const readyUrl = opencodeReadyUrl; - if (!readyUrl) { + const externalUrl = opencodeExternalUrl; + if (!readyUrl || !externalUrl) { vscode.window.showWarningMessage( "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", ); @@ -1456,7 +1478,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); return; } - const draftUrl = new URL(readyUrl.href); + const draftUrl = new URL(externalUrl.href); draftUrl.pathname = "/new-session"; draftUrl.search = ""; draftUrl.hash = ""; @@ -1467,7 +1489,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // other chat entries. The deck shares the one server with every ChatPanel. vscode.commands.registerCommand("amicode.chatDeck", async () => { const readyUrl = opencodeReadyUrl; - if (!readyUrl) { + const externalUrl = opencodeExternalUrl; + if (!readyUrl || !externalUrl) { vscode.window.showWarningMessage( "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", ); @@ -1478,7 +1501,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); return; } - DeckPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + DeckPanel.openOrReveal(ctx, externalUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); }), // Report a Bug (amicode#250): the palette entry + the composer bug button's // bridge command share this one handler — the manager owns create/arm/open,