diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index c2289c2aa..68bf1419d 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -461,6 +461,26 @@ workspaces-rollout scope (`docs/specs/layout.md` → `## Future`); restoring VS Code-style recovery here later is flipping that gate plus adding capture to the quit teardown, which already has the right ordering (flush → kill → flush → drain). +The browser-dev harness carries the same gate, for the same reason plus one of its +own: `BrowserSidecarAdapter.PERSIST_SESSION` is `false`, so `saveState` is a no-op, +`getState` returns null, and `persistsSession` is `false`. Its `init()` also +**deletes** the `dormouse.browser-sidecar.session` key rather than ignoring it — +snapshots carry transcripts, and `localStorage` is keyed by browser profile rather +than by the per-run temp state directory the harness gives every other slot +(`standalone/scripts/dev-agent-browser.mjs`), so a blob written before the gate +existed would otherwise outlive every run. Flip both `PERSIST_SESSION` flags +together; a harness that restored panes across a reload would be debugging a +save/restore path the shipped app does not take. + +What the gate costs on reload is the *layout*, not the Sessions. Nothing wires +`shutdown()` to `beforeunload`, so the sidecar and its PTYs outlive a page reload +and `lib/src/lib/reconnect.ts` still resumes over them — but it reads `getState()` +for the saved resume plan, and with the gate on there is none, so every live PTY +lands in one tab group with doors and saved titles dropped. Real standalone has +always behaved this way across a WebView reload and the harness now matches it; +the cost is just more visible here, since enabling `abDebugLogs` means reloading +(`.claude/skills/debug-standalone-agent-browser/SKILL.md`). + ## Quit flow Source of truth: `standalone/src-tauri/src/lib.rs` (`QuitState`, `request_quit`, diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 374c52178..9d7dc8f2e 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -51,7 +51,7 @@ The gate runs before routing and before any body read, and an unauthorized calle The remote Host rides the same shim: `remote_host_command` is one more invoke that writes `remoteHost:command` to the sidecar, and the sidecar's `remoteHost:*` events arrive on the SSE stream, so the harness runs a real Host against a per-run temp state directory (`docs/specs/standalone.md` → "Remote Host service"). -The harness may omit native-only desktop chrome such as window controls and update checks, but it must preserve the `PlatformAdapter` PTY, control-request, clipboard, iframe-proxy, remote-Host, and agent-browser contracts used by the app. Tauri APIs must not be required at static module-evaluation time when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set, because the page is loaded by a normal browser rather than the Tauri WebView. +The harness may omit native-only desktop chrome such as window controls and update checks, but it must preserve the `PlatformAdapter` PTY, control-request, clipboard, iframe-proxy, remote-Host, and agent-browser contracts used by the app. It also mirrors standalone's Session-persistence answer rather than choosing its own: `BrowserSidecarAdapter` carries the same `PERSIST_SESSION = false` gate as `TauriAdapter`, reports `persistsSession: false`, and deletes any pre-gate `localStorage` blob on `init()` (`docs/specs/standalone.md` → "Standalone persists no Session state"). Persisting here would restore panes across a reload that the real app drops, and would run the record build and its per-pane `getCwd` round trip on a path production never takes. Tauri APIs must not be required at static module-evaluation time when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set, because the page is loaded by a normal browser rather than the Tauri WebView. ## PTY lifecycle @@ -225,8 +225,11 @@ something ends it: Standalone therefore **persists no Session state at all.** A clean quit has nothing to clear and a crash has nothing to recover; the write path itself is removed rather than written-then-ignored, since the blob it wrote was the transcript-bearing one. -Live resume within a running app is unaffected — it reads the sidecar's live PTY -list, not disk. A legacy blob found at boot is deleted, not read. +The *Sessions* survive a reload within a running app — resume reads the sidecar's +live PTY list, not disk — but the layout does not: `lib/src/lib/reconnect.ts` reads +`getState()` for the saved resume plan, so with nothing persisted every live PTY +lands in one tab group with doors and saved titles dropped. A legacy blob found at +boot is deleted, not read. > Reserved: the workspaces-rollout scope (`docs/specs/layout.md` → `## Future`) > assumes a persisted `PersistedWindow` in standalone. Reconciling multi-Workspace diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index 4ef659657..165dd06d0 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -37,3 +37,50 @@ describe("BrowserSidecarAdapter capability surface", () => { expect(typeof adapter.onFilesDropped).toBe("function"); }); }); + +// The harness must not persist Session state that production standalone drops +// (docs/specs/standalone.md -> "Standalone persists no Session state"). +describe("BrowserSidecarAdapter session persistence", () => { + const KEY = "dormouse.browser-sidecar.session"; + + it("reports the same persistsSession as TauriAdapter", () => { + const harness: PlatformAdapter = new BrowserSidecarAdapter( + new BrowserSidecarHost("http://localhost:1234"), + ); + const tauri: PlatformAdapter = new TauriAdapter(); + expect(harness.persistsSession).toBe(tauri.persistsSession); + expect(harness.persistsSession).toBe(false); + }); + + it("does not write session state to localStorage", () => { + localStorage.removeItem(KEY); + const adapter: PlatformAdapter = new BrowserSidecarAdapter( + new BrowserSidecarHost("http://localhost:1234"), + ); + adapter.saveState({ version: 3, panes: [], lathLayout: null }); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it("does not restore a stale blob left by an earlier run", () => { + localStorage.setItem(KEY, JSON.stringify({ version: 3, panes: [], lathLayout: null })); + const adapter: PlatformAdapter = new BrowserSidecarAdapter( + new BrowserSidecarHost("http://localhost:1234"), + ); + expect(adapter.getState()).toBeNull(); + localStorage.removeItem(KEY); + }); + + it("deletes a pre-gate blob on init", async () => { + localStorage.setItem(KEY, JSON.stringify({ version: 3, panes: [], lathLayout: null })); + const host = new BrowserSidecarHost("http://localhost:1234"); + vi.spyOn(host, "init").mockResolvedValue(undefined); + vi.spyOn(host, "onEvent").mockReturnValue(() => {}); + // Claim the console-forwarder flag so init() doesn't patch console.* on the + // shared jsdom window for every later test in this file. + (window as typeof window & { __DORMOUSE_BROWSER_CONSOLE_PATCHED__?: boolean }) + .__DORMOUSE_BROWSER_CONSOLE_PATCHED__ = true; + const adapter = new BrowserSidecarAdapter(host); + await adapter.init(); + expect(localStorage.getItem(KEY)).toBeNull(); + }); +}); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index f154bb092..dc893a694 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -94,6 +94,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } async init(): Promise { + this.clearPersistedState(); await this.host.init(); this.unlistenHost = this.host.onEvent(({ event, data }) => this.handleHostEvent(event, data)); this.installConsoleForwarder(); @@ -253,15 +254,23 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private static STATE_KEY = 'dormouse.browser-sidecar.session'; + // Mirrors TauriAdapter's gate (docs/specs/standalone.md -> "Standalone persists + // no Session state"); flip both flags together. + private static PERSIST_SESSION = false; + + readonly persistsSession = BrowserSidecarAdapter.PERSIST_SESSION; + // See TauriAdapter: PersistedWindow when the workspaces flag is on, bare // PersistedSession when off; the helpers own the translation + JSON/storage // plumbing (docs/specs/transport.md). saveState(state: unknown): void { + if (!BrowserSidecarAdapter.PERSIST_SESSION) return; try { saveSessionState(localStorage, BrowserSidecarAdapter.STATE_KEY, state); } catch { console.error('[browser-sidecar] Failed to save session state'); } } getState(): unknown { + if (!BrowserSidecarAdapter.PERSIST_SESSION) return null; try { return loadSessionState(localStorage, BrowserSidecarAdapter.STATE_KEY); } catch { @@ -269,6 +278,14 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } } + // Delete (not just ignore) pre-gate blobs: they carry transcripts and localStorage + // outlives the harness's per-run temp state dir. + private clearPersistedState(): void { + if (BrowserSidecarAdapter.PERSIST_SESSION) return; + try { localStorage.removeItem(BrowserSidecarAdapter.STATE_KEY); } + catch { /* private-mode storage: nothing to clear */ } + } + private handleHostEvent(event: string, data: unknown): void { if (event === "pty:data") { const { id, data: text } = data as { id: string; data: string };