Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
dormouse-bot marked this conversation as resolved.

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`,
Expand Down
9 changes: 6 additions & 3 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions standalone/src/browser-sidecar-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
dormouse-bot marked this conversation as resolved.
expect(localStorage.getItem(KEY)).toBeNull();
});
});
17 changes: 17 additions & 0 deletions standalone/src/browser-sidecar-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter {
}

async init(): Promise<void> {
this.clearPersistedState();
await this.host.init();
this.unlistenHost = this.host.onEvent(({ event, data }) => this.handleHostEvent(event, data));
this.installConsoleForwarder();
Expand Down Expand Up @@ -253,22 +254,38 @@ 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 {
return null;
}
}

// 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 };
Expand Down