Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e831b11
Give both browser hosts an attach verb that never spawns a daemon
nedtwigg Sep 24, 2026
b1fa1a7
Drive every browser Surface through one Phase and one daemon gate
nedtwigg Sep 24, 2026
3260b57
Never persist a browser's stream port
nedtwigg Sep 24, 2026
ac52916
Release a transferred Workspace's browser viewers in the source Window
nedtwigg Sep 24, 2026
14698ba
Let the browser Surface's controller own its first launch
nedtwigg Sep 24, 2026
2a6b96a
Specify the browser Surface's Phase, daemon gate, attach and launch
nedtwigg Sep 24, 2026
760f598
Share the browser hosts' single-flight, probe and launch plumbing
nedtwigg Sep 24, 2026
a9ee767
Route every daemon command, edit and capture through one driver
nedtwigg Sep 24, 2026
4975cd0
Hand the dor stream port straight to the Surface's controller
nedtwigg Sep 24, 2026
319ec66
End a headless browser whose stream drops, and resume an unpark at once
nedtwigg Sep 24, 2026
302a7c3
Keep a presentation asked for before the browser is bound
nedtwigg Sep 24, 2026
82b1e80
Store a browser Surface's launch-failure policy on the Surface
nedtwigg Sep 24, 2026
abf485e
Replace a browser controller released under a mounted panel
nedtwigg Sep 24, 2026
bfcb2b5
Drop the panel's copies of the controller's attach tests
nedtwigg Sep 24, 2026
07d6f3b
Drop the stream connection's unread lost and live-port flags
nedtwigg Sep 24, 2026
e89e2f5
Specify the browser Phase's transitions and the launch-failure policy
nedtwigg Sep 24, 2026
1020898
Drop attach's unused fallback port, and pin the Tool's fallback scrub
nedtwigg Sep 24, 2026
ced637f
Pin the session echo guard and the panel's provider key
nedtwigg Sep 24, 2026
ef789f3
Say why the controller mirrors params before it rebinds
nedtwigg Sep 24, 2026
7e17551
Load a page a browser launch already opened only once
nedtwigg Sep 24, 2026
e3bcf70
Pin a pop-out's page over a navigation pending before it
nedtwigg Sep 24, 2026
dcc5131
Close the launch/park races the round-2 review found
nedtwigg Sep 24, 2026
a41d9a2
Drop an import the restack onto main doubled
nedtwigg Sep 24, 2026
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
145 changes: 90 additions & 55 deletions docs/specs/dor-browser.md

Large diffs are not rendered by default.

30 changes: 24 additions & 6 deletions docs/specs/dor-browser.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

**What moving a browser Surface's DOM would cost.** A re-parented `<iframe>` reloads, losing scroll, form contents, live scripts, and any open WebSocket. A screencast canvas that moves mid-click breaks click synthesis, whose device coordinates were computed against the old box. Parking the leaf instead of unmounting it makes minimize/reattach free rather than a reload.

**Why no stream port is a param.** It is ephemeral, and a persisted one was always wrong after a restart (static reading, 2026-09): the Playwright port is the host's own in-process viewer server, so every restored Playwright pane failed three connects (immediate, +2 s, +4 s) and read "ended" after ~6 s; an agent-browser one after a reboot named nothing, and the stale-port recovery that followed asked `stream status`, which starts a daemon at `about:blank` — the pane showed a blank page while its header still named the real one. The recovery machinery that existed only for stale ports (`maybeRecoverStalePort`, two generation counters, a "this port opened live" marker) went with it. Kept as an unpersisted param, `wsPort` still needed seven scrubs across four files, and the params diff could not see the same port handed over twice (review, 2026-09).

## Browser Chrome

**Why the robot is independent of presentation.** Agent visibility is the
Expand All @@ -22,18 +24,18 @@ views without weakening that first signal.

**Why the eager pane is created before `agent-browser open` runs.** A cold daemon boot is 1–3s, and a menu that closes on a pane appearing three seconds later reads as a click that did nothing.

