From eb4208b670377de813395f88985cffb941205053 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 14:39:31 -0700 Subject: [PATCH 1/6] Route the browser harness's alert stores through the sidecar The harness applied watched-command and settings changes to its own AlertManager and ignored the sidecar's broadcasts, so the seed-once and delta rules the shipped app relies on were never exercised there. It now sends alert_command like the Tauri adapter and applies the stores' broadcasts, with the harness forwarding the command to the sidecar. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u --- docs/specs/transport.md | 2 +- standalone/scripts/dev-agent-browser.mjs | 4 ++ .../src/browser-sidecar-adapter.test.ts | 14 +++++++ standalone/src/browser-sidecar-adapter.ts | 37 ++++++++++++++++--- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index f5b822558..8168b1e4a 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -46,7 +46,7 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY **An unauthorized caller gets the same `404 not found` as an unknown path**, so the port does not identify itself. The harness prints the token and a ready-made `curl` on startup. -The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. +The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 87739af9d..c497ba2b3 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -111,6 +111,10 @@ const fireAndForget = { // The Burrow's whole bridge rides one passthrough, exactly as it does // through Rust (`burrow_command` in src-tauri/src/lib.rs). burrow_command: ({ payload }) => writeSidecar('burrow:command', payload), + // The app-global alert stores live in the sidecar; their broadcasts come back + // over the event stream like every other sidecar line (`alert_command` in + // src-tauri/src/lib.rs). + alert_command: ({ payload }) => writeSidecar('alert:command', payload), kill_sidecar_now: () => shutdown(), }; diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index c29d064cd..6008de8a8 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -129,6 +129,20 @@ describe("BrowserSidecarAdapter terminal stream", () => { expect(send.mock.calls.filter(([cmd]) => cmd === "pty_write")).toEqual([]); }); + it("routes the alert stores through the sidecar and applies their broadcasts", async () => { + const { adapter, send, deliver } = await listening(); + adapter.alertSetCommandWatched("cargo", true); + adapter.alertPublishSettings({ ringEnabled: false } as never, { seed: true }); + expect(send.mock.calls.filter(([cmd]) => cmd === "alert_command").map(([, args]) => args)).toEqual([ + { payload: { op: "setCommandWatched", name: "cargo", watched: true } }, + { payload: { op: "initializeSettings", settings: { ringEnabled: false } } }, + ]); + const names: string[][] = []; + adapter.onWatchedCommands((next) => void names.push(next)); + deliver("alert:watchedCommands", { names: ["cargo", "make"] }); + expect(names).toEqual([["cargo", "make"]]); + }); + it("pushes the resolved theme so the sidecar can answer a colour query", async () => { const { adapter, send } = await listening(); adapter.requestInit(); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index bb08ae2db..3a8bf8903 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -72,6 +72,8 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private replayHandlers = new Set<(detail: PtyReplayDetail) => void>(); private markedHandlers = new Set<(detail: PtyMarkedDetail) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); + private watchedCommandHandlers = new Set<(names: string[]) => void>(); + private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>(); private alertManager = new AlertManager(); private unlistenHost: (() => void) | null = null; private unlistenRegistry: (() => void) | null = null; @@ -319,9 +321,20 @@ export class BrowserSidecarAdapter implements PlatformAdapter { notifySessionFlushComplete(_requestId: string): void {} alertRemove(id: string): void { this.alertManager.remove(id); } - alertSetWatchedCommands(names: string[]): void { this.alertManager.setWatchedCommands(names); } - alertSetCommandWatched(name: string, watched: boolean): void { this.alertManager.setCommandWatched(name, watched); } - alertPublishSettings(settings: AlertSettings): void { this.alertManager.applySettings(settings); } + // Through the sidecar's app-global stores and back as their broadcasts, the + // path the shipped app takes (see TauriAdapter), so the harness exercises + // the seed-once and delta rules rather than a private copy of the state. + alertSetWatchedCommands(names: string[]): void { + this.host.send("alert_command", { payload: { op: "initializeWatchedCommands", names } }); + } + alertSetCommandWatched(name: string, watched: boolean): void { + this.host.send("alert_command", { payload: { op: "setCommandWatched", name, watched } }); + } + alertPublishSettings(settings: AlertSettings, opts: { seed: boolean }): void { + this.host.send("alert_command", { + payload: { op: opts.seed ? "initializeSettings" : "updateSettings", settings }, + }); + } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } alertAttend(id: string): void { this.alertManager.attend(id); } alertResize(id: string): void { this.alertManager.onResize(id); } @@ -331,9 +344,8 @@ export class BrowserSidecarAdapter implements PlatformAdapter { alertClearTodo(id: string): void { this.alertManager.clearTodo(id); } alertAwait(id: string, options: AwaitOptions): AwaitHandle { return this.alertManager.awaitCompletion(id, options); } onAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.add(handler); } - // See TauriAdapter: single webview, so nothing is broadcast back. - onWatchedCommands(_handler: (names: string[]) => void): void {} - onAlertSettings(_handler: (settings: AlertSettings) => void): void {} + onWatchedCommands(handler: (names: string[]) => void): void { this.watchedCommandHandlers.add(handler); } + onAlertSettings(handler: (settings: AlertSettings) => void): void { this.alertSettingsHandlers.add(handler); } // The harness mirrors the shipped persistence answer, so a reload here // exercises what the app does (docs/specs/transport.md -> "The governing rule"). @@ -369,6 +381,19 @@ export class BrowserSidecarAdapter implements PlatformAdapter { this.onRegistrySnapshot?.(data as WorkspaceRegistrySnapshot); return; } + if (event === "alert:watchedCommands") { + const names = (data as { names?: string[] } | undefined)?.names ?? []; + this.alertManager.setWatchedCommands(names); + for (const handler of this.watchedCommandHandlers) handler(names); + return; + } + if (event === "alert:settings") { + const settings = (data as { settings?: AlertSettings } | undefined)?.settings; + if (!settings) return; + this.alertManager.applySettings(settings); + for (const handler of this.alertSettingsHandlers) handler(settings); + return; + } if (event === "pty:data") { // Already parsed by the sidecar, which owns the PTY; its events arrive as // the two messages below (docs/specs/terminal-escapes.md). From 6c1050d17fbd8e6420d26bc48254aa1b46a6e2c7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 17:32:27 -0700 Subject: [PATCH 2/6] Resolve the harness host's init on the SSE stream opening The seed round trip is now the only thing that populates the harness's AlertManager, and BrowserSidecarHost.init() resolved as soon as the EventSource was constructed. If the seed POSTs reached the bridge before the GET /events registered in sseClients, the broadcast fanned out to nobody and the harness ran with an empty rule set until a manual toggle. init() now settles on the stream's open event, rejects on an error before that (or after a bounded wait) and closes the stream, and reports every later open through onReconnect. The adapter remembers the two seeds it last sent and re-offers them on a reconnect, which makes the sidecar republish both stores: a repeat seed is refused as a seed but still answered with the canonical snapshot. The adapter test also asserted a settings field the wire never carries and never checked that a broadcast reached the manager; it now uses speakEnabled, spies on setWatchedCommands / applySettings, and delivers alert:settings including its empty-payload guard. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus --- docs/specs/transport.md | 2 +- scripts/spec-word-budgets.json | 2 +- .../src/browser-sidecar-adapter.test.ts | 66 ++++++++++++-- standalone/src/browser-sidecar-adapter.ts | 23 ++++- standalone/src/browser-sidecar-host.test.ts | 90 ++++++++++++++++++- standalone/src/browser-sidecar-host.ts | 66 ++++++++++++-- 6 files changed, 230 insertions(+), 19 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 8168b1e4a..07546df2b 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -46,7 +46,7 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY **An unauthorized caller gets the same `404 not found` as an unknown path**, so the port does not identify itself. The harness prints the token and a ready-made `curl` on startup. -The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. +The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; after a reconnect the adapter re-sends its last seeds so the stores republish (`standalone/src/browser-sidecar-host.test.ts`). It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 454c8d124..4e8fa51b3 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5550, + "docs/specs/transport.md": 5600, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index 6008de8a8..b8fd93416 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -12,6 +12,9 @@ vi.mock("@tauri-apps/plugin-shell", () => ({ open: vi.fn(async () => {}) })); import { BrowserSidecarAdapter } from "./browser-sidecar-adapter"; import { BrowserSidecarHost } from "./browser-sidecar-host"; import { TauriAdapter } from "./tauri-adapter"; +import type { AlertManager } from "dormouse-lib/lib/alert-manager"; +import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; +import { DEFAULT_ALERT_SETTINGS } from "dormouse-lib/lib/alert-settings-model"; // Both adapters are viewed as `PlatformAdapter` here on purpose: `onFilesDropped` // is optional precisely so consumers can probe for it @@ -103,11 +106,16 @@ describe("BrowserSidecarAdapter terminal stream", () => { async function listening() { const host = new BrowserSidecarHost("http://localhost:1234"); let emit: (event: { event: string; data: unknown }) => void = () => {}; + let reconnect: () => void = () => {}; vi.spyOn(host, "init").mockResolvedValue(undefined); vi.spyOn(host, "onEvent").mockImplementation((listener) => { emit = listener; return () => {}; }); + vi.spyOn(host, "onReconnect").mockImplementation((listener) => { + reconnect = listener; + return () => {}; + }); const send = vi.spyOn(host, "send").mockImplementation(() => {}); (window as typeof window & { __DORMOUSE_BROWSER_CONSOLE_PATCHED__?: boolean }) .__DORMOUSE_BROWSER_CONSOLE_PATCHED__ = true; @@ -115,7 +123,16 @@ describe("BrowserSidecarAdapter terminal stream", () => { const adapter = new BrowserSidecarAdapter(host); await adapter.init(); send.mockClear(); - return { adapter, send, deliver: (event: string, data: unknown) => emit({ event, data }) }; + // The manager is where a broadcast has to land for the rule to bite; the + // handler fan-out alone would pass with a private copy of the store. + const manager = (adapter as unknown as { alertManager: AlertManager }).alertManager; + const alertCommands = () => + send.mock.calls.filter(([cmd]) => cmd === "alert_command").map(([, args]) => args); + return { + adapter, send, manager, alertCommands, + reconnect: () => reconnect(), + deliver: (event: string, data: unknown) => emit({ event, data }), + }; } it("forwards the projection pair it was handed, parsing nothing again", async () => { @@ -130,17 +147,56 @@ describe("BrowserSidecarAdapter terminal stream", () => { }); it("routes the alert stores through the sidecar and applies their broadcasts", async () => { - const { adapter, send, deliver } = await listening(); + const { adapter, manager, alertCommands, deliver } = await listening(); + const quiet: AlertSettings = { ...DEFAULT_ALERT_SETTINGS, speakEnabled: false }; adapter.alertSetCommandWatched("cargo", true); - adapter.alertPublishSettings({ ringEnabled: false } as never, { seed: true }); - expect(send.mock.calls.filter(([cmd]) => cmd === "alert_command").map(([, args]) => args)).toEqual([ + adapter.alertPublishSettings(quiet, { seed: true }); + expect(alertCommands()).toEqual([ { payload: { op: "setCommandWatched", name: "cargo", watched: true } }, - { payload: { op: "initializeSettings", settings: { ringEnabled: false } } }, + { payload: { op: "initializeSettings", settings: quiet } }, ]); + + const setWatched = vi.spyOn(manager, "setWatchedCommands"); + const applySettings = vi.spyOn(manager, "applySettings"); const names: string[][] = []; + const settings: AlertSettings[] = []; adapter.onWatchedCommands((next) => void names.push(next)); + adapter.onAlertSettings((next) => void settings.push(next)); + deliver("alert:watchedCommands", { names: ["cargo", "make"] }); + expect(setWatched).toHaveBeenCalledWith(["cargo", "make"]); expect(names).toEqual([["cargo", "make"]]); + + const canonical: AlertSettings = { ...DEFAULT_ALERT_SETTINGS, deferAlertsUntilQuiet: true }; + deliver("alert:settings", { settings: canonical }); + expect(applySettings).toHaveBeenCalledWith(canonical); + expect(settings).toEqual([canonical]); + + // A broadcast with no blob is dropped, not applied as "no settings". + deliver("alert:settings", {}); + expect(applySettings).toHaveBeenCalledTimes(1); + expect(settings).toEqual([canonical]); + }); + + // The stream is the only path a store's snapshot takes back, and a dropped + // stream loses the bridge's fan-out entry with it. Re-offering the seeds is + // what makes the sidecar republish; a repeat seed is refused as a seed but + // still answered (lib/src/lib/watched-command-host.ts `initialize`). + it("re-sends its last seeds when the event stream reconnects", async () => { + const { adapter, alertCommands, reconnect, send } = await listening(); + const quiet: AlertSettings = { ...DEFAULT_ALERT_SETTINGS, speakEnabled: false }; + adapter.alertSetWatchedCommands(["cargo"]); + adapter.alertPublishSettings(quiet, { seed: true }); + // Neither a mutation nor a non-seed publish is a seed; neither is replayed. + adapter.alertSetCommandWatched("make", true); + adapter.alertPublishSettings({ ...quiet, pushEnabled: true }, { seed: false }); + send.mockClear(); + + reconnect(); + expect(alertCommands()).toEqual([ + { payload: { op: "initializeWatchedCommands", names: ["cargo"] } }, + { payload: { op: "initializeSettings", settings: quiet } }, + ]); }); it("pushes the resolved theme so the sidecar can answer a colour query", async () => { diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 3a8bf8903..2f977421d 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -76,7 +76,14 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>(); private alertManager = new AlertManager(); private unlistenHost: (() => void) | null = null; + private unlistenReconnect: (() => void) | null = null; private unlistenRegistry: (() => void) | null = null; + // The seeds this renderer last offered, keyed by op. The SSE stream is the + // only way a store's snapshot reaches this adapter, and a dropped stream + // takes the bridge's fan-out entry with it; re-offering the seeds after a + // reconnect makes the sidecar republish both stores (a repeat seed is + // ignored as a seed but still answered with the canonical snapshot). + private readonly alertSeeds = new Map>(); private onRegistrySnapshot: ((snapshot: WorkspaceRegistrySnapshot) => void) | null = null; private static STATE_KEY = 'dormouse.browser-sidecar.session'; private windowSlot = windowStateSlot(localStorage, BrowserSidecarAdapter.STATE_KEY, 'browser-sidecar'); @@ -116,6 +123,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter { async init(): Promise { await this.host.init(); this.unlistenHost = this.host.onEvent(({ event, data }) => this.handleHostEvent(event, data)); + this.unlistenReconnect = this.host.onReconnect(() => { + for (const payload of this.alertSeeds.values()) this.host.send("alert_command", { payload }); + }); this.installConsoleForwarder(); this.unlistenRegistry = await installWorkspaceRegistry({ invoke: (cmd, args) => this.host.invoke(cmd, args), @@ -137,6 +147,8 @@ export class BrowserSidecarAdapter implements PlatformAdapter { this.alertManager.dispose(); this.unlistenHost?.(); this.unlistenHost = null; + this.unlistenReconnect?.(); + this.unlistenReconnect = null; this.unlistenRegistry?.(); this.unlistenRegistry = null; this.burrowClient.dispose(); @@ -325,15 +337,18 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // path the shipped app takes (see TauriAdapter), so the harness exercises // the seed-once and delta rules rather than a private copy of the state. alertSetWatchedCommands(names: string[]): void { - this.host.send("alert_command", { payload: { op: "initializeWatchedCommands", names } }); + this.sendAlertSeed({ op: "initializeWatchedCommands", names }); } alertSetCommandWatched(name: string, watched: boolean): void { this.host.send("alert_command", { payload: { op: "setCommandWatched", name, watched } }); } alertPublishSettings(settings: AlertSettings, opts: { seed: boolean }): void { - this.host.send("alert_command", { - payload: { op: opts.seed ? "initializeSettings" : "updateSettings", settings }, - }); + if (opts.seed) this.sendAlertSeed({ op: "initializeSettings", settings }); + else this.host.send("alert_command", { payload: { op: "updateSettings", settings } }); + } + private sendAlertSeed(payload: { op: string } & Record): void { + this.alertSeeds.set(payload.op, payload); + this.host.send("alert_command", { payload }); } alertDismiss(id: string): void { this.alertManager.dismissAlert(id); } alertAttend(id: string): void { this.alertManager.attend(id); } diff --git a/standalone/src/browser-sidecar-host.test.ts b/standalone/src/browser-sidecar-host.test.ts index 4f2c0f8cd..42a54dfc7 100644 --- a/standalone/src/browser-sidecar-host.test.ts +++ b/standalone/src/browser-sidecar-host.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BrowserSidecarHost } from "./browser-sidecar-host"; // The dev bridge is an authenticated loopback control plane — `pty_spawn` @@ -28,3 +28,91 @@ describe("BrowserSidecarHost.url", () => { expect(url.pathname).toBe("/__dormouse_dev_host/send"); }); }); + +// jsdom ships no `EventSource`; this one is driven by hand. `EventTarget` +// gives it the `addEventListener` surface the host subscribes through. +class FakeEventSource extends EventTarget { + static instances: FakeEventSource[] = []; + closed = false; + constructor(readonly url: string | URL) { + super(); + FakeEventSource.instances.push(this); + } + close(): void { this.closed = true; } + open(): void { this.dispatchEvent(new Event("open")); } + fail(): void { this.dispatchEvent(new Event("error")); } +} + +// The bridge only fans a broadcast out to streams it has already registered, +// and the app POSTs its two alert seeds the moment `init()` resolves — replies +// that ride the stream. So `init()` settles on the stream being *open*, never +// on the `EventSource` merely existing (docs/specs/transport.md -> +// "Standalone browser-dev harness"). +describe("BrowserSidecarHost.init", () => { + beforeEach(() => { + FakeEventSource.instances = []; + vi.stubGlobal("EventSource", FakeEventSource); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + const stream = () => FakeEventSource.instances[0]!; + async function settled(promise: Promise): Promise<"pending" | "resolved" | "rejected"> { + let state: "pending" | "resolved" | "rejected" = "pending"; + promise.then(() => { state = "resolved"; }, () => { state = "rejected"; }); + await Promise.resolve(); + await Promise.resolve(); + return state; + } + + it("resolves on the stream's open event, not on construction", async () => { + const host = new BrowserSidecarHost("http://127.0.0.1:1422/?t=deadbeef"); + const ready = host.init(); + expect(await settled(ready)).toBe("pending"); + stream().open(); + expect(await settled(ready)).toBe("resolved"); + // A second call while connected is the same connection, not a second stream. + await host.init(); + expect(FakeEventSource.instances).toHaveLength(1); + }); + + it("rejects, and stops the stream, when it errors before opening", async () => { + const host = new BrowserSidecarHost("http://127.0.0.1:1422/?t=deadbeef"); + const ready = host.init(); + stream().fail(); + await expect(ready).rejects.toThrow("failed before it opened"); + expect(stream().closed).toBe(true); + }); + + it("gives up after a bounded wait when neither event arrives", async () => { + vi.useFakeTimers(); + const host = new BrowserSidecarHost("http://127.0.0.1:1422/?t=deadbeef"); + const ready = host.init(); + vi.advanceTimersByTime(BrowserSidecarHost.OPEN_TIMEOUT_MS); + await expect(ready).rejects.toThrow("did not open"); + expect(stream().closed).toBe(true); + }); + + // The browser re-establishes a dropped stream by itself, but the bridge's + // fan-out set forgot this client in between: whatever it broadcast is gone. + // The reconnect hook is how subscribers learn to re-ask; the first open is + // not a reconnect, `init()` already reports that one. + it("reports every open after the first as a reconnect", async () => { + const host = new BrowserSidecarHost("http://127.0.0.1:1422/?t=deadbeef"); + const reconnects = vi.fn(); + host.onReconnect(reconnects); + const ready = host.init(); + stream().open(); + await ready; + expect(reconnects).not.toHaveBeenCalled(); + stream().fail(); + expect(await settled(ready)).toBe("resolved"); + expect(reconnects).not.toHaveBeenCalled(); + stream().open(); + expect(reconnects).toHaveBeenCalledTimes(1); + }); +}); diff --git a/standalone/src/browser-sidecar-host.ts b/standalone/src/browser-sidecar-host.ts index b2027c61e..fbc7419d8 100644 --- a/standalone/src/browser-sidecar-host.ts +++ b/standalone/src/browser-sidecar-host.ts @@ -1,8 +1,13 @@ export type BrowserSidecarEvent = { event: string; data: unknown }; export class BrowserSidecarHost { + /** How long `init()` waits for the stream to open before giving up. */ + static readonly OPEN_TIMEOUT_MS = 10_000; + private events: EventSource | null = null; + private ready: Promise | null = null; private readonly eventHandlers = new Set<(event: BrowserSidecarEvent) => void>(); + private readonly reconnectHandlers = new Set<() => void>(); private nextId = 1; constructor(private readonly baseUrl: string) {} @@ -24,22 +29,62 @@ export class BrowserSidecarHost { return url; } - async init(): Promise { - if (this.events) return; + /** + * Resolves once the SSE stream is *open*, not once the `EventSource` is + * constructed. The bridge registers a stream in `sseClients` when the GET + * arrives, and the app's first act after `init()` is to POST the two alert + * seeds whose replies come back only over that stream — a POST that beat + * the GET would be answered to nobody (docs/specs/transport.md -> + * "Standalone browser-dev harness"). Tauri awaits its listener + * registration for the same reason. + */ + init(): Promise { + if (this.ready) return this.ready; const url = this.url('/__dormouse_dev_host/events'); - this.events = new EventSource(url); - this.events.addEventListener('sidecar', (event) => { + const events = new EventSource(url); + this.events = events; + events.addEventListener('sidecar', (event) => { const parsed = JSON.parse((event as MessageEvent).data) as BrowserSidecarEvent; this.deliver(parsed); }); - this.events.onerror = () => { - console.error('[browser-sidecar] event stream disconnected'); - }; + this.ready = new Promise((resolve, reject) => { + let opened = false; + const giveUp = (why: string) => { + clearTimeout(timer); + events.close(); + reject(new Error(`[browser-sidecar] event stream ${why}`)); + }; + const timer = setTimeout( + () => giveUp(`did not open within ${BrowserSidecarHost.OPEN_TIMEOUT_MS}ms`), + BrowserSidecarHost.OPEN_TIMEOUT_MS, + ); + events.addEventListener('open', () => { + if (!opened) { + opened = true; + clearTimeout(timer); + resolve(); + return; + } + // The browser reconnected on its own after a drop. The bridge's + // fan-out set forgot this client in between, so anything it would + // have broadcast is gone; subscribers re-ask for what they need. + for (const handler of this.reconnectHandlers) handler(); + }); + events.addEventListener('error', () => { + if (!opened) { + giveUp('failed before it opened'); + return; + } + console.error('[browser-sidecar] event stream disconnected'); + }); + }); + return this.ready; } close(): void { this.events?.close(); this.events = null; + this.ready = null; } onEvent(handler: (event: BrowserSidecarEvent) => void): () => void { @@ -47,6 +92,13 @@ export class BrowserSidecarHost { return () => this.eventHandlers.delete(handler); } + /** Fires on every `open` after the first — the stream was dropped and the + * browser re-established it. Not on the initial open; `init()` covers that. */ + onReconnect(handler: () => void): () => void { + this.reconnectHandlers.add(handler); + return () => this.reconnectHandlers.delete(handler); + } + send(cmd: string, args?: Record): void { fetch(this.url('/__dormouse_dev_host/send'), { method: 'POST', From 6540cef46f2f2e864d7b140d2c412c1959fb5b2a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 18:34:24 -0700 Subject: [PATCH 3/6] Retry transient harness connections and display startup failures --- docs/specs/transport.md | 2 +- standalone/scripts/dev-agent-browser.mjs | 2 +- standalone/src/browser-sidecar-host.test.ts | 20 ++++++++++++++++---- standalone/src/browser-sidecar-host.ts | 3 ++- standalone/src/main.tsx | 12 +++++++++++- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 324d5aa22..a4acfe2ba 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -46,7 +46,7 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY **An unauthorized caller gets the same `404 not found` as an unknown path**, so the port does not identify itself. The harness prints the token and a ready-made `curl` on startup. -The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; after a reconnect the adapter re-sends its last seeds so the stores republish (`standalone/src/browser-sidecar-host.test.ts`). It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. +The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; **must let retryable connection failures reconnect within the open timeout and display fatal startup errors with a reload action**; after a reconnect the adapter re-sends its last seeds so the stores republish (`standalone/src/browser-sidecar-host.test.ts`). It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index c497ba2b3..bbc80cc18 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -178,7 +178,7 @@ let registryRevision = 0; function registrySnapshot() { const workspaces = JSON.parse(registryEntries).map((entry) => ({ id: entry.id, - ref: /^workspace-(\d+)$/.test(entry.id) ? `workspace:${entry.id.slice('workspace-'.length)}` : null, + ref: /^workspace-(\d+)$/.test(entry.id) ? `workspace:${entry.id.slice('workspace-'.length)}` : `workspace:${entry.id}`, name: entry.name, active: Boolean(entry.active), })); diff --git a/standalone/src/browser-sidecar-host.test.ts b/standalone/src/browser-sidecar-host.test.ts index 42a54dfc7..c593aa8d1 100644 --- a/standalone/src/browser-sidecar-host.test.ts +++ b/standalone/src/browser-sidecar-host.test.ts @@ -33,14 +33,16 @@ describe("BrowserSidecarHost.url", () => { // gives it the `addEventListener` surface the host subscribes through. class FakeEventSource extends EventTarget { static instances: FakeEventSource[] = []; + static readonly CLOSED = 2; + readyState = 0; closed = false; constructor(readonly url: string | URL) { super(); FakeEventSource.instances.push(this); } - close(): void { this.closed = true; } - open(): void { this.dispatchEvent(new Event("open")); } - fail(): void { this.dispatchEvent(new Event("error")); } + close(): void { this.closed = true; this.readyState = FakeEventSource.CLOSED; } + open(): void { this.readyState = 1; this.dispatchEvent(new Event("open")); } + fail(fatal = false): void { this.readyState = fatal ? FakeEventSource.CLOSED : 0; this.dispatchEvent(new Event("error")); } } // The bridge only fans a broadcast out to streams it has already registered, @@ -83,11 +85,21 @@ describe("BrowserSidecarHost.init", () => { it("rejects, and stops the stream, when it errors before opening", async () => { const host = new BrowserSidecarHost("http://127.0.0.1:1422/?t=deadbeef"); const ready = host.init(); - stream().fail(); + stream().fail(true); await expect(ready).rejects.toThrow("failed before it opened"); expect(stream().closed).toBe(true); }); + it("allows retryable pre-open failures to reconnect within the deadline", async () => { + const host = new BrowserSidecarHost("http://127.0.0.1:1422"); + const ready = host.init(); + stream().fail(); + expect(await settled(ready)).toBe("pending"); + expect(stream().closed).toBe(false); + stream().open(); + await expect(ready).resolves.toBeUndefined(); + }); + it("gives up after a bounded wait when neither event arrives", async () => { vi.useFakeTimers(); const host = new BrowserSidecarHost("http://127.0.0.1:1422/?t=deadbeef"); diff --git a/standalone/src/browser-sidecar-host.ts b/standalone/src/browser-sidecar-host.ts index fbc7419d8..15e5cd22f 100644 --- a/standalone/src/browser-sidecar-host.ts +++ b/standalone/src/browser-sidecar-host.ts @@ -72,7 +72,8 @@ export class BrowserSidecarHost { }); events.addEventListener('error', () => { if (!opened) { - giveUp('failed before it opened'); + // CONNECTING is retryable; EventSource retries within the open budget. + if (events.readyState === EventSource.CLOSED) giveUp('failed before it opened'); return; } console.error('[browser-sidecar] event stream disconnected'); diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 9f54d0b2e..f37375f41 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -174,4 +174,14 @@ async function bootstrap() { ); } -bootstrap(); +void bootstrap().catch((error: unknown) => { + console.error("[dormouse] startup failed", error); + const root = document.getElementById("root"); + if (!root) return; + const message = document.createElement("p"); + message.textContent = `Dormouse could not start: ${error instanceof Error ? error.message : String(error)}`; + const retry = document.createElement("button"); + retry.textContent = "Reload"; + retry.addEventListener("click", () => window.location.reload()); + root.replaceChildren(message, retry); +}); From 0e39d5437b84ceba9af5594cf06ad4686f055582 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 18:59:15 -0700 Subject: [PATCH 4/6] Account for combined transport contracts in harness spec budget --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b79d04676..6245e29da 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5700, + "docs/specs/transport.md": 5750, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, From b47c5844577a1b3ffaaace69885a734dd4ec7830 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 19:14:36 -0700 Subject: [PATCH 5/6] Budget the Windows scan contract in the combined harness spec --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 6245e29da..83518a394 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5750, + "docs/specs/transport.md": 5800, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200, From 5abcfdbc66a274b67265f4e704f4a22f176c82cf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 19:32:30 -0700 Subject: [PATCH 6/6] Account for exited-buffer replay in the harness spec budget --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 83518a394..3c22ec720 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5800, + "docs/specs/transport.md": 5850, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7500, "docs/specs/webgl-text.md": 1200,