Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
eb4208b
Route the browser harness's alert stores through the sidecar
nedtwigg Sep 10, 2026
6c1050d
Resolve the harness host's init on the SSE stream opening
nedtwigg Sep 11, 2026
d248baf
Merge workspaces-move-verb into workspaces-harness
nedtwigg Sep 11, 2026
fe7e2f5
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
c8dbfd0
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
6c75ab9
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
b06c398
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
fe4ee63
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
9b222b3
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
5a8fd56
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
6540cef
Retry transient harness connections and display startup failures
nedtwigg Sep 11, 2026
b48107e
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
73c91fd
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
e2be6dc
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
37f23f2
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
aee4ecf
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
51ef258
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
0e39d54
Account for combined transport contracts in harness spec budget
nedtwigg Sep 11, 2026
ea55d27
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
4d222f4
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
b47c584
Budget the Windows scan contract in the combined harness spec
nedtwigg Sep 11, 2026
70ed0fb
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
d97a406
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
ea921ec
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
5abcfdb
Account for exited-buffer replay in the harness spec budget
nedtwigg Sep 11, 2026
77f5b29
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
4ba2a58
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
fc8a4eb
Merge branch 'workspaces-move-verb' into workspaces-harness
nedtwigg Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions TESTING_AND_MODIFICATION_GUIDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. **`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`.

Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": 5850,
"docs/specs/tutorial.md": 1900,
"docs/specs/vscode.md": 7500,
"docs/specs/webgl-text.md": 1200,
Expand Down
6 changes: 5 additions & 1 deletion standalone/scripts/dev-agent-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down Expand Up @@ -174,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),
}));
Expand Down
75 changes: 74 additions & 1 deletion standalone/src/browser-sidecar-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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
Expand Down Expand Up @@ -104,19 +107,33 @@ 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;

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 () => {
Expand All @@ -130,13 +147,69 @@ 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, manager, alertCommands, deliver } = await listening();
const quiet: AlertSettings = { ...DEFAULT_ALERT_SETTINGS, speakEnabled: false };
adapter.alertSetCommandWatched("cargo", true);
adapter.alertPublishSettings(quiet, { seed: true });
expect(alertCommands()).toEqual([
{ payload: { op: "setCommandWatched", name: "cargo", watched: true } },
{ 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 } },
]);
});

// Same contract as TauriAdapter: the replay is all a transferred pane's new
// window sees, so it rebuilds the AlertManager's half too.
it("rebuilds alert state from a replay, not only pane state", async () => {
const { adapter, deliver } = await listening();
const alerts: AlertStateDetail[] = [];
adapter.onAlertState((detail) => void alerts.push(detail));
// The rule set lives in the sidecar here: the seed goes out, its snapshot
// comes back, and only then does this manager watch anything.
adapter.alertSetWatchedCommands(["sleep"]);
deliver("alert:watchedCommands", { names: ["sleep"] });

deliver("pty:replay", { id: "replay-b", data: "\x1b]633;E;sleep 5\x07\x1b]633;C\x07" });

Expand Down
52 changes: 46 additions & 6 deletions standalone/src/browser-sidecar-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,18 @@ 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 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<string, Record<string, unknown>>();
private onRegistrySnapshot: ((snapshot: WorkspaceRegistrySnapshot) => void) | null = null;
private static STATE_KEY = 'dormouse.browser-sidecar.session';
private windowSlot = windowStateSlot(localStorage, BrowserSidecarAdapter.STATE_KEY, 'browser-sidecar');
Expand Down Expand Up @@ -114,6 +123,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter {
async init(): Promise<void> {
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),
Expand All @@ -135,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();
Expand Down Expand Up @@ -319,9 +333,23 @@ 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.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 {
if (opts.seed) this.sendAlertSeed({ op: "initializeSettings", settings });
else this.host.send("alert_command", { payload: { op: "updateSettings", settings } });
}
private sendAlertSeed(payload: { op: string } & Record<string, unknown>): 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); }
alertResize(id: string): void { this.alertManager.onResize(id); }
Expand All @@ -331,9 +359,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").
Expand Down Expand Up @@ -369,6 +396,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).
Expand Down
Loading