**Why a session-less eager pane is inert.** `maybeRecoverStalePort` returns early when params carry no `session`, so the pane spawns no CLI of its own — no `stream status` fired at a still-booting daemon, no race with the `open` behind it.

**Why the eager pane shows its own placeholder.** The idle placeholder asks for `dor ab open <url>`, telling the user to repeat the action they just took instead of naming the pane's actual state.

**Why the handover is a single params refresh.** Setting `session` is what reconciles the controller and connects it, so landing it ahead of `wsPort`/`binaryPath` — or before `agent-browser open` has returned — connects against a daemon that is not up. Handing it over even after a failed `open` lets the placeholder name what it is waiting for; the menu that would have reported the error closed long ago.

The persisted `wsPort` mirror can lag the controller's already-live port after a buffered write, so a simultaneous session change still reconciles when setting that port itself is a no-op.
**Why a reuse is one intent.** Revealing the target and then asking for its mode and its URL separately sent `open <url>` in the same tick as a pop-out began its close/reopen, deterministically: the navigation raced the relaunch, which had already captured the old URL.

## Display Modal And Render Swaps

**Why the iframe swap is eager.** The same 1–3s daemon boot as the context-menu connect, behind a modal that has already closed; and while the swap awaited `open`, a slow page held the iframe on screen for the whole load and a timed-out `open` dropped the swap silently — leaving an orphan `gui-<hex>` browser nobody could see or close.

**Why the Surface's controller launches, not the Wall.** The launch-and-bind existed four times — Tool swap, iframe/cross-provider swap, context-menu port, Tool serving — each with its own "still wanted?" predicate and failure rule, racing the controller it handed a session to. Because the launch lived in a Wall closure rather than on the Surface, a session-less pane that was persisted, or whose webview reloaded mid-launch, restored session-less forever, and Tool serving still blocked on the page load (static reading, 2026-09).

**Why a failed swap's restore reopens the previous session.** A fresh `gui-<hex>` session kept a `key` badge that `dor ab --key` no longer resolved to, and the next command opened a second pane.

## Agent-Browser Renderer

**Why one-session-one-surface is not an invariant.** `dor ab` forwards the user's command and then runs `stream status` before it asks the host for a surface, so a surface killed or render-swapped inside that window is gone by the time the trailing request arrives — and the session behind it is still live and needs somewhere to render.
Expand All @@ -54,7 +56,23 @@ The persisted `wsPort` mirror can lag the controller's already-live port after a

**Why every non-crisp painter must bump the draw generation.** The byte-dedup compares an incoming capture against the last crisp draw. A resting page whose crisp bytes match that draw dedups to a no-op and strands the pane on the blurry provisional frame; a freshly re-attached canvas mounts blank and has the same problem.

**Why the connection is dropped at relaunch start rather than left to fail.** The host closes the browser and kills the daemon, so the old socket's close is certain. Left connected, its three reconnect failures flagged the pane "ended" about three seconds into a pop-in that a slow page could hold open for 25s, and the popped-out CDP observer's `get cdp-url` — issued the moment `poppedOut` flipped — landed in the close→reopen gap, where a daemon command spawns a competing headless daemon that the headed relaunch then reattaches to.
**Why the connection is dropped at relaunch start rather than left to fail.** The host closes the browser and kills the daemon, so the old socket's close is certain. Left connected, its three reconnect failures flagged the pane "ended" about three seconds into a pop-in that a slow page could hold open for 25s, and the popped-out CDP observer's `get cdp-url` — issued the moment `poppedOut` flipped — landed in the close→reopen gap, where a daemon command spawns a competing headless daemon that the headed relaunch then reattaches to. The same holds for flipping headedness before leaving `live`: the observer would start, gate open, in the gap.

**Why one daemon gate, not a check per call site.** The rule against daemon commands in the relaunch gap was a `relaunching` flag each caller had to check, and most did not (static reading, 2026-09): the header's back/forward/reload/URL edit, the Display modal's device and custom viewport, tab clicks, sync-to-pane (reached by a resize once a pop-in had already flipped `poppedOut`), and Cmd-A/C/X all reached the daemon mid-relaunch. The lifecycle behind it was a dozen flags whose invariants lived in five predicates.

