From e831b11e7bf7a84bcbe2f44cb6578d9beddf8b76 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 20:22:15 -0700 Subject: [PATCH 01/23] Give both browser hosts an attach verb that never spawns a daemon The webview recovered a stale stream port through `stream status`, a CLI verb that starts a daemon to answer: after a reboot a restored pane bound to a fresh about:blank browser and never reopened the page it had, and a query in the pop-out close/reopen gap raced a competing daemon. `attach(session, { url, headed })` replaces the webview-facing stream status on every host layer (shared hosts, VS Code message/router/adapter, the Tauri command and sidecar case, the browser-dev harness): - agent-browser reads `.pid` / `.stream` and probes the port, as `launch` already did. A daemon that is up but not streaming is left alone; only a gone one, for a caller naming the page it had, is relaunched there (headed for a pop-out, tracked for shutdown), through the relaunch path's generations and blank-tab sweep. Concurrent attaches of one session join. - Playwright connects its viewer, relaunching at `url` only when the CLI registry lists no browser for the session (never one it merely cannot view), serialized with launches and closes. `dor pw`'s binding uses it with no page, so it never relaunches. `open` also takes a caller-chosen session, for launches that must land in a known one (a Tool's own, a restored swap). The existing callers switch to page-less attach, so behavior is otherwise unchanged here; the controller adopts the relaunch policy next. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- lib/src/components/Wall.test.tsx | 4 +- lib/src/components/WorkspaceWindow.test.tsx | 2 +- .../wall/AgentBrowserPanel.test.tsx | 36 +++---- .../agent-browser-surface-controller.test.ts | 40 ++++---- .../wall/agent-browser-surface-controller.ts | 8 +- lib/src/components/wall/browser-automation.ts | 6 +- .../components/wall/tool-browser-session.ts | 6 +- lib/src/components/wall/use-dor-control.ts | 5 +- lib/src/host/agent-browser-host.test.ts | 98 ++++++++++++++++++- lib/src/host/agent-browser-host.ts | 90 +++++++++++++---- .../host/playwright-host.lifecycle.test.ts | 66 ++++++++++--- lib/src/host/playwright-host.test.ts | 8 +- lib/src/host/playwright-host.ts | 31 ++++-- lib/src/lib/platform/browser-automation.ts | 4 +- lib/src/lib/platform/types.ts | 29 +++--- lib/src/lib/platform/vscode-adapter.ts | 21 ++-- standalone/scripts/dev-agent-browser.mjs | 4 +- standalone/sidecar/main.js | 6 +- standalone/src-tauri/src/lib.rs | 13 ++- standalone/src/browser-sidecar-adapter.ts | 13 +-- standalone/src/tauri-adapter.ts | 11 ++- vscode-ext/src/agent-browser-host.ts | 2 +- vscode-ext/src/message-router.ts | 14 ++- vscode-ext/src/message-types.ts | 8 +- 24 files changed, 370 insertions(+), 155 deletions(-) diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index ab14f6094..28b2ffd64 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -829,7 +829,7 @@ describe('Wall on the Lath engine', () => { }); (fake as PlatformAdapter).agentBrowserCommand = agentBrowserCommand; (fake as PlatformAdapter).agentBrowserOpen = vi.fn(() => openResult); - (fake as PlatformAdapter).agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 4321 })); + (fake as PlatformAdapter).agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 4321 })); try { await act(async () => { @@ -3840,7 +3840,7 @@ describe('Wall on the Lath engine', () => { it('names the command that drives a browser run by the other provider', async () => { (fake as PlatformAdapter).playwright = vi.fn(async (request: { op: string }) => ( - request.op === 'streamStatus' ? { ok: true, wsPort: 4555 } : { ok: true } + request.op === 'attach' ? { ok: true, wsPort: 4555 } : { ok: true } )); (fake as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index cdbf781b7..fde24393e 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -579,7 +579,7 @@ describe('WorkspaceWindow', () => { }); }); await flush(); - expect(playwright).toHaveBeenCalledWith(expect.objectContaining({ op: 'streamStatus', session: 'late' })); + expect(playwright).toHaveBeenCalledWith(expect.objectContaining({ op: 'attach', session: 'late' })); await act(async () => { expect(await handle.closeAll('silent')).toBeNull(); }); await act(async () => status.resolve({ ok: true, wsPort: 4321, headed: false })); await flush(); diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index c5374ca16..20dce0f5c 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -5,7 +5,7 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakePtyAdapter, setPlatform } from '../../lib/platform'; -import type { AgentBrowserPopResult, AgentBrowserStreamStatusResult, PlatformAdapter } from '../../lib/platform/types'; +import type { AgentBrowserPopResult, AgentBrowserAttachResult, PlatformAdapter } from '../../lib/platform/types'; import type { PaneProps } from './pane-props'; import { AgentBrowserPanel, HIDDEN_PARK_DELAY_MS } from './AgentBrowserPanel'; import { getAgentBrowserScreenController } from './agent-browser-screen'; @@ -149,14 +149,14 @@ describe('AgentBrowserPanel render mode controller', () => { ok: true, wsPort: 3456, })); - const streamStatus = vi.fn(async (): Promise => ({ + const streamStatus = vi.fn(async (): Promise => ({ ok: true, wsPort: 1234, })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); platform.agentBrowserPopOut = popOut; - platform.agentBrowserStreamStatus = streamStatus; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); await renderPanel(paneProps('ab-panel'), updateParameters); @@ -178,10 +178,10 @@ describe('AgentBrowserPanel render mode controller', () => { ok: true, wsPort: 4567, })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); platform.agentBrowserPopIn = popIn; - platform.agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 1234 })); + platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); setPlatform(platform); await renderPanel( @@ -206,10 +206,10 @@ describe('AgentBrowserPanel render mode controller', () => { ok: true, wsPort: 4567, })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); platform.agentBrowserPopIn = popIn; - platform.agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 1234 })); + platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); setPlatform(platform); await renderPanel( @@ -241,9 +241,9 @@ describe('AgentBrowserPanel render mode controller', () => { it('mirrors popped-out stream tab URL updates when the stream reports id instead of tabId', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 1234 })); + platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); setPlatform(platform); await renderPanel( @@ -269,12 +269,12 @@ describe('AgentBrowserPanel render mode controller', () => { it('mirrors popped-out manual navigation from CDP target events', async () => { const updateParameters = vi.fn(); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; platform.agentBrowserCommand = vi.fn(async (_session, args) => { if (args.join(' ') === 'get cdp-url') return { exitCode: 0, stdout: 'ws://127.0.0.1:9222/devtools/browser/test', stderr: '' }; return { exitCode: 0, stdout: '', stderr: '' }; }); - platform.agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 1234 })); + platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 1234 })); setPlatform(platform); await renderPanel( @@ -422,12 +422,12 @@ describe('AgentBrowserPanel render mode controller', () => { }); it('does not recover a stale port through stream status after that port opened live', async () => { - const streamStatus = vi.fn(async (): Promise => ({ + const streamStatus = vi.fn(async (): Promise => ({ ok: true, wsPort: 2222, })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); await renderPanel(paneProps('ab-panel', { surfaceType: 'browser', session: 'browser-session', wsPort: 1111 })); @@ -699,9 +699,9 @@ describe('AgentBrowserPanel visibility parking', () => { }); it('never queries stream status while parked', async () => { - const streamStatus = vi.fn(async () => ({ ok: false })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const streamStatus = vi.fn(async () => ({ ok: false })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); // No wsPort ⇒ the stale-port recovery effect is the code path that would diff --git a/lib/src/components/wall/agent-browser-surface-controller.test.ts b/lib/src/components/wall/agent-browser-surface-controller.test.ts index a058a03f5..08bbe45c1 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.test.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.test.ts @@ -511,9 +511,9 @@ describe('updateParams', () => { describe('stale-port recovery gating', () => { it('stays fully inert for a session-less pane until params deliver the session', async () => { - const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); // The pane context menu's instant connect mounts its surface WITHOUT a @@ -537,9 +537,9 @@ describe('stale-port recovery gating', () => { it('never queries stream status while parked', async () => { vi.useFakeTimers(); try { - const streamStatus = vi.fn(async () => ({ ok: false })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const streamStatus = vi.fn(async () => ({ ok: false })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); // No wsPort ⇒ the recovery path is what would query the daemon. @@ -559,9 +559,9 @@ describe('stale-port recovery gating', () => { }); it('does not recover a stale port through stream status after that port opened live', async () => { - const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 1111 }); @@ -579,9 +579,9 @@ describe('stale-port recovery gating', () => { it('clears live-port memory while parked so unpark can recover a changed stream port', async () => { vi.useFakeTimers(); try { - const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 2222 })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; setPlatform(platform); const controller = acquireAgentBrowserSurfaceController('id', { session: 'sess', wsPort: 1111 }); @@ -603,7 +603,7 @@ describe('stale-port recovery gating', () => { await vi.advanceTimersByTimeAsync(4000); await vi.advanceTimersByTimeAsync(0); - expect(streamStatus).toHaveBeenCalledWith('sess', undefined); + expect(streamStatus).toHaveBeenCalledWith('sess', {}, undefined); expect(sink.updateParameters).toHaveBeenCalledWith({ wsPort: 2222 }); } finally { vi.useRealTimers(); @@ -611,9 +611,9 @@ describe('stale-port recovery gating', () => { }); it('does not query the daemon while a relaunch is in flight', async () => { - const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 9999 })); - const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserStreamStatus = streamStatus; + const streamStatus = vi.fn(async () => ({ ok: true, wsPort: 9999 })); + const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; + platform.agentBrowserAttach = streamStatus; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); // A pop-out whose promise never settles pins `relaunching` true. platform.agentBrowserPopOut = vi.fn(() => new Promise(() => {})); @@ -654,12 +654,12 @@ describe('dispose', () => { }); describe('relaunch (pop-out / pop-in)', () => { - type RelaunchPlatform = FakePtyAdapter & Pick; + type RelaunchPlatform = FakePtyAdapter & Pick; function relaunchPlatform(): RelaunchPlatform & { resolvePopOut: (res: { ok: boolean; wsPort?: number }) => void } { const platform = new FakePtyAdapter() as RelaunchPlatform; let resolvePopOut!: (res: { ok: boolean; wsPort?: number }) => void; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); - platform.agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 9999 })); + platform.agentBrowserAttach = vi.fn(async () => ({ ok: true, wsPort: 9999 })); platform.agentBrowserPopOut = vi.fn(() => new Promise<{ ok: boolean; wsPort?: number }>((r) => { resolvePopOut = r; })); platform.agentBrowserPopIn = vi.fn(async () => ({ ok: true, wsPort: 5555 })); setPlatform(platform); @@ -692,7 +692,7 @@ describe('relaunch (pop-out / pop-in)', () => { expect(streamSockets(3456).length).toBe(1); expect(streamSockets(1111).length).toBe(1); expect(platform.agentBrowserCommand).toHaveBeenCalledWith('sess', ['get', 'cdp-url'], undefined); - expect(platform.agentBrowserStreamStatus).not.toHaveBeenCalled(); + expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); }); it('ignores a second pop-out or pop-in while one is in flight', async () => { @@ -805,7 +805,7 @@ describe('relaunch (pop-out / pop-in)', () => { controller.updateParams({ session: 'sess', wsPort: 4321, url: 'https://x.example/' }); await flushMicrotasks(); expect(streamSockets(4321).length).toBe(1); - expect(platform.agentBrowserStreamStatus).not.toHaveBeenCalled(); + expect(platform.agentBrowserAttach).not.toHaveBeenCalled(); }); }); diff --git a/lib/src/components/wall/agent-browser-surface-controller.ts b/lib/src/components/wall/agent-browser-surface-controller.ts index 40b82c614..ad4a71861 100644 --- a/lib/src/components/wall/agent-browser-surface-controller.ts +++ b/lib/src/components/wall/agent-browser-surface-controller.ts @@ -1046,8 +1046,8 @@ export class AgentBrowserSurfaceController { } if (this.streamPort && !this.connectionLost && this.status?.connected !== false) return; const platform = this.platform; - if (!platform.agentBrowserStreamStatus) return; - platform.agentBrowserStreamStatus(session, this.binaryPath).then((res) => { + if (!platform.agentBrowserAttach) return; + platform.agentBrowserAttach(session, {}, this.binaryPath).then((res) => { if (gen !== this.recoveryGen || this.disposed) return; if (!res.ok || !res.wsPort) return; this.setConnectionLost(false); @@ -1081,12 +1081,12 @@ export class AgentBrowserSurfaceController { const currentSession = this.session; const platform = this.platform; - if (!currentSession || !platform.agentBrowserStreamStatus) { + if (!currentSession || !platform.agentBrowserAttach) { this.bumpRecovery(); return Promise.resolve(false); } - return platform.agentBrowserStreamStatus(currentSession, this.binaryPath).then((res) => { + return platform.agentBrowserAttach(currentSession, {}, this.binaryPath).then((res) => { if (this.closeIfSessionMarkedClosed(currentSession)) return false; if (!res.ok || !res.wsPort) return false; if (res.wsPort !== this.streamPort) { diff --git a/lib/src/components/wall/browser-automation.ts b/lib/src/components/wall/browser-automation.ts index 374538ed9..e3489dcff 100644 --- a/lib/src/components/wall/browser-automation.ts +++ b/lib/src/components/wall/browser-automation.ts @@ -77,7 +77,7 @@ export function offeredRenderModes(isTool: boolean, current: BrowserAutomationPr export type BrowserPlatform = Pick; @@ -99,13 +99,13 @@ export function browserPlatform(provider: BrowserAutomationProvider, cwd?: strin if (r.bytes && !(r.bytes instanceof Uint8Array)) r.bytes = new Uint8Array(r.bytes); return r; }, - agentBrowserStreamStatus: (session: string, binaryPath?: string) => call({ op: 'streamStatus', session, binaryPath }), + agentBrowserAttach: (session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string) => call({ op: 'attach', session, ...opts, binaryPath }), getAgentBrowserStreamUrl: async (port: number) => { const r = await call({ op: 'streamUrl', port }); if (!r.ok || !r.url) throw new Error(r.error ?? 'Playwright stream unavailable'); return r.url; }, - agentBrowserOpen: (url: string, opts: { headed?: boolean }, binaryPath?: string) => call({ op: 'open', url, ...opts, binaryPath }), + agentBrowserOpen: (url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string) => call({ op: 'open', url, ...opts, binaryPath }), agentBrowserPopOut: (session: string, opts: { url?: string }, binaryPath?: string) => call({ op: 'popOut', session, ...opts, binaryPath }), agentBrowserPopIn: (session: string, opts: { url?: string }, binaryPath?: string) => call({ op: 'popIn', session, ...opts, binaryPath }), }; diff --git a/lib/src/components/wall/tool-browser-session.ts b/lib/src/components/wall/tool-browser-session.ts index f3404cea5..8e6e45abc 100644 --- a/lib/src/components/wall/tool-browser-session.ts +++ b/lib/src/components/wall/tool-browser-session.ts @@ -1,7 +1,7 @@ import type { PlatformAdapter } from '../../lib/platform/types'; /** The host capabilities this module needs — the same two the CLI path leans * on, narrowed so tests can stub them without a full adapter. */ -type ConnectPlatform = Pick; +type ConnectPlatform = Pick; /** * Open `url` in `session` and hand `surfaceId` the resulting `{session, wsPort}` @@ -35,8 +35,8 @@ export async function attachAgentBrowserSession({ // Best-effort stream port so the panel connects straight to the live screencast; // if it's absent or stale the panel recovers it later, so a miss is non-fatal. let wsPort: number | undefined; - if (platform.agentBrowserStreamStatus) { - const status = await platform.agentBrowserStreamStatus(session, binaryPath); + if (platform.agentBrowserAttach) { + const status = await platform.agentBrowserAttach(session, {}, binaryPath); if (status.ok) wsPort = status.wsPort; } // Setting `session` connects the controller (the daemon is up now, so its diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 8e70352c2..fd2c6ca9f 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1673,11 +1673,12 @@ export function useDorControl({ if (key) browserReservations.current.confirm(key); const cwd = stringParam(params.cwd); const platform = browserPlatform(provider, cwd); - if (!platform.agentBrowserStreamStatus) { + if (!platform.agentBrowserAttach) { detail.respond({ ok: false, error: 'Playwright is unavailable on this host' }); return; } - const status = await platform.agentBrowserStreamStatus(session, binaryPath); + // No page named: a session the command left closed is not relaunched. + const status = await platform.agentBrowserAttach(session, {}, binaryPath); if (!status.ok) { detail.respond({ ok: false, error: status.error ?? 'Playwright connection failed' }); return; diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index 17db52829..adb586d3a 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -349,6 +349,100 @@ describe('agent-browser host relaunch', () => { }); }); +describe('agent-browser host attach', () => { + const originalSocketDir = process.env.AGENT_BROWSER_SOCKET_DIR; + const session = 'dormouse.1.default'; + + beforeEach(() => { + spawnMock.mockReset(); + process.env.AGENT_BROWSER_SOCKET_DIR = mkdtempSync(join(tmpdir(), 'dormouse-ab-attach-test-')); + }); + + afterEach(() => { + if (originalSocketDir === undefined) delete process.env.AGENT_BROWSER_SOCKET_DIR; + else process.env.AGENT_BROWSER_SOCKET_DIR = originalSocketDir; + }); + + it('reads a live daemon\'s port from its state files and spawns nothing', async () => { + const { port, server } = await listen(); + try { + // This test process stands in for the live daemon. + writeState(session, 'pid', process.pid); + writeState(session, 'stream', port); + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + expect(await host.attach(session, { url: 'https://example.com/' })).toEqual({ ok: true, wsPort: port }); + expect(spawnMock).not.toHaveBeenCalled(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('never relaunches a daemon that is up but not streaming, nor a gone one it has no page for', async () => { + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + writeState(session, 'pid', process.pid); + writeState(session, 'stream', await closedPort()); + expect((await host.attach(session, { url: 'https://example.com/' })).ok).toBe(false); + + writeState(session, 'pid', DEAD_PID); + expect((await host.attach(session, {})).ok).toBe(false); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('relaunches a gone daemon at the page named, headed for a pop-out, and tracks that window for shutdown', async () => { + writeState(session, 'pid', DEAD_PID); + writeState(session, 'stream', await closedPort()); + const { port, server } = await listen(); + const opened = deferred(); + const calls = mockSpawnByCommand({ + '--headed open': () => { + writeState(session, 'pid', DEAD_PID + 1); + writeState(session, 'stream', port); + return opened.promise; + }, + close: () => ({}), + }); + try { + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + // Two panes restoring one session relaunch it once. + const [first, second] = await Promise.all([ + host.attach(session, { url: 'https://example.com/', headed: true }), + host.attach(session, { url: 'https://example.com/', headed: true }), + ]); + expect(first).toEqual({ ok: true, wsPort: port }); + expect(second).toEqual(first); + expect(calls).toEqual([['--session', session, '--headed', 'open', 'https://example.com/']]); + + await host.closePoppedOut(); + expect(calls).toContainEqual(['--session', session, 'close']); + opened.resolve({}); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('opens a page in a caller-named session, and refuses a malformed one', async () => { + const host = createAgentBrowserHost({ writeClipboardText: vi.fn() }); + const { port, server } = await listen(); + const calls = mockSpawnByCommand({ + open: () => { + writeState('dormouse.1.tool.a', 'pid', DEAD_PID); + writeState('dormouse.1.tool.a', 'stream', port); + return {}; + }, + stream: () => ({ stdout: JSON.stringify({ port }) }), + }); + try { + expect(await host.open('http://localhost:5173/', { session: 'dormouse.1.tool.a' })).toEqual({ ok: true, session: 'dormouse.1.tool.a', wsPort: port }); + expect(calls[0]).toEqual(['--session', 'dormouse.1.tool.a', 'open', 'http://localhost:5173/']); + const spawned = calls.length; + expect(await host.open('http://localhost:5173/', { session: '../evil' })).toEqual({ ok: false, error: 'a valid session name is required' }); + expect(calls).toHaveLength(spawned); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + describe('agent-browser host screenshot transport', () => { // Block body (not `() => spawnMock.mockReset()`): an arrow returning the mock // makes vitest register it as a teardown hook and call it — a phantom spawn. @@ -545,7 +639,7 @@ describe('agent-browser host screenshot transport', () => { // `binaryPath` crosses from the webview realm and off the persisted session // blob, so an unchecked one is arbitrary local execution in the extension host - // or the Tauri sidecar. The gate is at the spawn, so it covers streamStatus / + // or the Tauri sidecar. The gate is at the spawn, so it covers attach / // open / popOut too — the entry points the subcommand allowlist never saw. it('refuses a caller-supplied binary path that is not an agent-browser', async () => { enqueueSpawnResults([{}]); @@ -652,7 +746,7 @@ describe('agent-browser host webview argv', () => { expect((await host.command(session, ['close'])).exitCode).toBe(1); expect((await host.edit(session, 'copy')).ok).toBe(false); expect((await host.screenshotToFile(session, {})).ok).toBe(false); - expect((await host.streamStatus(session)).ok).toBe(false); + expect((await host.attach(session, { url: 'https://example.com/' })).ok).toBe(false); expect((await host.popOut(session, { url: 'https://example.com/' })).ok).toBe(false); expect((await host.popIn(session, { url: 'https://example.com/' })).ok).toBe(false); } diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 31e69e94a..be2a9ac1b 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -21,10 +21,11 @@ * (select-all/copy/cut) the stream input path can't dispatch; copy/cut land * on the OS clipboard. * 3. `screenshot` — captures one device-resolution frame and returns the bytes. - * 4. `streamStatus` — reads the current stream port so restored panels recover - * from a stale persisted `wsPort`. - * 5. `open` — spawns a managed namespaced session and opens a url, backing a - * render swap (docs/specs/dor-browser.md → "Display Modal And Render Swaps"). + * 4. `attach` — reports a session's live stream port from its state files, + * never spawning a daemon; relaunches a gone one at the page the pane had. + * 5. `open` — opens a url in a new managed session (or a caller-named one), + * backing every GUI launch (docs/specs/dor-browser.md → "Agent-Browser + * Connection"). * 6. `popOut` / `popIn` — relaunch a session headed/headless at its live active * url (Chrome's mode is fixed at launch, so this is a close + relaunch). * 7. `closePoppedOut` — close every still-headed window **and drop the capture @@ -55,13 +56,13 @@ import { randomBytes } from 'crypto'; import { isAllowedAgentBrowserBinary } from '../lib/agent-browser-binary'; import { type AgentBrowserTab, parseAgentBrowserTabs } from '../lib/agent-browser-tab'; import type { + AgentBrowserAttachResult, AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, - AgentBrowserStreamStatusResult, } from '../lib/platform/types'; import { isBrowsableUrl } from '../lib/platform/browser-automation'; import { privateCaptureDir } from './private-capture-dir'; @@ -121,8 +122,8 @@ export interface AgentBrowserHost { edit(session: string, op: AgentBrowserEditOp, binaryPath?: string): Promise; screenshot(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise; screenshotToFile(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise; - streamStatus(session: string, binaryPath?: string): Promise; - open(url: string, opts: { headed?: boolean }, binaryPath?: string): Promise; + attach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise; + open(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise; popOut(session: string, opts: { rect?: { x: number; y: number; width: number; height: number }; url?: string }, binaryPath?: string): Promise; popIn(session: string, opts: { url?: string }, binaryPath?: string): Promise; closePoppedOut(): Promise; @@ -136,7 +137,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // close it on shutdown or it orphans (spec → "Pop-Out" lifecycle: // "Dormouse/editor quits → headed windows are cleaned up; no orphans"). // Headless sessions are deliberately NOT tracked — they're left alive to - // reattach across webview reloads (the wsPort/stream-recovery design). + // reattach across webview reloads (`attach`). const poppedOutSessions = new Map(); // A relaunch returns once the daemon is streamable, while its `open` command // may remain pending until page load. Key the post-open blank-tab sweep so a @@ -161,7 +162,7 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // arbitrary local execution in the extension host or the Tauri sidecar — the // exact escape the nonce CSP exists to prevent, and reachable without any user // interaction on the next launch. The argv check in `command()` does not - // cover it: `streamStatus`, `open` and `popOut` supply their own args and + // cover it: `attach`, `open` and `popOut` supply their own args and // take a `binaryPath` of their own. A refused path is dropped, not fatal: the // host's own candidates still run, so a stale or hostile value degrades to // "resolve it yourself" rather than to a broken surface. @@ -279,6 +280,25 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser }); } + function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; // ESRCH (gone), or EPERM: not a daemon this user started + } + } + + /** The session's daemon as its state files describe it — a CLI verb would + * start one to answer (docs/specs/dor-browser.md → "Pop-Out"): the pid file's + * pid, whether that process is alive, and its stream port if it accepts. */ + async function daemonState(session: string): Promise<{ pid: number | undefined; alive: boolean; wsPort?: number }> { + const pid = await readStateNumber(session, 'pid'); + if (pid === undefined || !processAlive(pid)) return { pid, alive: false }; + const port = await readStateNumber(session, 'stream'); + return port !== undefined && await portAccepts(port) ? { pid, alive: true, wsPort: port } : { pid, alive: true }; + } + /** Terminate the session's daemon and wait for it to exit. Returns the pid * the pid file named (dead or not), so a relaunch can tell the daemon that * replaces it from the stale state files it leaves behind. */ @@ -573,20 +593,50 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser }); } - async function streamStatus(session: string, binaryPath?: string): Promise { + // Concurrent attaches of one session join, so two panes restoring it relaunch + // it once. + const attachesInFlight = new Map>(); + + // The session's live stream port, without starting anything. A daemon that is + // up but not streaming is left alone — relaunching would compete with it. Only + // a daemon that is gone, for a caller naming the page it had, is relaunched + // there: headed when the pane is a pop-out. + async function attach( + session: string, + opts: { url?: string; headed?: boolean }, + binaryPath?: string, + ): Promise { if (!isAgentBrowserSession(session)) return { ok: false, error: 'a valid session name is required' }; - const wsPort = await readStreamPort(session, binaryPath); - if (!wsPort) return { ok: false, error: 'stream port unavailable' }; - return { ok: true, wsPort }; + const pending = attachesInFlight.get(session); + if (pending) return pending; + const attaching = (async (): Promise => { + const daemon = await daemonState(session); + if (daemon.wsPort !== undefined) return { ok: true, wsPort: daemon.wsPort }; + if (daemon.alive) return { ok: false, error: `agent-browser session '${session}' is not streaming` }; + const url = opts?.url; + if (!isBrowsableUrl(url)) return { ok: false, error: `agent-browser session '${session}' is not running` }; + const generation = beginRelaunch(session); + log(`[ab-relaunch] attach session=${session} is gone -> open ${url}`); + if (opts.headed) poppedOutSessions.set(session, binaryPath); + else poppedOutSessions.delete(session); + const args = ['--session', session, ...(opts.headed ? ['--headed'] : []), 'open', url]; + return relaunch('attach', session, args, binaryPath, daemon.pid, generation); + })().finally(() => attachesInFlight.delete(session)); + attachesInFlight.set(session, attaching); + return attaching; } - // Spawn a managed session and open — backs swapping an iframe embed up - // to a live screencast (docs/specs/dor-browser.md → "Display Modal And Render Swaps"). With `headed`, - // the process launches headed in one shot so embed→popout doesn't open a - // headless browser only to tear it down. - async function open(url: string, opts: { headed?: boolean }, binaryPath?: string): Promise { + // Open in a new managed session, or in the caller's `session` (a Tool's + // own, or the one a failed swap restores) — every GUI launch + // (docs/specs/dor-browser.md → "Agent-Browser Connection"). A live daemon for + // that session just navigates. With `headed`, the process launches headed in + // one shot so embed→popout doesn't open a headless browser only to tear it down. + async function open(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise { if (!isBrowsableUrl(url)) return { ok: false, error: 'an http(s) url is required' }; - const session = generateGuiSession(); + if (opts?.session !== undefined && !isAgentBrowserSession(opts.session)) { + return { ok: false, error: 'a valid session name is required' }; + } + const session = opts?.session ?? generateGuiSession(); const args = ['--session', session, ...(opts?.headed ? ['--headed'] : []), 'open', url]; // A headed spawn is a real OS window — track it before the launch so a // window whose page never finishes loading is still closed on shutdown. @@ -704,5 +754,5 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser ]); } - return { command, edit, screenshot, screenshotToFile, streamStatus, open, popOut, popIn, closePoppedOut }; + return { command, edit, screenshot, screenshotToFile, attach, open, popOut, popIn, closePoppedOut }; } diff --git a/lib/src/host/playwright-host.lifecycle.test.ts b/lib/src/host/playwright-host.lifecycle.test.ts index 25dc7902d..4d5079fc8 100644 --- a/lib/src/host/playwright-host.lifecycle.test.ts +++ b/lib/src/host/playwright-host.lifecycle.test.ts @@ -58,7 +58,7 @@ test('concurrent captures share one CDP attachment and detach it once on close', test('closing during a CDP attachment releases it without capturing', async () => { let complete!: (value: typeof cdp) => void; attach.mockImplementation(() => new Promise(resolve => { complete = resolve; })); - const { wsPort } = await host.request({ ...binding, op: 'streamStatus' }); + const { wsPort } = await host.request({ ...binding, op: 'attach' }); const capture = host.request({ ...binding, op: 'screenshot' }); await vi.waitFor(() => expect(attach).toHaveBeenCalledTimes(1)); const closing = host.request({ ...binding, op: 'command', args: ['close'] }); @@ -82,7 +82,7 @@ test('a viewer listener failure releases its browser connection', async () => { queueMicrotask(() => this.emit('error', new Error('Listener unavailable'))); return this; }); - const result = await host.request({ ...binding, op: 'streamStatus' }); + const result = await host.request({ ...binding, op: 'attach' }); expect(result.error).toBe('Listener unavailable'); expect(browser.close).toHaveBeenCalledTimes(1); }); @@ -90,7 +90,7 @@ test('a viewer listener failure releases its browser connection', async () => { test('captures reuse recent tab state but refresh it when it expires', async () => { const now = vi.spyOn(Date, 'now').mockReturnValue(10000); browser.contexts = () => [{ pages: () => [page, Object.assign(new EventEmitter(), page)] }]; - expect((await host.request({ ...binding, op: 'streamStatus' })).ok).toBe(true); + expect((await host.request({ ...binding, op: 'attach' })).ok).toBe(true); mocks.cli.mockClear(); for (let i = 0; i < 10; i++) expect((await host.request({ ...binding, op: 'screenshot' })).ok).toBe(true); expect(mocks.cli).not.toHaveBeenCalled(); @@ -128,17 +128,17 @@ test('a native reopen in headless mode clears headed shutdown ownership', async } return result; }); - expect((await host.request({ ...binding, op: 'streamStatus' })).headed).toBe(true); + expect((await host.request({ ...binding, op: 'attach' })).headed).toBe(true); browser.emit('disconnected'); headless = true; - expect((await host.request({ ...binding, op: 'streamStatus' })).headed).toBe(false); + expect((await host.request({ ...binding, op: 'attach' })).headed).toBe(false); mocks.cli.mockClear(); await host.close(); expect(mocks.cli).not.toHaveBeenCalled(); }); test('a single-page browser refreshes without asking the CLI for its selection', async () => { - expect((await host.request({ ...binding, op: 'streamStatus' })).ok).toBe(true); + expect((await host.request({ ...binding, op: 'attach' })).ok).toBe(true); expect(mocks.cli.mock.calls.map(([, args]) => args[1])).toEqual(['list']); }); @@ -170,7 +170,7 @@ test('a relaunch without an http(s) page reopens blank; a GUI open still needs o }); test.each(['attach', 'startScreencast'] as const)('a screencast whose %s fails mid-navigation is retried on the next poll', async (failing) => { - const { wsPort } = await host.request({ ...binding, op: 'streamStatus' }); + const { wsPort } = await host.request({ ...binding, op: 'attach' }); const starts = () => cdp.send.mock.calls.filter(([method]) => method === 'Page.startScreencast').length; if (failing === 'attach') attach.mockRejectedValueOnce(new Error('Target navigated')); else { @@ -191,6 +191,48 @@ test.each(['attach', 'startScreencast'] as const)('a screencast whose %s fails m } }); +describe('attach', () => { + // The registry lists the session only once `open` has run for it. + let running: boolean; + let browserName: string; + beforeEach(() => { + running = false; + browserName = 'chromium'; + mocks.cli.mockImplementation(async (_binary, args) => { + if (args[1] === 'open') running = true; + const servers = running ? [{ + title: 'test', workspaceDir: process.cwd(), playwrightLib: process.cwd(), + endpoint: '/tmp/test-playwright.pipe', browser: { browserName }, + }] : []; + return { ok: true, exitCode: 0, stderr: '', stdout: JSON.stringify(args[1] === 'list' ? { servers } : { result: '- 0: (current) Test' }) }; + }); + }); + const verbs = () => mocks.cli.mock.calls.map(([, args]) => args[1]); + + test('a live session answers its viewer port without launching', async () => { + running = true; + const attached = await host.request({ ...binding, op: 'attach', url: 'http://localhost/' }); + expect(attached).toMatchObject({ ok: true, headed: false, wsPort: expect.any(Number) }); + expect(verbs()).not.toContain('open'); + }); + + test('a gone session relaunches at the page named, and fails without one', async () => { + expect((await host.request({ ...binding, op: 'attach' })).ok).toBe(false); + expect(verbs()).not.toContain('open'); + + const attached = await host.request({ ...binding, op: 'attach', url: 'http://localhost/', headed: true }); + expect(attached).toMatchObject({ ok: true, wsPort: expect.any(Number) }); + expect(mocks.cli.mock.calls.map(([, args]) => args)).toContainEqual(['--session=test', 'open', 'http://localhost/', '--browser=chromium', '--headed']); + }); + + test('a session it cannot view is never relaunched', async () => { + running = true; + browserName = 'firefox'; + expect((await host.request({ ...binding, op: 'attach', url: 'http://localhost/' })).ok).toBe(false); + expect(verbs()).not.toContain('open'); + }); +}); + test('copy runs the shared edit script and never overwrites the clipboard with an empty selection', async () => { page.evaluate = vi.fn(async (script: unknown) => typeof script === 'string' ? '' : { width: 640, height: 480 }); expect(await host.request({ ...binding, op: 'edit', edit: 'copy' })).toMatchObject({ ok: true, text: '' }); @@ -202,7 +244,7 @@ test('copy runs the shared edit script and never overwrites the clipboard with a }); test('viewers get tabs, url and status only when they change, and the current state when they connect', async () => { - const { wsPort } = await host.request({ ...binding, op: 'streamStatus' }); + const { wsPort } = await host.request({ ...binding, op: 'attach' }); const sockets: WebSocket[] = []; const connectViewer = async () => { const { url } = await host.request({ op: 'streamUrl', port: wsPort! }); @@ -217,10 +259,10 @@ test('viewers get tabs, url and status only when they change, and the current st try { const first = await connectViewer(); await vi.waitFor(() => expect(first).toEqual(['url', 'tabs', 'status'])); - await host.request({ ...binding, op: 'streamStatus' }); - await host.request({ ...binding, op: 'streamStatus' }); + await host.request({ ...binding, op: 'attach' }); + await host.request({ ...binding, op: 'attach' }); page.url = () => 'http://localhost/next'; - await host.request({ ...binding, op: 'streamStatus' }); + await host.request({ ...binding, op: 'attach' }); // A repeated state message would have arrived ahead of the navigation. await vi.waitFor(() => expect(first).toEqual(['url', 'tabs', 'status', 'url', 'tabs'])); const second = await connectViewer(); @@ -232,7 +274,7 @@ test('viewers get tabs, url and status only when they change, and the current st }); test('a long paste reaches the page whole without tripping the input backlog', async () => { - const { wsPort } = await host.request({ ...binding, op: 'streamStatus' }); + const { wsPort } = await host.request({ ...binding, op: 'attach' }); const { url } = await host.request({ op: 'streamUrl', port: wsPort! }); const ws = new WebSocket(url!); ws.on('error', () => {}); diff --git a/lib/src/host/playwright-host.test.ts b/lib/src/host/playwright-host.test.ts index 2ab0e85b8..705f1c51c 100644 --- a/lib/src/host/playwright-host.test.ts +++ b/lib/src/host/playwright-host.test.ts @@ -33,7 +33,7 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu expect(opened.ok).toBe(true); session = opened.session!; const req = { cwd, binaryPath, session }; - const nested = await host.request({ ...req, cwd: path.join(cwd, 'nested'), op: 'streamStatus' }); + const nested = await host.request({ ...req, cwd: path.join(cwd, 'nested'), op: 'attach' }); expect(nested.wsPort).toBe(opened.wsPort); expect(nested.nativeIdentity).toBe(opened.nativeIdentity); const stream = await host.request({ op: 'streamUrl', port: opened.wsPort! }); @@ -45,7 +45,7 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu // Parking drops the viewer socket, then reconnects to the same CLI browser. socket.close(); await new Promise(resolve => socket!.once('close', () => resolve())); - expect((await host.request({ ...req, op: 'streamStatus' })).wsPort).toBe(opened.wsPort); + expect((await host.request({ ...req, op: 'attach' })).wsPort).toBe(opened.wsPort); messages.length = 0; const resumedStream = await host.request({ op: 'streamUrl', port: opened.wsPort! }); socket = new WebSocket(resumedStream.url!); @@ -74,7 +74,7 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu expect((await host.request({ ...req, op: 'command', args: ['eval', 'process.exit()'] })).ok).toBe(false); const popped = await host.request({ ...req, op: 'popOut', url }); expect(popped.ok, popped.error).toBe(true); - expect((await host.request({ ...req, op: 'streamStatus' })).headed).toBe(true); + expect((await host.request({ ...req, op: 'attach' })).headed).toBe(true); await new Promise(r => setTimeout(r, 1000)); const popTabs = await host.request({ ...req, op: 'command', args: ['tab', 'list'] }); expect(JSON.parse(popTabs.stdout!).tabs).toHaveLength(1); @@ -83,7 +83,7 @@ test.skipIf(!binaryPath)('real CLI: GUI launch, stream grants, native tabs, inpu expect(relaunched.wsPort).not.toBe(opened.wsPort); expect((await host.request({ op: 'streamUrl', port: opened.wsPort! })).ok).toBe(false); expect((await host.request({ ...req, op: 'command', args: ['close'] })).ok).toBe(true); - expect((await host.request({ ...req, op: 'streamStatus' })).ok).toBe(false); + expect((await host.request({ ...req, op: 'attach' })).ok).toBe(false); } finally { socket?.terminate(); if (session) await host.request({ cwd, binaryPath, session, op: 'command', args: ['close'] }); diff --git a/lib/src/host/playwright-host.ts b/lib/src/host/playwright-host.ts index fbb4a6104..bb98dad97 100644 --- a/lib/src/host/playwright-host.ts +++ b/lib/src/host/playwright-host.ts @@ -56,6 +56,9 @@ function realpathOrUndefined(file: string): string | undefined { } } +/** The CLI registry lists no browser for the session: it is gone, not merely unviewable. */ +class SessionNotOpenError extends Error {} + /** `key` is the native identity: installation, CLI workspace and session. */ type Binding = { session: string; cwd: string; install: PlaywrightInstall; workspace: string | undefined; key: string }; function bind(session: string, cwd: string, install: PlaywrightInstall): Binding { @@ -287,7 +290,8 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v && (s.workspaceDir || undefined) === b.workspace && typeof s.playwrightLib === 'string' && realpathOrUndefined(s.playwrightLib) === b.install.libraryPath); - if (matches.length !== 1) throw new Error(matches.length ? 'Ambiguous Playwright session' : 'Playwright session is not open or has no viewable endpoint'); + if (matches.length > 1) throw new Error('Ambiguous Playwright session'); + if (matches.length === 0) throw new SessionNotOpenError('Playwright session is not open or has no viewable endpoint'); const descriptor = matches[0]; if (descriptor.browser?.browserName !== 'chromium') throw new Error('Dormouse currently views Chromium Playwright sessions only. The native CLI command still ran.'); const endpoint = descriptor.endpoint ?? descriptor.pipeName; @@ -415,7 +419,8 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v } const install = resolvePlaywrightInstall(request.binaryPath); const cwd = typeof request.cwd === 'string' && path.isAbsolute(request.cwd) ? request.cwd : process.cwd(); - const session = request.op === 'open' ? generateGuiSession() : request.session; + // `open` mints a session unless the caller names one to open the page in. + const session = request.op === 'open' ? request.session ?? generateGuiSession() : request.session; if (!isPlaywrightSession(session)) throw new Error('Invalid Playwright session name'); const b = bind(session, cwd, install); if (request.op === 'open' || request.op === 'popOut' || request.op === 'popIn') { @@ -426,11 +431,27 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v if (request.op === 'open' && !isBrowsableUrl(request.url)) throw new Error('Browser navigation requires an http(s) URL'); const url = isBrowsableUrl(request.url) ? request.url : undefined; const isHeaded = request.op === 'open' ? !!request.headed : request.op === 'popOut'; - const fresh = request.op === 'open'; + const fresh = request.op === 'open' && request.session === undefined; const deadline = Date.now() + REQUEST_BUDGET_MS; const v = await serialize(b, () => launch(b, url, isHeaded, fresh, deadline)); return { ok: true, session, cwd, binaryPath: install.binary, wsPort: v.port, nativeIdentity: b.key }; } + // The viewer for a live session; one whose browser is gone is relaunched at + // `url` when the caller names it, and fails otherwise. + if (request.op === 'attach') { + const url = request.url; + const deadline = Date.now() + REQUEST_BUDGET_MS; + const v = await serialize(b, async () => { + try { + return await connect(b); + } catch (error) { + if (!(error instanceof SessionNotOpenError) || !isBrowsableUrl(url)) throw error; + return launch(b, url, !!request.headed, false, deadline); + } + }); + await refresh(v); + return { ok: true, session, cwd, binaryPath: install.binary, wsPort: v.port, headed: v.headed, nativeIdentity: b.key }; + } // Parsed before anything connects, so a refused command costs nothing. const command = request.op === 'command' ? parseWebviewCommand(request.args) : undefined; if (command === null) throw new Error('Unsupported Playwright host command'); @@ -443,10 +464,6 @@ export function createPlaywrightHost(deps: { writeClipboardText(text: string): v }); } const v = await connect(b); - if (request.op === 'streamStatus') { - await refresh(v); - return { ok: true, wsPort: v.port, headed: v.headed, nativeIdentity: b.key }; - } await refresh(v, request.op !== 'screenshot'); const page = v.page; if (!page) throw new Error('No Playwright page is open'); diff --git a/lib/src/lib/platform/browser-automation.ts b/lib/src/lib/platform/browser-automation.ts index 004bcd250..e1ed3f8c2 100644 --- a/lib/src/lib/platform/browser-automation.ts +++ b/lib/src/lib/platform/browser-automation.ts @@ -1,12 +1,12 @@ /** Host-owned operations for the Playwright provider. No arbitrary code or CDP crosses this boundary. */ export type { BrowserAutomationProvider } from 'dor/commands/types'; export type PlaywrightRequest = { binaryPath?: string; cwd?: string } & ( - | { op: 'open'; url: string; headed?: boolean } + | { op: 'open'; url: string; headed?: boolean; session?: string } | { op: 'streamUrl'; port: number } | { op: 'command'; session: string; args: string[] } | { op: 'edit'; session: string; edit: 'selectAll' | 'copy' | 'cut' } | { op: 'screenshot'; session: string; format?: 'jpeg' | 'png'; quality?: number } - | { op: 'streamStatus'; session: string } + | { op: 'attach'; session: string; url?: string; headed?: boolean } | { op: 'popOut' | 'popIn'; session: string; url?: string } ); export interface PlaywrightResult { diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index a8ba2d488..d00c60952 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -121,11 +121,11 @@ export interface AgentBrowserEditResult { export type { IframeProxyResult }; -/** Result of asking the host for the current stream status of an existing - * session. Used to recover persisted panels whose saved wsPort went stale - * across VS Code/webview reloads without exposing a generic `stream` exec - * channel to the webview. */ -export interface AgentBrowserStreamStatusResult { +/** Result of attaching to a session's live stream (docs/specs/dor-browser.md → + * "Agent-Browser Connection"): the port its stream serves now, found without + * starting a daemon — or, when the caller named a page and the session is gone, + * the port of the browser relaunched there. */ +export interface AgentBrowserAttachResult { headed?: boolean; nativeIdentity?: string; ok: boolean; @@ -377,10 +377,10 @@ export interface PlatformAdapter { // Absent on hosts that can't run the binary — the panel then keeps every // changed stream frame as its final, lower-resolution image. agentBrowserScreenshot?(session: string, opts: { format?: 'jpeg' | 'png'; quality?: number }, binaryPath?: string): Promise; - // Reads the current stream port for an already-running session. This is a - // purpose-built status channel, not an agentBrowserCommand, - // so restored panels can recover from a stale persisted wsPort after reload. - agentBrowserStreamStatus?(session: string, binaryPath?: string): Promise; + // The session's live stream port, read without spawning anything — a CLI + // verb would start a daemon to answer. With `url`, a session whose daemon is + // gone is relaunched there (headed with `headed`); without, it fails. + agentBrowserAttach?(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise; // The WebSocket URL for a session's stream port. Hosts whose webview origin // the agent-browser stream server rejects (VS Code) return a tokenized relay // URL; absent or null falls back to ws://127.0.0.1:. @@ -410,13 +410,14 @@ export interface PlatformAdapter { // docs/specs/dor-browser.md → "Pop-Out"). All optional // so hosts degrade: the modal hides whatever isn't backed by a capability. // - // Spawn a managed agent-browser session and open — backs swapping an - // iframe embed up to a live screencast (`headed: false`) or straight to a - // popped-out window (`headed: true`, so embed→popout is one spawn, not a - // headless launch immediately torn down). `binaryPath` is the last one a + // Open in a new managed session, or in `session` when the caller names + // one — the launch behind every GUI-created browser Surface, headless or + // straight into a popped-out window (`headed: true`, so embed→popout is one + // spawn, not a headless launch immediately torn down). Resolves once the + // browser is up, never waiting for the page. `binaryPath` is the last one a // `dor ab` surface resolved (a GUI-launched host's own PATH may miss the // binary); the host falls back to PATH / DORMOUSE_AGENT_BROWSER_BIN. - agentBrowserOpen?(url: string, opts: { headed?: boolean }, binaryPath?: string): Promise; + agentBrowserOpen?(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise; // Relaunch a session's browser headed as a native OS window, reopening `url` // (headed/headless is fixed at launch, so this is a close+relaunch — v1 // preserves the active tab URL). Best-effort positioned over `rect` (CSS px diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 6499ea634..2601ec7a9 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,7 +1,7 @@ import { PLAYWRIGHT_REQUEST_TIMEOUT_MS, type PlaywrightRequest, type PlaywrightResult } from './browser-automation'; import { recordToolEvents } from '../tool-events'; import type { TerminalContextRequest, TerminalContextInfo } from '../terminal-context-types'; -import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserStreamStatusResult, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink, SpawnPtyOptions, ToolControlResult, ToolHostRequest, WritePtyOptions } from './types'; +import type { AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, AgentBrowserAttachResult, IframeProxyResult, OpenPort, PlatformAdapter, PtyDataDetail, PtyInfo, BurrowLink, SpawnPtyOptions, ToolControlResult, ToolHostRequest, WritePtyOptions } from './types'; import { openPortRequestTimeoutMs } from './types'; import { createBurrowLinkClient } from '../../host/remote/link-client'; import { createAlertClient, type AlertClientMethods } from '../../host/alert-client'; @@ -130,7 +130,7 @@ export class VSCodeAdapter implements PlatformAdapter { this.agentBrowserCommand = this.agentBrowserCommand.bind(this); this.agentBrowserEdit = this.agentBrowserEdit.bind(this); this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this); - this.agentBrowserStreamStatus = this.agentBrowserStreamStatus.bind(this); + this.agentBrowserAttach = this.agentBrowserAttach.bind(this); this.getAgentBrowserStreamUrl = this.getAgentBrowserStreamUrl.bind(this); this.agentBrowserOpen = this.agentBrowserOpen.bind(this); this.agentBrowserPopOut = this.agentBrowserPopOut.bind(this); @@ -414,14 +414,15 @@ export class VSCodeAdapter implements PlatformAdapter { return result ?? { ok: false, error: 'agent-browser screenshot timed out' }; } - async agentBrowserStreamStatus(session: string, binaryPath?: string): Promise { - const result = await this.requestResponse( - 'agentBrowser:streamStatus', 'agentBrowser:streamStatusResult', - { session, binaryPath }, + async agentBrowserAttach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise { + // A gone session relaunches, so this waits as long as an open does. + const result = await this.requestResponse( + 'agentBrowser:attach', 'agentBrowser:attachResult', + { session, url: opts.url, headed: opts.headed, binaryPath }, (msg) => ({ ok: msg.ok, wsPort: msg.wsPort, error: msg.error }), - 5000, + 15000, ); - return result ?? { ok: false, error: 'agent-browser stream status timed out' }; + return result ?? { ok: false, error: 'agent-browser attach timed out' }; } getAgentBrowserStreamUrl(port: number): Promise { @@ -434,9 +435,9 @@ export class VSCodeAdapter implements PlatformAdapter { ); } - async agentBrowserOpen(url: string, opts: { headed?: boolean }, binaryPath?: string): Promise { + async agentBrowserOpen(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise { const result = await this.requestResponse( - 'agentBrowser:open', 'agentBrowser:openResult', { url, headed: opts.headed, binaryPath }, + 'agentBrowser:open', 'agentBrowser:openResult', { url, headed: opts.headed, session: opts.session, binaryPath }, (msg) => ({ ok: msg.ok, session: msg.session, wsPort: msg.wsPort, binaryPath: msg.binaryPath, error: msg.error }), 15000, ); diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index ea73b779d..0d09596aa 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -160,12 +160,12 @@ const invokeMap = { agent_browser_screenshot: async ({ session, format, quality, binaryPath }) => readCapture( await requestSidecar('agentBrowser:screenshot', { session, format, quality, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), ), - agent_browser_stream_status: ({ session, binaryPath }) => requestSidecar('agentBrowser:streamStatus', { session, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), + agent_browser_attach: ({ session, url, headed, binaryPath }) => requestSidecar('agentBrowser:attach', { session, url, headed, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), tool_control: ({ request }) => requestSidecar('tool:control', { request }, 'tool:result', (data) => data.result), git_info: ({ paths }) => requestSidecar('git:info', { paths }, 'git:infoResult', (data) => data.result), - agent_browser_open: ({ url, headed, binaryPath }) => requestSidecar('agentBrowser:open', { url, headed, binaryPath }, 'agentBrowser:result', (data) => data.result, 30000), + agent_browser_open: ({ url, headed, session, binaryPath }) => requestSidecar('agentBrowser:open', { url, headed, session, 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 diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 09ea98a42..245e6f445 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -247,14 +247,14 @@ function handleLine(line) { return { result: { ok: true, mime: shot.mime, path: shot.path } }; }); break; - case 'agentBrowser:streamStatus': + case 'agentBrowser:attach': respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.streamStatus(data.session, data.binaryPath), + result: await agentBrowser.attach(data.session, { url: data.url, headed: data.headed }, data.binaryPath), })); break; case 'agentBrowser:open': respondAsync('agentBrowser:result', data.requestId, async () => ({ - result: await agentBrowser.open(data.url, { headed: data.headed }, data.binaryPath), + result: await agentBrowser.open(data.url, { headed: data.headed, session: data.session }, data.binaryPath), })); break; case 'agentBrowser:popOut': diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index b0858f7c6..173d9daf8 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1562,15 +1562,17 @@ fn agent_browser_edit( } #[tauri::command(async)] -fn agent_browser_stream_status( +fn agent_browser_attach( state: tauri::State<'_, SidecarState>, session: String, + url: Option, + headed: Option, binary_path: Option, ) -> Result { agent_browser_forward( &state, - "agentBrowser:streamStatus", - serde_json::json!({ "session": session, "binaryPath": binary_path }), + "agentBrowser:attach", + serde_json::json!({ "session": session, "url": url, "headed": headed, "binaryPath": binary_path }), ) } @@ -1579,12 +1581,13 @@ fn agent_browser_open( state: tauri::State<'_, SidecarState>, url: String, headed: Option, + session: Option, binary_path: Option, ) -> Result { agent_browser_forward( &state, "agentBrowser:open", - serde_json::json!({ "url": url, "headed": headed, "binaryPath": binary_path }), + serde_json::json!({ "url": url, "headed": headed, "session": session, "binaryPath": binary_path }), ) } @@ -4568,7 +4571,7 @@ pub fn run() { playwright_screenshot, agent_browser_edit, agent_browser_screenshot, - agent_browser_stream_status, + agent_browser_attach, agent_browser_open, agent_browser_pop_out, agent_browser_pop_in, diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 4b1bf6b0e..974ef2a9c 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -3,13 +3,14 @@ import { recordToolEvents } from '../../lib/src/lib/tool-events'; import type { TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types'; import { installWorkspaceRegistry, type WorkspaceRegistrySnapshot } from "./workspace-registry"; import type { + AgentBrowserAttachResult, AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, - AgentBrowserStreamStatusResult, + AgentBrowserAttachResult, IframeProxyResult, OpenPort, PlatformAdapter, @@ -112,7 +113,7 @@ export class BrowserSidecarAdapter implements PlatformAdapter { this.agentBrowserCommand = this.agentBrowserCommand.bind(this); this.agentBrowserEdit = this.agentBrowserEdit.bind(this); this.agentBrowserScreenshot = this.agentBrowserScreenshot.bind(this); - this.agentBrowserStreamStatus = this.agentBrowserStreamStatus.bind(this); + this.agentBrowserAttach = this.agentBrowserAttach.bind(this); this.agentBrowserOpen = this.agentBrowserOpen.bind(this); this.agentBrowserPopOut = this.agentBrowserPopOut.bind(this); this.agentBrowserPopIn = this.agentBrowserPopIn.bind(this); @@ -303,13 +304,13 @@ export class BrowserSidecarAdapter implements PlatformAdapter { } } - async agentBrowserStreamStatus(session: string, binaryPath?: string): Promise { - try { return await this.host.invoke("agent_browser_stream_status", { session, binaryPath }); } + async agentBrowserAttach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise { + try { return await this.host.invoke("agent_browser_attach", { session, url: opts.url, headed: opts.headed, binaryPath }); } catch (err) { return { ok: false, error: errMessage(err) }; } } - async agentBrowserOpen(url: string, opts: { headed?: boolean }, binaryPath?: string): Promise { - try { return await this.host.invoke("agent_browser_open", { url, headed: opts.headed, binaryPath }); } + async agentBrowserOpen(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise { + try { return await this.host.invoke("agent_browser_open", { url, headed: opts.headed, session: opts.session, binaryPath }); } catch (err) { return { ok: false, error: errMessage(err) }; } } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index b72dc723a..3c4a22a7c 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -5,13 +5,14 @@ import { invoke as rawInvoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-shell"; import { coalesceCwds } from "./coalesce-cwds"; import type { + AgentBrowserAttachResult, AgentBrowserCommandResult, AgentBrowserEditOp, AgentBrowserEditResult, AgentBrowserOpenResult, AgentBrowserPopResult, AgentBrowserScreenshotResult, - AgentBrowserStreamStatusResult, + AgentBrowserAttachResult, IframeProxyResult, OpenPort, PlatformAdapter, @@ -521,17 +522,17 @@ export class TauriAdapter implements PlatformAdapter { } } - async agentBrowserStreamStatus(session: string, binaryPath?: string): Promise { + async agentBrowserAttach(session: string, opts: { url?: string; headed?: boolean }, binaryPath?: string): Promise { try { - return await rawInvoke("agent_browser_stream_status", { session, binaryPath }); + return await rawInvoke("agent_browser_attach", { session, url: opts.url, headed: opts.headed, binaryPath }); } catch (err) { return { ok: false, error: errMessage(err) }; } } - async agentBrowserOpen(url: string, opts: { headed?: boolean }, binaryPath?: string): Promise { + async agentBrowserOpen(url: string, opts: { headed?: boolean; session?: string }, binaryPath?: string): Promise { try { - return await rawInvoke("agent_browser_open", { url, headed: opts.headed, binaryPath }); + return await rawInvoke("agent_browser_open", { url, headed: opts.headed, session: opts.session, binaryPath }); } catch (err) { return { ok: false, error: errMessage(err) }; } diff --git a/vscode-ext/src/agent-browser-host.ts b/vscode-ext/src/agent-browser-host.ts index ac79548db..18b5712c7 100644 --- a/vscode-ext/src/agent-browser-host.ts +++ b/vscode-ext/src/agent-browser-host.ts @@ -32,7 +32,7 @@ const host = createAgentBrowserHost({ export const runAgentBrowserCommand = host.command; export const runAgentBrowserEdit = host.edit; export const runAgentBrowserScreenshot = host.screenshot; -export const runAgentBrowserStreamStatus = host.streamStatus; +export const runAgentBrowserAttach = host.attach; export const runAgentBrowserOpen = host.open; export const runAgentBrowserPopOut = host.popOut; export const runAgentBrowserPopIn = host.popIn; diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 523362383..7a8c59027 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -16,7 +16,7 @@ import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { WebviewMessage, ExtensionMessage } from './message-types'; import type { DorControlRequest } from './pty-manager'; import { dorWorkspaceRefusal } from './dor-workspace-guard'; -import { createStreamRelayUrl, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runAgentBrowserStreamStatus, runPlaywrightRequest } from './agent-browser-host'; +import { createStreamRelayUrl, runAgentBrowserAttach, runAgentBrowserCommand, runAgentBrowserEdit, runAgentBrowserOpen, runAgentBrowserPopIn, runAgentBrowserPopOut, runAgentBrowserScreenshot, runPlaywrightRequest } from './agent-browser-host'; import { createIframeProxyUrl } from './iframe-proxy-host'; import { toolControl } from './tool-host'; import type { ToolHostRequest } from '../../lib/src/lib/platform/types'; @@ -703,13 +703,17 @@ export function attachRouter( } satisfies ExtensionMessage); }); break; - case 'agentBrowser:streamStatus': - runAgentBrowserStreamStatus( + case 'agentBrowser:attach': + runAgentBrowserAttach( msg.session, + { + url: typeof msg.url === 'string' ? msg.url : undefined, + headed: msg.headed === true, + }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { post({ - type: 'agentBrowser:streamStatusResult', requestId: msg.requestId, ...result, + type: 'agentBrowser:attachResult', requestId: msg.requestId, ...result, } satisfies ExtensionMessage); }); break; @@ -731,7 +735,7 @@ export function attachRouter( case 'agentBrowser:open': runAgentBrowserOpen( typeof msg.url === 'string' ? msg.url : '', - { headed: msg.headed === true }, + { headed: msg.headed === true, session: typeof msg.session === 'string' ? msg.session : undefined }, typeof msg.binaryPath === 'string' ? msg.binaryPath : undefined, ).then((result) => { post({ type: 'agentBrowser:openResult', requestId: msg.requestId, ...result } satisfies ExtensionMessage); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 046eb223c..b1b3dc150 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -5,7 +5,7 @@ import type { TerminalColors, TerminalProtocolEvent } from '../../lib/src/lib/te import type { AlertCommand, AlertEvents } from '../../lib/src/host/alert-protocol'; import type { PersistedAlertState } from '../../lib/src/lib/session-types'; import type { DorControlCancelPayload, DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; -import type { AgentBrowserStreamStatusResult, IframeProxyResult, OpenPort, ToolControlResult, ToolHostRequest } from '../../lib/src/lib/platform/types'; +import type { AgentBrowserAttachResult, IframeProxyResult, OpenPort, ToolControlResult, ToolHostRequest } from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; import type { BurrowCommand, BurrowResult } from '../../lib/src/host/remote/service-protocol'; import type { VolatileNotepadSnapshot } from '../../lib/src/lib/notepad/types'; @@ -28,9 +28,9 @@ export type WebviewMessage = | { type: 'agentBrowser:command'; session: string; args: string[]; binaryPath?: string; requestId: string } | { type: 'agentBrowser:edit'; session: string; op: 'selectAll' | 'copy' | 'cut'; binaryPath?: string; requestId: string } | { type: 'agentBrowser:screenshot'; session: string; format?: 'jpeg' | 'png'; quality?: number; binaryPath?: string; requestId: string } - | { type: 'agentBrowser:streamStatus'; session: string; binaryPath?: string; requestId: string } + | { type: 'agentBrowser:attach'; session: string; url?: string; headed?: boolean; binaryPath?: string; requestId: string } | { type: 'agentBrowser:getStreamUrl'; port: number; requestId: string } - | { type: 'agentBrowser:open'; url: string; headed?: boolean; binaryPath?: string; requestId: string } + | { type: 'agentBrowser:open'; url: string; headed?: boolean; session?: string; binaryPath?: string; requestId: string } | { type: 'agentBrowser:popOut'; session: string; url?: string; rect?: { x: number; y: number; width: number; height: number }; binaryPath?: string; requestId: string } | { type: 'agentBrowser:popIn'; session: string; url?: string; binaryPath?: string; requestId: string } | { type: 'iframe:createProxyUrl'; url: string; embedderOrigins: string[]; requestId: string } @@ -91,7 +91,7 @@ export type ExtensionMessage = | { type: 'agentBrowser:commandResult'; requestId: string; exitCode: number; stdout: string; stderr: string } | { type: 'agentBrowser:editResult'; requestId: string; ok: boolean; text?: string; error?: string } | { type: 'agentBrowser:screenshotResult'; requestId: string; ok: boolean; bytes?: Uint8Array; mime?: string; error?: string } - | ({ type: 'agentBrowser:streamStatusResult'; requestId: string } & AgentBrowserStreamStatusResult) + | ({ type: 'agentBrowser:attachResult'; requestId: string } & AgentBrowserAttachResult) | { type: 'agentBrowser:streamUrl'; requestId: string; url: string | null } | { type: 'agentBrowser:openResult'; requestId: string; ok: boolean; session?: string; wsPort?: number; binaryPath?: string; error?: string } | { type: 'agentBrowser:popResult'; requestId: string; ok: boolean; wsPort?: number; error?: string } From b1fa1a75bfc1c504efc0a14e250cee83a2dcb7f1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 20:35:40 -0700 Subject: [PATCH 02/23] Drive every browser Surface through one Phase and one daemon gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that nothing may query the daemon during a relaunch's close/reopen gap was enforced by a `relaunching` flag checked at some call sites and not others: header back/forward/reload/URL edits, the Display modal's device and custom viewport, tab clicks, sync-to-pane and the Cmd-A/C/X edit channel all still reached the daemon mid-relaunch, and the pane context menu's reuse deterministically sent `open ` into a pop-out's gap. The lifecycle itself was an implicit state machine over a dozen flags (`relaunching`, `poppedOut`, `headedConnected`, `parked`, `liveStreamPort`, `recoveryGen`, `recoverySeq`, …). `AgentBrowserSurfaceController` now holds one `Phase` union — idle, unbound, attaching, live, parked, relaunching, ended, disposed — and the stream connection and CDP observer exist exactly in `live`. Every daemon command (chrome actions, screen actions, tabs, sync, `get cdp-url`, the connection's tab selection, edit chords, screenshots) goes through one `canDrive()` gate that runs only in `live`; a navigation asked for meanwhile is kept as the one latest intent and runs once live. `poppedOut`/`relaunching` in the view snapshot are projections, and the snapshot carries the phase. Stale-port recovery becomes the attaching step: a restored pane attaches with its page and presentation (relaunching there if the daemon is gone), an unpark attaches without a page (a daemon that ended while hidden is not relaunched behind the user's back) and falls back to the parked port, and a failed pop-out/pop-in comes back in the pane through the same attach. The controller no longer writes `wsPort` into params. `setRenderMode(mode, { url })` makes the context menu's reuse one intent: the relaunch opens the port's page instead of a navigation racing it. The webview-global closed-session set goes: `closeBrowserSurface` closes a Surface's session through its controller, which re-closes after any relaunch or relaunching attach that lands later, and falls back to the params for a Surface no controller holds. Wall kills, swaps and Tool retirement use it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- lib/src/components/Wall.test.tsx | 54 ++ lib/src/components/Wall.tsx | 68 +- .../wall/AgentBrowserPanel.test.tsx | 24 +- lib/src/components/wall/AgentBrowserPanel.tsx | 19 +- .../components/wall/agent-browser-screen.ts | 8 +- .../wall/agent-browser-sessions.test.ts | 35 - .../components/wall/agent-browser-sessions.ts | 33 - .../agent-browser-surface-controller.test.ts | 350 ++++++-- .../wall/agent-browser-surface-controller.ts | 837 +++++++++--------- lib/src/components/wall/browser-automation.ts | 7 - .../components/wall/use-tool-serving.test.tsx | 18 +- lib/src/components/wall/use-tool-serving.ts | 17 +- .../host/playwright-host.lifecycle.test.ts | 2 +- 13 files changed, 817 insertions(+), 655 deletions(-) delete mode 100644 lib/src/components/wall/agent-browser-sessions.test.ts delete mode 100644 lib/src/components/wall/agent-browser-sessions.ts diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 28b2ffd64..dcc2ed670 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -783,6 +783,60 @@ describe('Wall on the Lath engine', () => { } }); + it('pops out a reused context-port browser at the port, never navigating into the relaunch', async () => { + const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); + const helperSpy = vi.spyOn(helpers, 'openHelper').mockResolvedValue({ id: 'context-helper', parentId: 'pane-a', command: '', status: 'preserved' }); + const command = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + const popOut = vi.fn(() => new Promise(() => {})); + Object.assign(fake, { + agentBrowserOpen: vi.fn(async () => ({ ok: true, session: 'second', wsPort: 1 })), + agentBrowserAttach: vi.fn(async () => ({ ok: true, wsPort: 4321 })), + agentBrowserCommand: command, + agentBrowserPopOut: popOut, + }); + try { + // The port's browser, navigated away from the port's page since. + await act(async () => { + root.render(); + }); + await flush(); + if (!fake.hasPty('pane-a')) fake.spawnPty('pane-a'); + fake.setOpenPorts('pane-a', [{ protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port: 5173, pid: 100, processName: 'vite' }]); + await act(async () => { + container.querySelector('[data-pane-header-for="pane-a"]')!.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, clientX: 10, clientY: 10, + })); + }); + await flush(); + command.mockClear(); + await act(async () => { + document.querySelector('[data-terminal-context] button[aria-label="Open in agent-browser popout"]')!.click(); + }); + await flush(); + + // One relaunch, at the port — not at the page it was on, with the port's + // `open` queued behind it. + expect(leafCount()).toBe(2); + expect(popOut).toHaveBeenCalledExactlyOnceWith('restored', expect.objectContaining({ url: 'http://localhost:5173/' }), undefined); + expect(command).not.toHaveBeenCalledWith('restored', ['open', 'http://localhost:5173/'], undefined); + } finally { + helperSpy.mockRestore(); + untouchedSpy.mockRestore(); + } + }); + it('names Playwright when a context-menu Playwright launch fails', async () => { const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); // The mocked TerminalPane registers no terminal, so the helper would report diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 6f5f8f306..6e6f1b6e0 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -24,11 +24,9 @@ const RemotePairingModalHost = lazy(() => })), ); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; -import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions'; -import { automationProvider, browserPlatform, browserSessionKey, isPopout, LaunchBinaryPath, PROVIDER_LABEL, type AutomationRenderMode } from './wall/browser-automation'; -import { isAllowedBinaryFor } from '../lib/agent-browser-binary'; +import { automationProvider, browserPlatform, isPopout, LaunchBinaryPath, PROVIDER_LABEL, type AutomationRenderMode } from './wall/browser-automation'; import { isToolRender } from '../lib/platform/tool-types'; -import { disposeAgentBrowserSurfaceController } from './wall/agent-browser-surface-controller'; +import { closeBrowserSessionFromParams, closeBrowserSurface, disposeAgentBrowserSurfaceController } from './wall/agent-browser-surface-controller'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; import { NotepadArchiveFailureModal, type NotepadArchiveFailure } from './NotepadArchiveFailure'; import { messageOf } from '../lib/errors'; @@ -82,7 +80,6 @@ import type { Edge } from '../lib/lath/model'; import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; import { resolveRenderMode, - agentBrowserSessionFromParams, browserDisplayModeFromParams, browserUrlFromParams, isBrowserParams, @@ -217,28 +214,6 @@ function compareBySurfaceRef(a: DorSurface, b: DorSurface): number { - (surfaceRefNumber(b.ref) ?? Number.MAX_SAFE_INTEGER); } -/** Killing or swapping away from an agent-browser surface closes its session — - * surface lifetime and browser lifetime are bound (spec → Lifecycle). No-op - * for other surface types. */ -function closeAgentBrowserSession(params: unknown): void { - const session = agentBrowserSessionFromParams(params); - if (!session) return; - const { renderMode, cwd, binaryPath } = params as { renderMode?: unknown; cwd?: string; binaryPath?: unknown }; - const provider = automationProvider(renderMode); - if (!provider) return; - // Mark before issuing the close so a popped-out surface's auto-revert sees - // the impending teardown and doesn't relaunch the session we're killing. - markAgentBrowserSessionClosed(browserSessionKey(session, provider, cwd)); - browserPlatform(provider, cwd).agentBrowserCommand?.( - session, - ['close'], - // Checked, not merely typed: these params come off the persisted session - // blob, and `binaryPath` names a program the host will spawn - // (`lib/src/lib/agent-browser-binary.ts`). - isAllowedBinaryFor(provider, binaryPath) ? binaryPath : undefined, - ).catch(() => {}); -} - /** The params binding a browser Surface to the `session` a GUI launch returned. */ function boundBrowserParams(session: string, result: AgentBrowserOpenResult, cwd: string | undefined) { return { @@ -706,8 +681,7 @@ export function Wall({ // teardown lands here: the throwaway was created straight into a door. const door = doorsRef.current.find(d => d.id === id); if (!door) return; - closeAgentBrowserSession(lath.getMeta(id)?.params); - disposeAgentBrowserSurfaceController(id); + closeBrowserSurface(id, lath.getMeta(id)?.params); // Destroy the Door: drop the meta the store kept for it and, if it was parked, // unmount the DOM (and any iframe document still running inside it) with it. lath.store.forgetLeaf(id); @@ -730,11 +704,10 @@ export function Wall({ fireEvent({ type: 'kill', id }); return; } - const params = nav.paneParams(id); - closeAgentBrowserSession(params); - // Release the surface's client-side controller (connection, loops, timers, - // screen registration). A safe no-op for iframe/terminal surfaces. - disposeAgentBrowserSurfaceController(id); + // Close its browser session and release the client-side controller + // (connection, loops, timers, screen registration). A safe no-op for + // iframe/terminal surfaces. + closeBrowserSurface(id, nav.paneParams(id)); // Two-phase kill (docs/specs/tiling-engine.md → "Animation"): fade the pane in // place (a last-pane kill also shrinks it toward the bottom-right), then commit // `remove` once the fade completes — survivors tween into the reclaimed space. @@ -1561,10 +1534,9 @@ export function Wall({ const oldParams = nav.paneParams(oldId); const oldVisible = nav.hasPane(oldId); if (!oldVisible) return null; - closeAgentBrowserSession(oldParams); - // The old renderer's controller is going away with this swap; release its - // client-side resources (no-op for a non-agent-browser surface). - disposeAgentBrowserSurfaceController(oldId); + // The old renderer goes away with this swap: its session and its + // controller's client-side resources (no-op for a non-automated surface). + closeBrowserSurface(oldId, oldParams); // A browser Surface has no helper; the terminal's goes with the old id. closeHelperParent(oldId); const newId = generatePaneId(); @@ -1925,7 +1897,7 @@ export function Wall({ if (!result.ok || !result.session) return result.error ?? `Could not open ${PROVIDER_LABEL[provider]}`; launchBinaryPath.remember(provider, result.binaryPath); const binding = boundBrowserParams(result.session, result, cwd); - if (!lath.getMeta(eagerId) || lath.isDying(eagerId)) closeAgentBrowserSession({ renderMode: mode, ...binding }); + if (!lath.getMeta(eagerId) || lath.isDying(eagerId)) closeBrowserSessionFromParams({ renderMode: mode, ...binding }); else updateSurfaceParams(eagerId, binding); return null; }, [launchBinaryPath, lath, updateSurfaceParams]); @@ -2039,8 +2011,7 @@ export function Wall({ const url = browserUrlFromParams(params); const platform = getPlatform(); if (!url || (mode === 'ab-screencast' && !platform.agentBrowserOpen)) return; - closeAgentBrowserSession(params); - disposeAgentBrowserSurfaceController(id); + closeBrowserSurface(id, params); lath.store.updateParams(id, { toolRender: mode, renderMode: mode, url, session: undefined, wsPort: undefined, syncEngaged: mode === 'ab-screencast', @@ -2050,7 +2021,7 @@ export function Wall({ void platform.agentBrowserOpen!(url, {}, launchBinaryPath.get('agent-browser')).then(result => { const current = lath.getMeta(id)?.params; if (!current || lath.isDying(id) || current.renderMode !== mode || current.url !== url || getTerminalPaneState(id).currentCommand?.id !== runId) { - if (result.session) closeAgentBrowserSession({ renderMode: mode, session: result.session, binaryPath: result.binaryPath }); + if (result.session) closeBrowserSessionFromParams({ renderMode: mode, session: result.session, binaryPath: result.binaryPath }); return; } if (result.ok && result.session) { @@ -2147,7 +2118,7 @@ export function Wall({ if (!r.session) return; const bound = boundBrowserParams(r.session, r, cwd); if (r.ok && lath.getMeta(restoredId) && !lath.isDying(restoredId)) updateSurfaceParams(restoredId, bound); - else closeAgentBrowserSession({ renderMode: currentRenderMode, ...bound }); + else closeBrowserSessionFromParams({ renderMode: currentRenderMode, ...bound }); }) .catch((error) => console.warn('[dormouse] could not restore browser provider:', error)); }; @@ -2165,7 +2136,7 @@ export function Wall({ // tree. Close only when the eager Surface was genuinely destroyed (or // its visible pane is mid-fade); otherwise hand the session to its meta. if (!eagerSurfaceExists() || lath.isDying(eagerId)) { - closeAgentBrowserSession({ renderMode: mode, ...bound }); + closeBrowserSessionFromParams({ renderMode: mode, ...bound }); return; } updateSurfaceParams(eagerId, bound); @@ -2221,11 +2192,10 @@ export function Wall({ const existing = findSurfaceByParams(params => (params as { contextPortKey?: unknown } | undefined)?.contextPortKey === key); if (existing) { revealSurface(existing.id); - if (mode !== 'iframe') { - const controller = getAgentBrowserScreenController(existing.id); - controller?.actions.setRenderMode?.(mode); - controller?.chromeActions.navigate(entry.url); - } else updateSurfaceParams(existing.id, { url: entry.url }); + // One intent: a pop-out/pop-in relaunch opens the URL rather than + // racing a navigation into its close/reopen gap. + if (mode !== 'iframe') getAgentBrowserScreenController(existing.id)?.actions.setRenderMode?.(mode, { url: entry.url }); + else updateSurfaceParams(existing.id, { url: entry.url }); return; } if (provider && !platform?.agentBrowserOpen) throw new Error(`${PROVIDER_LABEL[provider]} is unavailable on this host`); diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index 20dce0f5c..22c2c4be8 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -149,14 +149,14 @@ describe('AgentBrowserPanel render mode controller', () => { ok: true, wsPort: 3456, })); - const streamStatus = vi.fn(async (): Promise => ({ + const attach = vi.fn(async (): Promise => ({ ok: true, wsPort: 1234, })); const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; platform.agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); platform.agentBrowserPopOut = popOut; - platform.agentBrowserAttach = streamStatus; + platform.agentBrowserAttach = attach; setPlatform(platform); await renderPanel(paneProps('ab-panel'), updateParameters); @@ -421,13 +421,13 @@ describe('AgentBrowserPanel render mode controller', () => { expect(getAgentBrowserScreenController('ab-panel')?.chrome().url).toBe('https://github.com/diffplug/dormouse'); }); - it('does not recover a stale port through stream status after that port opened live', async () => { - const streamStatus = vi.fn(async (): Promise => ({ + it('does not attach again after the port it streams from drops', async () => { + const attach = vi.fn(async (): Promise => ({ ok: true, wsPort: 2222, })); const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserAttach = streamStatus; + platform.agentBrowserAttach = attach; setPlatform(platform); await renderPanel(paneProps('ab-panel', { surfaceType: 'browser', session: 'browser-session', wsPort: 1111 })); @@ -435,14 +435,14 @@ describe('AgentBrowserPanel render mode controller', () => { await act(async () => { await Promise.resolve(); }); - streamStatus.mockClear(); + attach.mockClear(); await act(async () => { WebSocketMock.instances.at(-1)?.emitMessage(JSON.stringify({ type: 'status', connected: false, screencasting: false })); await Promise.resolve(); }); - expect(streamStatus).not.toHaveBeenCalled(); + expect(attach).not.toHaveBeenCalled(); }); it('swaps straight to iframe with no extra tabs (no confirm gate)', async () => { @@ -698,10 +698,10 @@ describe('AgentBrowserPanel visibility parking', () => { expect(liveStreamSocket(4321)?.readyState).toBe(1); }); - it('never queries stream status while parked', async () => { - const streamStatus = vi.fn(async () => ({ ok: false })); + it('never attaches while parked', async () => { + const attach = vi.fn(async () => ({ ok: false })); const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; - platform.agentBrowserAttach = streamStatus; + platform.agentBrowserAttach = attach; setPlatform(platform); // No wsPort ⇒ the stale-port recovery effect is the code path that would @@ -710,12 +710,12 @@ describe('AgentBrowserPanel visibility parking', () => { surfaceType: 'browser', session: 'browser-session', }); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - streamStatus.mockClear(); + attach.mockClear(); await act(async () => { setVisible(false); }); await act(async () => { await vi.advanceTimersByTimeAsync(HIDDEN_PARK_DELAY_MS + 50); }); - expect(streamStatus).not.toHaveBeenCalled(); + expect(attach).not.toHaveBeenCalled(); }); it('reconnects and repaints from the stream when it becomes visible again', async () => { diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 77a9d3b8c..c139b5d42 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -73,7 +73,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r ); const snapshot = useSyncExternalStore(controller.subscribe, controller.snapshot); - const { tabs, status, connectionLost, hasFrame, poppedOut, relaunching, streamPort } = snapshot; + const { tabs, status, connectionLost, hasFrame, poppedOut, phase, error } = snapshot; // Gated on the same Workspace-aware visibility the streaming body reads, so a // Workspace left in passthrough on a browser pane stops forwarding (and @@ -321,20 +321,23 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // --- placeholder state (derived from the snapshot) --- + // The browser is on its way — a launch, an attach, or a relaunch whose new + // stream is not yet known — not a session that ended. + const opening = phase === 'unbound' || phase === 'attaching' || phase === 'relaunching'; const placeholder = (() => { // Session-less: the pane context menu's eager connect pane, on screen before // the daemon boots (docs/specs/dor-browser.md → Pane Context Menu Connect). // It is mid-boot, not idle — telling the user to run `dor ab open` here would // ask them to redo the click they just made. - if (!session) return 'Connecting to browser session…'; + if (phase === 'unbound') return 'Connecting to browser session…'; // Mid pop-in: the headed browser is closed by design and the headless one // is booting — not a session that ended. - if (relaunching) return 'Relaunching browser…'; + if (phase === 'relaunching') return 'Relaunching browser…'; // Addressed to this pane: a bare `dor ab open` drives the caller's default // key, which for a keyed or GUI-launched pane is some other browser. const command = `${cli} --surface ${actions.resolveSurfaceRef(id)} open `; - if (!streamPort) return `Waiting for the browser — run ${command}`; - if (connectionLost || status?.connected === false) { + if (phase === 'ended' && error) return `The browser could not be opened (${error}) — run ${command} to retry, or close this surface.`; + if (phase === 'ended' || connectionLost || status?.connected === false) { return `The browser session ended — run ${command} to restart it, or close this surface.`; } if (!hasFrame) { @@ -400,14 +403,14 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r if (interactiveRef.current) e.preventDefault(); }} /> - {poppedOut ? ( + {poppedOut && phase !== 'ended' ? ( // Popped out to a headed OS window — the pane is a clean stub. While // the window is still being opened (a relaunch in flight, or an eager // swap whose daemon has not yet named its session) there is nothing to // pop back in, so the affordance waits with it.
-
{!session || relaunching ? 'Opening the browser window…' : 'This browser is running in a separate window.'}
- {session && !relaunching &&
+
{opening ? 'Opening the browser window…' : 'This browser is running in a separate window.'}
+ {!opening &&