Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .claude/skills/debug-standalone-agent-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,18 @@ For click targeting, see **Clicking a link inside the screencast** above (1:1 ca

## What To Watch

In the harness terminal, correlate:
The high-rate `[ab-panel]`/`[agent-browser]` stream and screenshot console
diagnostics are now **off by default** — they fire per frame (~20Hz). Enable them
before a run and reload (the flag is read once at module load):

```js
localStorage.setItem('dormouse.flags.abDebugLogs', 'true'); // then reload
```

The connection's always-on `debugSnapshot()` ring is unaffected, and the
`stalled`/`failed`/`error` screenshot warnings stay unconditional.

In the harness terminal, correlate (with the flag on):

- `[sidecar] ...` for sidecar behavior
- `[browser log] [ab-panel] connecting stream ...`
Expand Down
86 changes: 73 additions & 13 deletions docs/specs/dor-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,17 @@ Surface lifetime owns backing resources:
the same path.
- A popped-out window closing is normally auto-reverted to headless, but the
closed-session mark prevents Dormouse-initiated kill/swap from resurrecting it.
- Killing or swapping an agent-browser pane also disposes its surface controller
(`disposeAgentBrowserSurfaceController`), releasing the connection, screenshot
loop, CDP observer, timers, and screen registration. This is the immediate
per-surface teardown hook Future Work still lists generally; it exists today
for agent-browser surfaces.
- Iframe proxy grants are currently reclaimed by the proxy idle sweep, not by an
immediate per-surface teardown hook.

Source of truth: `Wall.tsx` (`killPaneImmediately`, `closeAgentBrowserSession`,
`replaceSurface`), `lib/src/components/wall/agent-browser-sessions.ts`,
`lib/src/components/wall/agent-browser-surface-controller.ts`,
`lib/src/host/iframe-proxy.ts` (`GRANT_IDLE_TTL_MS`, `MAX_GRANTS`).

## Browser Chrome
Expand Down Expand Up @@ -215,10 +221,40 @@ Source of truth: `dor/src/commands/agent-browser.ts`,

### Agent-Browser Connection

Each visible agent-browser surface owns one `AgentBrowserConnection` for
`{ session, streamPort, binaryPath }`. Minimize unmounts the panel and disposes
the connection; the agent-browser daemon/session stays alive and reattaches from
persisted params.
Each agent-browser surface's live client state lives in a surface-id-keyed
controller registry (`agent-browser-surface-controller.ts`, mirroring
`terminal-lifecycle.ts`); `AgentBrowserPanel` is a thin view that mounts a
canvas, feeds params/visibility, forwards DOM input, and subscribes to one
snapshot via `useSyncExternalStore`. The controller owns one
`AgentBrowserConnection` for `{ session, streamPort, binaryPath }` paired with
its screenshot loop, and it is SURFACE-scoped rather than panel-scoped: it
survives panel unmount (minimize, dockview layout churn, React StrictMode). So
minimize no longer synchronously disposes the connection — the view detaches,
which counts as hidden and parks the connection after the ~1s debounce, reaching
the same zero-resource end state with less thrash. The agent-browser
daemon/session stays alive throughout and reattaches from persisted params. The
controller's client resources are released only at pane kill or a render swap
away from the renderer (`disposeAgentBrowserSurfaceController` in `Wall.tsx`).

Hidden-but-mounted panes park too. Browser panels use `renderer: 'always'`, so
an inactive dockview tab or a backgrounded window stays mounted and would keep
its ~20Hz stream plus per-pulse screenshot loop running for nothing. A pane that
goes off-screen — or whose view unmounts (minimize) — parks after a ~1s debounce
(so quick tab-flipping or a StrictMode remount doesn't thrash the connection):
the connection and screenshot loop are disposed while the daemon/session stays
alive, and daemon-side frame streaming stops on its own because clients trigger
it. Parking also clears the controller's "this stream port opened live" marker,
so a reattach that fails to reconnect can ask `stream status` and adopt a daemon
port that changed while the pane was hidden. Becoming visible (or reattaching)
reconnects and re-primes from the stream's re-broadcast frame/tabs (the last good
frame is kept on screen across an unpark rather than blanking to the placeholder;
a fresh reattach mounts a blank canvas, so it shows the placeholder until the
first screenshot). Popped-out panes are exempt from parking so their stream/CDP
observer keeps running and window-close auto-revert still works — now even while
minimized, because the controller (and its observer) outlive the panel unmount,
where before it silently did not.
Caveat: `AGENT_BROWSER_IDLE_TIMEOUT_MS` (daemon self-exit when idle) would defeat
"alive while parked" and must not be set for Dormouse-managed sessions.

The stream WebSocket provides:

Expand All @@ -232,8 +268,15 @@ DPR knob, so its frames upscale to mush on HiDPI; this is a Chromium limit, not
agent-browser's, so owning the CDP connection wouldn't change it. So Dormouse
treats frame messages as change pulses, captures a crisp device-resolution
screenshot through the host's `agentBrowserScreenshot`, and draws that to canvas
with latest-only backpressure. If the host cannot screenshot, it falls back to the
stream frame path.
with latest-only backpressure. A capture whose bytes are identical to the last
displayed frame (a static page the daemon keeps re-pulsing) costs no decode or
draw; a re-attach bumps a draw generation so a fresh blank canvas still repaints.
If the host cannot screenshot, it falls back to the stream frame path.

The high-rate `[ab-panel]`/`[agent-browser]` stream and screenshot console
diagnostics sit behind the `dormouse.flags.abDebugLogs` localStorage flag, read
once at module load (reload to apply); the connection's `debugSnapshot()` ring is
always on as the post-hoc tool.

Important input details:

Expand All @@ -249,8 +292,9 @@ the in-body tab strip appears for two or more. Tab select/close actions go
through `agentBrowserCommand`.

Source of truth: `lib/src/components/wall/AgentBrowserPanel.tsx`,
`agent-browser-connection.ts`, `agent-browser-screenshot-loop.ts`,
`agent-browser-input.ts`, `agent-browser-tab.ts`, and their tests.
`agent-browser-surface-controller.ts`, `agent-browser-connection.ts`,
`agent-browser-screenshot-loop.ts`, `agent-browser-input.ts`,
`agent-browser-tab.ts`, `use-surface-visibility.ts`, and their tests.

### Pop-Out

Expand Down Expand Up @@ -287,7 +331,10 @@ Capabilities:
allowlist is `AGENT_BROWSER_ALLOWED_SUBCOMMANDS` in
`lib/src/lib/platform/types.ts`; host-side `get` is further limited to
`get cdp-url`.
- `agentBrowserScreenshot`: one device-resolution JPEG/PNG frame.
- `agentBrowserScreenshot`: one device-resolution JPEG/PNG frame. VS Code
structured-clones the bytes to the webview. Standalone hands Rust the capture's
temp-file PATH over the sidecar stdio; Rust reads the file itself, so the image
bytes never ride the JSON-lines pipe shared with PTY terminal traffic.
- `agentBrowserStreamStatus`: current stream port for stale-`wsPort` recovery.
- `agentBrowserEdit`: select-all/copy/cut via fixed host-owned JS and OS
clipboard write.
Expand Down Expand Up @@ -435,9 +482,9 @@ Source of truth: `lib/src/lib/platform/types.ts`,
- Chrome/modal: `SurfacePaneHeader.tsx`, `AgentBrowserScreenModal.tsx`,
`agent-browser-screen.ts`, `browser-url.ts`.
- Agent-browser renderer: `AgentBrowserPanel.tsx`,
`agent-browser-connection.ts`, `agent-browser-input.ts`,
`agent-browser-screenshot-loop.ts`, `agent-browser-tab.ts`,
`agent-browser-sessions.ts`.
`agent-browser-surface-controller.ts`, `agent-browser-connection.ts`,
`agent-browser-input.ts`, `agent-browser-screenshot-loop.ts`,
`agent-browser-tab.ts`, `agent-browser-sessions.ts`.
- Iframe renderer/proxy: `IframePanel.tsx`, `iframe-proxy-registry.ts`,
`lib/src/host/iframe-proxy.ts`, `lib/src/host/iframe-proxy-rewrite.ts`,
`lib/src/lib/platform/iframe-proxy-types.ts`.
Expand All @@ -454,7 +501,20 @@ Source of truth: `lib/src/lib/platform/types.ts`,
- Upstream support for stream keyboard `commands`, replacing the host edit
workaround and enabling undo/redo.
- General per-surface teardown hook for iframe proxy grants and future
Dormouse-owned backend processes.
Dormouse-owned backend processes. (Agent-browser surfaces already dispose their
controller on kill/swap; iframe proxy grants still wait on the idle sweep.)
- Plugin/backend target axis: spawn, health-check, proxy, and reap a local web
process such as `openvscode-server`.
- Optional terminal-side "this port is viewed by surface:N" indicator.
- Replace the spawn-per-shot CLI screenshot with a persistent host-side CDP
capture channel. Measured against agent-browser 0.27.3 (headless, attach
dance + correct-target selection): naive `Page.captureScreenshot` is
byte-identical to the CLI at DPR 1 and tracks external `set viewport`
automatically, but returns CSS-resolution frames at DPR>1 — the crisp-HiDPI
point of this path — unless the client re-applies
`Emulation.setDeviceMetricsOverride`, which Dormouse can only do correctly
while sync-to-pane owns the values (an external `set device`/`set viewport`
DPR is unrecoverable from frames). `captureBeyondViewport:true` bypasses
emulation and crashed the headless daemon in testing; `clip.scale` returns
blank frames. Adopt only with a daemon-side answer (e.g. an upstream verb
exposing current viewport+DPR, or a daemon-owned capture channel).
16 changes: 15 additions & 1 deletion lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ExternalLinkModalHost } from './ExternalLinkModalHost';
import { AgentBrowserScreenModalHost } from './AgentBrowserScreenModalHost';
import { getAgentBrowserScreenController } from './wall/agent-browser-screen';
import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions';
import { disposeAgentBrowserSurfaceController } from './wall/agent-browser-surface-controller';
import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm';
import {
clearSessionAttention,
Expand Down Expand Up @@ -598,6 +599,9 @@ export function Wall({
const panel = api?.getPanel(id);
if (!api || !panel) return;
closeAgentBrowserSession(panel.params);
// Release the surface's client-side controller (connection, loops, timers,
// screen registration). A safe no-op for iframe/terminal surfaces.
disposeAgentBrowserSurfaceController(id);
orchestrateKill(api, id, selectPane, setSelectedId, killInProgressRef, overlayElRef);
clearLocalSurfaceActivity(id);
fireEvent({ type: 'kill', id });
Expand All @@ -606,7 +610,14 @@ export function Wall({
const acceptKill = useCallback(() => {
const ck = confirmKillRef.current;
if (!ck || ck.exit) return;
setConfirmKill({ ...ck, exit: 'confirm' });
const staged = { ...ck, exit: 'confirm' as const };
// Written to the ref synchronously, not just via setState: the ref otherwise
// updates on the NEXT render, so a second confirm keydown arriving before
// React flushes would pass this guard and kill the same pane twice (two
// orchestrateKill animations racing one animationend — the second removePanel
// then throws dockview's 'invalid operation' on the already-removed panel).
confirmKillRef.current = staged;
setConfirmKill(staged);
killPaneImmediately(ck.id);
confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS);
}, [killPaneImmediately]);
Expand Down Expand Up @@ -1083,6 +1094,9 @@ export function Wall({
const panel = api?.getPanel(oldId);
if (!api || !panel) return null;
closeAgentBrowserSession(panel.params);
// 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);
const newId = generatePaneId();
api.addPanel({
id: newId,
Expand Down
Loading
Loading