**Why an unpark streams from its parked port first, and attaches with no page.** Asking the host first cost every unpark an attach round trip (a pid and stream-file read plus a port probe) before the first frame, for a daemon that almost never moves while hidden (review, 2026-09). When it did move, or went away: a daemon gone while the pane was hidden is the same failure as one gone while visible, which the pane shows as ended; relaunching it on unpark would hide that failure and undo a `dor ab close` made meanwhile. A restore has no such history, so it reopens the page.

**Why a dropped headless stream ends.** While a drop only flagged the connection lost, the phase stayed `live` and the gate open, so a URL-bar navigation ran `open`, which started a daemon on a port the controller never learned; the placeholder read "ended" off three separate signals (review, 2026-09). Attach never spawns, so leaving `live` loses nothing a navigation or `dor` handover cannot reach again.

**Why a launch opens the pending page itself.** A navigation out of `ended` relaunched a gone daemon at that page through `attach`, then ran `open` for it again on `live`: the page reloaded right after its first load, dropping any state it had set up and repeating a non-idempotent GET (review of #775, 2026-09). The controller cannot tell a relaunch from a found daemon on its own, since both answer a port, so `attach` says which. A launch carrying the previous page instead loaded that page first and then navigated away from it.

**Why the launch-failure policy is a param.** Its four creators each awaited an in-memory waiter with its own liveness check, so a pane persisted mid-launch — or whose webview reloaded — then failed, showed "ended" instead of its creator's fallback (review, 2026-09).

**Why a launch waits out its session's close.** A failed swap's restore reopens the previous provider's session, whose `close` was issued at swap time; a fast failure (no Playwright installed) lands before that close does, so a reopen racing it was closed under the restored pane. A Tool re-run relaunching the `tool.<leafId>` session its last run closed meets the same race. So does a Surface closed while its own launch, relaunch or relaunching attach into the session was in flight: that work closed the session when it landed, after the next launch into the name had opened it, and a close issued before the launch had bound the session registered nothing to wait on. The Tool serving loop that awaited each open never overlapped two launches; its replacement does (review of #775, 2026-09).

**Why a new `url` mid-launch is a navigation.** Tool serving stopped awaiting the launch, so an announcement landing mid-launch wrote a new `url` with the session still unbound; nothing rebound, the launch went live on the old page, and serving had already recorded the announcement, so the pane was never re-framed (review of #775, 2026-09).

**Why an unpark drives nothing until its stream opens.** The unpark entered `live` at once, so sync-to-pane and a pending navigation ran before the parked port was proven. With the daemon gone while hidden (`dor ab close`, a crash) and the pane resized meanwhile, as restoring a Door does, `set viewport` started a fresh `about:blank` daemon through the CLI, and the failed stream's attach then streamed it instead of ending (review of #775, 2026-09).

**Why `url` is tracked separately from `tabs`.** Measured against agent-browser 0.31.1 (2026-09): on `open`, the stream sends `tabs` (about:blank), then `url` naming the target at navigation commit, and refreshes `tabs` only when the CLI command completes — after `load`. During a slow load the tab list still named the previous page, so a pop-out issued then relaunched the page before the one being loaded.

Expand Down
6 changes: 3 additions & 3 deletions docs/specs/dor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,9 @@ and the host applies two gates in order:
pw-screencast) — drive it with dor pw --surface surface:2`, or for an iframe
`… open its page with dor ab open <its url>`.

Neither gate covers an agent-browser Surface the context menu created eagerly,
whose daemon boot has not yet named it ([dor-browser.md](dor-browser.md) → Pane
Context Menu Connect): capability and renderer but no session, failing with
Neither gate covers an agent-browser Surface whose launch has not yet named its
session ([dor-browser.md](dor-browser.md) → Agent-Browser Connection):
capability and renderer but no session, failing with
`surface 'surface:2' has no agent-browser session yet`.

Like every handle target, `--surface` requires a live control endpoint.
Expand Down
5 changes: 2 additions & 3 deletions docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,12 @@ Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrust
- **Must poll unbound Tools every 1.5 seconds while their command runs.** Reset settle memory and retire browser resources when the observed command-run id changes, even when the command text is unchanged; an initial observation preserves an imported live binding. (rationale)
- **Must let a changed announced port or path override a committed conflict or browser**, but only after a matching scan. An unchanged announcement never undoes URL-bar navigation. (rationale)
- **Must stop ordinary port scans once a browser or conflict is committed.** An unannounced additional port appearing after settle is not detected.
- **Must display the browser destination before awaiting agent-browser startup**, clearing the existing session/stream binding during a reopen as well. Keep the session-less renderer inert and block Workspace transfer until the binding arrives. Close any browser session whose Tool disappeared or changed command during startup.
- **Must reuse an existing browser session and its binary path when an announcement changes its destination.**
- **Must display the browser destination at once and leave the launch to the Surface's controller** (`docs/specs/dor-browser.md` → Agent-Browser Connection), in the session the Tool has or else its own `tool.<leafId>`, falling back to the embed. Block Workspace transfer until the session binds.
- **Must retain a runtime re-key within the Tool's namespace**, following [Identity and dedupe](#identity-and-dedupe).

Reserved: **Must derive a Tool's URL again on cold restore**, compatible with future `prespawn_port` and `DORMOUSE_TOOL_PORT` in scope **dor-tools**; [Persistence and hosts](#persistence-and-hosts) owns the saved projection.

Source of truth: `useToolServing` in `lib/src/components/wall/use-tool-serving.ts`; `attachAgentBrowserSession` in `lib/src/components/wall/tool-browser-session.ts`; `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`. Tests: `lib/src/components/wall/use-tool-serving.test.tsx`.
Source of truth: `useToolServing` in `lib/src/components/wall/use-tool-serving.ts`; `toolBrowserLaunchParams` in `lib/src/components/wall/browser-surface.ts`; `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`. Tests: `lib/src/components/wall/use-tool-serving.test.tsx`.

## Lifecycle

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/tiling-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ Source of truth: `createAnimator` in `lib/src/lib/lath/animator.ts`; the animato
**Every pane body / header component takes plain `PaneProps` and never sees the engine**, `use-pane-chrome` / `use-surface-visibility` included:

- **Read side**: `PaneProps` — `{ id, title, params, parked? }`, supplied by LathHost straight from `leafMeta`, parked leaves included; a meta commit re-renders the leaf, so params stay live either way.
- **Write side**: `PaneWriteContext` (`{ setTitle(id, t), updateParams(id, patch) }`), provided by the Wall over the store (`lath.store.setTitle` / `lath.store.updateParams`); the `wsPort`-refresh and render-swap flows route through the same seam. The value is stable per mount; the `AgentBrowserPanel` controller sink captures it once.
- **Write side**: `PaneWriteContext` (`{ setTitle(id, t), updateParams(id, patch) }`), provided by the Wall over the store (`lath.store.setTitle` / `lath.store.updateParams`); the `dor` params refresh and render-swap flows route through the same seam. The value is stable per mount; the `AgentBrowserPanel` controller sink captures it once.
- **Visibility**: a mounted leaf is engine-visible unless **parked**, so `parked` is the one non-meta pane prop and absent means "not parked" — right for anything rendered outside LathHost. `useSurfaceVisibility(parked)` folds it with document visibility and the Wall's Workspace being the visible one (`docs/specs/layout.md` → "Workspaces"), so a backgrounded window, a hidden Workspace, and a minimized browser or Tool Surface all gate streaming while the session stays alive.
- **Terminal sizing**: `TerminalResizeContext` gates fitting; `docs/specs/layout.md` → "Animations" owns the rule.
- `use-pane-chrome` registers the pane's root element in `PaneElementsContext`, for the overlays to measure, and nothing else — there is no CSS spawn-animation to trigger.
Expand Down
Loading
Loading