From 4fdaa3b1dc9375a18f6750bdc48a54409b1fac6e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:20:16 -0700 Subject: [PATCH 01/11] Parameterize the shared restore path and add alertSeed One Window has to plan a resume or a cold restore per Workspace off one boot payload, so the pieces `resumeOrRestore` did in one pass are now separable: `collectLivePtys` runs the single `requestInit` wait for the whole window, and `resumeOrRestoreFrom` plans over a slice of that list against an explicitly supplied record. `restoreSession` takes the same two sources. The wrapper `resumeOrRestore(platform)` is unchanged, so VS Code, Pocket, and the website keep their existing behavior. `claimUnowned` mirrors the unowned-PTY claim in message-router.ts: a live PTY that no saved Workspace names joins the plan that asks for it, and because such an id has no saved layout slot the plan degrades to the flat live list exactly as a single Wall already does. `PlatformAdapter.alertSeed` is the standalone twin of the seed the VS Code extension host does while answering a cold boot: standalone's AlertManager lives in the webview, so the restore path is the only thing that can hand a fresh PTY the TODO its saved pane carried. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/lib/platform/types.ts | 13 ++ lib/src/lib/reconnect.test.ts | 120 +++++++++++++++++- lib/src/lib/reconnect.ts | 187 +++++++++++++++++----------- lib/src/lib/session-restore.test.ts | 60 +++++++++ lib/src/lib/session-restore.ts | 23 +++- 5 files changed, 329 insertions(+), 74 deletions(-) diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 9476be67e..85ef6f804 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -7,6 +7,7 @@ import type { ShellEntry } from '../shell-defaults'; // share it without pulling this browser-typed module into a Node tsconfig. import type { IframeProxyResult } from './iframe-proxy-types'; import type { NotepadArchivePort } from '../notepad/types'; +import type { PersistedAlertState } from '../session-types'; export interface PtyInfo { helper?: HelperIdentity; @@ -240,6 +241,18 @@ export interface PlatformAdapter { */ getRecoveryCommands?(): Record; + /** + * Seed a cold-restored Surface's persisted TODO/alert into the host's + * `AlertManager`, so the freshly spawned PTY inherits the state its saved pane + * carried (`docs/specs/alert.md` -> "Persist only"). + * + * Present only where the adapter owns the manager: standalone runs it in the + * webview, so the restore path is the only thing that can seed it. VS Code + * omits it — its extension host seeds its own manager while answering the + * webview's boot (`vscode-ext/src/message-router.ts`). + */ + alertSeed?(id: string, state: PersistedAlertState): void; + // PTY queries getCwd(id: string): Promise; /** TCP listening ports opened by this terminal's process tree (shell + descendants). */ diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 99b5db82a..388f828cf 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -16,10 +16,11 @@ vi.mock('./terminal-registry', () => ({ getDefaultShellOpts: terminalRegistryMocks.getDefaultShellOpts, })); -import { resumeOrRestore } from './reconnect'; +import { collectLivePtys, resumeOrRestore, resumeOrRestoreFrom } from './reconnect'; import { addPlainNote, buildVolatileSnapshot, clearAllNotepads, getNotes } from './notepad/notepad-store'; import type { VolatileNotepadSnapshot } from './notepad/types'; import { getHelper, forgetHelper } from './helper-terminal'; +import { setPlatform } from './platform'; import type { LathNode } from './lath/model'; /** A native Lath persisted layout over `ids` (row split; empty tree for none) — @@ -569,3 +570,120 @@ describe('browser-only notepad resume', () => { expect(platform.spawnPty).not.toHaveBeenCalled(); }); }); + +describe('resumeOrRestoreFrom', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const savedFor = (...ids: string[]): PersistedSession => ({ + version: 3, + lathLayout: lathLayoutFor(...ids), + panes: ids.map((id) => ({ id, title: id, cwd: null, untouched: false })), + }); + + /** One `collectLivePtys` for the whole Window, exactly as `main.tsx` boots. */ + async function live(ptys: PtyInfo[], savedState: PersistedSession | null = null) { + const platform = createPlatform(ptys, savedState); + return { platform, live: await collectLivePtys(platform) }; + } + + it('gives each Workspace only the live PTYs its own saved record names', async () => { + const { platform, live: collected } = await live([ + { id: 'a1', alive: true }, + { id: 'b1', alive: true }, + ]); + + const a = resumeOrRestoreFrom(platform, collected, { + savedSession: savedFor('a1'), + ptyIds: new Set(['a1']), + }); + const b = resumeOrRestoreFrom(platform, collected, { + savedSession: savedFor('b1'), + ptyIds: new Set(['b1']), + }); + + expect(a.paneIds).toEqual(['a1']); + expect(b.paneIds).toEqual(['b1']); + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('a1', 'a1-replay', expect.anything()); + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('b1', 'b1-replay', expect.anything()); + }); + + it('keeps a helper with the Workspace that holds its source', async () => { + const helper = { parentId: 'a1', command: 'git status' }; + const { platform, live: collected } = await live([ + { id: 'a1', alive: true }, + { id: 'a-helper', alive: true, helper }, + { id: 'b1', alive: true }, + ]); + + const a = resumeOrRestoreFrom(platform, collected, { + savedSession: savedFor('a1'), + ptyIds: new Set(['a1', 'a-helper']), + }); + expect(a.paneIds).toEqual(['a1']); + expect(getHelper('a1')?.status).toBe('preserved'); + forgetHelper('a1'); + + // The same helper handed to a Workspace WITHOUT its source is an ordinary + // pane there: `ptyById` is the slice, so the parent lookup misses and the + // orphan is adopted rather than restored as a helper. + vi.clearAllMocks(); + setPlatform(platform); + const b = resumeOrRestoreFrom(platform, collected, { + savedSession: null, + ptyIds: new Set(['b1', 'a-helper']), + }); + expect(b.paneIds).toEqual(['a-helper', 'b1']); + expect(getHelper('a1')).toBeUndefined(); + }); + + it('claims a live PTY no saved Workspace names for the plan that asks', async () => { + const { platform, live: collected } = await live([ + { id: 'a1', alive: true }, + { id: 'stray', alive: true }, + ]); + + const inactive = resumeOrRestoreFrom(platform, collected, { + savedSession: savedFor('b1'), + ptyIds: new Set(['b1']), + }); + // No live PTY of its own: a cold restore of its saved panes. + expect(inactive.paneIds).toEqual(['b1']); + + const active = resumeOrRestoreFrom(platform, collected, { + savedSession: savedFor('a1'), + ptyIds: new Set(['a1']), + claimUnowned: new Set(['stray']), + }); + // An adopted id has no saved layout slot, so the plan degrades to the flat + // live list rather than restoring a layout that cannot hold it. + expect(active.paneIds).toEqual(['a1', 'stray']); + expect(active.lathLayout).toBeUndefined(); + }); + + it('plans against the record it is handed, not the platform slot', async () => { + const { platform, live: collected } = await live([], savedFor('slot-pane')); + + expect(resumeOrRestoreFrom(platform, collected, { savedSession: savedFor('given') }).paneIds) + .toEqual(['given']); + // `null` is "this Workspace has no record", never "read the slot". + expect(resumeOrRestoreFrom(platform, collected, { savedSession: null }).paneIds).toEqual([]); + // Omitted still reads the slot, which is what the single-Wall hosts take. + expect(resumeOrRestoreFrom(platform, collected, {}).paneIds).toEqual(['slot-pane']); + }); + + it('hands each plan its own recovery commands', async () => { + const { platform, live: collected } = await live([]); + platform.getRecoveryCommands = vi.fn(() => ({ 'a1': 'whole-window' })); + + resumeOrRestoreFrom(platform, collected, { + savedSession: savedFor('a1'), + recoveryCommands: { 'a1': 'claude --resume abc' }, + }); + expect(terminalRegistryMocks.restoreTerminal).toHaveBeenCalledWith( + 'a1', expect.objectContaining({ resumeCommand: 'claude --resume abc' }), + ); + expect(platform.getRecoveryCommands).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index efb55e4e7..cbe27ac6b 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -3,7 +3,7 @@ import type { LathPersistedLayout } from './lath/persistence'; import type { PlatformAdapter, PtyInfo } from './platform/types'; import { hydrateNotepadFromVolatile } from './notepad/notepad-store'; import { restoreBrowserSurfaceTodo, resumeTerminal } from './terminal-registry'; -import { carrySurfaceRefs, readPersistedSession, type PersistedDoor, type PersistedSurfaceRefs } from './session-types'; +import { carrySurfaceRefs, readPersistedSession, type PersistedDoor, type PersistedSession, type PersistedSurfaceRefs } from './session-types'; import { persistedLathLayout, restoreSession } from './session-restore'; export interface ReconnectResult { @@ -19,6 +19,34 @@ export interface ReconnectResult { surfaceRefsNext?: number; } +/** Every PTY the host still holds, with whatever replay each one sent. Collected + * ONCE per Window: the wait below is a single `requestInit` round trip, and the + * host answers it for the whole webview, not per Workspace. */ +export interface LivePtys { + ptys: PtyInfo[]; + replay: Map; +} + +/** + * What one plan may claim out of `LivePtys`, and what it plans against. Every + * field defaults to the whole-window answer, so the single-Wall hosts reach the + * same behavior through `resumeOrRestore`. + */ +export interface ResumePlanOptions { + /** The record to plan from; `undefined` reads the platform slot, `null` is "none". */ + savedSession?: PersistedSession | null; + /** Live ids this plan owns by name. Omitted claims every live PTY. */ + ptyIds?: ReadonlySet; + /** Live ids no saved Workspace named, adopted by this plan — the active + * Workspace's, mirroring the unowned claim in + * `vscode-ext/src/message-router.ts`. An adopted id has no saved layout + * position, so a plan that takes one falls back to the flat live list, exactly + * as a single Wall does when a live PTY outruns its last save. */ + claimUnowned?: ReadonlySet; + /** Single-use resume invocations already claimed for this plan's panes. */ + recoveryCommands?: Record; +} + /** * Resume over live PTYs, or cold-restore from saved session. * @@ -29,27 +57,19 @@ export interface ReconnectResult { * 3. Neither → return empty (Wall creates a fresh terminal) */ export async function resumeOrRestore(platform: PlatformAdapter): Promise { - const liveResult = await resumeLiveSessions(platform); - if (liveResult) return liveResult; - - const restored = await restoreSession(platform); - if (restored) { - const saved = readPersistedSession(platform.getState()); - // Browser-only views have no PTY with which to prove a live resume. Their - // host-memory mirror is that proof; an extension restart supplies null. - // Rebuild their layout first, then hydrate only those surviving Surfaces. - if (saved?.panes.length && saved.panes.every((pane) => pane.surfaceType === 'browser')) { - return hydrateNotepad(platform, restored); - } - return restored; - } - - return { paneIds: [] }; + return resumeOrRestoreFrom(platform, await collectLivePtys(platform)); } -function resumeLiveSessions(platform: PlatformAdapter): Promise { - return new Promise((resolve) => { - const replayBuffer = new Map(); +/** + * Ask the host for its PTYs and gather the replay each one sends back. + * + * Bounded rather than counted-to-completion: a host that lists PTYs but never + * replays one of them must not hold up boot, so 500 ms is the ceiling and a + * short list resolves as soon as every replay has arrived. + */ +export function collectLivePtys(platform: PlatformAdapter): Promise { + return new Promise((resolve) => { + const replay = new Map(); let ptyList: PtyInfo[] | null = null; const timeout = setTimeout(() => finish(), 500); @@ -62,8 +82,8 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise { - replayBuffer.set(detail.id, detail.data); - if (ptyList && replayBuffer.size >= ptyList.length) { + replay.set(detail.id, detail.data); + if (ptyList && replay.size >= ptyList.length) { finish(); } }; @@ -75,50 +95,7 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise pty.id)); - const ids: string[] = []; - const ptyById = new Map(ptyList.map((pty) => [pty.id, pty])); - for (const pty of ptyList) { - const resumeInfo: { alive: boolean; exitCode?: number; shell?: string; title?: string; untouched?: boolean; helper?: PtyInfo['helper'] } = { - alive: pty.alive, - exitCode: pty.exitCode, - }; - if (pty.shell !== undefined) resumeInfo.shell = pty.shell; - const savedInfo = savedResumeInfo.get(pty.id); - if (savedInfo?.title !== undefined) resumeInfo.title = savedInfo.title; - if (savedInfo?.untouched) resumeInfo.untouched = true; - // A helper stays one only while its source is also live; helpers cannot - // have helpers. - const parent = pty.helper && ptyById.get(pty.helper.parentId); - const helper = parent && !parent.helper ? pty.helper : undefined; - if (helper) resumeInfo.helper = helper; - resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, resumeInfo); - if (helper) { restoreHelper(pty.id, helper); continue; } - ids.push(pty.id); - if (pty.helper) adoptOrphanedHelper(pty.id); - } - // Pull saved visible/doors state so a resume (e.g. after panel - // close/reopen) restores splits and doors instead of stacking every live - // PTY into one tab group. - const savedPlan = getSavedResumePlan(savedState, ids); - if (savedPlan) { - resolve(hydrateNotepad(platform, savedPlan)); - return; - } - - const saved = readPersistedSession(savedState); - resolve(hydrateNotepad(platform, { - paneIds: ids, - doors: [], - ...carrySurfaceRefs(saved), - })); + resolve({ ptys: ptyList ?? [], replay }); } platform.onPtyList(handleList); @@ -127,6 +104,78 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise + opts.ptyIds === undefined || opts.ptyIds.has(pty.id) || opts.claimUnowned?.has(pty.id)); + const resumed = mine.length > 0 ? resumeLivePtys(mine, live.replay, saved) : null; + if (resumed) return hydrateNotepad(platform, resumed); + + const restored = restoreSession(platform, { + savedSession: saved, + ...(opts.recoveryCommands !== undefined ? { recoveryCommands: opts.recoveryCommands } : {}), + }); + if (restored) { + // Browser-only views have no PTY with which to prove a live resume. Their + // host-memory mirror is that proof; an extension restart supplies null. + // Rebuild their layout first, then hydrate only those surviving Surfaces. + if (saved?.panes.length && saved.panes.every((pane) => pane.surfaceType === 'browser')) { + return hydrateNotepad(platform, restored); + } + return restored; + } + + return { paneIds: [] }; +} + +function resumeLivePtys( + ptyList: PtyInfo[], + replayBuffer: Map, + saved: PersistedSession | null, +): ReconnectResult { + const savedResumeInfo = getSavedPaneResumeInfo(saved, ptyList.map((pty) => pty.id)); + const ids: string[] = []; + const ptyById = new Map(ptyList.map((pty) => [pty.id, pty])); + for (const pty of ptyList) { + const resumeInfo: { alive: boolean; exitCode?: number; shell?: string; title?: string; untouched?: boolean; helper?: PtyInfo['helper'] } = { + alive: pty.alive, + exitCode: pty.exitCode, + }; + if (pty.shell !== undefined) resumeInfo.shell = pty.shell; + const savedInfo = savedResumeInfo.get(pty.id); + if (savedInfo?.title !== undefined) resumeInfo.title = savedInfo.title; + if (savedInfo?.untouched) resumeInfo.untouched = true; + // A helper stays one only while its source is also live; helpers cannot + // have helpers. `ptyById` is this plan's slice, so a helper whose parent + // went to another Workspace is resumed as an ordinary pane rather than + // restored into a Wall that does not hold its source. + const parent = pty.helper && ptyById.get(pty.helper.parentId); + const helper = parent && !parent.helper ? pty.helper : undefined; + if (helper) resumeInfo.helper = helper; + resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, resumeInfo); + if (helper) { restoreHelper(pty.id, helper); continue; } + ids.push(pty.id); + if (pty.helper) adoptOrphanedHelper(pty.id); + } + // Pull saved visible/doors state so a resume (e.g. after panel + // close/reopen) restores splits and doors instead of stacking every live + // PTY into one tab group. + return getSavedResumePlan(saved, ids) ?? { + paneIds: ids, + doors: [], + ...carrySurfaceRefs(saved), + }; +} + /** * Give a resumed webview back the notes the host mirrored for it * (docs/specs/notepad.md → "Live resume"). Only reachable from the live-PTY @@ -143,8 +192,7 @@ function hydrateNotepad(platform: PlatformAdapter, result: ReconnectResult): Rec return result; } -function getSavedPaneResumeInfo(savedState: unknown, liveIds: string[]): Map { - const saved = readPersistedSession(savedState); +function getSavedPaneResumeInfo(saved: PersistedSession | null, liveIds: string[]): Map { if (!saved || !Array.isArray(saved.panes)) return new Map(); const liveSet = new Set(liveIds); @@ -157,8 +205,7 @@ function getSavedPaneResumeInfo(savedState: unknown, liveIds: string[]): Map { ); }); }); + +describe('restoreSession alert seeding', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const alert = { status: 'WATCHING_DISABLED' as const, todo: true, notification: null }; + + it('seeds a terminal pane\'s persisted TODO and leaves browser panes to the todo restore', () => { + const saved: PersistedSession = { + version: 3, + panes: [ + { id: 'shell', title: 'Shell', cwd: '/tmp', untouched: false, alert }, + { id: 'quiet', title: 'Quiet', cwd: '/tmp', untouched: false }, + { id: 'web', title: 'Web', cwd: null, untouched: false, surfaceType: 'browser', alert }, + ], + }; + const platform = createPlatform(saved); + const alertSeed = vi.fn(); + platform.alertSeed = alertSeed; + + restoreSession(platform); + + // Only the terminal pane that carried one, and only that pane's blob. + expect(alertSeed.mock.calls).toEqual([['shell', alert]]); + }); + + it('restores without a seeding host', () => { + const saved: PersistedSession = { + version: 3, + panes: [{ id: 'shell', title: 'Shell', cwd: '/tmp', untouched: false, alert }], + }; + // VS Code omits `alertSeed`; its extension host seeds its own manager. + expect(restoreSession(createPlatform(saved))?.paneIds).toEqual(['shell']); + }); + + it('restores the record it is handed with the commands it is handed', () => { + const given: PersistedSession = { + version: 3, + panes: [{ id: 'given', title: 'Given', cwd: '/w', untouched: false }], + }; + const platform = createPlatform( + { version: 3, panes: [{ id: 'slot', title: 'Slot', cwd: null, untouched: false }] }, + { given: 'from-the-window' }, + ); + + const result = restoreSession(platform, { + savedSession: given, + recoveryCommands: { given: 'claude --resume xyz' }, + }); + + expect(result?.paneIds).toEqual(['given']); + expect(terminalRegistryMocks.restoreTerminal).toHaveBeenCalledWith( + 'given', expect.objectContaining({ cwd: '/w', resumeCommand: 'claude --resume xyz' }), + ); + expect(platform.getRecoveryCommands).not.toHaveBeenCalled(); + // An explicit `null` is "no record", never a fallback to the slot. + expect(restoreSession(platform, { savedSession: null })).toBeNull(); + }); +}); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index 283b3730a..fc3eb318c 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -21,8 +21,20 @@ export function persistedLathLayout(saved: PersistedSession): LathPersistedLayou return isLathPersistedLayout(saved.lathLayout) ? saved.lathLayout : undefined; } -export function restoreSession(platform: PlatformAdapter): RestoredSession | null { - const saved = readPersistedSession(platform.getState()); +/** What a restore reads instead of the platform slot, so one Window can plan a + * cold restore per Workspace off one boot payload. Both default to the + * platform's own answer, which is what the single-Wall hosts still take. */ +export interface RestoreSources { + /** The record to restore; `undefined` reads the platform slot, `null` is "none". */ + savedSession?: PersistedSession | null; + /** Host-captured single-use resume invocations, already claimed for this plan. */ + recoveryCommands?: Record; +} + +export function restoreSession(platform: PlatformAdapter, sources: RestoreSources = {}): RestoredSession | null { + const saved = sources.savedSession !== undefined + ? sources.savedSession + : readPersistedSession(platform.getState()); if (!saved || !saved.panes || saved.panes.length === 0) return null; const doors = saved.doors ?? []; const doorIds = new Set(doors.map((item) => item.id)); @@ -38,7 +50,7 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul // would replay it (docs/specs/transport.md -> "Consuming it"). Restore-only — // the live-resume path in reconnect.ts never reaches here, because there the // agent is still Live and has nothing to resume. - const recoveryCommands = platform.getRecoveryCommands?.() ?? {}; + const recoveryCommands = sources.recoveryCommands ?? platform.getRecoveryCommands?.() ?? {}; for (const pane of saved.panes) { // Browser surfaces have no PTY or xterm; the persisted layout recreates them @@ -56,6 +68,11 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul untouched: pane.untouched, resumeCommand: recoveryCommands[pane.id] ?? null, }); + // The fresh PTY inherits the pane's persisted TODO/alert, on the hosts whose + // AlertManager lives in the webview. Seeded after `restoreTerminal` so the + // state change lands on a registered pane. Restore-only: a live resume still + // has the manager's own state (docs/specs/alert.md -> "Persist only"). + if (pane.alert) platform.alertSeed?.(pane.id, pane.alert); } return { From 2b6e88824681b111d7145f7d5dfb91462a8be2e1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:27:59 -0700 Subject: [PATCH 02/11] Complete the Window aggregator and retire the workspaces flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregator now owns the whole Window blob: `seedWindowSession` installs what the last run left on disk before any Wall mounts, `previousWorkspaceSession` answers a Workspace's save with its own record (seed until its Wall publishes, which is what keeps a dead PTY's retained cwd and alert from being dropped on the first save after a restore), and one 500 ms debounced writer collapses N Workspaces reacting to one event into a single host write. Installing the writer also subscribes to the Workspace store, because a reorder, rename, or active switch changes the blob with no Session changing. `flushWindowSession` is the quit step between the last Wall flush and the host's drain. `window-persistence.ts` loses its flag branches for `loadWindowState` / `saveWindowState`; a pre-Window blob is wrapped as the one Workspace, the only migration. `dormouse.flags.workspaces` is gone with them. Both stores that feed the dirty tracker now name the Surface that changed, so a Workspace only rebuilds its record for its own Surfaces — unkeyed stays a store-wide reset every Wall takes. Without this every idle Workspace would run a `getCwd` per pane on every heartbeat while any Workspace was busy. Both adapters are rewired onto the Window helpers; they still persist nothing until the flip. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/Wall.test.tsx | 37 ++++ .../wall/use-session-persistence.ts | 30 +-- lib/src/lib/feature-flags.test.ts | 39 ---- lib/src/lib/feature-flags.ts | 24 --- lib/src/lib/notepad/never-persisted.test.ts | 15 +- lib/src/lib/session-activity-store.ts | 19 +- lib/src/lib/terminal-lifecycle.ts | 2 +- lib/src/lib/terminal-state-store.ts | 22 ++- lib/src/lib/window-persistence.test.ts | 178 ++++++------------ lib/src/lib/window-persistence.ts | 88 +++------ lib/src/lib/window-session-aggregator.test.ts | 168 ++++++++++++++--- lib/src/lib/window-session-aggregator.ts | 143 +++++++++++--- standalone/src/browser-sidecar-adapter.ts | 18 +- standalone/src/tauri-adapter.ts | 18 +- 14 files changed, 461 insertions(+), 340 deletions(-) delete mode 100644 lib/src/lib/feature-flags.test.ts diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 7d876077c..b3a8e724f 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -26,6 +26,8 @@ import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal import { getWallHandle, listWallHandles } from './wall/wall-handles'; import { mountWallHarness, type WallHarness } from './wall/wall-test-utils'; import { DEFAULT_WORKSPACE_ID } from '../lib/session-types'; +import { clearTerminalActivity, setTerminalActivity } from '../lib/session-activity-store'; +import { resetTerminalPaneState } from '../lib/terminal-state-store'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -2100,4 +2102,39 @@ describe('Wall session persistence: ownership filtering', () => { vi.useRealTimers(); } }); + + it('ignores an activity or pane-state change belonging to another Workspace', async () => { + vi.useFakeTimers(); + try { + const saveState = vi.spyOn(fake, 'saveState'); + const settle = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + + await act(async () => { + root.render(); + }); + // Past a heartbeat, so the mount's own dirty state has been written off. + await settle(31_000); + saveState.mockClear(); + + // Both stores are Window-global. A change keyed to a foreign Surface must + // not make this Wall rebuild its record — that is a `getCwd` per pane, on + // every idle Workspace, every heartbeat. + await act(async () => { setTerminalActivity('pane-elsewhere', { todo: true }); }); + await act(async () => { resetTerminalPaneState('pane-elsewhere'); }); + await settle(31_000); + expect(saveState, 'foreign Surface').not.toHaveBeenCalled(); + + await act(async () => { setTerminalActivity('pane-a', { todo: true }); }); + await settle(31_000); + expect(saveState, 'own Surface').toHaveBeenCalled(); + saveState.mockClear(); + + // An unkeyed notification is a store-wide reset, which every Wall takes. + await act(async () => { clearTerminalActivity(); }); + await settle(31_000); + expect(saveState, 'store-wide reset').toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 9272071fc..6b5f4d9be 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -3,7 +3,7 @@ import { pasteFilePaths } from '../../lib/clipboard'; import { getPlatform } from '../../lib/platform'; import { saveSession, type SaveSink } from '../../lib/session-save'; import { createSessionDirtyTracker } from '../../lib/session-dirty'; -import { publishWorkspaceSession } from '../../lib/window-session-aggregator'; +import { previousWorkspaceSession, publishWorkspaceSession } from '../../lib/window-session-aggregator'; import { subscribeToActivity, subscribeToTerminalPaneState, @@ -12,7 +12,7 @@ import { import { surfaceKindFromParams } from './browser-surface'; import type { LathWallEngine } from './lath-wall-engine'; import type { DooredItem, WallSelectionKind } from './wall-types'; -import type { PersistedDoor, PersistedSession, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; +import type { PersistedDoor, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; export interface SessionPersistenceHandle { /** Persist immediately, awaiting the whole queued pipeline. */ @@ -61,19 +61,15 @@ export function useSessionPersistence({ const pendingSaveNeededRef = useRef(false); // See session-dirty.ts for the conservative-under-races generation model. const trackerRef = useRef(createSessionDirtyTracker()); - // This Workspace's last published record: `getPreviousPaneMap`'s source, which - // must be this Workspace's own (a dead PTY's cwd is retained there), not the - // Window's active Workspace. - const publishedRef = useRef(null); - + // This Workspace's own previous record, which is where a dead PTY's retained + // cwd and alert live. The aggregator holds it because the first save after a + // restore has to read the record BOOT seeded, not the (still empty) one this + // Wall has published. const sink = useMemo(() => { if (workspaceId === undefined) return undefined; return { - previous: () => publishedRef.current, - publish: (session) => { - publishedRef.current = session; - publishWorkspaceSession(workspaceId, session); - }, + previous: () => previousWorkspaceSession(workspaceId), + publish: (session) => publishWorkspaceSession(workspaceId, session), }; }, [workspaceId]); @@ -204,8 +200,14 @@ export function useSessionPersistence({ // store of its own to report it (the registry mutates silently), which is // why the pty echo above is what marks it. platform.onPtyData(handlePtyData); - const unsubActivity = subscribeToActivity(markDirty); - const unsubPaneState = subscribeToTerminalPaneState(markDirty); + // Keyed like the PTY triggers: both stores are Window-global, so an + // unfiltered listener would rebuild every idle Workspace's record — a + // `getCwd` per pane — whenever any Workspace changed. A notification with no + // id is a store-wide reset, which every Wall must take. + const ownsChange = (id?: string) => id === undefined || ownsSurface(id); + const markDirtyFor = (id?: string) => { if (ownsChange(id)) markDirty(); }; + const unsubActivity = subscribeToActivity(markDirtyFor); + const unsubPaneState = subscribeToTerminalPaneState(markDirtyFor); // Heartbeat: idle sessions no longer write (only when something marked dirty). const interval = setInterval(() => { diff --git a/lib/src/lib/feature-flags.test.ts b/lib/src/lib/feature-flags.test.ts deleted file mode 100644 index 4704f47d9..000000000 --- a/lib/src/lib/feature-flags.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { isWorkspacesEnabled, setWorkspacesEnabled, WORKSPACES_FLAG_KEY } from './feature-flags'; - -function stubLocalStorage(): Map { - const store = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => (store.has(k) ? store.get(k)! : null), - setItem: (k: string, v: string) => store.set(k, v), - removeItem: (k: string) => store.delete(k), - }); - return store; -} - -describe('feature-flags: workspaces', () => { - afterEach(() => vi.unstubAllGlobals()); - - it('is off by default (dormant)', () => { - stubLocalStorage(); - expect(isWorkspacesEnabled()).toBe(false); - }); - - it('round-trips via localStorage', () => { - const store = stubLocalStorage(); - setWorkspacesEnabled(true); - expect(store.get(WORKSPACES_FLAG_KEY)).toBe('true'); - expect(isWorkspacesEnabled()).toBe(true); - setWorkspacesEnabled(false); - expect(store.has(WORKSPACES_FLAG_KEY)).toBe(false); - expect(isWorkspacesEnabled()).toBe(false); - }); - - describe('without localStorage', () => { - beforeEach(() => vi.stubGlobal('localStorage', undefined)); - it('treats the flag as disabled and never throws', () => { - expect(isWorkspacesEnabled()).toBe(false); - expect(() => setWorkspacesEnabled(true)).not.toThrow(); - }); - }); -}); diff --git a/lib/src/lib/feature-flags.ts b/lib/src/lib/feature-flags.ts index 639c1c5c7..42fd30338 100644 --- a/lib/src/lib/feature-flags.ts +++ b/lib/src/lib/feature-flags.ts @@ -1,17 +1,8 @@ /** * Runtime feature flags, toggled via `localStorage` so they work uniformly * across standalone, the VS Code webview, the website, Storybook, and tests. - * - * The **workspaces** flag gates the Workspace/Window container (stage 2b) and - * everything built on it — the switching UI (stage 3) and real multi-Workspace - * support (stage 4). It is **off by default**: with the flag off, the app - * persists and restores a single bare `PersistedSession` exactly as before, so - * the container code is dormant. See `docs/specs/glossary.md` → Implementation - * status. */ -export const WORKSPACES_FLAG_KEY = 'dormouse.flags.workspaces'; - function readBoolFlag(key: string): boolean { try { return globalThis.localStorage?.getItem(key) === 'true'; @@ -21,21 +12,6 @@ function readBoolFlag(key: string): boolean { } } -/** Whether the Workspace/Window container is enabled. Off by default (dormant). */ -export function isWorkspacesEnabled(): boolean { - return readBoolFlag(WORKSPACES_FLAG_KEY); -} - -/** Toggle the workspaces flag (used by dev tooling / the stage-3 Storybook UI). */ -export function setWorkspacesEnabled(enabled: boolean): void { - try { - if (enabled) globalThis.localStorage?.setItem(WORKSPACES_FLAG_KEY, 'true'); - else globalThis.localStorage?.removeItem(WORKSPACES_FLAG_KEY); - } catch { - // No localStorage: nothing to persist. - } -} - export const AB_DEBUG_LOGS_FLAG_KEY = 'dormouse.flags.abDebugLogs'; /** Whether the agent-browser high-rate `[ab-panel]`/`[agent-browser]` stream and diff --git a/lib/src/lib/notepad/never-persisted.test.ts b/lib/src/lib/notepad/never-persisted.test.ts index bcfd23905..ce2272471 100644 --- a/lib/src/lib/notepad/never-persisted.test.ts +++ b/lib/src/lib/notepad/never-persisted.test.ts @@ -11,10 +11,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../platform'; -import { setWorkspacesEnabled } from '../feature-flags'; import { saveSession } from '../session-save'; -import { readPersistedSession, type PersistedDoor } from '../session-types'; -import { saveSessionState, storedValueForSession } from '../window-persistence'; +import { readPersistedSession, wrapSessionInWindow, type PersistedDoor } from '../session-types'; +import { saveWindowState } from '../window-persistence'; import { addPlainNote, addTerminalNote, buildVolatileSnapshot, clearAllNotepads } from './notepad-store'; /** Distinctive enough that a substring hit anywhere is a real leak. */ @@ -41,7 +40,6 @@ beforeEach(() => { afterEach(() => { clearAllNotepads(); - setWorkspacesEnabled(false); }); describe('live notes are never persisted', () => { @@ -71,14 +69,11 @@ describe('live notes are never persisted', () => { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => void store.set(key, value), }; - saveSessionState(storage, 'dormouse.session', saved); + // The standalone Window wrapper re-nests the same Session, so it inherits + // the property rather than reintroducing notes. + saveWindowState(storage, 'dormouse.session', wrapSessionInWindow(saved!)); expect(store.get('dormouse.session')).not.toContain(SECRET); - // The standalone Window wrapper (workspaces flag on) re-nests the same - // Session, so it inherits the property rather than reintroducing notes. - setWorkspacesEnabled(true); - expect(JSON.stringify(storedValueForSession(null, saved))).not.toContain(SECRET); - // The one place the notes do live outside the store: host memory, cleared on // restart and never written to disk. expect(JSON.stringify(buildVolatileSnapshot())).toContain(SECRET); diff --git a/lib/src/lib/session-activity-store.ts b/lib/src/lib/session-activity-store.ts index 5b1a8d865..d0a11f039 100644 --- a/lib/src/lib/session-activity-store.ts +++ b/lib/src/lib/session-activity-store.ts @@ -30,7 +30,7 @@ export const DEFAULT_ACTIVITY_STATE: ActivityState = { ringSeq: 0, }; -const activityListeners = new Set<() => void>(); +const activityListeners = new Set<(changedId?: string) => void>(); let cachedSnapshot: Map | null = null; // Terminal activity keeps the same home before and after xterm initialization. @@ -42,12 +42,15 @@ const terminalActivity = new Map(); -export function notifyActivityListeners(): void { +/** `changedId` names the one Surface whose activity moved, so a listener scoped + * to a subset of the Window can ignore the rest. Omitting it means a store-wide + * change every listener must take. */ +export function notifyActivityListeners(changedId?: string): void { cachedSnapshot = null; - activityListeners.forEach((listener) => listener()); + activityListeners.forEach((listener) => listener(changedId)); } -export function subscribeToActivity(listener: () => void): () => void { +export function subscribeToActivity(listener: (changedId?: string) => void): () => void { activityListeners.add(listener); return () => activityListeners.delete(listener); } @@ -87,7 +90,7 @@ export function setTerminalActivity(id: string, state: Partial): voi state: { ...DEFAULT_ACTIVITY_STATE, ...activity }, attentionDismissedRing, }); - notifyActivityListeners(); + notifyActivityListeners(id); } /** Called after registry removal, or without an id to reset the terminal cache. */ @@ -98,7 +101,7 @@ export function clearTerminalActivity(id?: string): void { } else { terminalActivity.delete(id); } - notifyActivityListeners(); + notifyActivityListeners(id); } /** @@ -108,7 +111,7 @@ export function clearTerminalActivity(id?: string): void { */ export function clearLocalSurfaceActivity(id: string): void { if (!localSurfaceActivity.delete(id)) return; - notifyActivityListeners(); + notifyActivityListeners(id); } function setLocalSurfaceTodo(id: string, todo: boolean): void { @@ -118,7 +121,7 @@ function setLocalSurfaceTodo(id: string, todo: boolean): void { } localSurfaceActivity.set(id, { ...DEFAULT_ACTIVITY_STATE, todo: true }); - notifyActivityListeners(); + notifyActivityListeners(id); } /** diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index f61e55091..ffe115a8e 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -387,7 +387,7 @@ function setupTerminalEntry(id: string, options: { shell?: string; untouched?: b registry.set(id, entry); ensureTerminalPaneState(id); - notifyActivityListeners(); + notifyActivityListeners(id); startThemeObserver(); return entry; } diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 6b4fa8ba0..5836582d2 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -28,7 +28,7 @@ const promptAltScreenFilters = new Map(); // Panes with authentic OSC 633/133 boundaries; the keystroke fallback stands // down for each id here until the pane is reset or removed. const oscDrivenPanes = new Set(); -const listeners = new Set<() => void>(); +const listeners = new Set<(changedId?: string) => void>(); // Authentic shell boundaries; heuristic-synthesized prompt markers are excluded. function isOscDrivenBoundary(event: TerminalSemanticEvent): boolean { @@ -45,7 +45,9 @@ function isOscDrivenBoundary(event: TerminalSemanticEvent): boolean { } let cachedSnapshot: Map | null = null; -export function subscribeToTerminalPaneState(listener: () => void): () => void { +/** `changedId` names the one pane whose state moved; omitting it means a + * store-wide change every listener must take. */ +export function subscribeToTerminalPaneState(listener: (changedId?: string) => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); @@ -105,7 +107,7 @@ export function ensureTerminalPaneState(id: string, initial?: Partial): void { clearPaneScratch(id); paneStates.set(id, createTerminalPaneState(initial)); - notifyTerminalPaneStateListeners(); + notifyTerminalPaneStateListeners(id); } export function removeTerminalPaneState(id: string): void { clearPaneScratch(id); if (!paneStates.delete(id)) return; - notifyTerminalPaneStateListeners(); + notifyTerminalPaneStateListeners(id); } export function applyTerminalSemanticEvents( @@ -155,7 +157,7 @@ export function applyTerminalSemanticEvents( } if (next === prev && paneStates.has(id)) return; paneStates.set(id, next); - notifyTerminalPaneStateListeners(); + notifyTerminalPaneStateListeners(id); } // Reads the cursor's full rendered logical line (`prompt + command`) from the @@ -307,7 +309,7 @@ export function seedTerminalManualCwd(id: string, path: string | null | undefine } if (current.cwd) return; paneStates.set(id, { ...current, cwd }); - notifyTerminalPaneStateListeners(); + notifyTerminalPaneStateListeners(id); } export function fillTerminalProcessCwd(id: string, path: string | null | undefined): void { @@ -322,7 +324,7 @@ function updateCwdIfAllowed(id: string, cwd: CwdState): void { if (!current) return; if (!processCwdMayReplace(current.cwd?.source)) return; paneStates.set(id, { ...current, cwd }); - notifyTerminalPaneStateListeners(); + notifyTerminalPaneStateListeners(id); } // Detect a returned/idle shell prompt for shells without OSC 133/633 @@ -475,7 +477,7 @@ class PromptAltScreenFilter { } } -function notifyTerminalPaneStateListeners(): void { +function notifyTerminalPaneStateListeners(changedId?: string): void { cachedSnapshot = null; - listeners.forEach((listener) => listener()); + listeners.forEach((listener) => listener(changedId)); } diff --git a/lib/src/lib/window-persistence.test.ts b/lib/src/lib/window-persistence.test.ts index 965aa70ea..3e53c0fc4 100644 --- a/lib/src/lib/window-persistence.test.ts +++ b/lib/src/lib/window-persistence.test.ts @@ -1,147 +1,81 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { activeSessionFromStored, loadSessionState, saveSessionState, storedValueForSession } from './window-persistence'; +import { describe, expect, it, vi } from 'vitest'; +import { loadWindowState, saveWindowState, type SessionKeyValueStore } from './window-persistence'; import { DEFAULT_WORKSPACE_ID, + DEFAULT_WORKSPACE_NAME, wrapSessionInWindow, type PersistedSession, type PersistedWindow, } from './session-types'; -import { setWorkspacesEnabled } from './feature-flags'; -function stubLocalStorage(): void { - const store = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => (store.has(k) ? store.get(k)! : null), - setItem: (k: string, v: string) => store.set(k, v), - removeItem: (k: string) => store.delete(k), - }); +function memoryStore(seed?: string): SessionKeyValueStore & { value: () => string | null } { + let stored: string | null = seed ?? null; + return { + getItem: () => stored, + setItem: (_key, value) => { stored = value; }, + value: () => stored, + }; } const sessionA: PersistedSession = { version: 3, - panes: [{ id: 'pane-a', title: 'A', cwd: null, untouched: false }], + panes: [{ id: 'pane-a', title: 'A', cwd: '/a', untouched: false }], }; const sessionB: PersistedSession = { version: 3, - panes: [{ id: 'pane-b', title: 'B', cwd: null, untouched: false }], + panes: [{ id: 'pane-b', title: 'B', cwd: '/b', untouched: false }], }; -describe('window-persistence', () => { - beforeEach(stubLocalStorage); - afterEach(() => vi.unstubAllGlobals()); - - describe('flag off (passthrough — identical to today)', () => { - beforeEach(() => setWorkspacesEnabled(false)); - - it('load returns the stored value unchanged', () => { - expect(activeSessionFromStored(sessionA)).toBe(sessionA); - }); +const twoWorkspaces: PersistedWindow = { + version: 1, + workspaces: [ + { id: 'ws-1', name: 'One', session: sessionA }, + { id: 'ws-2', name: 'Two', session: sessionB }, + ], + activeWorkspaceId: 'ws-2', +}; - it('save returns the session unchanged (bare, not wrapped)', () => { - expect(storedValueForSession(null, sessionA)).toBe(sessionA); - }); +describe('window-persistence', () => { + it('round-trips a Window through the slot', () => { + const store = memoryStore(); + saveWindowState(store, 'k', twoWorkspaces); + expect(loadWindowState(store, 'k')).toEqual(twoWorkspaces); }); - describe('flag on (Window container)', () => { - beforeEach(() => setWorkspacesEnabled(true)); - - it('save wraps a fresh session into a single-Workspace Window', () => { - const stored = storedValueForSession(null, sessionA) as PersistedWindow; - expect(stored.version).toBe(1); - expect(stored.workspaces).toHaveLength(1); - expect(stored.activeWorkspaceId).toBe(DEFAULT_WORKSPACE_ID); - expect(stored.workspaces[0].session.panes[0].id).toBe('pane-a'); - }); - - it('round-trips: save then load yields the same active session', () => { - const stored = storedValueForSession(null, sessionA); - expect(activeSessionFromStored(stored)).toEqual(sessionA); - }); - - it('save replaces only the active Workspace, preserving the others', () => { - const existing: PersistedWindow = { - version: 1, - activeWorkspaceId: 'ws-b', - workspaces: [ - { id: 'ws-a', name: 'A', session: sessionA }, - { id: 'ws-b', name: 'B', session: sessionA }, - ], - }; - const stored = storedValueForSession(existing, sessionB) as PersistedWindow; - expect(stored.workspaces.find((w) => w.id === 'ws-a')!.session).toEqual(sessionA); - expect(stored.workspaces.find((w) => w.id === 'ws-b')!.session).toEqual(sessionB); - }); - - it('load returns the active Workspace session from a multi-Workspace Window', () => { - const win = wrapSessionInWindow(sessionA); - const multi: PersistedWindow = { - version: 1, - activeWorkspaceId: 'ws-b', - workspaces: [...win.workspaces, { id: 'ws-b', name: 'B', session: sessionB }], - }; - expect(activeSessionFromStored(multi)).toEqual(sessionB); - }); - - it('load returns null for unusable stored input', () => { - expect(activeSessionFromStored(null)).toBeNull(); - expect(activeSessionFromStored({ junk: true })).toBeNull(); + it('wraps a pre-Window blob as this Window\'s one Workspace', () => { + const store = memoryStore(JSON.stringify(sessionA)); + expect(loadWindowState(store, 'k')).toEqual({ + version: 1, + workspaces: [{ id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, session: sessionA }], + activeWorkspaceId: DEFAULT_WORKSPACE_ID, }); + expect(loadWindowState(store, 'k')).toEqual(wrapSessionInWindow(sessionA)); }); - describe('storage round trip (loadSessionState / saveSessionState)', () => { - function spyStorage(initial: string | null = null) { - let value = initial; - const getItem = vi.fn((_key: string) => value); - const setItem = vi.fn((_key: string, next: string) => { value = next; }); - const storage = { getItem, setItem, removeItem: vi.fn() } as unknown as Storage; - return { storage, getItem, setItem }; - } - - it('flag off: saves the bare session WITHOUT reading the existing blob', () => { - setWorkspacesEnabled(false); - const { storage, getItem, setItem } = spyStorage(JSON.stringify(sessionB)); - saveSessionState(storage, 'k', sessionA); - // The efficiency win: no wasted read/parse of the (scrollback-bearing) blob. - expect(getItem).not.toHaveBeenCalled(); - expect(JSON.parse(setItem.mock.calls[0]![1])).toEqual(sessionA); - }); - - it('flag off: load returns the bare stored session', () => { - setWorkspacesEnabled(false); - const { storage } = spyStorage(JSON.stringify(sessionA)); - expect(loadSessionState(storage, 'k')).toEqual(sessionA); - }); - - it('flag on: save wraps into a Window and load round-trips the active session', () => { - setWorkspacesEnabled(true); - const { storage } = spyStorage(); - saveSessionState(storage, 'k', sessionA); - const stored = JSON.parse((storage.getItem('k'))!) as PersistedWindow; - expect(stored.version).toBe(1); - expect(stored.activeWorkspaceId).toBe(DEFAULT_WORKSPACE_ID); - expect(loadSessionState(storage, 'k')).toEqual(sessionA); - }); - - it('flag on: save preserves other Workspaces by reading the existing Window', () => { - setWorkspacesEnabled(true); - const existing: PersistedWindow = { - version: 1, - activeWorkspaceId: 'ws-b', - workspaces: [ - { id: 'ws-a', name: 'A', session: sessionA }, - { id: 'ws-b', name: 'B', session: sessionA }, - ], - }; - const { storage } = spyStorage(JSON.stringify(existing)); - saveSessionState(storage, 'k', sessionB); - const stored = JSON.parse((storage.getItem('k'))!) as PersistedWindow; - expect(stored.workspaces.find((w) => w.id === 'ws-a')!.session).toEqual(sessionA); - expect(stored.workspaces.find((w) => w.id === 'ws-b')!.session).toEqual(sessionB); - }); + it('starts fresh on an absent, corrupt, or unrecognizable blob', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(loadWindowState(memoryStore(), 'k')).toBeNull(); + expect(loadWindowState(memoryStore('{not json'), 'k')).toBeNull(); + expect(loadWindowState(memoryStore('{"version":99}'), 'k')).toBeNull(); + // A v3 blob that fails its own guard is not retried as a Window. + expect(loadWindowState(memoryStore('{"version":3,"panes":"nope"}'), 'k')).toBeNull(); + warn.mockRestore(); + }); - it('load returns null when storage is empty', () => { - const { storage } = spyStorage(null); - expect(loadSessionState(storage, 'k')).toBeNull(); - }); + it('drops an unreadable Workspace instead of the whole Window', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = memoryStore(JSON.stringify({ + version: 1, + workspaces: [ + { id: 'ws-1', name: 'One', session: sessionA }, + { id: 'ws-2', name: 'Two', session: { version: 3, panes: 'nope' } }, + ], + activeWorkspaceId: 'ws-2', + })); + const loaded = loadWindowState(store, 'k'); + expect(loaded?.workspaces.map((ws) => ws.id)).toEqual(['ws-1']); + // The dangling active id is repaired to a Workspace that survived. + expect(loaded?.activeWorkspaceId).toBe('ws-1'); + warn.mockRestore(); }); }); diff --git a/lib/src/lib/window-persistence.ts b/lib/src/lib/window-persistence.ts index c064a7432..7b11c97ee 100644 --- a/lib/src/lib/window-persistence.ts +++ b/lib/src/lib/window-persistence.ts @@ -1,86 +1,60 @@ -import { isWorkspacesEnabled } from './feature-flags'; +import { isRecord } from './is-record'; import { - activeWorkspaceSession, readPersistedSession, readPersistedWindow, - replaceActiveSession, wrapSessionInWindow, + type PersistedWindow, } from './session-types'; /** - * Translate between the standalone host's stored top-level blob and the bare - * `PersistedSession` the shared persistence code (`reconnect.ts`, - * `session-save.ts`) operates on (stage 2b). + * The standalone host's stored top-level blob is a `PersistedWindow` + * (`docs/specs/transport.md` → "Persisted session"). These two functions own the + * JSON and the storage slot; the Workspace-level composition is the aggregator's + * (`lib/src/lib/window-session-aggregator.ts`). * - * With the workspaces flag **off** these are identity passthroughs, so the host - * stores and restores a bare `PersistedSession` exactly as before. With the flag - * **on**, the stored blob is a `PersistedWindow`; load returns the active - * Workspace's session, and save merges the new session back into the active - * Workspace slot while preserving every other Workspace. - * - * The flag is read per call, so toggling it mid-run is consistent within a save - * or load. (Turning the flag off while a Window is stored makes that blob look - * unparseable to the bare-session reader — acceptable for a dev-only flag.) - * - * Source of truth: `docs/specs/transport.md`. VS Code does not use this — it - * persists one bare `PersistedSession` per webview. + * VS Code does not use this — it persists one bare `PersistedSession` per + * webview through the extension host's own state APIs. */ -/** Parsed stored blob → the `PersistedSession` to restore (or null). */ -export function activeSessionFromStored(stored: unknown): unknown { - if (!isWorkspacesEnabled()) return stored; - const window = readPersistedWindow(stored); - return window ? activeWorkspaceSession(window) : null; -} - -/** Existing stored blob + new active session → the blob to store. */ -export function storedValueForSession(existingStored: unknown, session: unknown): unknown { - if (!isWorkspacesEnabled()) return session; - const next = readPersistedSession(session); - if (!next) return session; - const existingWindow = readPersistedWindow(existingStored); - return existingWindow ? replaceActiveSession(existingWindow, next) : wrapSessionInWindow(next); -} - /** * The seam below the shared save/restore code: a single synchronous key/value * slot the host persists natively. `localStorage` (browser-dev sidecar) and the * standalone `TauriSessionStore` (a Rust-backed, boot-seeded cache) both satisfy * it — the same interface, two host-native backings (`docs/specs/standalone.md` * §Persistence). `Storage` is a structural superset, so passing `localStorage` - * still type-checks. VS Code does not go through here; it persists one bare - * `PersistedSession` per webview through the extension host's own state APIs. + * still type-checks. */ export interface SessionKeyValueStore { getItem(key: string): string | null; setItem(key: string, value: string): void; } -// Storage-level round trip shared by the standalone adapters (Tauri + the -// browser-dev sidecar). Owns the JSON parse/stringify and the store access -// so each adapter's get/save collapses to one call instead of re-implementing -// the read-merge-write dance. - -/** Read the stored blob and return the `PersistedSession` to restore (or null). A - * corrupt (unparseable) blob is discarded, so a bad save can never block startup. */ -export function loadSessionState(storage: SessionKeyValueStore, key: string): unknown { +/** + * Read the stored Window, or null when nothing readable is there. A corrupt blob + * is discarded so a bad save can never block startup. + * + * A blob written before standalone persisted Windows is a bare + * `PersistedSession`; it is wrapped as this Window's one Workspace. That is the + * only migration — every write since is a Window. + */ +export function loadWindowState(storage: SessionKeyValueStore, key: string): PersistedWindow | null { const raw = storage.getItem(key); if (raw === null) return null; - return activeSessionFromStored(parseStoredJson(raw)); + const parsed = parseStoredJson(raw); + if (parsed === null) return null; + // Dispatch on the version discriminator rather than trying both readers: each + // one warns on a shape it does not recognize, and a Window handed to the + // Session reader would warn on every boot. + if (isRecord(parsed) && parsed.version === 3) { + const legacy = readPersistedSession(parsed); + return legacy ? wrapSessionInWindow(legacy) : null; + } + return readPersistedWindow(parsed); } -/** Persist `session` under `key`, merging into the active Workspace when the flag is on. */ -export function saveSessionState(storage: SessionKeyValueStore, key: string, session: unknown): void { - // Flag off (the default): store the bare session without reading the existing - // blob — its previous value is irrelevant, so skip parsing the (potentially - // large, scrollback-bearing) stored snapshot. - if (!isWorkspacesEnabled()) { - storage.setItem(key, JSON.stringify(session)); - return; - } - const raw = storage.getItem(key); - const existing = raw === null ? null : parseStoredJson(raw); - storage.setItem(key, JSON.stringify(storedValueForSession(existing, session))); +/** Persist `window` under `key`. */ +export function saveWindowState(storage: SessionKeyValueStore, key: string, window: PersistedWindow): void { + storage.setItem(key, JSON.stringify(window)); } /** Parse a stored JSON blob, or null when it is corrupt — a bad blob degrades to a diff --git a/lib/src/lib/window-session-aggregator.test.ts b/lib/src/lib/window-session-aggregator.test.ts index 16956110f..5aba57732 100644 --- a/lib/src/lib/window-session-aggregator.test.ts +++ b/lib/src/lib/window-session-aggregator.test.ts @@ -1,15 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + adoptWorkspaceSession, + flushWindowSession, forgetWorkspaceSession, getWindowSnapshot, installWindowSessionWriter, + previousWorkspaceSession, publishWorkspaceSession, resetWindowSessionAggregator, + seedWindowSession, } from './window-session-aggregator'; -import type { PersistedSession } from './session-types'; +import type { PersistedSession, PersistedWindow } from './session-types'; import { createWorkspace, moveWorkspace, + renameWorkspace, resetWorkspaces, setActiveWorkspace, getWorkspacesSnapshot, @@ -19,11 +24,21 @@ function session(paneId: string): PersistedSession { return { version: 3, panes: [{ id: paneId, title: paneId, cwd: null, untouched: true, alert: null }], doors: [] }; } +/** Run the debounce out and let the writer's own promise settle. */ +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(500); +} + beforeEach(() => { + vi.useFakeTimers(); resetWindowSessionAggregator(); resetWorkspaces(); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe('window session aggregator', () => { it('orders Workspaces by the store and carries the active id', () => { const first = getWorkspacesSnapshot().workspaces[0].id; @@ -43,38 +58,149 @@ describe('window session aggregator', () => { expect(getWindowSnapshot().activeWorkspaceId).toBe(first); }); - it('drops a Workspace that has published nothing rather than writing it empty', () => { + it('drops a Workspace with neither a published nor a seeded session', () => { const first = getWorkspacesSnapshot().workspaces[0].id; createWorkspace({ name: 'Second' }); publishWorkspaceSession(first, session('a')); expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([first]); }); - it('forgets a Workspace session', () => { + it('forgets a Workspace session and its seed', () => { const first = getWorkspacesSnapshot().workspaces[0].id; + seedWindowSession({ version: 1, workspaces: [{ id: first, name: 'One', session: session('seed') }], activeWorkspaceId: first }); publishWorkspaceSession(first, session('a')); forgetWorkspaceSession(first); expect(getWindowSnapshot().workspaces).toEqual([]); + expect(previousWorkspaceSession(first)).toBeNull(); }); - it('hands each snapshot to the installed writer until it is uninstalled', () => { - const write = vi.fn(); - const uninstall = installWindowSessionWriter(write); - const first = getWorkspacesSnapshot().workspaces[0].id; - publishWorkspaceSession(first, session('a')); - expect(write).toHaveBeenCalledTimes(1); - expect(write.mock.calls[0][0].workspaces).toHaveLength(1); - forgetWorkspaceSession(first); - expect(write).toHaveBeenCalledTimes(2); - forgetWorkspaceSession(first); - expect(write).toHaveBeenCalledTimes(2); - uninstall(); - publishWorkspaceSession(first, session('a')); - expect(write).toHaveBeenCalledTimes(2); + describe('seed', () => { + const first = () => getWorkspacesSnapshot().workspaces[0].id; + + it('answers for a Workspace whose Wall has not published yet', () => { + const second = createWorkspace({ id: 'ws-2', name: 'Second' }).id; + const seeded: PersistedWindow = { + version: 1, + workspaces: [ + { id: first(), name: 'One', session: session('a-seed') }, + { id: second, name: 'Second', session: session('b-seed') }, + ], + activeWorkspaceId: second, + }; + seedWindowSession(seeded); + + // Both Workspaces are in the snapshot before any Wall mounts, so a write + // taken mid-boot cannot replace a restored Workspace with a blank one. + expect(getWindowSnapshot().workspaces.map((ws) => ws.session.panes[0].id)) + .toEqual(['a-seed', 'b-seed']); + expect(previousWorkspaceSession(second)?.panes[0].id).toBe('b-seed'); + + // A publish takes over for that Workspace only. + publishWorkspaceSession(second, session('b-live')); + expect(previousWorkspaceSession(second)?.panes[0].id).toBe('b-live'); + expect(previousWorkspaceSession(first())?.panes[0].id).toBe('a-seed'); + }); + + it('replaces the previous seed, and null clears it', () => { + seedWindowSession({ version: 1, workspaces: [{ id: first(), name: 'One', session: session('old') }], activeWorkspaceId: first() }); + seedWindowSession(null); + expect(previousWorkspaceSession(first())).toBeNull(); + expect(getWindowSnapshot().workspaces).toEqual([]); + }); + + it('adopts a record from elsewhere as that Workspace\'s previous', () => { + const second = createWorkspace({ id: 'ws-2', name: 'Second' }).id; + publishWorkspaceSession(second, session('stale')); + adoptWorkspaceSession(second, session('moved-in')); + expect(previousWorkspaceSession(second)?.panes[0].id).toBe('moved-in'); + }); }); - it('ships with no writer installed', () => { - const first = getWorkspacesSnapshot().workspaces[0].id; - expect(() => publishWorkspaceSession(first, session('a'))).not.toThrow(); + describe('writer', () => { + it('debounces publishes into one write and flushes on demand', async () => { + const write = vi.fn(); + installWindowSessionWriter(write); + const first = getWorkspacesSnapshot().workspaces[0].id; + const second = createWorkspace({ name: 'Second' }).id; + + publishWorkspaceSession(first, session('a')); + publishWorkspaceSession(second, session('b')); + expect(write).not.toHaveBeenCalled(); + + await settle(); + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].workspaces).toHaveLength(2); + + // Flush writes immediately and leaves nothing pending behind it. + publishWorkspaceSession(first, session('a2')); + await flushWindowSession(); + expect(write).toHaveBeenCalledTimes(2); + await settle(); + expect(write).toHaveBeenCalledTimes(2); + }); + + it('awaits the host write a flush starts', async () => { + let resolveWrite = () => {}; + const write = vi.fn(() => new Promise((resolve) => { resolveWrite = resolve; })); + installWindowSessionWriter(write); + publishWorkspaceSession(getWorkspacesSnapshot().workspaces[0].id, session('a')); + + let done = false; + const flushed = flushWindowSession().then(() => { done = true; }); + await vi.advanceTimersByTimeAsync(0); + expect(done).toBe(false); + resolveWrite(); + await flushed; + expect(done).toBe(true); + }); + + it('writes on a Workspace-store change with no session change', async () => { + const write = vi.fn(); + const first = getWorkspacesSnapshot().workspaces[0].id; + publishWorkspaceSession(first, session('a')); + installWindowSessionWriter(write); + + renameWorkspace(first, 'Renamed'); + await settle(); + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].workspaces[0].name).toBe('Renamed'); + + const second = createWorkspace({ name: 'Second' }).id; + setActiveWorkspace(second); + await settle(); + expect(write.mock.calls.at(-1)?.[0].activeWorkspaceId).toBe(second); + }); + + it('stops writing once uninstalled, pending timer included', async () => { + const write = vi.fn(); + const uninstall = installWindowSessionWriter(write); + const first = getWorkspacesSnapshot().workspaces[0].id; + + publishWorkspaceSession(first, session('a')); + uninstall(); + await settle(); + expect(write).not.toHaveBeenCalled(); + + publishWorkspaceSession(first, session('b')); + await settle(); + expect(write).not.toHaveBeenCalled(); + // The store subscription goes with it. + renameWorkspace(first, 'Renamed'); + await settle(); + expect(write).not.toHaveBeenCalled(); + }); + + it('ships with no writer installed', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + expect(() => publishWorkspaceSession(first, session('a'))).not.toThrow(); + await expect(flushWindowSession()).resolves.toBeUndefined(); + }); + + it('survives a rejecting host write', async () => { + const write = vi.fn(() => Promise.reject(new Error('disk full'))); + installWindowSessionWriter(write); + publishWorkspaceSession(getWorkspacesSnapshot().workspaces[0].id, session('a')); + await expect(flushWindowSession()).resolves.toBeUndefined(); + }); }); }); diff --git a/lib/src/lib/window-session-aggregator.ts b/lib/src/lib/window-session-aggregator.ts index 14dd4ac39..f367b2c2f 100644 --- a/lib/src/lib/window-session-aggregator.ts +++ b/lib/src/lib/window-session-aggregator.ts @@ -1,60 +1,153 @@ -import { getWorkspacesSnapshot } from './workspace-store'; +import { getWorkspacesSnapshot, subscribeToWorkspaces } from './workspace-store'; import type { PersistedSession, PersistedWindow, PersistedWorkspace, WorkspaceId } from './session-types'; /** * Collects each Workspace's latest `PersistedSession` into one `PersistedWindow` - * (`docs/specs/transport.md` → "Persisted session"). The Wall's persistence hook - * publishes here instead of writing the platform slot when it runs under a - * Workspace; the writer that turns snapshots into a host write is installed - * separately, and standalone installs none yet. The push path is deliberately - * complete ahead of its consumer: standalone persistence installs the writer and - * retires `window-persistence.ts`'s flag-gated merge (`docs/specs/layout.md` → - * "Future"). + * and hands it to the host (`docs/specs/transport.md` → "Persisted session"). + * The Wall's persistence hook publishes here instead of writing the platform + * slot when it runs under a Workspace; the standalone boot installs the writer. + * + * Two maps, because a Workspace's record has two possible ages. `published` is + * what its Wall has saved this run. `seeded` is what the last run left on disk, + * which is the answer until that Wall has saved anything — it is what keeps a + * Window snapshot taken mid-boot from replacing a restored Workspace with a + * blank one, and it is what a save reads its retained `cwd` out of + * (`previousWorkspaceSession`). */ -const sessions = new Map(); -let writer: ((snapshot: PersistedWindow) => void) | null = null; +const published = new Map(); +const seeded = new Map(); +let writer: ((snapshot: PersistedWindow) => void | Promise) | null = null; +let unsubscribeWorkspaces: (() => void) | null = null; +let timer: ReturnType | null = null; +let inFlight: Promise | null = null; -/** Record a Workspace's latest session and hand the whole Window to the writer. */ +/** How long the Window blob waits for its Workspaces to settle. Each Wall + * already debounces its own record, so this is what collapses N Workspaces + * reacting to one event into a single host write. */ +const WRITE_DEBOUNCE_MS = 500; + +/** + * The record on disk for every Workspace this Window is restoring, installed at + * boot before any Wall mounts. Replaces whatever was seeded before; `null` + * clears it (a fresh Window). + */ +export function seedWindowSession(window: PersistedWindow | null): void { + seeded.clear(); + for (const workspace of window?.workspaces ?? []) seeded.set(workspace.id, workspace.session); +} + +/** Record a Workspace's latest session and schedule the Window write. */ export function publishWorkspaceSession(workspaceId: WorkspaceId, session: PersistedSession): void { - sessions.set(workspaceId, session); - writer?.(getWindowSnapshot()); + published.set(workspaceId, session); + scheduleWrite(); +} + +/** + * Install a record for a Workspace whose Wall is not the one that built it — a + * Workspace moving in from another Window. It stands as that Workspace's + * previous record until its new Wall publishes, exactly as a seed does. + */ +export function adoptWorkspaceSession(workspaceId: WorkspaceId, session: PersistedSession): void { + seeded.set(workspaceId, session); + published.delete(workspaceId); + scheduleWrite(); } /** Drop a Workspace's session (its Workspace was closed or moved away). */ export function forgetWorkspaceSession(workspaceId: WorkspaceId): void { - if (!sessions.delete(workspaceId)) return; - writer?.(getWindowSnapshot()); + const had = published.delete(workspaceId); + if (!seeded.delete(workspaceId) && !had) return; + scheduleWrite(); +} + +/** + * This Workspace's last persisted record — what its Wall published, or what boot + * seeded until then. A save's previous-pane map reads a dead PTY's retained + * `cwd` and `alert` out of it, so answering with the Window's active Workspace + * (or with nothing) would drop them on the first save after a restore. + */ +export function previousWorkspaceSession(workspaceId: WorkspaceId): PersistedSession | null { + return published.get(workspaceId) ?? seeded.get(workspaceId) ?? null; } /** * The Window as it stands: Workspaces in strip order carrying the id, name, and - * latest published session of each. A Workspace whose Wall has published nothing - * yet is omitted rather than written empty, so a crash mid-boot cannot replace a - * restored layout with a blank one. + * latest session of each. A Workspace with neither a published nor a seeded + * session is omitted rather than written empty. */ export function getWindowSnapshot(): PersistedWindow { const { workspaces, activeId } = getWorkspacesSnapshot(); const collected: PersistedWorkspace[] = []; for (const workspace of workspaces) { - const session = sessions.get(workspace.id); + const session = previousWorkspaceSession(workspace.id); if (!session) continue; collected.push({ id: workspace.id, name: workspace.name, session }); } return { version: 1, workspaces: collected, activeWorkspaceId: activeId }; } -/** Install the sink that persists a Window snapshot; returns its uninstaller. - * Replace-on-repeat: only one writer is live, so a re-install cannot double-write. */ -export function installWindowSessionWriter(write: (snapshot: PersistedWindow) => void): () => void { +/** + * Install the sink that persists a Window snapshot; returns its uninstaller. + * Replace-on-repeat: only one writer is live, so a re-install cannot double-write. + * + * Installing also subscribes to the Workspace store, because reordering, + * renaming, and switching the active Workspace all change the blob without any + * Session changing. + */ +export function installWindowSessionWriter(write: (snapshot: PersistedWindow) => void | Promise): () => void { writer = write; + unsubscribeWorkspaces?.(); + unsubscribeWorkspaces = subscribeToWorkspaces(scheduleWrite); return () => { - if (writer === write) writer = null; + if (writer !== write) return; + writer = null; + unsubscribeWorkspaces?.(); + unsubscribeWorkspaces = null; + cancelPending(); }; } -/** Forget every published session and any installed writer (tests). */ +/** + * Write now and resolve when the host has taken the snapshot. The quit teardown's + * step between the last Wall flush and the host's own drain — a debounce timer + * still pending at exit would otherwise lose the final save. + */ +export async function flushWindowSession(): Promise { + cancelPending(); + writeNow(); + // Not a loop: `writeNow` is synchronous up to the writer's own promise, and + // nothing schedules behind it once the timer is cancelled. + await inFlight; +} + +function scheduleWrite(): void { + if (!writer || timer) return; + timer = setTimeout(() => { + timer = null; + writeNow(); + }, WRITE_DEBOUNCE_MS); +} + +function writeNow(): void { + if (!writer) return; + const result = writer(getWindowSnapshot()); + inFlight = result ? Promise.resolve(result).catch(() => undefined) : null; +} + +function cancelPending(): void { + if (!timer) return; + clearTimeout(timer); + timer = null; +} + +/** Forget every session, seed, and installed writer (tests). */ export function resetWindowSessionAggregator(): void { - sessions.clear(); + published.clear(); + seeded.clear(); writer = null; + unsubscribeWorkspaces?.(); + unsubscribeWorkspaces = null; + cancelPending(); + inFlight = null; } diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 4384f3238..163043c12 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -34,7 +34,8 @@ import type { AwaitHandle, AwaitOptions } from "dormouse-lib/lib/alert-manager"; import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { createMemoryNotepadArchivePort } from "dormouse-lib/lib/notepad/memory-archive-port"; -import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; +import { loadWindowState, saveWindowState } from "dormouse-lib/lib/window-persistence"; +import type { PersistedWindow } from "dormouse-lib/lib/session-types"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -278,19 +279,24 @@ export class BrowserSidecarAdapter implements PlatformAdapter { 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). + // See TauriAdapter: one `PersistedWindow` per window, in `localStorage` rather + // than the Rust file store (docs/specs/transport.md). saveState(state: unknown): void { if (!BrowserSidecarAdapter.PERSIST_SESSION) return; - try { saveSessionState(localStorage, BrowserSidecarAdapter.STATE_KEY, state); } + try { saveWindowState(localStorage, BrowserSidecarAdapter.STATE_KEY, state as PersistedWindow); } catch { console.error('[browser-sidecar] Failed to save session state'); } } + /** See TauriAdapter.getState: the blob here is a Window, and the boot reads it + * through `getWindowState`. */ getState(): unknown { + return null; + } + + getWindowState(): PersistedWindow | null { if (!BrowserSidecarAdapter.PERSIST_SESSION) return null; try { - return loadSessionState(localStorage, BrowserSidecarAdapter.STATE_KEY); + return loadWindowState(localStorage, BrowserSidecarAdapter.STATE_KEY); } catch { return null; } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 0a051c231..fd61fe0c2 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -41,7 +41,8 @@ import { AlertManager } from "dormouse-lib/lib/alert-manager"; import type { AwaitHandle, AwaitOptions } from "dormouse-lib/lib/alert-manager"; import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; -import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; +import { loadWindowState, saveWindowState } from "dormouse-lib/lib/window-persistence"; +import type { PersistedWindow } from "dormouse-lib/lib/session-types"; import { TauriSessionStore } from "./tauri-session-store"; import { withTimeout } from "./with-timeout"; import { @@ -611,19 +612,30 @@ export class TauriAdapter implements PlatformAdapter { */ readonly persistsSession = TauriAdapter.PERSIST_SESSION; + /** The aggregator's writer: one `PersistedWindow` per window + * (`docs/specs/transport.md` -> "Persisted session"). */ saveState(state: unknown): void { if (!TauriAdapter.PERSIST_SESSION) return; try { - saveSessionState(this.sessionStore, TauriAdapter.STATE_KEY, state); + saveWindowState(this.sessionStore, TauriAdapter.STATE_KEY, state as PersistedWindow); } catch { console.error('[tauri-adapter] Failed to save session state'); } } + /** The stored blob here is a Window, and the shared readers of `getState` want a + * bare Session — so this answers nothing and `getWindowState` is the reader. + * Standalone boots per Workspace, handing each plan its own record, so no + * shared caller reaches this (`standalone/src/main.tsx`). */ getState(): unknown { + return null; + } + + /** The persisted Window, read from the boot-seeded cache. */ + getWindowState(): PersistedWindow | null { if (!TauriAdapter.PERSIST_SESSION) return null; try { - return loadSessionState(this.sessionStore, TauriAdapter.STATE_KEY); + return loadWindowState(this.sessionStore, TauriAdapter.STATE_KEY); } catch { return null; } From de947749e05fb6710f6a8d78ceb3c53283549d7f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:33:10 -0700 Subject: [PATCH 03/11] Lift the agent-recovery capture machine into lib/src/host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The press-wait-press machine never needed anything host-specific: interrupt, a monotonic received count, the output since a mark, and the live id set. It now lives in `lib/src/host/recovery-capture.ts` over that four-method port, with every rule and constant carried across, and reports each detected invocation the moment it is found rather than owning a file. `lib/src/host/recovery-store.ts` is the record half for a Node-resident host: begin-capture clears once per process and merges after (a second window must not wipe the first), writes are owner-only and temp-then-rename, and `take` is a single-use destructive read with a 7-day expiry, a shape guard, and null-prototype maps. No directory means memory-only with one warning. `recovery.ts` is the pair, for the sidecar bundle. VS Code's `captureAgentRecoveryCommands` becomes an adapter over the lifted machine and keeps only what is its own: where the record lives and how it is written. Its behavior is unchanged. The capture is now pinned by `recovery-capture.test.ts` on a virtual clock — `session-state.test.ts` covers alert persistence only and never exercised the machine, so this is its first real net. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/host/recovery-capture.test.ts | 195 ++++++++++++++++++++++ lib/src/host/recovery-capture.ts | 225 ++++++++++++++++++++++++++ lib/src/host/recovery-store.test.ts | 131 +++++++++++++++ lib/src/host/recovery-store.ts | 176 ++++++++++++++++++++ lib/src/host/recovery.ts | 15 ++ vscode-ext/src/session-state.ts | 188 ++++----------------- 6 files changed, 770 insertions(+), 160 deletions(-) create mode 100644 lib/src/host/recovery-capture.test.ts create mode 100644 lib/src/host/recovery-capture.ts create mode 100644 lib/src/host/recovery-store.test.ts create mode 100644 lib/src/host/recovery-store.ts create mode 100644 lib/src/host/recovery.ts diff --git a/lib/src/host/recovery-capture.test.ts b/lib/src/host/recovery-capture.test.ts new file mode 100644 index 000000000..8a7e6e7af --- /dev/null +++ b/lib/src/host/recovery-capture.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; +import { + BLIND_SECOND_PRESS_MS, + QUIET_BEFORE_RETRY_MS, + captureAgentRecovery, + type RecoveryHost, +} from './recovery-capture'; + +/** + * A PTY host on a virtual clock. `sleep` is the only thing that moves time, so + * the whole capture runs synchronously-fast while the second-press rules see + * exactly the delays a real agent would produce. + */ +class FakePtys implements RecoveryHost { + time = 0; + /** Every `^C` batch, in order. */ + readonly presses: string[][] = []; + readonly found: Record = {}; + private readonly text = new Map(); + private readonly count = new Map(); + private queue: Array<{ due: number; id: string; data: string }> = []; + private reactions = new Map void>>(); + + constructor(private live: string[]) { + for (const id of live) { this.text.set(id, ''); this.count.set(id, 0); } + } + + /** Pre-existing output, received before the capture takes its mark. */ + seed(id: string, data: string): this { + this.emitNow(id, data); + return this; + } + + /** `data` arrives `delay` ms after the pane's `press`-th `^C`. */ + onPress(id: string, press: number, delay: number, data: string): this { + const list = this.reactions.get(id) ?? []; + list.push((n) => { if (n === press) this.queue.push({ due: this.time + delay, id, data }); }); + this.reactions.set(id, list); + return this; + } + + exit(id: string): this { + this.live = this.live.filter((other) => other !== id); + return this; + } + + liveIds(): string[] { return [...this.live]; } + + async interrupt(ids: string[]): Promise { + this.presses.push([...ids]); + for (const id of ids) { + const n = this.presses.filter((batch) => batch.includes(id)).length; + for (const react of this.reactions.get(id) ?? []) react(n); + } + } + + receivedChars(id: string): number { return this.count.get(id) ?? 0; } + + outputSince(id: string, mark: number): string { + // No eviction in the fake: the buffer holds everything, so a mark always + // resolves exactly. + const received = this.count.get(id) ?? 0; + if (mark >= received) return ''; + return (this.text.get(id) ?? '').slice(mark); + } + + onCommand(id: string, command: string): void { this.found[id] = command; } + + now(): number { return this.time; } + + async sleep(ms: number): Promise { + this.time += ms; + const due = this.queue.filter((item) => item.due <= this.time); + this.queue = this.queue.filter((item) => item.due > this.time); + for (const item of due) this.emitNow(item.id, item.data); + } + + private emitNow(id: string, data: string): void { + this.text.set(id, (this.text.get(id) ?? '') + data); + this.count.set(id, (this.count.get(id) ?? 0) + data.length); + } +} + +const CLAUDE_HINT = 'claude --resume 01JABCDEF'; +const CODEX_HINT = 'codex resume 01HXYZ'; + +describe('captureAgentRecovery', () => { + it('presses every live pane once and reports each hint as it arrives', async () => { + const host = new FakePtys(['a', 'b']) + .onPress('a', 1, 80, `\r\nResume with \`${CLAUDE_HINT}\`.\r\n`) + .onPress('b', 1, 240, `\r\nRun ${CODEX_HINT} to continue\r\n`); + + const found = await captureAgentRecovery(host); + + expect(host.presses[0].sort()).toEqual(['a', 'b']); + expect(found).toBe(2); + expect(host.found).toEqual({ a: CLAUDE_HINT, b: CODEX_HINT }); + }); + + it('presses again as soon as a pane asks, through its TUI escapes', async () => { + // claude renders the prompt inside its TUI, so the raw bytes carry escapes + // through the phrase — the ask gate strips before it matches. + const ask = 'Press Ctrl-C again to exit'; + const host = new FakePtys(['a']) + .onPress('a', 1, 40, ask) + .onPress('a', 2, 40, `\r\n${CLAUDE_HINT}\r\n`); + + await captureAgentRecovery(host); + + // Second press well inside the blind window, so the ask is what triggered it. + expect(host.presses).toHaveLength(2); + expect(host.presses[1]).toEqual(['a']); + expect(host.found.a).toBe(CLAUDE_HINT); + }); + + it('waits for both fallback clocks before pressing a silent pane again', async () => { + const host = new FakePtys(['a']); + // Chatty right up to just before the blind window closes, so `quietFor` is + // what holds the second press back after `elapsed` has passed. + for (let at = 40; at <= BLIND_SECOND_PRESS_MS; at += 40) host.onPress('a', 1, at, '.'); + host.onPress('a', 2, 40, `\r\n${CLAUDE_HINT}\r\n`); + + await captureAgentRecovery(host); + + expect(host.presses).toHaveLength(2); + // Not at BLIND_SECOND_PRESS_MS: the pane was still printing, and a press + // landing mid-print destroys the hint. + const secondPressAt = host.time; + expect(secondPressAt).toBeGreaterThanOrEqual(BLIND_SECOND_PRESS_MS + QUIET_BEFORE_RETRY_MS); + }); + + it('never presses a pane that already yielded, and presses each pane at most twice', async () => { + const host = new FakePtys(['quick', 'silent']) + .onPress('quick', 1, 40, `\r\n${CLAUDE_HINT}\r\n`); + + await captureAgentRecovery(host); + + expect(host.presses[0].sort()).toEqual(['quick', 'silent']); + // Every later press is the silent pane's alone, and there is only one. + expect(host.presses.slice(1)).toEqual([['silent']]); + }); + + it('never presses an exited pane', async () => { + const host = new FakePtys(['alive', 'gone']).exit('gone'); + await captureAgentRecovery(host); + expect(host.presses.every((batch) => !batch.includes('gone'))).toBe(true); + }); + + it('does nothing at all when no pane is live', async () => { + const host = new FakePtys([]); + expect(await captureAgentRecovery(host)).toBe(0); + expect(host.presses).toEqual([]); + }); + + it('reads only bytes received after its own mark', async () => { + // A hint from a PREVIOUS run, sitting in the buffer before the capture starts. + const host = new FakePtys(['a']).seed('a', `\r\nold: claude --resume STALE0000\r\n`); + await captureAgentRecovery(host); + expect(host.found).toEqual({}); + }); + + it('does not finish early on quiet — a pane that speaks late is still caught', async () => { + // codex says nothing for ~250ms after the interrupt and then prints its whole + // shutdown at once; settling on the gap loses it. + const host = new FakePtys(['a']).onPress('a', 1, 1_000, `\r\n${CODEX_HINT}\r\n`); + expect(await captureAgentRecovery(host)).toBe(1); + expect(host.found.a).toBe(CODEX_HINT); + }); + + it('stops at the ceiling and reports what it has', async () => { + const host = new FakePtys(['a', 'b']) + .onPress('a', 1, 40, `\r\n${CLAUDE_HINT}\r\n`); + // 'b' never answers at all. + expect(await captureAgentRecovery(host, { maxWaitMs: 300 })).toBe(1); + expect(host.time).toBeLessThan(600); + expect(host.found).toEqual({ a: CLAUDE_HINT }); + }); + + it('exits as soon as every pane has yielded', async () => { + const host = new FakePtys(['a']).onPress('a', 1, 40, `\r\n${CLAUDE_HINT}\r\n`); + await captureAgentRecovery(host, { maxWaitMs: 10_000 }); + expect(host.time).toBeLessThanOrEqual(80); + }); + + it('restricts the capture to the ids it is given', async () => { + const host = new FakePtys(['a', 'b']) + .onPress('a', 1, 40, `\r\n${CLAUDE_HINT}\r\n`) + .onPress('b', 1, 40, `\r\n${CODEX_HINT}\r\n`); + + await captureAgentRecovery(host, { ids: ['a'] }); + + expect(host.presses.flat()).toEqual(['a']); + expect(host.found).toEqual({ a: CLAUDE_HINT }); + }); +}); diff --git a/lib/src/host/recovery-capture.ts b/lib/src/host/recovery-capture.ts new file mode 100644 index 000000000..121ef7f2e --- /dev/null +++ b/lib/src/host/recovery-capture.ts @@ -0,0 +1,225 @@ +/** + * Interrupt the live PTYs, then detect each pane's agent resume invocation. + * + * The press-wait-press machine, host-agnostic: the VS Code extension host runs it + * over `vscode-ext/src/pty-manager.ts`, the Tauri sidecar over `pty-core.js`. + * Both reach it through the same four primitives — interrupt, a monotonic + * received count, the output since a mark, and the live id set — because that is + * all the detection ever needed (docs/specs/vscode.md -> "Capturing agent + * recovery", docs/specs/standalone.md -> "Agent recovery"). + * + * The scrollback read here never leaves this module: only the detected + * invocation reaches `onCommand`, so no transcript can reach persisted state. + */ + +import { detectResumeCommand } from '../lib/resume-patterns'; +import { stripTerminalControls } from '../lib/terminal-controls'; + +// Claude's explicit request permits an immediate second press. Other panes +// without a recovery hint must pass both fallback clocks below before retrying. +const ASKS_FOR_SECOND_PRESS = /Press Ctrl-C again/i; + +// When to press a silent pane again without having been asked. +// +// Both agents' response to `^C` turns out to be state-dependent. Observed in a +// real pane: codex answered the first press by repainting its TUI (+256 bytes of +// cursor positioning, ending on its footer hint) and simply carried on running. +// It never printed a hint and never asked for another press, so an ask-only gate +// left it stuck there for the whole poll. +export const BLIND_SECOND_PRESS_MS = 600; + +// ...but a second press that lands while an agent is mid-shutdown destroys its +// hint, so require the pane to have been silent for this long first. Note this is +// quiet used *correctly*: not as evidence that the pane is finished (that mistake +// cost two rounds), but as evidence that pressing again cannot interrupt a print +// already in flight. +export const QUIET_BEFORE_RETRY_MS = 200; + +// `Press Ctrl-C again` is a live TUI footer, so it is always within a few hundred +// bytes of the tail. Bounding the strip matters: the buffer runs to ~1MB, and +// stripping all of it costs ~3.5ms per pane on every 40ms tick — stolen from the +// same thread that has to deliver the hints being polled for. +const ASK_TAIL_CHARS = 8192; + +/** How long the whole capture may take by default. */ +export const DEFAULT_RECOVERY_WAIT_MS = 1300; + +const POLL_STEP_MS = 40; + +export interface RecoveryLog { + info(message: string): void; + error(message: string): void; +} + +/** Everything the machine needs from whichever host owns the PTYs. */ +export interface RecoveryHost { + /** Ids that can still take a `^C`. An exited PTY can neither receive one nor + * ever yield a hint, so it must not appear here. */ + liveIds(): string[]; + /** Send exactly ONE `^C` to each id and resolve when the host has acked it. + * The second press is this module's decision, never the host's. */ + interrupt(ids: string[]): Promise; + /** Chars ever received for a pane, never decremented by a buffer trim. */ + receivedChars(id: string): number; + /** Output received after a `receivedChars` mark, clamped to what is still held. */ + outputSince(id: string, mark: number): string; + /** A detected invocation, handed over the moment it is found. */ + onCommand(id: string, command: string): void; + now?(): number; + sleep?(ms: number): Promise; + log?: RecoveryLog; +} + +export interface RecoveryCaptureOptions { + /** Restrict the capture to these ids (intersected with the live set). Omitted + * takes every live PTY. */ + ids?: readonly string[]; + maxWaitMs?: number; +} + +/** Null-prototype: surface ids are arbitrary strings, and on a plain literal an + * id of `constructor` or `toString` reads back as an inherited function while + * `__proto__` refuses to be stored at all. */ +export const noCommands = (): Record => Object.create(null); + +const silent: RecoveryLog = { info: () => {}, error: () => {} }; + +/** + * Press, wait, press again where it helps, and report what each pane printed. + * Resolves with the number of commands detected. + * + * Two properties earn the complexity: + * + * 1. **It runs first in a teardown.** The budget has never once been generous + * enough to reach the end, so the one step whose data cannot be reconstructed + * goes before the ones whose data can (cwd re-reads, alert merges). + * 2. **It reports eagerly.** `onCommand` fires the moment a pane yields, so being + * killed mid-poll costs at most a late agent's command, never everything found + * so far. + */ +export async function captureAgentRecovery( + host: RecoveryHost, + options: RecoveryCaptureOptions = {}, +): Promise { + const log = host.log ?? silent; + // Called through `host` rather than pulled off it: a class-based host loses + // `this` the moment one of these is captured as a bare function. + const now = (): number => host.now?.() ?? Date.now(); + const sleep = (ms: number): Promise => + host.sleep?.(ms) ?? new Promise((resolve) => { setTimeout(resolve, ms); }); + const maxWaitMs = options.maxWaitMs ?? DEFAULT_RECOVERY_WAIT_MS; + const started = now(); + + const wanted = options.ids ? new Set(options.ids) : null; + const liveIds = host.liveIds().filter((id) => wanted === null || wanted.has(id)); + if (liveIds.length === 0) { + log.info('[recovery] no live PTYs to interrupt'); + return 0; + } + + const commands: Record = noCommands(); + // Marks come from the exact monotonic counter the buffer already maintains, not + // from its *length*: a pane at the buffer cap holds its length pinned while + // output keeps flowing, so a length is neither a usable growth signal nor a + // usable offset — and that pane is exactly the long-running agent this exists + // for. + const startMark = new Map(liveIds.map((id) => [id, host.receivedChars(id)])); + const lastMark = new Map(startMark); + // Seeded once the interrupt is acked, not here — see `interruptedAt`. + const lastGrewAt = new Map(); + + const pending = () => liveIds.filter((id) => !commands[id]); + // Panes that asked for a second press during the most recent scan. + const asked = new Set(); + // One buffer read per pending pane per tick, shared by both things a tick needs + // to know about that pane: joining the chunks is the expensive part, so asking + // twice would double the cost of the poll for no new information. + const scanPending = () => { + asked.clear(); + for (const id of pending()) { + // Recovery commands are executable state, so only trust bytes that arrived + // after this teardown started interrupting the pane. Scanning the existing + // buffer would let an old launch echo or a previous agent hint run on the + // next restore. If bounded scrollback evicted bytes past the mark in the + // meantime, this can only return less than the pane printed; it cannot + // expose stale output as fresh. + const outputSinceInterrupt = host.outputSince(id, startMark.get(id) ?? Infinity); + if (!outputSinceInterrupt) continue; + const detected = detectResumeCommand(outputSinceInterrupt); + if (detected) { + commands[id] = detected; + log.info(`[recovery] ${id} -> ${detected} (+${now() - started}ms)`); + host.onCommand(id, detected); + continue; + } + // Strip presentation controls first — claude renders that prompt inside its + // TUI, so the raw buffer can carry escapes through the phrase. + if (ASKS_FOR_SECOND_PRESS.test(stripTerminalControls(outputSinceInterrupt.slice(-ASK_TAIL_CHARS)))) { + asked.add(id); + } + } + }; + + // One press to everything, then retry through the ask or quiet fallback gate. + // `interrupt` is already bounded and always settles within its own timeout. + await host.interrupt(liveIds); + // The clock the second-press rules run on, taken *after* the ack rather than at + // entry. `BLIND_SECOND_PRESS_MS` is a statement about the agent ("long enough + // that a one-press agent would already have spoken"), and the agent's clock + // starts when the `^C` lands. Measuring from `started` folds the interrupt's own + // round trip into the window, which at worst leaves a claude 200ms to answer in + // and fires the blind press while codex is still on its first ~255ms of silence. + // The wall-clock `deadline` below stays anchored to `started`, because *that* is + // a shutdown budget rather than an agent timing. + const interruptedAt = now(); + for (const id of liveIds) lastGrewAt.set(id, interruptedAt); + const pressedTwice = new Set(); + + // Poll to the ceiling. Do NOT try to finish early on quiet: codex says nothing + // for ~250ms after the interrupt and then prints its whole shutdown at once, so + // silence is what it looks like *before* it speaks, not after. Two heuristics + // died on that — settling when detections stopped arriving and settling when + // output stopped arriving — both mistaking the gap for completion. + // + // Waiting is close to free now that every command is reported the moment it is + // found: the only cost is budget taken from the later teardown steps, and those + // are precisely the ones whose data can be reconstructed. The one early exit + // that is safe is having nothing left to wait for. + const deadline = started + maxWaitMs; + while (now() < deadline) { + await sleep(POLL_STEP_MS); + scanPending(); + if (pending().length === 0) break; + + // Retry an uncaptured pane when it asks, or after both fallback clocks pass. + const elapsed = now() - interruptedAt; + for (const id of pending()) { + const mark = host.receivedChars(id); + if (mark !== lastMark.get(id)) { lastMark.set(id, mark); lastGrewAt.set(id, now()); } + } + const quietFor = (id: string) => now() - (lastGrewAt.get(id) ?? interruptedAt); + const retry = pending().filter((id) => !pressedTwice.has(id) + && (asked.has(id) + || (elapsed >= BLIND_SECOND_PRESS_MS && quietFor(id) >= QUIET_BEFORE_RETRY_MS))); + if (retry.length > 0) { + const why = retry.some((id) => asked.has(id)) ? 'asked' : `silent past ${BLIND_SECOND_PRESS_MS}ms`; + retry.forEach((id) => pressedTwice.add(id)); + log.info(`[recovery] second press for ${retry.length} pane(s) at +${elapsed}ms after ^C (${why})`); + await host.interrupt(retry); + } + } + + const found = Object.keys(commands).length; + log.info(`[recovery] settled with ${found} command(s) across ${liveIds.length} live PTY(s) at +${now() - started}ms`); + // A pane that yielded nothing is worth a line, but only its shape — never its + // output. Whether the interrupt produced *any* bytes separates "the ^C never + // landed" from "it ran and kept going", which is the fork that matters, and it + // is the one piece of this that can be logged forever: dumping the actual tail + // would write terminal output into a log file, which is precisely the + // disclosure this whole scope exists to remove. + for (const id of pending()) { + const after = host.receivedChars(id) - (startMark.get(id) ?? 0); + log.info(`[recovery] no hint from ${id}: +${after} bytes since interrupt, asked=${asked.has(id)}, pressedTwice=${pressedTwice.has(id)}`); + } + return found; +} diff --git a/lib/src/host/recovery-store.test.ts b/lib/src/host/recovery-store.test.ts new file mode 100644 index 000000000..d67f3e7af --- /dev/null +++ b/lib/src/host/recovery-store.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRecoveryStore, RECOVERY_MAX_AGE_MS } from './recovery-store'; + +let dir: string; +const messages: string[] = []; +const log = { + info: (message: string) => messages.push(`info ${message}`), + error: (message: string) => messages.push(`error ${message}`), +}; + +const file = () => join(dir, 'recovery.json'); +const read = () => JSON.parse(fs.readFileSync(file(), 'utf8')) as { createdAt: number; commands: Record }; +const write = (payload: unknown) => fs.writeFileSync(file(), JSON.stringify(payload), 'utf8'); + +beforeEach(() => { + dir = fs.mkdtempSync(join(tmpdir(), 'dormouse-recovery-')); + messages.length = 0; +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('recovery store', () => { + describe('capture', () => { + it('replaces the previous record on the first beginCapture and merges after', () => { + write({ createdAt: Date.now(), commands: { old: 'claude --continue' } }); + + const store = createRecoveryStore(dir, { log }); + store.beginCapture(); + // The stale record is gone before anything can be detected, so a teardown + // that captures nothing cannot carry it forward. + expect(fs.existsSync(file())).toBe(false); + + store.record('a', 'claude --resume A'); + expect(read().commands).toEqual({ a: 'claude --resume A' }); + + // A second capture in the same process (another window) merges. + store.beginCapture(); + store.record('b', 'codex resume B'); + expect(read().commands).toEqual({ a: 'claude --resume A', b: 'codex resume B' }); + }); + + it('writes the record and its directory owner-only', () => { + const store = createRecoveryStore(dir, { log }); + store.beginCapture(); + store.record('a', 'claude --continue'); + const mode = (path: string) => fs.statSync(path).mode & 0o777; + expect(mode(file())).toBe(0o600); + // The temp sibling is renamed over the target, so nothing torn is left. + expect(fs.readdirSync(dir)).toEqual(['recovery.json']); + }); + + it('does not throw when the record cannot be written', () => { + // A file where the state directory should be: `mkdirSync` cannot make it. + const blocked = join(dir, 'blocked'); + fs.writeFileSync(blocked, 'not a directory', 'utf8'); + const store = createRecoveryStore(blocked, { log }); + store.beginCapture(); + expect(() => store.record('a', 'claude --continue')).not.toThrow(); + expect(messages.some((message) => message.startsWith('error [recovery] write failed'))).toBe(true); + // Nothing was captured, so nothing can be claimed either. + expect(store.take(['a'])).toEqual({}); + }); + }); + + describe('take', () => { + it('unlinks on the first call and hands out each id exactly once', () => { + write({ createdAt: Date.now(), commands: { a: 'claude --resume A', b: 'codex resume B' } }); + const store = createRecoveryStore(dir, { log }); + + expect(store.take(['a'])).toEqual({ a: 'claude --resume A' }); + // The durable copy is gone before anything can act on it, so a failed start + // cannot replay it. + expect(fs.existsSync(file())).toBe(false); + + // A second container claims its share of the same read; the first id is + // spent. + expect(store.take(['a', 'b'])).toEqual({ b: 'codex resume B' }); + expect(store.take(['b'])).toEqual({}); + }); + + it('returns nothing when there is no record', () => { + expect(createRecoveryStore(dir, { log }).take(['a'])).toEqual({}); + }); + + it('is destructive even on a record it cannot parse', () => { + fs.writeFileSync(file(), '{ torn', 'utf8'); + const store = createRecoveryStore(dir, { log }); + expect(store.take(['a'])).toEqual({}); + expect(fs.existsSync(file())).toBe(false); + }); + + it('discards a record past its expiry, having removed it', () => { + write({ createdAt: Date.now() - RECOVERY_MAX_AGE_MS - 1, commands: { a: 'claude --continue' } }); + const store = createRecoveryStore(dir, { log }); + expect(store.take(['a'])).toEqual({}); + expect(fs.existsSync(file())).toBe(false); + }); + + it('drops a non-string entry rather than handing it on', () => { + write({ createdAt: Date.now(), commands: { a: 'claude --continue', b: { evil: true } } }); + const store = createRecoveryStore(dir, { log }); + expect(store.take(['a', 'b'])).toEqual({ a: 'claude --continue' }); + }); + + it('cannot be tricked by an id that names an Object prototype member', () => { + write({ createdAt: Date.now(), commands: { constructor: 'claude --continue' } }); + const store = createRecoveryStore(dir, { log }); + // A plain literal would answer `toString` with an inherited function. + expect(store.take(['toString'])).toEqual({}); + expect(store.take(['constructor'])).toEqual({ constructor: 'claude --continue' }); + }); + }); + + describe('without a state directory', () => { + it('keeps the record in memory and says so once', () => { + const store = createRecoveryStore(undefined, { log }); + expect(store.persistent).toBe(false); + expect(messages.filter((message) => message.includes('no state directory'))).toHaveLength(1); + + store.beginCapture(); + store.record('a', 'claude --continue'); + expect(store.take(['a'])).toEqual({ a: 'claude --continue' }); + expect(store.take(['a'])).toEqual({}); + }); + }); +}); diff --git a/lib/src/host/recovery-store.ts b/lib/src/host/recovery-store.ts new file mode 100644 index 000000000..942bedd24 --- /dev/null +++ b/lib/src/host/recovery-store.ts @@ -0,0 +1,176 @@ +/** + * Where a Node-resident host keeps the agent resume invocations it captured + * while tearing down, and how a cold start claims them exactly once + * (docs/specs/standalone.md -> "Agent recovery"). + * + * The record is single-use, rebuilt-invocation-only, and never a buffer: only + * what `detectResumeCommand` recognized is written, so no transcript reaches + * disk. It is deliberately NOT part of the persisted session — a webview that + * could save it back would replay a stale invocation on a later restore + * (docs/specs/transport.md -> "Consuming it"). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { noCommands, type RecoveryLog } from './recovery-capture'; + +const FILE_NAME = 'recovery.json'; + +/** How long a record stays offerable. One cold start consumes it; this only + * bounds a host that never comes back. */ +export const RECOVERY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +interface PersistedRecovery { + createdAt: number; + /** Surface id -> canonical agent resume invocation. */ + commands: Record; +} + +export interface RecoveryStore { + /** + * A teardown is about to capture. The FIRST call of a process replaces + * whatever the last run left; later calls merge, because a Window captures + * separately and the second must not wipe the first. + */ + beginCapture(): void; + /** Merge one detected invocation and persist immediately. */ + record(id: string, command: string): void; + /** Claim the commands belonging to `paneIds`, removing each as it is handed out. */ + take(paneIds: Iterable): Record; + /** Whether a write survives this process. `false` is the no-directory store. */ + readonly persistent: boolean; +} + +const silent: RecoveryLog = { info: () => {}, error: () => {} }; + +/** + * The record under `dir`, or a memory-only store when no directory was given. + * + * Owner-only and temp-then-rename, because a kill during the write must not + * leave a torn record for the next start to parse — the same durability shape as + * the standalone session snapshot. + */ +export function createRecoveryStore(dir?: string, opts: { log?: RecoveryLog } = {}): RecoveryStore { + const log = opts.log ?? silent; + const file = dir ? path.join(dir, FILE_NAME) : null; + if (!file) { + log.error('[recovery] no state directory; agent recovery will not survive this process'); + } + + // What this process has captured. Also the memory-only store's whole content. + let captured: Record = noCommands(); + let clearedThisProcess = false; + // What is left of the record on disk, once read. `null` until the first `take`. + let unclaimed: Record | null = null; + + const persist = (): void => { + if (!file) return; + const payload: PersistedRecovery = { createdAt: Date.now(), commands: captured }; + try { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp`; + // Mode on create, so the bytes are never briefly world-readable; the rename + // preserves it. + fs.writeFileSync(tmp, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tmp, file); + } catch (err) { + log.error(`[recovery] write failed: ${String(err)}`); + } + }; + + return { + persistent: file !== null, + + beginCapture(): void { + if (clearedThisProcess) return; + clearedThisProcess = true; + // Clear before anything can return early. A record is only ever consumed by + // a cold start that actually restores, so a teardown that captures nothing + // must not leave the last one sitting there — otherwise a run that restores + // nothing carries the record forward and a much later restore auto-runs a + // week-old invocation unprompted. `record` re-creates it the moment + // anything is detected. + captured = noCommands(); + if (!file) return; + try { + fs.rmSync(file, { force: true }); + } catch (err) { + log.error(`[recovery] could not clear the previous record: ${String(err)}`); + } + }, + + record(id: string, command: string): void { + captured[id] = command; + // Persist on every change rather than once at the end. The write is a few + // hundred bytes and costs well under a millisecond, and the shutdown budget + // can end the capture at any instant. + persist(); + }, + + take(paneIds: Iterable): Record { + unclaimed ??= file ? readAndClearRecord(file, log) : captured; + const claimed: Record = noCommands(); + for (const id of paneIds) { + const command = unclaimed[id]; + if (command === undefined) continue; + claimed[id] = command; + // Entries leave the map as they are claimed, so no id is ever handed out + // twice — a second container claiming its share sees only the remainder. + delete unclaimed[id]; + } + log.info(`[recovery] handing ${Object.keys(claimed).length} command(s) to a cold restore` + + ` (${Object.keys(unclaimed).length} unclaimed)`); + return claimed; + }, + }; +} + +/** + * Read the record and remove it. Destructive on the first call of a process, so + * the durable copy is gone before anything can act on it and a failed start + * cannot replay it. + */ +function readAndClearRecord(file: string, log: RecoveryLog): Record { + if (!fs.existsSync(file)) return noCommands(); + + let recovery: PersistedRecovery | null = null; + try { + recovery = JSON.parse(fs.readFileSync(file, 'utf8')) as PersistedRecovery; + } catch (err) { + log.error(`[recovery] unreadable record; discarding: ${String(err)}`); + } + // Destructive even on a parse failure: a record that cannot be understood must + // not sit on disk waiting to be retried forever. + try { + fs.unlinkSync(file); + } catch { + // If it cannot be removed, do not use it — better to lose one recovery than + // to re-run an agent on every start from a record we cannot clear. + log.error('[recovery] could not clear record; ignoring it'); + return noCommands(); + } + if (!recovery) return noCommands(); + + const age = Date.now() - (recovery.createdAt ?? 0); + if (age > RECOVERY_MAX_AGE_MS) { + log.info(`[recovery] discarding record ${Math.round(age / 86_400_000)}d old`); + return noCommands(); + } + + // Shape-guard every entry. This file is plain JSON on disk and its values end up + // typed into a shell, so a torn or hand-edited record must fail as one dropped + // entry rather than as something later code has to survive. + const raw: unknown = recovery.commands; + const commands: Record = noCommands(); + if (raw && typeof raw === 'object') { + for (const [id, command] of Object.entries(raw)) { + if (typeof command !== 'string') { + log.error(`[recovery] dropping ${id}: expected a string, got ${typeof command}`); + continue; + } + commands[id] = command; + } + } + log.info(`[recovery] read ${Object.keys(commands).length} command(s) from the record`); + return commands; +} diff --git a/lib/src/host/recovery.ts b/lib/src/host/recovery.ts new file mode 100644 index 000000000..36e6aa4b5 --- /dev/null +++ b/lib/src/host/recovery.ts @@ -0,0 +1,15 @@ +/** + * The sidecar's entry into agent recovery: the capture machine plus the record + * store, bundled together as `sidecar/recovery.cjs` by + * `standalone/scripts/build-sidecar-proxy.mjs`. `detectResumeCommand` and + * `stripTerminalControls` come along transitively. + * + * The VS Code extension host imports `recovery-capture.ts` directly and keeps its + * own record in extension storage (docs/specs/vscode.md -> "Capturing agent + * recovery"). + */ + +export { captureAgentRecovery, DEFAULT_RECOVERY_WAIT_MS, noCommands } from './recovery-capture'; +export type { RecoveryCaptureOptions, RecoveryHost, RecoveryLog } from './recovery-capture'; +export { createRecoveryStore, RECOVERY_MAX_AGE_MS } from './recovery-store'; +export type { RecoveryStore } from './recovery-store'; diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index f69157594..905750378 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -4,8 +4,11 @@ import * as path from 'path'; import * as ptyManager from './pty-manager'; import type { AlertState } from '../../lib/src/lib/alert-manager'; import { browserPersistedPane, readPersistedSession, toPersistedAlertState, type PersistedAlertState, type PersistedPane, type PersistedSession } from '../../lib/src/lib/session-types'; -import { detectResumeCommand } from '../../lib/src/lib/resume-patterns'; -import { stripTerminalControls } from '../../lib/src/lib/terminal-controls'; +import { + captureAgentRecovery, + DEFAULT_RECOVERY_WAIT_MS, + noCommands, +} from '../../lib/src/host/recovery-capture'; import { log } from './log'; const SESSION_STATE_KEY = 'dormouse.session'; @@ -105,37 +108,15 @@ interface PersistedRecovery { commands: Record; } -// Claude's explicit request permits an immediate second press. Other panes -// without a recovery hint must pass both fallback clocks below before retrying. -const ASKS_FOR_SECOND_PRESS = /Press Ctrl-C again/i; - -// When to press a silent pane again without having been asked. -// -// Both agents' response to `^C` turns out to be state-dependent. Observed in a -// real pane: codex answered the first press by repainting its TUI (+256 bytes of -// cursor positioning, ending on its footer hint) and simply carried on running. -// It never printed a hint and never asked for another press, so an ask-only gate -// left it stuck there for the whole poll. -const BLIND_SECOND_PRESS_MS = 600; - -// ...but a second press that lands while an agent is mid-shutdown destroys its -// hint, so require the pane to have been silent for this long first. Note this is -// quiet used *correctly*: not as evidence that the pane is finished (that mistake -// cost two rounds), but as evidence that pressing again cannot interrupt a print -// already in flight. -const QUIET_BEFORE_RETRY_MS = 200; - -// `Press Ctrl-C again` is a live TUI footer, so it is always within a few hundred -// bytes of the tail. Bounding the strip matters: the scrollback buffer runs to -// 1MB, and stripping all of it costs ~3.5ms per pane on every 40ms tick — stolen -// from the same thread that has to deliver the hints being polled for. -const ASK_TAIL_CHARS = 8192; - /** * Interrupt the live PTYs, then record each pane's agent resume invocation. * * The only writer of recovery state (docs/specs/vscode.md -> "Capturing agent - * recovery"). Two properties earn their complexity: + * recovery"). The press-wait-press machine itself is shared with the Tauri + * sidecar (`lib/src/host/recovery-capture.ts`); what stays here is the extension + * host's half — where the record lives and how it is written. + * + * Two properties earn their complexity: * * 1. **Runs first in `deactivate()`.** The extension host is killed on a budget * that has never once been generous enough to reach `[deactivate] done`, so @@ -146,14 +127,13 @@ const ASK_TAIL_CHARS = 8192; * webview's copy, whose `resumeCommand` is always the stale `null` it last * saw. A separate record makes the write order stop mattering. * - * The scrollback read here never leaves this function — only the detected + * The scrollback the capture reads never leaves it — only the detected * invocation is stored, so no transcript reaches persisted state. */ export async function captureAgentRecoveryCommands( context: vscode.ExtensionContext, - maxWaitMs = 1300, + maxWaitMs = DEFAULT_RECOVERY_WAIT_MS, ): Promise { - const started = Date.now(); const file = recoveryFilePath(context); if (!file) { log.error('[recovery] no storage path available; cannot persist'); @@ -169,38 +149,17 @@ export async function captureAgentRecoveryCommands( try { fs.rmSync(file, { force: true }); } catch (err) { - log.error('[recovery] could not clear the previous record:', String(err)); + log.error('[recovery] could not clear the previous record: ' + String(err)); } - // Exited PTYs are kept in the buffer map until `kill()`, and one can neither - // receive a `^C` nor ever yield a hint — including them would scan them on every - // tick and permanently defeat the `pending().length === 0` early exit. - const liveIds = [...ptyManager.getBufferedPtys()].filter(([, e]) => e.alive).map(([id]) => id); - if (liveIds.length === 0) { - log.info('[recovery] no live PTYs to interrupt'); - return; - } - - // Null-prototype: surface ids are arbitrary strings, and on a plain literal an - // id of `constructor` or `toString` reads back as an inherited function — the - // pane would test as already-captured on the very first tick and never be - // scanned, interrupted again, or waited for. const commands: Record = noCommands(); - // Marks come from the exact monotonic counter the buffer already maintains, not - // from its *length*: a pane at the 1MB cap holds its length pinned while output - // keeps flowing, so a length is neither a usable growth signal nor a usable - // offset — and that pane is exactly the long-running agent this exists for. - const startMark = new Map(liveIds.map((id) => [id, ptyManager.getScrollbackReceived(id)])); - const lastMark = new Map(startMark); - // Seeded once the interrupt is acked, not here — see `interruptedAt`. - const lastGrewAt = new Map(); // Persist on every change rather than once at the end. The write is a few // hundred bytes and costs well under a millisecond, so there is no reason for // it to wait behind a slow agent — and the shutdown budget can end this - // function at any instant. Writing eagerly makes the settle loop below a pure - // optimisation for *completeness*: being killed mid-poll now costs at most a - // late agent's command, never everything detected so far. + // function at any instant. Writing eagerly makes the capture's settle loop a + // pure optimisation for *completeness*: being killed mid-poll now costs at most + // a late agent's command, never everything detected so far. // // Temp-then-rename so a kill during the write cannot leave a torn record for // the next activation to parse (same durability trick as the standalone store, @@ -213,106 +172,21 @@ export async function captureAgentRecoveryCommands( fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8'); fs.renameSync(tmp, file); } catch (err) { - log.error('[recovery] write failed:', String(err)); - } - }; - const pending = () => liveIds.filter((id) => !commands[id]); - // Panes that asked for a second press during the most recent scan. - const asked = new Set(); - // One buffer read per pending pane per tick, shared by both things a tick needs - // to know about that pane: joining the chunks is the expensive part, so asking - // twice would double the cost of the poll for no new information. - const scanPending = () => { - asked.clear(); - let changed = false; - for (const id of pending()) { - // Recovery commands are executable state, so only trust bytes that arrived - // after this teardown started interrupting the pane. Scanning the existing - // buffer would let an old launch echo or a previous agent hint run on the - // next restore. If bounded scrollback evicted bytes past the mark in the - // meantime, this can only return less than the pane printed; it cannot - // expose stale output as fresh. - const outputSinceInterrupt = ptyManager.getScrollbackSince(id, startMark.get(id) ?? Infinity); - if (!outputSinceInterrupt) continue; - const detected = detectResumeCommand(outputSinceInterrupt); - if (detected) { - commands[id] = detected; - log.info(`[recovery] ${id} -> ${detected} (+${Date.now() - started}ms)`); - changed = true; - continue; - } - // Strip presentation controls first — claude renders that prompt inside its - // TUI, so the raw buffer can carry escapes through the phrase. - if (ASKS_FOR_SECOND_PRESS.test(stripTerminalControls(outputSinceInterrupt.slice(-ASK_TAIL_CHARS)))) { - asked.add(id); - } + log.error('[recovery] write failed: ' + String(err)); } - if (changed) persist(); }; - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - // One press to everything, then retry through the ask or quiet fallback gate. - // `interrupt` is already bounded and always settles within its own timeout. - await ptyManager.interrupt(liveIds); - // The clock the second-press rules run on, taken *after* the ack rather than at - // entry. `BLIND_SECOND_PRESS_MS` is a statement about the agent ("long enough - // that a one-press agent would already have spoken"), and the agent's clock - // starts when the `^C` lands. Measuring from `started` folds the interrupt's own - // round trip — up to its 400ms timeout — into the window, which at worst leaves - // a claude 200ms to answer in and fires the blind press while codex is still on - // its first ~255ms of silence. The wall-clock `deadline` below stays anchored to - // `started`, because *that* is a shutdown budget rather than an agent timing. - const interruptedAt = Date.now(); - for (const id of liveIds) lastGrewAt.set(id, interruptedAt); - const pressedTwice = new Set(); - // Poll to the ceiling. Do NOT try to finish early on quiet: codex says nothing - // for ~250ms after the interrupt and then prints its whole shutdown at once, so - // silence is what it looks like *before* it speaks, not after. Two heuristics - // died on that — settling when detections stopped arriving (exited +219ms) and - // settling when output stopped arriving (exited +160ms) — both mistaking the - // gap for completion. - // - // Waiting is close to free now that every command is persisted the moment it is - // found: the only cost is budget taken from the later teardown steps, and those - // are precisely the ones whose data can be reconstructed. The one early exit - // that is safe is having nothing left to wait for. - const deadline = started + maxWaitMs; - while (Date.now() < deadline) { - await sleep(40); - scanPending(); - if (pending().length === 0) break; - - // Retry an uncaptured pane when it asks, or after both fallback clocks pass. - const elapsed = Date.now() - interruptedAt; - for (const id of pending()) { - const mark = ptyManager.getScrollbackReceived(id); - if (mark !== lastMark.get(id)) { lastMark.set(id, mark); lastGrewAt.set(id, Date.now()); } - } - const quietFor = (id: string) => Date.now() - (lastGrewAt.get(id) ?? interruptedAt); - const retry = pending().filter((id) => !pressedTwice.has(id) - && (asked.has(id) - || (elapsed >= BLIND_SECOND_PRESS_MS && quietFor(id) >= QUIET_BEFORE_RETRY_MS))); - if (retry.length > 0) { - const why = retry.some((id) => asked.has(id)) ? 'asked' : `silent past ${BLIND_SECOND_PRESS_MS}ms`; - retry.forEach((id) => pressedTwice.add(id)); - log.info(`[recovery] second press for ${retry.length} pane(s) at +${elapsed}ms after ^C (${why})`); - await ptyManager.interrupt(retry); - } - } - - const found = Object.keys(commands).length; - log.info(`[recovery] settled with ${found} command(s) across ${liveIds.length} live PTY(s) at +${Date.now() - started}ms`); - // A pane that yielded nothing is worth a line, but only its shape — never its - // output. Whether the interrupt produced *any* bytes separates "the ^C never - // landed" from "it ran and kept going", which is the fork that matters, and it - // is the one piece of this that can be logged forever: dumping the actual tail - // would write terminal output into a log file, which is precisely the - // disclosure this whole scope exists to remove. - for (const id of pending()) { - const after = ptyManager.getScrollbackReceived(id) - (startMark.get(id) ?? 0); - log.info(`[recovery] no hint from ${id}: +${after} bytes since interrupt, asked=${asked.has(id)}, pressedTwice=${pressedTwice.has(id)}`); - } + await captureAgentRecovery({ + // Exited PTYs are kept in the buffer map until `kill()`, and one can neither + // receive a `^C` nor ever yield a hint — including them would scan them on + // every tick and permanently defeat the capture's early exit. + liveIds: () => [...ptyManager.getBufferedPtys()].filter(([, e]) => e.alive).map(([id]) => id), + interrupt: (ids) => ptyManager.interrupt(ids), + receivedChars: (id) => ptyManager.getScrollbackReceived(id), + outputSince: (id, mark) => ptyManager.getScrollbackSince(id, mark), + onCommand: (id, command) => { commands[id] = command; persist(); }, + log: { info: (message) => log.info(message), error: (message) => log.error(message) }, + }, { maxWaitMs }); // Nothing to write here: every command was persisted the moment it was found. } @@ -333,12 +207,6 @@ const RECOVERY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; */ let unclaimedRecovery: Record | null = null; -/** Null-prototype throughout, for the same reason the capture side is: these are - * keyed by arbitrary surface id, and on a plain literal an id of `constructor` or - * `toString` reads back as an inherited function while `__proto__` refuses to be - * stored at all. */ -const noCommands = (): Record => Object.create(null); - /** * Claim the recovery commands belonging to `paneIds` — `surfaceId -> invocation` * for the boot payload of one cold-starting webview. From 13d9a0ab706b8b7b08dd93dc5d9870705cc13edb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:35:25 -0700 Subject: [PATCH 04/11] Give the sidecar an output mark, a recovery record, and its two messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pty-core` gains the three primitives the shared capture machine needs: `received`, a monotonic per-pane counter a replay trim never decrements (the buffer's own `chars` is pinned at the cap on exactly the long-running agent pane recovery exists for, so it is not a usable coordinate), `outputSince` clamped to what the buffer still holds, and `liveIds`. `recovery.cjs` joins the three bundles the sidecar requires, and `main.js` answers `pty:captureRecovery` (begin, capture, report the count) and `recovery:take` (claim, once). The record lives under `DORMOUSE_RECOVERY_DIR`. The sidecar owns this rather than Rust because the replay buffers the detection reads are here, its lifetime is exactly one activation so read-and-unlink has one home, and the browser-dev harness gets the feature for free — which is why the harness now passes its own per-run temp dir. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- .gitignore | 1 + standalone/scripts/build-sidecar-proxy.mjs | 5 +- standalone/scripts/dev-agent-browser.mjs | 6 +- standalone/sidecar/main.js | 45 ++++++++++++-- standalone/sidecar/pty-core.js | 47 +++++++++++++- standalone/sidecar/pty-core.test.js | 71 ++++++++++++++++++++++ 6 files changed, 166 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index fbc49a524..082f78933 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ standalone/src-tauri/gen/ standalone/dist/ standalone/sidecar/dor-cli/ standalone/sidecar/iframe-proxy.cjs +standalone/sidecar/recovery.cjs standalone/sidecar/agent-browser-host.cjs standalone/sidecar/burrow.cjs # Kept beside it: a checkout that built before the Burrow rename still holds diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 8b86f2b4e..bfe44813c 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -4,7 +4,9 @@ // - lib/src/host/iframe-proxy.ts → sidecar/iframe-proxy.cjs // - lib/src/host/agent-browser-host.ts → sidecar/agent-browser-host.cjs // - lib/src/host/remote/sidecar-entry.ts → sidecar/burrow.cjs -// See docs/specs/dor-browser.md and docs/specs/remote-api.md. +// - lib/src/host/recovery.ts → sidecar/recovery.cjs +// See docs/specs/dor-browser.md, docs/specs/remote-api.md, and +// docs/specs/standalone.md -> "Agent recovery". import { build } from 'esbuild'; import { rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; @@ -26,6 +28,7 @@ const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); const bundles = [ { entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' }, { entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' }, + { entry: 'recovery.ts', out: 'recovery.cjs' }, { entry: 'remote/sidecar-entry.ts', out: 'burrow.cjs', diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 2f32f3d27..0d651a7f8 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -241,10 +241,14 @@ function startSidecar() { DORMOUSE_CLI_JS: dorEntrypoint, DORMOUSE_CONTROL_TOKEN: controlToken, DORMOUSE_STATE_DIR: stateDir, + // The harness mirrors the persistence answer, so a reload here exercises + // the same agent-recovery record the app writes — under this run's own + // temp state, never the installed app's. + DORMOUSE_RECOVERY_DIR: stateDir, }, }); log(`sidecar pid=${sidecar.pid}`); - log(`burrow state dir: ${stateDir}`); + log(`burrow + recovery state dir: ${stateDir}`); createInterface({ input: sidecar.stdout }).on('line', (line) => { let msg; diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 3fa735078..de43ace00 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -22,6 +22,10 @@ const { createAgentBrowserHost } = require('./agent-browser-host.cjs'); // the relay socket, the enrollment, the ACL, and remote-api v1 — running next to // the PTYs it serves. See docs/specs/remote-api.md. const { createSidecarBurrow } = require('./burrow.cjs'); +// Same pattern again: lib/src/host/recovery.ts is the agent-recovery capture +// machine (shared with the VS Code extension host) plus the single-use record +// store. See docs/specs/standalone.md -> "Agent recovery". +const { captureAgentRecovery, createRecoveryStore } = require('./recovery.cjs'); const agentBrowser = createAgentBrowserHost({ writeClipboardText: (text) => clipboard.writeClipboardText(text), @@ -32,6 +36,14 @@ function send(event, data) { process.stdout.write(JSON.stringify({ event, data }) + '\n'); } +// stdout is the JSON-lines protocol channel, so every log line goes to stderr. +const recoveryLog = { info: (m) => console.error(m), error: (m) => console.error(m) }; + +// The record lives beside the session snapshots, under the state root Rust +// picks (dev and the installed app get different ones). Without a directory the +// store is memory-only and says so once. +const recovery = createRecoveryStore(process.env.DORMOUSE_RECOVERY_DIR || undefined, { log: recoveryLog }); + const mgr = create((event, data) => { // Output goes through the host's parser — one per PTY, feeding the webview // and every attached Client from the same pass (docs/specs/terminal-escapes.md @@ -128,11 +140,36 @@ function handleLine(line) { case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break; case 'pty:getOpenPorts': mgr.getOpenPorts(data.id, data.requestId); break; case 'pty:getShells': mgr.getShells(data.requestId); break; - // Reserved: no standalone caller yet — recovery capture ships for VS Code - // only, which reaches the same `pty-core` through its own `pty-host.js` - // rather than this route (docs/specs/vscode.md -> "Capturing agent - // recovery"). case 'pty:interrupt': mgr.interrupt(data.ids, data.requestId); break; + // Quit teardown, first step: press ^C, detect each agent's resume + // invocation, and write the single-use record. Runs here rather than in + // Rust because the replay buffers the detection reads are here, this + // process's lifetime is exactly one activation, and the browser-dev + // harness gets the same answer for free. + case 'pty:captureRecovery': + respondAsync('recoveryDone', data.requestId, async () => { + recovery.beginCapture(); + const count = await captureAgentRecovery({ + liveIds: () => mgr.liveIds(), + // One press, and the caller decides about a second: `mgr.interrupt` + // writes synchronously, so the ack is immediate. + interrupt: async (ids) => { mgr.interrupt(ids); }, + receivedChars: (id) => mgr.receivedChars(id), + outputSince: (id, mark) => mgr.outputSince(id, mark), + onCommand: (id, command) => recovery.record(id, command), + log: recoveryLog, + }, { ids: data.ids, maxWaitMs: data.timeout }); + return { count }; + }); + break; + // Cold start: claim the invocations belonging to these panes. Destructive + // on the first call, so nothing can replay them. + case 'recovery:take': + send('recovery:commands', { + requestId: data.requestId, + commands: recovery.take(Array.isArray(data.paneIds) ? data.paneIds : []), + }); + break; case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout, data.requestId); break; // The webview's resolved terminal theme, so the parser here can answer // OSC 10/11/12 (docs/specs/terminal-escapes.md → Supported OSCs). diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index fa762660b..209fabc96 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1071,7 +1071,11 @@ module.exports.create = function create(send, ptyModule, { replay = false } = {} const helpers = new Map(); // id -> { parentId, command } for helper PTYs // Every spawned, unkilled PTY (exited ones included). Only the standalone host // asks for `replay`; VS Code's extension host keeps its own buffers. - const sessions = new Map(); // id -> { chunks: string[], chars: number } + // `chars` is what the buffer currently holds (a trim decrements it); + // `received` is everything ever received and is never decremented, so it is the + // only stable coordinate for marking a position in a pane's output + // (docs/specs/transport.md -> "Persisted session"). + const sessions = new Map(); // id -> { chunks: string[], chars: number, received: number } const REPLAY_CHARS = 200000; const ptyShells = new Map(); // id -> resolved shell executable // Repaint restoration belongs to the PTY owner, where every local and remote @@ -1133,7 +1137,7 @@ module.exports.create = function create(send, ptyModule, { replay = false } = {} cancelRepaint(id); ptys.set(id, p); - const session = { chunks: [], chars: 0 }; + const session = { chunks: [], chars: 0, received: 0 }; sessions.set(id, session); ptyShells.set(id, config.shell); @@ -1141,6 +1145,7 @@ module.exports.create = function create(send, ptyModule, { replay = false } = {} if (replay && ptys.get(id) === p) { session.chunks.push(data); session.chars += data.length; + session.received += data.length; // Drop whole chunks off the front, then trim the head: O(chunk) per write. while (session.chunks.length > 1 && session.chars - session.chunks[0].length >= REPLAY_CHARS) session.chars -= session.chunks.shift().length; if (session.chars > REPLAY_CHARS) { session.chunks[0] = session.chunks[0].slice(session.chars - REPLAY_CHARS); session.chars = REPLAY_CHARS; } @@ -1193,6 +1198,41 @@ module.exports.create = function create(send, ptyModule, { replay = false } = {} repaintTimers.set(id, timer); } + /** Ids that can still take input. An exited PTY leaves `ptys` in its `onExit`, + * so this is exactly the live set the recovery capture must interrupt. */ + function liveIds() { + return [...ptys.keys()]; + } + + /** A mark in the pane's output stream, cheap enough to take on every poll tick: + * `received` is maintained exactly by the data handler, so a caller watching a + * pane for growth pays nothing instead of a full `join()`. */ + function receivedChars(id) { + const session = sessions.get(id); + return session ? session.received : 0; + } + + /** The output received after a `receivedChars` mark, clamped to what the bounded + * buffer still holds. Joins only the chunks that span the mark, so repeatedly + * reading a pane's recent tail costs the tail, not the buffer. Eviction can have + * carried the mark off the front; the oldest char still held is the furthest + * back this can honestly answer. */ + function outputSince(id, mark) { + const session = sessions.get(id); + if (!session) return ''; + const oldestHeld = session.received - session.chars; + const wanted = session.received - Math.max(mark, oldestHeld); + if (wanted <= 0) return ''; + const tail = []; + let held = 0; + for (let i = session.chunks.length - 1; i >= 0 && held < wanted; i--) { + tail.push(session.chunks[i]); + held += session.chunks[i].length; + } + const joined = tail.reverse().join(''); + return held > wanted ? joined.slice(held - wanted) : joined; + } + // Synchronous lifetime observation for the Burrow's atomic // subscribe-then-check. Natural exits delete the generation from `ptys`, and // a spawn under the same id installs the new generation before it can emit. @@ -1351,5 +1391,6 @@ module.exports.create = function create(send, ptyModule, { replay = false } = {} } return { spawn, write, resize, hasPty, kill, killAll, list, context, - getCwd, getOpenPorts, interrupt, gracefulKillAll, getShells }; + getCwd, getOpenPorts, interrupt, gracefulKillAll, getShells, + liveIds, receivedChars, outputSince }; }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 1376acc79..fce5e44df 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1480,3 +1480,74 @@ test('getOpenPortsForPid de-duplicates and sorts by port', () => { test('getOpenPortsForPid returns [] for a non-integer pid', () => { assert.deepEqual(getOpenPortsForPid(undefined, { platform: 'linux' }), []); }); + +test('receivedChars counts everything ever received, past a replay-buffer trim', () => { + const listeners = {}; + const writes = []; + const fakePty = { + pid: 1, + onData(handler) { listeners.data = handler; }, + onExit(handler) { listeners.exit = handler; }, + resize() {}, write(data) { writes.push(data); }, kill() {}, + }; + const mgr = create(() => {}, { spawn() { return fakePty; } }, { replay: true }); + mgr.spawn('pane-1'); + + assert.equal(mgr.receivedChars('pane-1'), 0); + const mark = mgr.receivedChars('pane-1'); + listeners.data('hello'); + assert.equal(mgr.receivedChars('pane-1'), 5); + assert.equal(mgr.outputSince('pane-1', mark), 'hello'); + + // Overflow the 200k replay cap: the buffer trims, but the counter is the mark + // space and must not move backwards. + const before = mgr.receivedChars('pane-1'); + for (let i = 0; i < 30; i++) listeners.data('x'.repeat(10_000)); + const after = mgr.receivedChars('pane-1'); + assert.equal(after, before + 300_000); + // What the trimmed buffer can honestly answer is clamped to what it holds; it + // can only be less than the pane printed, never stale bytes offered as fresh. + const since = mgr.outputSince('pane-1', before); + assert.ok(since.length <= 200_000, `held ${since.length}`); + assert.ok(since.length > 0); + assert.equal(since, 'x'.repeat(since.length)); + + // A mark at or past the head answers empty rather than replaying the tail. + assert.equal(mgr.outputSince('pane-1', after), ''); + assert.equal(mgr.outputSince('pane-1', after + 10), ''); + assert.equal(mgr.outputSince('unknown-pane', 0), ''); + assert.equal(mgr.receivedChars('unknown-pane'), 0); +}); + +test('liveIds names every unexited PTY, and nothing after it exits', () => { + const listeners = {}; + const fakePty = (id) => ({ + pid: 1, + onData() {}, onExit(handler) { listeners[id] = handler; }, + resize() {}, write() {}, kill() {}, + }); + let next = 'a'; + const mgr = create(() => {}, { spawn() { return fakePty(next); } }, { replay: true }); + next = 'a'; mgr.spawn('a'); + next = 'b'; mgr.spawn('b'); + assert.deepEqual(mgr.liveIds().sort(), ['a', 'b']); + + listeners.a({ exitCode: 0, signal: undefined }); + assert.deepEqual(mgr.liveIds(), ['b']); +}); + +test('outputSince returns nothing without a replay buffer', () => { + const listeners = {}; + const fakePty = { + pid: 1, + onData(handler) { listeners.data = handler; }, + onExit() {}, resize() {}, write() {}, kill() {}, + }; + // VS Code's host keeps its own buffers, so `replay` is off there and these + // readers have nothing to answer from. + const mgr = create(() => {}, { spawn() { return fakePty; } }); + mgr.spawn('pane-1'); + listeners.data('hello'); + assert.equal(mgr.receivedChars('pane-1'), 0); + assert.equal(mgr.outputSince('pane-1', 0), ''); +}); From 030615e40e9ada9e4f3920ae58333a8bbccbb075 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:38:19 -0700 Subject: [PATCH 05/11] Split the dev state root, sweep orphan temps, bridge agent recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capture_agent_recovery` and `take_recovery_commands` forward the two new sidecar messages. Both are `#[tauri::command(async)]` because they reach the blocking helper, which `sidecar_commands_are_async` now covers. The boot-time `clear_session` is replaced by `sweep_orphan_session_temps`: once standalone persists, deleting the snapshot at every launch is exactly wrong, but a crash between the temp write and the rename still leaves a file nothing will ever read or overwrite — and one written before Dormouse stopped storing transcripts carries a transcript. The sweep derives its suffix through the real writer so the two cannot drift. `state_root` gives a debug build `app_data_dir/dev`. Dev and the installed app resolve to the same `app_data_dir()` (it is keyed by the Tauri identifier), so without the split a dev launch would restore the installed app's Workspaces and the two would clobber one snapshot. The notepad archive and the Burrow state directory stay shared — they are machine-local stores, not this build's copy of the user's window. The sidecar gets the root as `DORMOUSE_RECOVERY_DIR`. The quit phase budget goes to 14 s, staying clear of the webview's new 10 s teardown ceiling. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- standalone/src-tauri/src/lib.rs | 263 ++++++++++++++++++++++++++------ 1 file changed, 213 insertions(+), 50 deletions(-) diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 83a4e2ee4..3c1f06bbe 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -81,9 +81,9 @@ struct QuitState { const QUIT_ACK_TIMEOUT_MS: u64 = 2_000; // Phase 3: per-phase budget once teardown is running. Each reported phase // (teardown, install) refreshes it, so it bounds a single stalled phase, not the -// sum of all teardown work. Comfortably exceeds the webview's own 8 s teardown +// sum of all teardown work. Comfortably exceeds the webview's own 10 s teardown // ceiling (docs/specs/standalone.md §Quit flow). -const QUIT_PHASE_TIMEOUT_MS: u64 = 12_000; +const QUIT_PHASE_TIMEOUT_MS: u64 = 14_000; const QUIT_POLL_STEP_MS: u64 = 500; fn quit_approved(app: &AppHandle) -> bool { @@ -499,6 +499,52 @@ async fn pty_graceful_kill_all( Ok(()) } +// --- Agent recovery (docs/specs/standalone.md -> "Agent recovery") ------------ +// +// Both commands are thin: the sidecar owns the capture machine and the record, +// because the replay buffers the detection reads live there and its lifetime is +// exactly one activation. Both are `(async)` because they reach the blocking +// sidecar helper (see the INVARIANT above `request_from_sidecar_timeout`; +// `sidecar_commands_are_async` enforces it). + +/// Interrupt the live PTYs and let the sidecar detect and record each agent's +/// resume invocation. First step of the quit teardown: the hint exists only +/// between the interrupt and the kill. +#[tauri::command(async)] +fn capture_agent_recovery( + state: tauri::State<'_, SidecarState>, + ids: Option>, + timeout: u64, +) -> Result<(), String> { + request_from_sidecar_timeout( + &state, + "pty:captureRecovery", + serde_json::json!({ "ids": ids, "timeout": timeout }), + // Margin for the round trip beyond the sidecar's own ceiling. + Duration::from_millis(timeout + 1500), + )?; + Ok(()) +} + +/// Claim the resume invocations belonging to `pane_ids`. Destructive on the +/// sidecar's first call, so nothing can replay them. +#[tauri::command(async)] +fn take_recovery_commands( + state: tauri::State<'_, SidecarState>, + pane_ids: Vec, +) -> Result { + let response = request_from_sidecar_timeout( + &state, + "recovery:take", + serde_json::json!({ "paneIds": pane_ids }), + Duration::from_secs(5), + )?; + Ok(response + .get("commands") + .cloned() + .unwrap_or_else(|| JsonValue::Object(JsonMap::new()))) +} + // Stands up the loopback iframe proxy in the sidecar and returns the // IframeProxyResult JSON the webview's IframePanel expects. The proxy server is // the shared lib/src/host/iframe-proxy.ts; this only bridges the request. @@ -753,8 +799,28 @@ fn app_data_dir(app: &AppHandle) -> Result { .map_err(|e| format!("app_data_dir unavailable: {e}")) } +/// Everything this build's own state lives under. +/// +/// `app_data_dir()` is keyed by the Tauri identifier, so a `pnpm dev:standalone` +/// run and the installed app resolve to the same directory: without this split a +/// dev launch would restore the installed app's Workspaces and the two would +/// clobber one another's snapshot. The notepad archive and the Burrow state +/// directory stay shared — they are machine-local stores, not this build's copy +/// of the user's window. +fn state_root_from(app_data: PathBuf) -> PathBuf { + if cfg!(debug_assertions) { + app_data.join("dev") + } else { + app_data + } +} + +fn state_root(app: &AppHandle) -> Result { + Ok(state_root_from(app_data_dir(app)?)) +} + fn sessions_dir(app: &AppHandle) -> Result { - Ok(app_data_dir(app)?.join("sessions")) + Ok(state_root(app)?.join("sessions")) } // Window labels are app-controlled (e.g. "main"), but sanitize defensively so a @@ -1009,20 +1075,50 @@ async fn save_session(window: tauri::Window, state: String) -> Result<(), String write_session_to(&sessions_dir(window.app_handle())?, window.label(), &state) } -fn remove_session_from(dir: &Path, label: &str) -> Result<(), String> { - let snapshot = dir.join(session_file_name(label)); - // The temp name comes from the writer, so changing the convention can never - // leave this sweep looking for a file `write_file_atomically` stopped making. - let tmp = temp_write_path(&snapshot); - let paths = [snapshot, tmp]; +/// The suffix `write_file_atomically` leaves on a session snapshot's temp +/// sibling, derived through the real writer so changing the convention can never +/// leave the sweep below looking for a name nothing makes any more. +fn session_temp_suffix() -> String { + let name = session_file_name("probe"); + temp_write_path(Path::new(&name)) + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_prefix("probe")) + .map(str::to_owned) + .unwrap_or_else(|| ".json.tmp".to_string()) +} + +/// Delete every orphaned temp write in the sessions directory, at boot. +/// +/// A crash between the temp write and the rename leaves a file `load_session` +/// cannot see and nothing else will ever overwrite — and a snapshot written +/// before Dormouse stopped storing transcripts carries one. Deleting is the +/// point: those bytes have to leave the disk +/// (docs/specs/transport.md -> "Retiring the transcripts already on disk"). +/// Never touches a live snapshot; the window that owns one rewrites it itself. +fn sweep_orphan_session_temps(dir: &Path) -> Result<(), String> { + let suffix = session_temp_suffix(); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + // No sessions directory yet (a first launch) is the desired end state. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(format!("read sessions dir {}: {e}", dir.display())), + }; let mut first_error = None; - for path in paths { - match std::fs::remove_file(&path) { + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !name.ends_with(&suffix) { + continue; + } + match std::fs::remove_file(entry.path()) { Ok(()) => {} - // Already gone is the desired end state, not a failure. Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) if first_error.is_none() => { - first_error = Some(format!("remove session artifact {}: {e}", path.display())); + first_error = Some(format!( + "remove orphaned session temp {}: {e}", + entry.path().display() + )); } Err(_) => {} } @@ -1030,18 +1126,6 @@ fn remove_session_from(dir: &Path, label: &str) -> Result<(), String> { first_error.map_or(Ok(()), Err) } -/// Delete this window's snapshot and any orphaned temp write outright. -/// -/// Deleting rather than blanking matters: a pre-upgrade snapshot carries a -/// transcript, and the point of clearing it is that those bytes stop being on -/// disk (docs/specs/transport.md -> "Retiring the transcripts already on disk"). -/// Overwriting with an empty string would also leave every reader of the store -/// obliged to treat `""` as a distinct third state alongside present and absent. -#[tauri::command] -async fn clear_session(window: tauri::Window) -> Result<(), String> { - remove_session_from(&sessions_dir(window.app_handle())?, window.label()) -} - // --- Notepad archive (docs/specs/notepad.md) --------------------------------- // // One machine-local archive per host, kept as `/notepad-archive-v1.json` @@ -1608,6 +1692,32 @@ fn burrow_state_dir(app: &AppHandle) -> Option { Some(dir.to_string_lossy().into_owned()) } +/// Where the sidecar writes the single-use agent-recovery record. Under the +/// state root, so a dev run never consumes the installed app's. Created here so +/// a first launch hands the sidecar a directory that exists; owner-only for the +/// same reason the Burrow's is — the record holds command lines the user typed, +/// and a unix mode is a silent no-op on Windows. +fn recovery_state_dir(app: &AppHandle) -> Option { + let dir = match state_root(app) { + Ok(dir) => dir, + Err(e) => { + append_log(format!("[recovery] state root unavailable: {e}")); + return None; + } + }; + if let Err(e) = create_dir_all(&dir) { + append_log(format!("[recovery] create state dir: {e}")); + return None; + } + if let Err(e) = restrict_to_owner(&dir, 0o700) { + append_log(format!( + "[recovery] WARNING could not restrict state dir {}: {e}", + dir.display() + )); + } + Some(dir.to_string_lossy().into_owned()) +} + fn start_sidecar(app: &AppHandle) -> Result { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let sidecar_path = resolve_sidecar_path(app.path().resource_dir().ok(), manifest_dir); @@ -1616,6 +1726,7 @@ fn start_sidecar(app: &AppHandle) -> Result { let dor_node_path = resolve_dor_node_path(&node_path, app); let dor_control_token = dor_control_token(); let state_dir = burrow_state_dir(app); + let recovery_dir = recovery_state_dir(app); append_log(format!( "[sidecar] resolved script: {}", sidecar_path.display() @@ -1634,6 +1745,10 @@ fn start_sidecar(app: &AppHandle) -> Result { "[burrow] state dir: {}", state_dir.as_deref().unwrap_or("(none)") )); + append_log(format!( + "[recovery] state dir: {}", + recovery_dir.as_deref().unwrap_or("(none)") + )); let mut wrap = CommandWrap::with_new(&node_path, |c| { c.arg(&sidecar_path) @@ -1643,6 +1758,10 @@ fn start_sidecar(app: &AppHandle) -> Result { .env("DORMOUSE_CLI_JS", &dor_cli_paths.entrypoint) .env("DORMOUSE_CONTROL_TOKEN", &dor_control_token) .env("DORMOUSE_STATE_DIR", state_dir.as_deref().unwrap_or("")) + .env( + "DORMOUSE_RECOVERY_DIR", + recovery_dir.as_deref().unwrap_or(""), + ) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1865,6 +1984,19 @@ pub fn run() { // Quit-interception state (docs/specs/standalone.md §Quit flow). app.manage(QuitState::default()); + // A crash between a snapshot's temp write and its rename leaves a + // file nothing will ever read or overwrite; one written before + // Dormouse stopped storing transcripts carries one + // (docs/specs/standalone.md §Persistence). Never fatal. + match sessions_dir(app.handle()) { + Ok(dir) => { + if let Err(e) = sweep_orphan_session_temps(&dir) { + append_log(format!("[session] {e}")); + } + } + Err(e) => append_log(format!("[session] {e}")), + } + // Serializes this process's notepad-archive access (§Notepad // archive); the revision itself is read off the stored bytes and // the cross-process exclusion is a lock file, so there is no @@ -1893,6 +2025,8 @@ pub fn run() { pty_context, pty_get_open_ports, pty_graceful_kill_all, + capture_agent_recovery, + take_recovery_commands, iframe_create_proxy_url, pty_request_init, dor_control_response, @@ -1909,7 +2043,6 @@ pub fn run() { read_update_log, load_session, save_session, - clear_session, load_notepad_archive, save_notepad_archive, reset_notepad_archive, @@ -1951,9 +2084,10 @@ pub fn run() { mod tests { use super::{ find_node_binary, notepad_archive_lock_path, read_notepad_archive_from, read_session_from, - remove_session_from, reset_notepad_archive_at, resolve_dor_cli_paths, resolve_sidecar_path, - session_file_name, strip_windows_verbatim_prefix, write_notepad_archive_to, - write_session_to, NOTEPAD_ARCHIVE_FILE, + reset_notepad_archive_at, resolve_dor_cli_paths, resolve_sidecar_path, session_file_name, + session_temp_suffix, state_root_from, strip_windows_verbatim_prefix, + sweep_orphan_session_temps, temp_write_path, write_notepad_archive_to, write_session_to, + NOTEPAD_ARCHIVE_FILE, }; use std::fs; use std::path::{Path, PathBuf}; @@ -2416,43 +2550,72 @@ mod tests { } #[test] - fn clearing_a_session_removes_the_file_and_leaves_other_windows_alone() { - let dir = TempDir::new("sessions-clear"); + fn sweep_orphan_session_temps_removes_only_temps() { + let dir = TempDir::new("sessions-sweep"); write_session_to(dir.path(), "main", r#"{"v":1,"who":"main"}"#).unwrap(); write_session_to(dir.path(), "win-2", r#"{"v":1,"who":"win-2"}"#).unwrap(); - fs::write(dir.path().join("main.json.tmp"), b"main transcript").unwrap(); - fs::write(dir.path().join("win-2.json.tmp"), b"win-2 transcript").unwrap(); + // What a crash between the temp write and the rename leaves behind. A + // pre-persistence one carries a transcript, and the point of the sweep is + // that those bytes leave the disk. + fs::write(dir.path().join("main.json.tmp"), b"legacy transcript").unwrap(); + fs::write(dir.path().join("win-2.json.tmp"), b"legacy transcript").unwrap(); + // Not ours: a sibling store's file must survive untouched. + fs::write(dir.path().join("notes.txt"), b"keep me").unwrap(); - remove_session_from(dir.path(), "main").unwrap(); + sweep_orphan_session_temps(dir.path()).unwrap(); - // Absent, not blank: a pre-upgrade snapshot carries a transcript, and the - // point of clearing is that those bytes leave the disk. - assert_eq!(read_session_from(dir.path(), "main").unwrap(), None); - assert!(!dir.path().join("main.json").exists()); assert!(!dir.path().join("main.json.tmp").exists()); + assert!(!dir.path().join("win-2.json.tmp").exists()); + // Every live snapshot is left exactly as it was — the window that owns + // one rewrites it itself. + assert_eq!( + read_session_from(dir.path(), "main").unwrap().as_deref(), + Some(r#"{"v":1,"who":"main"}"#), + ); assert_eq!( read_session_from(dir.path(), "win-2").unwrap().as_deref(), Some(r#"{"v":1,"who":"win-2"}"#), ); - assert!(dir.path().join("win-2.json.tmp").exists()); + assert!(dir.path().join("notes.txt").exists()); } #[test] - fn clearing_an_orphaned_temp_session_removes_it() { - let dir = TempDir::new("sessions-clear-orphaned-temp"); - let tmp = dir.path().join("main.json.tmp"); - fs::write(&tmp, b"legacy transcript").unwrap(); - - remove_session_from(dir.path(), "main").unwrap(); + fn sweeping_an_absent_sessions_directory_succeeds() { + // A first launch has no sessions directory yet; that is the desired end + // state, not an error. + let dir = TempDir::new("sessions-sweep-missing"); + assert!(sweep_orphan_session_temps(&dir.path().join("nope")).is_ok()); + } - assert!(!tmp.exists()); + /// The sweep's suffix comes from the writer, so the two can never drift. + #[test] + fn session_temp_suffix_matches_what_the_writer_leaves() { + assert_eq!(session_temp_suffix(), ".json.tmp"); + assert_eq!( + temp_write_path(Path::new(&session_file_name("main"))) + .file_name() + .unwrap() + .to_str() + .unwrap(), + format!("main{}", session_temp_suffix()), + ); } + /// A dev build and the installed app share one `app_data_dir()`, so this + /// split is what keeps a `pnpm dev:standalone` run from restoring the + /// installed app's Workspaces and clobbering its snapshot. #[test] - fn clearing_an_absent_session_succeeds() { - // Already gone is the desired end state; a first launch must not error. - let dir = TempDir::new("sessions-clear-missing"); - assert!(remove_session_from(dir.path(), "main").is_ok()); + fn dev_and_installed_state_roots_are_separate() { + let app_data = PathBuf::from("/app-data"); + let root = state_root_from(app_data.clone()); + if cfg!(debug_assertions) { + assert_eq!(root, app_data.join("dev")); + } else { + assert_eq!(root, app_data); + } + // The notepad archive is a sibling of app_data, never under the root, so + // dev and the installed app keep sharing it. + assert_ne!(root.join("sessions"), app_data.join(NOTEPAD_ARCHIVE_FILE)); } #[test] From a0f72f513700e5921fb6486dff3560b7de4f30f8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:45:05 -0700 Subject: [PATCH 06/11] Persist standalone window state and restore per Workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both adapters persist: `PERSIST_SESSION` is gone, the boot-time blob deletion with it (Rust's orphan-temp sweep is what retires the transcripts already on disk). `getWindowState` is the boot reader; `getState` answers nothing, because the blob is a Window and every shared reader of `getState` wants a bare Session. `init()` also claims the agent-recovery record for the panes it is about to restore, so `getRecoveryCommands` is a synchronous answer by the time the cold restore asks. `main.tsx` rebuilds the Window: seed the aggregator, install the Workspaces, install the writer, then plan each Workspace off one `collectLivePtys`. Reload and relaunch are the same path with a different live list — on a reload the PTYs partition by saved pane id and every Workspace resumes over its own; on a relaunch the list is empty and every Workspace cold-restores into fresh shells at its saved cwds, with nothing replayed. A live PTY no Workspace names goes to the active one. The quit teardown captures agent recovery first (the hint exists only between the interrupt and the kill) and flushes the Window blob after the final per-Workspace save; the ceiling goes to 10 s. A failed capture cannot abort the save behind it. Deviation from the plan: `gracefulKillPtys(ids?)` is not introduced. Nothing in this stage kills a subset, and neither Rust nor the sidecar can honor an id list yet, so the argument would be silently ignored. Multi-window adds it with its own half. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/App.tsx | 10 +- lib/src/components/WorkspaceWindow.tsx | 20 ++-- lib/src/components/wall/wall-types.ts | 13 ++- standalone/scripts/dev-agent-browser.mjs | 9 +- .../src/browser-sidecar-adapter.test.ts | 47 +++++---- standalone/src/browser-sidecar-adapter.ts | 56 +++++++---- standalone/src/main.tsx | 80 +++++++++++++-- standalone/src/quit.test.ts | 35 ++++++- standalone/src/quit.ts | 19 ++-- standalone/src/tauri-adapter.test.ts | 75 ++++++++++++-- standalone/src/tauri-adapter.ts | 97 +++++++++++-------- 11 files changed, 344 insertions(+), 117 deletions(-) diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 068c493fa..e523e9568 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -2,7 +2,7 @@ import { Component, type ReactNode } from "react"; import { Wall } from "./components/Wall"; import { WorkspaceWindow } from "./components/WorkspaceWindow"; import { ThemeDebuggerGlobal } from "./components/ThemeDebugger"; -import type { WallBootProps } from "./components/wall/wall-types"; +import type { WallBootPlans, WallBootProps } from "./components/wall/wall-types"; class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { state: { error: Error | null } = { error: null }; @@ -28,6 +28,7 @@ export default function App({ dialogHost, enableBurrow, multiWorkspace = false, + initialPlans, ...boot }: WallBootProps & { baseboardNotice?: ReactNode; @@ -37,11 +38,14 @@ export default function App({ * standalone host sets it; VS Code and the website playground mount a bare * Wall (docs/specs/layout.md → "Workspaces"). */ multiWorkspace?: boolean; + /** One boot record per Workspace; `multiWorkspace` only. */ + initialPlans?: WallBootPlans; }) { - const Shell = multiWorkspace ? WorkspaceWindow : Wall; return ( - + {multiWorkspace + ? + : } diff --git a/lib/src/components/WorkspaceWindow.tsx b/lib/src/components/WorkspaceWindow.tsx index 3a1ca96ac..4461d023a 100644 --- a/lib/src/components/WorkspaceWindow.tsx +++ b/lib/src/components/WorkspaceWindow.tsx @@ -4,7 +4,7 @@ import { Wall } from './Wall'; import { listWallHandles } from './wall/wall-handles'; import { getPlatform } from '../lib/platform'; import { getWorkspacesSnapshot, subscribeToWorkspaces } from '../lib/workspace-store'; -import type { WallBootProps } from './wall/wall-types'; +import type { WallBootPlans, WallBootProps } from './wall/wall-types'; /** * One Window's Workspaces: a mounted `` each, all in the same grid cell so @@ -16,17 +16,23 @@ export function WorkspaceWindow({ baseboardNotice, dialogHost, enableBurrow, + initialPlans, ...boot }: WallBootProps & { baseboardNotice?: ReactNode; dialogHost?: ReactNode; enableBurrow?: boolean; + /** One record per Workspace, from the restored Window. Takes precedence over + * the single-record props, which stay for the compositions that restore one + * Session (stories, the website playground). */ + initialPlans?: WallBootPlans; }) { const { workspaces, activeId } = useSyncExternalStore(subscribeToWorkspaces, getWorkspacesSnapshot); - // The boot record belongs to the Workspace that was active at first render. - // Every Workspace created later gets no boot props, so its Wall takes Lath's - // fresh branch and spawns exactly one default-shell pane. + // Without per-Workspace plans the single boot record belongs to the Workspace + // that was active at first render. Either way a Workspace with no record takes + // Lath's fresh branch and spawns exactly one default-shell pane. const bootWorkspaceIdRef = useRef(activeId); + const plansRef = useRef(initialPlans); // The Window, not each Wall, answers the host's flush request: the adapter // completes on the FIRST notification, so a per-Wall answer would let a quit @@ -50,7 +56,9 @@ export function WorkspaceWindow({ > {workspaces.map((workspace) => { const isActive = workspace.id === activeId; - const isBoot = workspace.id === bootWorkspaceIdRef.current; + const plan = plansRef.current + ? plansRef.current[workspace.id] ?? {} + : workspace.id === bootWorkspaceIdRef.current ? boot : {}; return (
; + export type WallEvent = | { type: 'modeChange'; mode: WallMode } | { type: 'zoomChange'; zoomed: boolean } diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 0d651a7f8..92c621124 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -143,6 +143,12 @@ const invokeMap = { agent_browser_open: ({ url, headed, binaryPath }) => requestSidecar('agentBrowser:open', { url, headed, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), agent_browser_pop_out: ({ session, url, rect, binaryPath }) => requestSidecar('agentBrowser:popOut', { session, url, rect, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), agent_browser_pop_in: ({ session, url, binaryPath }) => requestSidecar('agentBrowser:popIn', { session, url, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), + // Agent recovery (docs/specs/standalone.md -> "Agent recovery"). The harness + // mirrors the persistence answer, so it captures and claims exactly as Rust + // does; the sidecar half is identical. + capture_agent_recovery: ({ ids, timeout }) => + requestSidecar('pty:captureRecovery', { ids, timeout }, 'recoveryDone', (data) => data, (timeout ?? 1300) + 1500), + recovery_take: ({ paneIds }) => requestSidecar('recovery:take', { paneIds }, 'recovery:commands', (data) => data), }; async function readJson(req) { @@ -248,7 +254,8 @@ function startSidecar() { }, }); log(`sidecar pid=${sidecar.pid}`); - log(`burrow + recovery state dir: ${stateDir}`); + log(`burrow state dir: ${stateDir}`); + log(`recovery state dir: ${stateDir}`); createInterface({ input: sidecar.stdout }).on('line', (line) => { let msg; diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index 9f94551b5..1dd0367c7 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -38,10 +38,16 @@ describe("BrowserSidecarAdapter capability surface", () => { }); }); -// The harness must not persist Session state that production standalone drops -// (docs/specs/standalone.md -> "Standalone persists no Session state"). +// The harness mirrors the shipped persistence answer, so a reload there +// exercises what the app does (docs/specs/transport.md -> "The governing rule"). describe("BrowserSidecarAdapter session persistence", () => { const KEY = "dormouse.browser-sidecar.session"; + const session = { version: 3 as const, panes: [{ id: "pane-a", title: "A", cwd: "/a", untouched: false }] }; + const windowBlob = { + version: 1 as const, + workspaces: [{ id: "ws-1", name: "One", session }], + activeWorkspaceId: "ws-1", + }; it("reports the same persistsSession as TauriAdapter", () => { const harness: PlatformAdapter = new BrowserSidecarAdapter( @@ -49,39 +55,44 @@ describe("BrowserSidecarAdapter session persistence", () => { ); const tauri: PlatformAdapter = new TauriAdapter(); expect(harness.persistsSession).toBe(tauri.persistsSession); - expect(harness.persistsSession).toBe(false); + expect(harness.persistsSession).toBe(true); }); - it("does not write session state to localStorage", () => { + it("round-trips a Window through localStorage", () => { + localStorage.removeItem(KEY); + const adapter = new BrowserSidecarAdapter(new BrowserSidecarHost("http://localhost:1234")); + adapter.saveState(windowBlob); + expect(adapter.getWindowState()).toEqual(windowBlob); + // The shared `getState` readers want a bare Session and the blob is a Window, + // so it answers nothing; the boot reads `getWindowState`. + expect((adapter as PlatformAdapter).getState()).toBeNull(); 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(); + it("wraps a pre-Window blob rather than dropping it", () => { + localStorage.setItem(KEY, JSON.stringify(session)); + const adapter = new BrowserSidecarAdapter(new BrowserSidecarHost("http://localhost:1234")); + expect(adapter.getWindowState()?.workspaces.map((ws) => ws.session)).toEqual([session]); localStorage.removeItem(KEY); }); - it("deletes a pre-gate blob on init", async () => { - localStorage.setItem(KEY, JSON.stringify({ version: 3, panes: [], lathLayout: null })); + it("claims the recovery commands for its saved panes during init", async () => { + localStorage.setItem(KEY, JSON.stringify(windowBlob)); const host = new BrowserSidecarHost("http://localhost:1234"); vi.spyOn(host, "init").mockResolvedValue(undefined); vi.spyOn(host, "onEvent").mockReturnValue(() => {}); + const invoke = vi.spyOn(host, "invoke").mockResolvedValue({ commands: { "pane-a": "claude --continue" } }); // 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(); + + expect(invoke).toHaveBeenCalledWith("recovery_take", { paneIds: ["pane-a"] }); + expect(adapter.getRecoveryCommands()).toEqual({ "pane-a": "claude --continue" }); + localStorage.removeItem(KEY); }); }); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 163043c12..2918f9302 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -35,7 +35,7 @@ import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { createMemoryNotepadArchivePort } from "dormouse-lib/lib/notepad/memory-archive-port"; import { loadWindowState, saveWindowState } from "dormouse-lib/lib/window-persistence"; -import type { PersistedWindow } from "dormouse-lib/lib/session-types"; +import type { PersistedAlertState, PersistedWindow } from "dormouse-lib/lib/session-types"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -103,10 +103,12 @@ 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(); + // Before `resumeOrRestore` runs, so the cold restore's synchronous read has + // an answer (see TauriAdapter.takeRecoveryCommands). + await this.takeRecoveryCommands(); } shutdown(): void { @@ -157,6 +159,38 @@ export class BrowserSidecarAdapter implements PlatformAdapter { try { return await this.host.invoke("pty_get_cwd", { id }); } catch { return null; } } + /** See TauriAdapter: claimed once during `init()`, read synchronously by the + * cold restore. */ + private recoveryCommands: Record = {}; + + getRecoveryCommands(): Record { + return this.recoveryCommands; + } + + private async takeRecoveryCommands(): Promise { + const saved = this.getWindowState(); + const paneIds = saved?.workspaces.flatMap((workspace) => workspace.session.panes.map((pane) => pane.id)) ?? []; + if (paneIds.length === 0) return; + try { + const result = await this.host.invoke<{ commands?: Record }>("recovery_take", { paneIds }); + this.recoveryCommands = result?.commands ?? {}; + } catch (err) { + console.error("[browser-sidecar] recovery take failed:", err); + } + } + + alertSeed(id: string, state: PersistedAlertState): void { + this.alertManager.seed(id, state); + } + + async captureAgentRecovery(timeoutMs: number, ids?: string[]): Promise { + try { + await this.host.invoke("capture_agent_recovery", { ids: ids ?? null, timeout: timeoutMs }); + } catch (err) { + console.warn("[browser-sidecar] captureAgentRecovery failed; proceeding", err); + } + } + async getOpenPorts(id: string): Promise { try { return await this.host.invoke("pty_get_open_ports", { id }); } catch { return []; } } @@ -273,16 +307,13 @@ 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; + // The harness mirrors the shipped persistence answer, so a reload here + // exercises what the app does (docs/specs/transport.md -> "The governing rule"). + readonly persistsSession = true; // See TauriAdapter: one `PersistedWindow` per window, in `localStorage` rather // than the Rust file store (docs/specs/transport.md). saveState(state: unknown): void { - if (!BrowserSidecarAdapter.PERSIST_SESSION) return; try { saveWindowState(localStorage, BrowserSidecarAdapter.STATE_KEY, state as PersistedWindow); } catch { console.error('[browser-sidecar] Failed to save session state'); } } @@ -294,7 +325,6 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } getWindowState(): PersistedWindow | null { - if (!BrowserSidecarAdapter.PERSIST_SESSION) return null; try { return loadWindowState(localStorage, BrowserSidecarAdapter.STATE_KEY); } catch { @@ -313,14 +343,6 @@ export class BrowserSidecarAdapter implements PlatformAdapter { // adapter sets this. The shipped Tauri build owns its keyboard and does not. readonly browserReservesNotepadChord = true; - // 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") { // Already parsed by the sidecar, which owns the PTY; its events arrive as diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 466843882..eb05010c0 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -3,7 +3,15 @@ import { createRoot } from "react-dom/client"; import { setPlatform } from "dormouse-lib/lib/platform"; import { installPeerSurfaceResponder } from "dormouse-lib/remote/burrow/peer-surfaces"; import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; -import { resumeOrRestore } from "dormouse-lib/lib/reconnect"; +import { collectLivePtys, resumeOrRestoreFrom } from "dormouse-lib/lib/reconnect"; +import { + installWindowSessionWriter, + seedWindowSession, +} from "dormouse-lib/lib/window-session-aggregator"; +import { setWorkspaces } from "dormouse-lib/lib/workspace-store"; +import { DEFAULT_WORKSPACE_ID } from "dormouse-lib/lib/session-types"; +import type { PersistedSession, PersistedWindow, WorkspaceId } from "dormouse-lib/lib/session-types"; +import type { WallBootPlans } from "dormouse-lib/components/wall/wall-types"; import { seedShellStore } from "dormouse-lib/lib/shell-store"; import { restoreActiveTheme } from "dormouse-lib/lib/themes"; import App from "dormouse-lib/App"; @@ -124,7 +132,7 @@ async function bootstrap() { // omits `shell` and the sidecar resolves the OS default itself. seedShellStore(await shellsPromise); - const result = await resumeOrRestore(platform); + const initialPlans = await restoreWindow(platform); startUpdateCheck(); @@ -132,11 +140,7 @@ async function bootstrap() { } dialogHost={} enableBurrow @@ -145,4 +149,66 @@ async function bootstrap() { , ); } + +/** The adapters that persist a Window. Both standalone adapters answer this; the + * shared `PlatformAdapter.getState` cannot, because the blob it stores is a + * Window and every shared reader of `getState` wants a bare Session. */ +type WindowPersistingAdapter = PlatformAdapter & { getWindowState?(): PersistedWindow | null }; + +/** + * Rebuild the Window: install its Workspaces, then plan each one's Session off a + * single view of the host's live PTYs (docs/specs/layout.md → "Session + * persistence"). + * + * Reload and relaunch are the same code path with a different live list. On a + * reload the PTYs are still there and partition by saved pane id, so every + * Workspace resumes over its own; on a relaunch the list is empty and every + * Workspace cold-restores into fresh shells at its saved cwds, with nothing + * replayed because scrollback is never persisted. + */ +async function restoreWindow(platform: WindowPersistingAdapter): Promise { + const saved = platform.getWindowState?.() ?? null; + // Before any Wall mounts: a Workspace's first save compares against its own + // record, and a snapshot taken mid-boot must not replace a restored Workspace + // with a blank one. + seedWindowSession(saved); + if (saved) { + setWorkspaces({ + workspaces: saved.workspaces.map(({ id, name }) => ({ id, name })), + activeId: saved.activeWorkspaceId, + }); + } + // After `setWorkspaces`, so installing does not immediately write back what was + // just read. + installWindowSessionWriter((window) => platform.saveState(window)); + + const live = await collectLivePtys(platform); + const restoring: Array<{ id: WorkspaceId; session: PersistedSession | null }> = saved + ? saved.workspaces.map((workspace) => ({ id: workspace.id, session: workspace.session })) + : [{ id: DEFAULT_WORKSPACE_ID, session: null }]; + const activeId = saved?.activeWorkspaceId ?? DEFAULT_WORKSPACE_ID; + + // A live PTY no saved Workspace names — a pane created inside the last save's + // debounce, or one left by a Workspace that is gone — goes to the active + // Workspace rather than being stranded with no Wall. + const named = new Set(restoring.flatMap(({ session }) => session?.panes.map((pane) => pane.id) ?? [])); + const unowned = new Set(live.ptys.map((pty) => pty.id).filter((id) => !named.has(id))); + + const plans: WallBootPlans = {}; + for (const { id, session } of restoring) { + const result = resumeOrRestoreFrom(platform, live, { + savedSession: session, + ptyIds: new Set(session?.panes.map((pane) => pane.id) ?? []), + ...(id === activeId ? { claimUnowned: unowned } : {}), + }); + plans[id] = { + initialPaneIds: result.paneIds, + restoredLathLayout: result.lathLayout, + initialDoors: result.doors, + initialSurfaceRefs: result.surfaceRefs, + initialSurfaceRefsNext: result.surfaceRefsNext, + }; + } + return plans; +} bootstrap(); diff --git a/standalone/src/quit.test.ts b/standalone/src/quit.test.ts index a16ef2286..7ed5d3462 100644 --- a/standalone/src/quit.test.ts +++ b/standalone/src/quit.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ archiveSurfaceNotes: vi.fn(async (_ids: readonly string[], _opts?: { signal?: AbortSignal }) => {}), notepadSurfaceIds: vi.fn(() => [] as string[]), removeSurface: vi.fn(), + flushWindowSession: vi.fn(async () => {}), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); @@ -33,6 +34,11 @@ vi.mock("dormouse-lib/lib/notepad/notepad-store", () => ({ notepadSurfaceIds: mocks.notepadSurfaceIds, removeSurface: mocks.removeSurface, })); +// The aggregator's write step. Mocked for the same reason as the registry: what +// this file tests is where it sits in the order. +vi.mock("dormouse-lib/lib/window-session-aggregator", () => ({ + flushWindowSession: mocks.flushWindowSession, +})); vi.mock("./updater", () => ({ hasPendingUpdate: mocks.hasPendingUpdate, installPendingUpdate: mocks.installPendingUpdate, @@ -57,7 +63,7 @@ const oneNotedSurface = () => ["pane-a"]; let quitRequested: (() => void) | null = null; // Drain the microtask-driven teardown chain (no real timers on the happy path — -// withTimeout's 8s guard is cleared when the work wins). +// withTimeout's 10s guard is cleared when the work wins). const settle = () => new Promise((r) => setTimeout(r, 0)); // A fake adapter whose teardown steps append their name to `order` so the call @@ -69,6 +75,7 @@ function fakeAdapter(order: string[] = [], overrides: Partial { expect(mocks.invoke).toHaveBeenCalledWith("quit_proceed"); }); - it("runs teardown steps flush → kill → flush → drain → install → proceed in order", async () => { + it("runs teardown steps capture → flush → kill → flush → window → drain → install → proceed in order", async () => { const order: string[] = []; mocks.invoke.mockImplementation(async (cmd: string) => { order.push(cmd); return undefined; }); + mocks.flushWindowSession.mockImplementation(async () => { + order.push("flushWindow"); + }); mocks.hasPendingUpdate.mockReturnValue(true); mocks.installPendingUpdate.mockImplementation(async () => { order.push("install"); @@ -129,14 +139,17 @@ describe("quit orchestrator", () => { await triggerQuit(fakeAdapter(order)); - // `quit_progress` marks each phase boundary (teardown start, install start) - // so Rust's watchdog budgets teardown and install separately. + // The capture is first: an agent's resume invocation exists only between the + // interrupt and the kill. `quit_progress` marks each phase boundary (teardown + // start, install start) so Rust's watchdog budgets them separately. expect(order).toEqual([ "quit_ack", "quit_progress", + "captureRecovery", "flush", "gracefulKill", "flush", + "flushWindow", "drain", "quit_progress", "install", @@ -144,6 +157,20 @@ describe("quit orchestrator", () => { ]); }); + it("still saves and exits when the recovery capture rejects", async () => { + // Recovery is the one step whose data cannot be reconstructed, but losing it + // must never cost the save behind it. + const order: string[] = []; + const adapter = fakeAdapter(order, { + captureRecovery: () => Promise.reject(new Error("sidecar gone")), + }); + await triggerQuit(adapter); + + expect(order).toEqual(["flush", "gracefulKill", "flush", "drain"]); + expect(mocks.flushWindowSession).toHaveBeenCalled(); + expect(mocks.invoke).toHaveBeenCalledWith("quit_proceed"); + }); + it("skips install and its phase signal when no update is pending", async () => { mocks.hasPendingUpdate.mockReturnValue(false); await triggerQuit(fakeAdapter()); diff --git a/standalone/src/quit.ts b/standalone/src/quit.ts index 2c6930b41..673da009e 100644 --- a/standalone/src/quit.ts +++ b/standalone/src/quit.ts @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import { countRunningSessions } from "dormouse-lib/lib/terminal-registry"; import { archiveSurfaceNotes } from "dormouse-lib/lib/notepad/close-coordinator"; import { notepadSurfaceIds, removeSurface } from "dormouse-lib/lib/notepad/notepad-store"; +import { flushWindowSession } from "dormouse-lib/lib/window-session-aggregator"; import type { TauriAdapter } from "./tauri-adapter"; import { openQuitArchiveFailure } from "./quit-confirm-store"; import { hasPendingUpdate, installPendingUpdate } from "./updater"; @@ -125,7 +126,7 @@ async function archiveThenTeardown(): Promise { } // Ordering and rationale: docs/specs/standalone.md §Quit flow (Teardown -// ordering). The 8s ceiling is belt-and-suspenders over the per-step bounds. +// ordering). The 10s ceiling is belt-and-suspenders over the per-step bounds. // `quit_progress` tells Rust teardown has begun (ending the confirmation-wait // suspension) and marks each phase boundary so its watchdog gives teardown and // install separate budgets rather than one shared clock. @@ -137,18 +138,20 @@ async function runQuitTeardown(): Promise { if (adapter) { await withTimeout( (async () => { - // The two flushes are near-free while standalone persists nothing — - // `saveSession` returns immediately on `persistsSession: false`, so - // neither one runs a `getCwd` round trip. The shape is kept because - // the ordering is the load-bearing part and the workspaces-rollout - // scope turns persistence back on (docs/specs/layout.md -> `## Future`). + // Capture FIRST: an agent's resume invocation exists only between the + // interrupt and the kill, and it is the one thing here that cannot be + // reconstructed afterwards. Losing it must never cost the save behind + // it, so this step alone cannot abort the rest. + await adapter.captureAgentRecovery(1300).catch((err) => + console.warn("[quit] agent recovery capture failed; proceeding", err)); await adapter.requestSessionFlush(1500); // save while PTYs are alive await adapter.gracefulKillAllPtys(2000); // SIGTERM; wait for exits and final output await adapter.requestSessionFlush(1500); // final post-exit save + await flushWindowSession(); // the Walls' records become one Window blob await adapter.drainSessionSaves(2000); // last write reaches disk })(), - 8000, - "[quit] teardown exceeded 8000ms; proceeding to exit", + 10000, + "[quit] teardown exceeded 10000ms; proceeding to exit", ); } // Install strictly after the completed final save. A fresh `quit_progress` diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 09f63a061..3cc10e321 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -87,17 +87,80 @@ describe("TauriAdapter session-flush handshake", () => { }); }); -describe("TauriAdapter legacy session cleanup", () => { - it("asks Rust to clear orphaned temp state when no main snapshot exists", async () => { +// docs/specs/transport.md -> "The governing rule": standalone restores window +// state, so nothing is deleted at boot and the record is claimed once. +describe("TauriAdapter window persistence", () => { + const session = { version: 3 as const, panes: [{ id: "pane-a", title: "A", cwd: "/a", untouched: false }] }; + const windowBlob = { + version: 1 as const, + workspaces: [{ id: "ws-1", name: "One", session }], + activeWorkspaceId: "ws-1", + }; + + /** Stub Rust with a per-command implementation and boot the adapter. */ + async function booted(impl: (cmd: string, args?: Record) => unknown) { const invoke = vi.mocked(rawInvoke); invoke.mockClear(); - invoke.mockResolvedValue(undefined); + invoke.mockImplementation((async (cmd: string, args?: Record) => + impl(cmd, args)) as unknown as typeof rawInvoke); const adapter = new TauriAdapter(); - await adapter.init(); + return { adapter, invoke }; + } + + it("persists, and never clears the snapshot at boot", async () => { + const { adapter, invoke } = await booted((cmd) => (cmd === "load_session" ? JSON.stringify(windowBlob) : undefined)); - expect(invoke).toHaveBeenNthCalledWith(1, "load_session"); - expect(invoke).toHaveBeenNthCalledWith(2, "clear_session"); + expect(adapter.persistsSession).toBe(true); + expect(adapter.getWindowState()).toEqual(windowBlob); + expect(invoke.mock.calls.map(([cmd]) => cmd)).not.toContain("clear_session"); + adapter.shutdown(); + }); + + it("wraps a pre-Window blob as the one Workspace", async () => { + const { adapter } = await booted((cmd) => (cmd === "load_session" ? JSON.stringify(session) : undefined)); + expect(adapter.getWindowState()?.workspaces.map((ws) => ws.session)).toEqual([session]); + adapter.shutdown(); + }); + + it("claims the recovery commands for every saved pane, before restore reads them", async () => { + const { adapter, invoke } = await booted((cmd) => { + if (cmd === "load_session") return JSON.stringify(windowBlob); + if (cmd === "take_recovery_commands") return { "pane-a": "claude --continue" }; + return undefined; + }); + + expect(invoke).toHaveBeenCalledWith("take_recovery_commands", { paneIds: ["pane-a"] }); + // Synchronous by the time the cold restore asks, which is what `init()` + // completing before `resumeOrRestore` buys. + expect(adapter.getRecoveryCommands()).toEqual({ "pane-a": "claude --continue" }); + adapter.shutdown(); + }); + + it("restores without recovery when the record cannot be read", async () => { + const { adapter } = await booted((cmd) => { + if (cmd === "load_session") return JSON.stringify(windowBlob); + if (cmd === "take_recovery_commands") throw new Error("sidecar gone"); + return undefined; + }); + expect(adapter.getRecoveryCommands()).toEqual({}); + adapter.shutdown(); + }); + + it("asks for nothing when there are no saved panes", async () => { + const { adapter, invoke } = await booted(() => undefined); + expect(invoke.mock.calls.map(([cmd]) => cmd)).not.toContain("take_recovery_commands"); + expect(adapter.getRecoveryCommands()).toEqual({}); + adapter.shutdown(); + }); + + it("captures agent recovery and proceeds when the capture fails", async () => { + const { adapter, invoke } = await booted((cmd) => { + if (cmd === "capture_agent_recovery") throw new Error("sidecar gone"); + return undefined; + }); + await expect(adapter.captureAgentRecovery(1300)).resolves.toBeUndefined(); + expect(invoke).toHaveBeenCalledWith("capture_agent_recovery", { ids: null, timeout: 1300 }); adapter.shutdown(); }); }); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index fd61fe0c2..99c671dd4 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -42,7 +42,7 @@ import type { AwaitHandle, AwaitOptions } from "dormouse-lib/lib/alert-manager"; import type { AlertSettings } from "dormouse-lib/lib/alert-settings"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; import { loadWindowState, saveWindowState } from "dormouse-lib/lib/window-persistence"; -import type { PersistedWindow } from "dormouse-lib/lib/session-types"; +import type { PersistedAlertState, PersistedWindow } from "dormouse-lib/lib/session-types"; import { TauriSessionStore } from "./tauri-session-store"; import { withTimeout } from "./with-timeout"; import { @@ -243,7 +243,29 @@ export class TauriAdapter implements PlatformAdapter { console.error("[tauri-adapter] load_session failed:", err); } this.sessionStore.hydrate(seed); - await this.clearLegacySessionState(); + // The agent-recovery record is read here, at the same boot boundary and for + // the same reason as the session blob: `getRecoveryCommands` is synchronous + // because the cold restore reads it before React mounts. + await this.takeRecoveryCommands(); + } + + /** + * Claim the resume invocations the last teardown captured for the panes this + * Window is about to restore. Destructive in the sidecar on the first call, so + * a relaunch that gets this far can never replay them + * (docs/specs/transport.md -> "Consuming it"). + */ + private async takeRecoveryCommands(): Promise { + const saved = this.getWindowState(); + const paneIds = saved?.workspaces.flatMap((workspace) => workspace.session.panes.map((pane) => pane.id)) ?? []; + if (paneIds.length === 0) return; + try { + this.recoveryCommands = await rawInvoke>("take_recovery_commands", { paneIds }) ?? {}; + } catch (err) { + // A record we could not read is one restore without auto-resume, never a + // failed boot. + console.error("[tauri-adapter] take_recovery_commands failed:", err); + } } shutdown(): void { @@ -287,6 +309,33 @@ export class TauriAdapter implements PlatformAdapter { invoke("pty_kill", { id }); } + /** Agent resume invocations captured at the last teardown, claimed once during + * `init()` and read synchronously by the cold restore. */ + private recoveryCommands: Record = {}; + + getRecoveryCommands(): Record { + return this.recoveryCommands; + } + + /** Seed a cold-restored Surface's persisted TODO/alert; the manager lives here, + * so the restore path is the only thing that can. */ + alertSeed(id: string, state: PersistedAlertState): void { + this.alertManager.seed(id, state); + } + + /** + * Interrupt the live PTYs so each agent prints its resume invocation, and let + * the sidecar record what it detects. Warn-and-proceed: a quit must never wedge + * on this (docs/specs/standalone.md -> "Agent recovery"). + */ + async captureAgentRecovery(timeoutMs: number, ids?: string[]): Promise { + try { + await rawInvoke("capture_agent_recovery", { ids: ids ?? null, timeout: timeoutMs }); + } catch (err) { + console.warn("[tauri-adapter] captureAgentRecovery failed; proceeding", err); + } + } + async getCwd(id: string): Promise { try { return await rawInvoke("pty_get_cwd", { id }); @@ -593,29 +642,17 @@ export class TauriAdapter implements PlatformAdapter { private static STATE_KEY = 'dormouse.session'; - // Standalone persists no Session state: quitting the app is a deliberate - // ending, and a crash captured nothing, so every launch starts fresh - // (docs/specs/transport.md -> "The governing rule"). - // - // This is a gate at the adapter boundary, not a removal of the store. The - // plumbing below it — TauriSessionStore, the Rust temp-then-rename file store, - // the quit flush/drain ordering — is intact and still needed by the - // workspaces-rollout scope (docs/specs/layout.md -> `## Future`). Bringing - // VS Code-style restoration to standalone later also needs to reconcile - // the unconditional boot deletion in clearLegacySessionState and add capture - // to the existing quit teardown (flush -> kill -> flush -> drain). - private static PERSIST_SESSION = false; - /** * Read by `saveSession`, which skips the whole record build — not just the * write — when a host persists nothing (`PlatformAdapter.persistsSession`). + * Standalone persists window state (`docs/specs/transport.md` -> + * "The governing rule"). */ - readonly persistsSession = TauriAdapter.PERSIST_SESSION; + readonly persistsSession = true; /** The aggregator's writer: one `PersistedWindow` per window * (`docs/specs/transport.md` -> "Persisted session"). */ saveState(state: unknown): void { - if (!TauriAdapter.PERSIST_SESSION) return; try { saveWindowState(this.sessionStore, TauriAdapter.STATE_KEY, state as PersistedWindow); } catch { @@ -633,7 +670,6 @@ export class TauriAdapter implements PlatformAdapter { /** The persisted Window, read from the boot-seeded cache. */ getWindowState(): PersistedWindow | null { - if (!TauriAdapter.PERSIST_SESSION) return null; try { return loadWindowState(this.sessionStore, TauriAdapter.STATE_KEY); } catch { @@ -668,29 +704,4 @@ export class TauriAdapter implements PlatformAdapter { resetUnreadable: () => rawInvoke("reset_notepad_archive"), }; - /** - * Delete any pre-upgrade snapshot or orphaned temp write. Those carry - * transcripts, so ignoring the slot is not enough — the bytes have to leave the - * disk (docs/specs/transport.md -> "Retiring the transcripts already on disk"). - * Called from init() after the store hydrates. - * - * Deletes the file through the Rust store that owns it rather than blanking the - * slot: a sentinel would leave the bytes in place until some later write, and - * would oblige every reader to treat `''` as a third state alongside present - * and absent. - */ - private async clearLegacySessionState(): Promise { - const hadReadableSnapshot = this.sessionStore.getItem(TauriAdapter.STATE_KEY) !== null; - try { - // Always ask Rust to clear: load_session cannot see a .json.tmp left by a - // crash before rename, but that file still contains the legacy transcript. - await rawInvoke("clear_session"); - this.sessionStore.hydrate(null); - if (hadReadableSnapshot) { - console.info('[tauri-adapter] Cleared legacy persisted session (transcripts are no longer stored)'); - } - } catch (err) { - console.error('[tauri-adapter] Failed to clear legacy session state:', err); - } - } } From da0aadaf14e30d990a9f0bac3d5dbd4c40392550 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 22:00:10 -0700 Subject: [PATCH 07/11] Promote standalone persistence and agent recovery into the specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transport.md's governing rule reverses for standalone: window state is the app's contract, so quit restores structure and auto-resumes agents, and the rationale records why — the two objections to the old store were both about content (transcripts, a WKWebView WAL) and both were already fixed. The persisted-session types describe the aggregator as it now is: seeded at boot, one debounced writer, a Workspace compared against its own record, a store change writing too. The transcript-retirement bullet becomes the orphan-temp sweep. standalone.md rewrites Persistence around the per-Workspace boot and the dev state root, gains an Agent recovery subsection for the sidecar-owned record, and its quit teardown puts the capture first with the new 10 s / 14 s budgets. layout.md promotes standalone persistence out of the rollout ledger and states the two rules it owns: publish to the aggregator, and mark dirty only for Surfaces this Wall owns. vscode.md records that the capture machine is shared and drops the workspaces-flag Future item. security-local.md, notepad.md, and glossary.md follow. Budgets ratcheted: layout 8400, notepad 3800, security-local 2600, standalone 4650, transport 4650, vscode 7400. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- docs/specs/auto-update.rationale.md | 2 +- docs/specs/glossary.md | 2 +- docs/specs/layout.md | 13 +-- docs/specs/notepad.md | 4 +- docs/specs/notepad.rationale.md | 2 +- docs/specs/security-local.md | 26 +++-- docs/specs/security-local.rationale.md | 6 +- docs/specs/standalone.md | 132 +++++++++++++++---------- docs/specs/standalone.rationale.md | 12 +-- docs/specs/transport.md | 37 ++++--- docs/specs/transport.rationale.md | 10 +- docs/specs/vscode.md | 20 ++-- scripts/spec-word-budgets.json | 12 +-- standalone/src-tauri/src/lib.rs | 6 +- 14 files changed, 165 insertions(+), 119 deletions(-) diff --git a/docs/specs/auto-update.rationale.md b/docs/specs/auto-update.rationale.md index 6f1bb963f..2fd8b4b01 100644 --- a/docs/specs/auto-update.rationale.md +++ b/docs/specs/auto-update.rationale.md @@ -4,7 +4,7 @@ ## Quit-time install -**Why install runs last.** A Windows NSIS install force-kills the app the moment it starts, so starting it early interrupts teardown. This ordering originally protected persisted scrollback; standalone now persists no Session state. The retained save/drain hooks and their completion semantics are explained in `docs/specs/standalone.rationale.md` → Quit flow. +**Why install runs last.** A Windows NSIS install force-kills the app the moment it starts, so starting it early interrupts teardown. This ordering originally protected persisted scrollback; what it protects now is the window's structure, which standalone does persist. The retained save/drain hooks and their completion semantics are explained in `docs/specs/standalone.rationale.md` → Quit flow. **Why Vite dev mode skips `install()`.** The updater resolves its replacement target from the current executable path, which in dev is the dev executable's directory, not a packaged bundle. diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 10f7d78ca..06094a6b1 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -88,7 +88,7 @@ A Workspace's **union status** is its display projection of member Surfaces' Act ### Implementation status -The Pane / Surface model, surface kinds, and the Workspace model are live; a Window still means one OS window, and `dormouse.flags.workspaces` still controls the stored Window wrapper (`docs/specs/layout.md` → Workspaces). Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. +The Pane / Surface model, surface kinds, the Workspace model, and per-Workspace persistence are live; a Window still means one OS window (`docs/specs/layout.md` → Workspaces). Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. ## Roles diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2379d5f73..332f1304b 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -155,9 +155,9 @@ Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). S **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. -The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. +The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`: standalone stores one `PersistedWindow` per window, so a relaunch restores every Workspace ([Session persistence](#session-persistence)). -Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. +Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`. What multi-window, per-Workspace persistence, and the `dor workspace` verbs still owe is staged in [Future](#future) — this spec's `## Future` is the single rollout ledger; other specs link here. @@ -358,19 +358,21 @@ Three save triggers, in ascending urgency: `docs/specs/standalone.md` §Persistence owns the dirty-gating mechanism and the store-level identical-value backstop. -Container shapes and the `dormouse.flags.workspaces` wrapping are `docs/specs/transport.md` → "Persisted session types" ([Workspaces](#workspaces)); VS Code persists one Workspace per webview (`WebviewView` / `WebviewPanel`). +**Under a Workspace, a Wall publishes its record to the Window aggregator instead of the platform slot**, and compares each save against its own Workspace's previous record — container shapes and the aggregator's rules are `docs/specs/transport.md` → "Persisted session types" ([Workspaces](#workspaces)). **A Wall marks itself dirty only for Surfaces it owns**: both content stores are Window-global and name the Surface that changed, so an idle Workspace does not rebuild its record — a `getCwd` per pane — whenever another Workspace moves. VS Code persists one Workspace per webview (`WebviewView` / `WebviewPanel`). Snapshots are read through `readPersistedSession()`, which tolerates a stringified blob and logs-and-discards an unreadable one so malformed storage starts fresh rather than blocking startup (`docs/specs/transport.md` → "Persisted session types"). Startup recovery is priority-based: +**A Window plans once per Workspace off one live-PTY list**: `collectLivePtys` runs the single PTY-list round trip for the whole webview, and each Workspace takes the slice its own saved panes name, so one host answer restores N Workspaces (`docs/specs/standalone.md` → Persistence). A single-Wall host reaches the same behavior through `resumeOrRestore`. + 1. **Resume** (webview recreated, retained Live or Exited PTYs): request PTY list + replay data from the platform, `resumeTerminal()` each (500ms timeout). **Saved pane and door titles are seeded back via `setTerminalUserTitle()`** (`docs/specs/transport.md`), so persisted placeholder labels never replay as user pins. If the saved session covers every retained PTY, restore the saved Lath layout when its leaf set matches and reattach saved minimized items as doors. **Never fall through to cold restore just because the visible `paneIds` list is empty** — a wall whose retained sessions are all minimized is still a resume. -2. **Restore** (app restart, cold start): the Wall's `seed` hydrates from the restored Lath layout, else falls to (3); `restoreTerminal()` per pane with its saved cwd and title. Browser surfaces are rebuilt from their persisted params instead. +2. **Restore** (app restart, cold start): the Wall's `seed` hydrates from the restored Lath layout, else falls to (3); `restoreTerminal()` per pane with its saved cwd and title, plus the single-use agent resume invocation the host captured (`docs/specs/transport.md` → "Consuming it") and, on a host whose AlertManager lives in the webview, the pane's persisted TODO through `PlatformAdapter.alertSeed`. Browser surfaces are rebuilt from their persisted params instead. 3. **Fallback/manual pane creation**: with no saved layout safely applicable, add panes as splits from the previous pane. 4. **Empty state**: one new pane. Every PTY spawned by (2)–(4) uses the current default shell selection. -Source of truth: `lib/src/components/wall/use-session-persistence.ts` (save triggers and flushes), `lib/src/lib/session-save.ts` (serialization), `lib/src/lib/reconnect.ts` (recovery priority). +Source of truth: `lib/src/components/wall/use-session-persistence.ts` (save triggers and flushes), `lib/src/lib/session-save.ts` (serialization), `collectLivePtys` / `resumeOrRestoreFrom` in `lib/src/lib/reconnect.ts` (recovery priority), `restoreWindow` in `standalone/src/main.tsx` (the per-Workspace boot). ### Activity state @@ -427,7 +429,6 @@ A store commit that empties the tree (last pane killed or minimized) triggers th **Scope: workspaces-rollout** — what the multi-Workspace feature still owes. Current implementation: [Workspaces](#workspaces). Persisted containers are owned by `docs/specs/transport.md`; union projection by `docs/specs/alert.md`. This ledger is the single home for what remains; other specs link here rather than restating it. -- **Standalone persistence and agent recovery.** Every Workspace's record already reaches the Window collector, which has no writer, so nothing is stored and a relaunch restores one Workspace. Turning it on means seeding the collector at boot, debouncing and flushing its writes, adopting a restored `PersistedWindow` into the Workspace store, and lifting VS Code's agent-recovery capture into a host-agnostic module the sidecar bundles. - **Multiple OS windows.** PTY ownership routing in Rust, window lifecycle, tearing a Workspace out into its own window, dropping one onto another window, and restoring N windows. `WorkspaceStrip`'s `onDragOutsideWindow` / `onDropOnOtherWindow` and the router's `window:` rejection are the seams; `WINDOW_REF` names the only Window this build addresses. - **`dor workspace` verbs.** `new` / `rename` / `close` / `switch`, plus `dor list --all` for cross-Workspace targeting and `workspace:` as the stable handle beside today's positional `workspace:`. diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index b035a4410..ae0ce3758 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -140,12 +140,14 @@ Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator **Archiving is a gate step before teardown**: after the running-work confirmation, or immediately on an all-idle quit, and **before the first `quit_progress`** (`docs/specs/standalone.md` → "Quit flow"; rationale). **It is bounded at 3 s.** +**Standalone still archives at quit even though it now restores its windows** (`docs/specs/transport.md` → "The governing rule"): VS Code's live notes survive a Reload only through the extension host's in-memory mirror ([Live resume](#live-resume)), and quitting standalone leaves no such survivor. + - **A failure or timeout leaves the quit pending in Rust**, whose phase-2 wait is unbounded for exactly this (`docs/specs/standalone.md` → "Quit flow"), and the dialog shows the error with **Cancel** (default) and **Quit anyway**, which discards the notes. **Only Cancel calls `quit_cancel`**: Quit anyway must reach teardown with the watchdog still armed. - **A timeout aborts the archive it stopped waiting for** ([Closure](#closure)). - **Must include Surfaces with pending batch IDs even after their last note is deleted**, both when archiving and discarding on Quit anyway; pinned by `standalone/src/quit-notepad.test.ts`. - **Teardown's own rule is untouched**: once teardown begins, no failing step prevents exit. -The store is `/notepad-archive-v1.json`, **a sibling of `sessions/`, never inside it** — a Surface's notes outlive the window whose closure archived them, so they must not ride the per-window session blob or be swept by `clear_session`. **It is written owner-only and atomically through the same `write_file_atomically` the session snapshot uses** (`docs/specs/security-local.md` → "Persisted state"). **The revision is a hash of the stored bytes, and every load, save and reset holds an exclusive lock on the sidecar `notepad-archive-v1.lock`** — a second Dormouse sharing `app_data_dir()`, a dev build beside the installed app, then conflicts instead of overwriting batches it never read. **Recovery renames it to `notepad-archive-v1.unreadable-.json` beside the original**, disambiguating rather than overwriting an earlier quarantine; only a temp file a crash left behind is dropped. +The store is `/notepad-archive-v1.json`, **outside `sessions/` and outside the state root**, so every build shares one — a Surface's notes outlive the window whose closure archived them, so they must not ride the per-window session blob or the sweep over its directory. **It is written owner-only and atomically through the same `write_file_atomically` the session snapshot uses** (`docs/specs/security-local.md` → "Persisted state"). **The revision is a hash of the stored bytes, and every load, save and reset holds an exclusive lock on the sidecar `notepad-archive-v1.lock`** — a second Dormouse sharing `app_data_dir()`, a dev build beside the installed app, then conflicts instead of overwriting batches it never read. **Recovery renames it to `notepad-archive-v1.unreadable-.json` beside the original**, disambiguating rather than overwriting an earlier quarantine; only a temp file a crash left behind is dropped. Source of truth: `archiveNotesBeforeQuit` in `standalone/src/quit.ts`, the `'archive-failed'` phase in `standalone/src/quit-confirm-store.ts`; `write_notepad_archive_to`, `lock_notepad_archive` and `reset_notepad_archive_at` in `standalone/src-tauri/src/lib.rs`; the port in `standalone/src/tauri-adapter.ts`. diff --git a/docs/specs/notepad.rationale.md b/docs/specs/notepad.rationale.md index 603af843f..b1f5e84d1 100644 --- a/docs/specs/notepad.rationale.md +++ b/docs/specs/notepad.rationale.md @@ -203,7 +203,7 @@ who had just been told the notes were not stored and had chosen Cancel. quit they already asked for; a slower answer is a failure worth surfacing. The file is a sibling of `sessions/` rather than a member of it because the two have -different lifetimes: session snapshots are per window and swept by `clear_session`, +different lifetimes: session snapshots are per window and swept with it, while archived notes outlive the window that produced them and must survive that sweep. They share `write_file_atomically` because both carry user text and both must survive a crash mid-write; that is one implementation, not two. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 58407e0bd..b36f4feee 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -135,22 +135,30 @@ Source of truth: the shared rule and predicates — `isLoopbackHost`, `isOwnOrig The attacker is another local account reading disk; what the remote stack leaves behind is `docs/specs/security-remote.md` -> "Credentials at rest". -**Session snapshots are owner-only before any bytes are written.** -`restrict_to_owner` locks `/sessions/` and, *first*, the temp file -renamed into it, applying a protected single-ACE DACL on Windows where a unix -mode is a silent no-op (`docs/specs/standalone.md` -> "Persistence"). The same -helper locks the whole standalone app-data directory before the sidecar spawns. +**Session snapshots are owner-only before any bytes are written.** Standalone +persists one window's structure per file as `/sessions/