Skip to content

One browser-automation provider abstraction: registry, typed request, one control pair, one host lifecycle - #777

Merged
nedtwigg merged 10 commits into
browser-lifecyclefrom
browser-providers
Sep 24, 2026
Merged

nedtwigg merged 10 commits into
browser-lifecyclefrom
browser-providers

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Unifies agent-browser and Playwright behind one provider abstraction on every layer. Adding a provider is now one registry row plus one host provider module, instead of special cases across the webview, control plane, CLI and 8 host transports. Stacked on #775. Items A1–A4 from the browser-stack deep dive.

One provider registry (A2)

dor-lib-common/browser-providers (BROWSER_PROVIDERS) holds each provider's facts: label, CLI alias, render modes, session args and charset, binary env var and default name, install hint, and binary allowlist.

  • parseRenderMode / renderModeFor decode the persisted renderMode strings, which are unchanged (they are the public render_mode and dormouse.yml render values).
  • The webview's GUI table, the display labels, the port actions, the Display modal rows and dor's passthrough aliases all derive from the registry.

One typed, provider-tagged request (A1)

browser(request: BrowserRequest) replaces the 8 agentBrowser* PlatformAdapter methods and playwright().

  • Each host layer has one entry: VS Code browser:request, Tauri browser_request + browser_screenshot, the sidecar browser:request, and the dev harness.
  • Nine Rust commands and the base64 crate are gone.
  • The webview no longer speaks CLI argv. Its typed browserHandle(provider, binding) gates binaryPath. The agent-browser provider renders each op to a fixed argv, and Playwright maps ops directly (its argv re-parse is deleted).
  • The capability probe is browserProviders.

One control pair, binding and identity (A3)

  • surface.browser / surface.resolveBrowser serve both providers. surface.agentBrowser / surface.resolveAgentBrowser remain as aliases for one release, for terminals still running an older dor.
  • One binding {provider, session, cwd?, binaryPath?}. agent-browser launches now honour cwd, so a project agent-browser.json applies.
  • Identity is nativeIdentity for both providers.
  • --key finds the Surface by key first, then uses sessionForKey(key, scope). A bare Wall now gets a unique w<8hex> scope, which fixes every VS Code webview sharing dormouse.1.default.
  • dor has one runBrowserCli(descriptor). dor ab reads the stream port itself (stream status --json) after every command that may bind and hands it over, as before; the host's state-file lookup serves only GUI attaches. The host never receives a socket directory, because it signals the pid it reads there.

One host lifecycle (A4)

lib/src/host/browser-host.ts owns:

  • request validation;
  • per-nativeIdentity serialization with generations, and headed tracking;
  • the blank-tab sweep and the bounded launch;
  • capture transport, edit scripts, JPEG quality, and GUI session minting;
  • a first-class closeSession, and shutdown.

The providers are narrow primitives (createAgentBrowserProvider, createPlaywrightProvider), created lazily.

Close/launch ordering is strict on every transport, including Tauri's worker-pool commands:

  • A named launch waits for the webview's closes of that session to be answered.
  • A close cancels, by a webview-minted request id, the Surface's own launches and attaches that are still unanswered, so one arriving late opens nothing.
  • The host still serializes and supersedes by arrival.

The webview-side close bookkeeping from #775 is deleted.

Behaviour changes worth a look

  • A named launch into a browser that is already up in the requested mode navigates it; only a browser that is gone, or in the other mode, is relaunched. stop signals a daemon only when its state files prove it live: a pid file written since boot, naming a live process, beside a .stream port that accepts.
  • One blank-tab sweep policy now covers every launch of either provider, and agent-browser launches are bounded to fit the transport's wait.
  • Context-menu port targets are named by provider label ("agent-browser", "agent-browser popout", …).
  • The last launch binary is remembered per provider.
  • Captures go to one private dormouse-browser- directory. Each capture is read into memory and its file removed, and every reader deletes the file it was handed.

Specs: docs/specs/dor-browser.md is reorganized into Providers, then Automated Browser (identity, connection, pop-out, a Browser Host contract written once), then the genuine per-provider differences. dor-cli.md has one "Browser Surface Addressing" section. The glossary, transport, vscode, standalone, shortcuts and dor-tool specs and the rationale files are updated.

Testing

  • Root pnpm test passes (lib 3900+, dor 190, dor-lib-common 17, vscode-ext 181, standalone, relay, website, and every lint).
  • The Argos story runs pass in Chromium and WebKit (294/294 each).
  • The race tests run the controller against the real createBrowserHost.
  • Every fixed line was mutation-checked.
  • Nothing was run against a real agent-browser or Playwright CLI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: f24214f
Status: ✅  Deploy successful!
Preview URL: https://ab206a6f.mouseterm.pages.dev
Branch Preview URL: https://browser-providers.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is feedback on a draft, not a merge verdict. Mark the PR ready when you want the full review.

1. Re-announcing a Tool now kills the agent's browser daemon. When a Tool re-announces (new port or path), toolBrowserLaunchParams sets launchSession to the session the Tool already has, and the controller sends a named launch. On base, agent-browser's open → coldLaunch ran open <url> on the live daemon. Now launch calls p.stop, which runs close, then killDaemon (SIGTERM, then SIGKILL after 2 s). An agent driving that Tool loses its tabs, page state and CDP clients every time the dev server moves, and any dor ab command in flight fails or starts a daemon mid-relaunch. The comment above updateParams in use-tool-serving.ts still says a changed destination "just navigates". The PR body lists stop-first as a deliberate change, so you have two options: have tool-serving navigate the live session instead of relaunching it, or accept the new behaviour and fix that comment.

This path also widens who reaches killDaemon. Before, only pop-out/pop-in killed, and only a live session's pid. Now a first Tool serve, or a swap restore with a leftover <session>.pid (after a reboot, say), signals whatever process now owns that pid.

2. dor ab may no longer open a pane on an agent-browser that writes no state files. dor ab now reads the stream port itself only under a custom AGENT_BROWSER_SOCKET_DIR (callerStreamStatus). Otherwise the host's find decides, and it reads only <session>.pid / <session>.stream. readStateNumber itself notes those files can be absent for "an older CLI". With such a CLI, find answers "not running" and dor ab open … prints the could-not-open warning and opens no pane. On base, dor ab always ran stream status --json itself. The fix is either a stream status fallback in find when there is no pid file, or a stated minimum agent-browser version.

3. Playwright frames on standalone now reuse one file per browser. On base, Playwright's requestFile wrote a fresh random .frame each time, and Rust deleted it after reading. Now it writes to the shared capturePath(id, …) name (here), and browser_screenshot leaves the file in place. This has two effects:

  • joinInFlight drops its entry when the capture answers, before Rust reads the file. A second capture of the same session in that gap (a second pane, or the modal beside the loop) truncates and rewrites the file under the read, which gives a torn or empty frame.
  • forgetInFlight rotates the name without unlinking the old file. Each closed session's last frame of the user's page therefore stays in the capture directory until shutdown.

agent-browser had the first effect already. For Playwright, both are new.

4. Playwright --key sessions can now collide after a Workspace transfer. On base, a Playwright key minted dormouse.pw.<uuid>. Now BrowserBindingReservations.resolve uses sessionForKey(key, scope), but the key lookup searches only the answering Wall. So after dor pw --key foo open in ws1, moving that pane to ws2 and running the same command in ws1 again finds no pane and binds a second one to the same dormouse.ws1.foo. Closing either pane then closes the other's browser. agent-browser already worked this way, but for Playwright it is new.

@nedtwigg
nedtwigg marked this pull request as ready for review September 24, 2026 07:49
@argos-ci

argos-ci Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
storybook-chromium (Inspect) 👍 Approved by Ned Twigg 17 changed Sep 24, 2026, 4:04 PM
storybook-webkit (Inspect) 👍 Approved by Ned Twigg 18 changed Sep 24, 2026, 4:04 PM
Deployment Status Branch Updated (UTC)
preview (Open) Ready browser-providers Sep 24, 2026, 4:05 PM

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two points are new since the draft review. The four points in that review still apply to this commit.

1. dor ab can now warn about a bind that later succeeds. On base, dor ab sent the port it had read itself, so surface.agentBrowser answered immediately. Now the host handler awaits browser.attach() for agent-browser too. That call goes through bringUp, so it waits behind any launch, relaunch or close of the same session, and a launch can hold that slot for up to about 38 s (REQUEST_BUDGET_MS). Meanwhile browserSurface still uses the client's default 5 s deadline.

So if an agent runs dor ab --key default snapshot while a restored pane is relaunching its session, or just after a pop-out/pop-in, the command succeeds but prints Warning: could not open the Dormouse browser surface: …timed out. The host then binds the pane anyway. Agents read stderr, so the false warning misleads them. Playwright had the same exposure on base; this PR extends it to dor ab. Raising this call's timeout above the host's request budget would fix it, as awaitSurface does with its per-call timeoutMs. The control server's reaper already allows 10 s beyond whatever timeout the client sets.

2. A stuck agent-browser close now blocks every later request for that session. closeSession runs bound.p.close(bound.b) inside serialize(bound.id, …) and passes no timeout. agent-browser's close forwards that undefined to spawnAndCapture, so the call has no time limit. The launch deadline check only runs once the queue reaches the launch, so it cannot help here.

Example: a Surface closes session S while S's open is stalled on a slow page. The daemon queues close behind the open, and if the daemon hangs, the close never returns. From then on, every attach, relaunch and pop-in/pop-out of S waits forever, and each webview request gives up after 40 s. At shutdown, Promise.allSettled(lifecycle.values()) never settles either. On base, the webview's close went through the unqueued command() path, so a stuck close only stalled itself. A bounded timeout on this close, like the budget launch already gives p.stop, would keep one hung daemon from wedging the session.

@nedtwigg

Copy link
Copy Markdown
Member Author

Thanks. All six points from the draft review and the full review are addressed in 43f3ade.

1. A Tool re-announce killed the agent's daemon; a stale pid could be killed.

  • A named launch into a browser that is already up in the mode it asks for now navigates that browser, without stopping it or waiting on the page (reuse in browser-host.ts). Only a browser that is gone, or is in the other mode (pop-out/pop-in), is relaunched. The "just navigates" comment in use-tool-serving.ts is true again. agent-browser can't report its mode, so a browser this host didn't launch headed counts as headless.
  • stop now signals a pid only when the state files prove it is the session's live daemon: a pid file written since this boot, naming a live process, next to a .stream port that accepts. This is checked before close. A state file older than the boot reads as absent, so a <session>.pid left over from before a reboot never leads to a signal.
  • Tests: a Tool re-announce navigates and stops nothing; a pre-boot pid file, and a live pid with no accepting stream, are never signalled; a proven daemon gets SIGTERM.

2. Older agent-browser without state files. dor ab again reads the stream port itself (stream status --json) after every command that may bind, and hands it over, as on base. The Surface streams from that port without asking the host. The special case for a custom socket directory is gone. The host's state-file find is kept only for GUI attaches.

3. Capture files.

  • Every capture is read into memory and its capture file removed. Each caller then gets a fresh private file (file transport) or the bytes (VS Code).
  • Rust's browser_screenshot and the dev harness delete the file after reading it.
  • A browser's close or relaunch deletes its frames that were never read, and each new frame deletes any unread frame older than the 40 s request timeout.
  • Tests: a second capture taken while the first is still unread never rewrites it (both providers); joined callers get separate copies; no files are left behind.

4. --key collisions. Minting a key's session now checks the whole Window, through a new WallHandle.browserSessions(provider), plus the other keys' reservations for that provider. If the name is taken, it takes the first free .2, .3, …. The numbering is deterministic, so the key resolves to the same session again. Tests: a reservation unit test, and a Wall test where a Surface in another Workspace holds the key's session and the same command here gets .2.

5. The false "could not open" warning. dor's surface.browser call now waits BROWSER_REQUEST_TIMEOUT_MS + 5 s. That constant moved to dor-lib-common, so dor, lib and the transports share it. With point 2, agent-browser binds skip the host's attach entirely; a Playwright bind may still queue behind a launch. Test: a host that answers after the client's default deadline still binds.

6. An unbounded close. Every provider call a browser's lifecycle queue waits on is now bounded:

  • a close at 10 s (agent-browser's close had been forwarded with no timeout);
  • the stream-port read that ends a launch, by the launch's deadline;
  • the stop, as before.

Shutdown waits on the queue for at most 10 s. Tests: a hung close holds up a later launch only for its bound, and shutdown finishes within 10 s even when a close ignores its bound.

The dor-browser, dor-cli and standalone specs and their rationale state each rule. Root pnpm test, both Argos browsers and cargo check pass, and each of the 18 mutation checks turns its test red.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixes in 43f3ade cover all six earlier points. The PR description now contradicts the code on two of them, and a maintainer judges this merge from that description:

  • Under Behaviour changes worth a look, it says a named launch "closes the session and stops its daemon before relaunching; it no longer reuses a running daemon." reuse in browser-host.ts now navigates a browser that is already up in the requested mode. It relaunches only a browser that is gone or in the other mode.
  • Under One control pair, binding and identity (A3), it says dor ab "reads its stream port itself only under a custom AGENT_BROWSER_SOCKET_DIR." callerStreamStatus is gone. dor ab now runs stream status --json after every command that may bind, as base did, and the host's state-file find serves only GUI attaches.

Both bullets should describe the new behaviour. The first could also say that stop signals only a daemon its state files prove live.

@nedtwigg

Copy link
Copy Markdown
Member Author

Updated the PR description: both bullets now describe the new behaviour (a named launch navigates a live browser in the requested mode, stop signals only a daemon its state files prove live, and dor ab reads its own stream port after every command that may bind). The captures bullet also covers read-then-delete now.

nedtwigg and others added 10 commits September 24, 2026 08:22
Every per-provider fact now comes from one table instead of ~13 separate
enumerations of the mode pair and scattered provider ternaries.

- dor-lib-common's `browser-providers.ts` (renamed from `agent-browser.ts`)
  holds `BROWSER_PROVIDERS`: each provider's persisted render modes, CLI
  alias, binary override and default name, install hint and allowlist
  predicate. `parseRenderMode` decodes a mode to `{provider, presentation}`
  (anything else is the embed) and `renderModeFor` inverts it. dor's
  `SurfaceRenderMode` and `BrowserAutomationProvider` derive from it.
- lib's `BROWSER_PROVIDER_GUI` holds the GUI half: label, CLI, device
  presets, viewport hint. `RenderMode`, `BrowserDisplayMode` and its labels
  and glyphs, the context menu's port targets, the Display modal's provider
  rows and devices, and the binary gate all read from the two tables.
- The context menu's capability is now the list of providers the host can
  launch, so both of a provider's targets name it when it is missing, and
  agent-browser's targets read like Playwright's.

Persisted `renderMode` strings are unchanged: they are the public
`render_mode` and `dormouse.yml` `render` values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
The webview spoke eight agent-browser-named adapter methods plus an argv
"command" channel, and Playwright was adapted back into those names and its
host re-parsed agent-browser argv. Each op was plumbed by hand through every
host layer.

- `PlatformAdapter.browser(request)` carries a `BrowserRequest`:
  `{ provider, binding: { session?, cwd?, binaryPath? } }` plus one typed
  operation (launch, attach, streamUrl, screenshot, edit, navigate, history,
  tab, viewport, device, cdpUrl, close). `browserProviders` replaces the
  per-method presence probes. `launch` covers GUI open, pop-out and pop-in: a
  named session is relaunched, so a Tool's or a failed swap's named launch
  now closes whatever held the session first.
- One entry per layer: VS Code `browser:request`/`browser:result`, Tauri
  `browser_request` plus `browser_screenshot` for raw bytes, one sidecar case,
  one browser-dev harness entry. The dead base64 screenshot fallback and its
  crate go with the nine Rust commands.
- `lib/src/host/browser-host.ts` validates the request once for both
  providers (`parseBrowserRequest`, replacing `parseWebviewCommand`); the
  agent-browser host renders each op to a fixed argv and Playwright maps ops
  directly. `get cdp-url` is parsed host-side.
- The webview drives a typed `browserHandle(provider, binding)`, which also
  applies the binary allowlist before sending; the controller holds it
  through its `driver()` gate.
- A Playwright capture file is reused per session like agent-browser's, so
  Rust never deletes one. The GUI's launch-binary memo is kept per provider.

Tests go through the new request API: host tests through `createBrowserHost`,
webview tests through a fake host (`installBrowserHost`).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
The agent-browser and Playwright hosts each carried their own headed
tracking, relaunch generations, blank-tab sweep, capture handling, edit
scripts and shutdown, and the copies had drifted: only Playwright serialized
closes with relaunches, so the webview kept its own record of closes in
flight for agent-browser.

- `createBrowserHost` owns the lifecycle once: launches, relaunching attaches
  and closes serialized per native identity; the launch deadline and the
  failed-launch landing rule; the post-launch blank-tab sweep (last first,
  only beside a real page); capture joins, the one private capture directory
  and the file-vs-bytes transport; the editing scripts and clipboard write;
  GUI session minting; headed tracking and shutdown. Providers are made on
  their first request, so the sidecar still loads Playwright's `ws` lazily.
  An attach that relaunches answers `relaunched`, for both providers.
- A provider implements only `BrowserProvider`'s primitives. agent-browser
  keeps its state files, pid kill and argv; Playwright its install and
  registry discovery, viewer server and CDP.
- agent-browser launches now run in the binding's project directory while it
  exists, so a host-issued relaunch reads the same `./agent-browser.json` the
  `dor ab` there did. Its launches are bounded like Playwright's, and every
  launch of either provider is swept once `open` returns.
- The host, not the webview, orders a closed Surface's work against the next
  launch into its session. A close runs after the launch or attach of that
  browser already running, closing what it brings up, and supersedes one sent
  before it that has not begun. So the controller sends a Surface's close at
  once — for the session it is bound to, or the one its launch names — and
  never again when its work lands, and its `closeLanded`/`closesInFlight`
  wait and landing tracking are gone. The races #775 reproduced now run the
  controller against the real host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
`dor ab` and `dor pw` spoke two control-method pairs with three identity
encodings and two `--key` naming schemes, and each had its own runner.

- One pair: `surface.resolveBrowser { provider, key | surface, proposed? }`
  answers a binding `{ session, cwd?, binaryPath? }`, and `surface.browser`
  binds the Surface after a command succeeds. `surface.resolveAgentBrowser`
  and `surface.agentBrowser` stay as agent-browser aliases for a terminal
  still running the `dor` it was staged with.
- One `--key` flow for both: the Surface in this Wall holding the key, else
  `sessionForKey(key, scope)`, the reservation pinning only cwd and
  executable. A bare Wall now mints a scope of its own, so two VS Code
  webviews' `--key default` are two browsers instead of one shared
  `dormouse.1.default`; a Surface keeps whatever session it was bound to.
- One identity: the host reports a native identity for both providers
  (agent-browser's is its session), and Surface reuse keys on it. The host
  also reports the stream port for agent-browser, so `dor ab` no longer
  spawns `stream status` after every command, and an agent-browser bind
  now hits the closing-Workspace guard too.
- One `runBrowserCli(descriptor, argv, options)` in `dor`; `dor ab` and
  `dor pw` are descriptors of what differs (session flag, navigation verbs,
  no-bind verbs, project scope, missing-binary message).
- The controller follows a host-reported headedness for either provider.

Specs: dor-browser.md is reorganized around one automated-browser model —
Providers, Managed identity for both, Browser Connection, the Browser Host
contract once, then agent-browser and Playwright sections for what genuinely
differs — and dor-cli.md merges its two addressing sections into Browser
Surface Addressing, with a provider table and the aliases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
- The registry carries each provider's label, session flag and session
  charset, and the one `BrowserBinding` type; the unused `command` field
  goes. `dor`'s descriptors, dispatch and alias map, the GUI registry (whose
  viewport hint and paste encoding now derive per provider), the coordinator's
  validation and both host providers read them instead of restating them.
  `parseStreamPort` and `stream status` argv move into the agent-browser
  provider, their only user; `tab`'s verbs are refused there too.
- The coordinator releases a browser once before closing it (`shut`), so
  Playwright's `close` is only its CLI call; the sweep closes tabs through a
  `closeTab` primitive, skipping Playwright's refresh around each; a failed
  launch waits for its `open` with `settleAllWithin`; screenshot defaults
  are applied in one place; the shutdown generation bump that nothing read
  is gone, and helpers only the module uses are no longer exported.
- The controller builds its browser handle once per binding rather than per
  stream frame, the capture loop's capability check builds none, and one
  gate filters `binaryPath` on the way out. Shared helpers replace inlined
  copies: `isBlankUrl`, `providerUnavailable`, `headedRenderMode`,
  `isHeadedMode`, `displayModeFor`/`displayView`, `isDirectory`.
- `dor` resolves a non-null binding and requires a provider on the merged
  pair; a pinned executable that is the caller's own no longer reads as gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
Since the host began reporting agent-browser's stream port, a caller that
sets its own AGENT_BROWSER_SOCKET_DIR could not bind a Surface: the host
looks for `<session>.pid` / `<session>.stream` in its own directory and
answered "not running". Carrying the directory to the host is no fix — the
host kills the pid it reads there, so a webview-chosen directory would aim
that kill at any process.

- Under a caller's own AGENT_BROWSER_SOCKET_DIR, `dor ab` runs
  `stream status --json` itself after a command, as it did before the host
  reported the port, and hands the port over on `surface.browser`
  (`callerStreamStatus` on its descriptor). The webview streams from a port
  `dor ab` read without asking the host; the legacy `surface.agentBrowser`,
  whose `dor ab` always read it, gets the same.
- `streamStatusArgs` and `parseStreamPort` move back to dor-lib-common's
  registry, now shared by `dor` and the host, and the parse accepts only a
  TCP port.
- dor-browser.md states that host-side operations for a session in a socket
  directory the host does not share are unsupported, as they always were,
  the port-target labels of the context menu, and that one blank-tab sweep
  policy covers every launch of either provider.
- Four code comments still cited the pre-reorganization heading "Agent-Browser
  Host Capabilities", wrapped across lines where the heading lint cannot see
  them; they cite "Browser Host" on one line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
…wered

The host orders a close and the next launch into its session by arrival,
which VS Code's message channel preserves but standalone's Tauri commands,
run on a worker pool, can swap for two requests sent an instant apart; the
next Surface's browser would then be closed under it.

- The controller records every close it sends per provider and session
  until the host answers (`trackClose`), including the close of a Surface
  whose launch names a session, and a launch naming that session is sent
  only once they are all answered (`closeInFlight`); released while it
  waits, it sends nothing. An unnamed launch, or a named one with no close
  in flight, is sent at once as before.
- The host's ordering and supersede stay as the second layer.
- A test runs the controller against the real host over a transport that
  holds the close back, for a Surface bound to the session and one still
  launching it; the swap-restore Wall test again sees the launch only after
  the close lands.
- dor-browser.md states the wait; the rationale records both layers and what
  neither covers (a Surface's own work reaching the host after its close).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
…close

A Surface's launch, relaunch or page-naming attach sent an instant before its
close can reach the host after it on standalone, where Tauri runs each
command on a worker pool. The close then found nothing to close, and the
request brought a browser up for a closed Surface — a headless one outliving
even shutdown.

- The controller mints a `requestId` for each request that can bring its
  browser up and holds it until the host answers (`bringUp`); its close
  carries the ids still unanswered as `cancels`, the newest
  `BROWSER_CLOSE_MAX_CANCELS` of them.
- The host records a close's cancelled ids on arrival and answers a request
  carrying one, whenever it arrives, that the browser was closed, opening
  nothing. A cancelled request already queued or running is covered by the
  close's existing supersede and close-after rules. Entries last five
  minutes, well past any request's 40 s wait, and at most 256 are kept,
  oldest dropped first.
- `parseBrowserRequest` accepts a request id of at most 64 `[A-Za-z0-9-]`
  and at most 32 cancelled ids per close, refusing anything else.
- Tests: the controller against the real host over a transport that delivers
  the close before the Surface's own launch, pop-out or relaunching attach
  opens nothing; a close cancels only unanswered requests; the host's
  expiry, bound and validation. The fake host's request log leaves out the
  minted ids no test can predict.
- dor-browser.md states the rule, drops a stale "work that lands after the
  close closes what it brought up", and the rationale replaces the
  residual-gap caveat; the budget is ratcheted to 7050.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
…s, key collisions, bounded waits

1. A named launch into a browser up in the mode it asks for navigates it
   instead of relaunching (`reuse` in browser-host.ts), so a Tool
   re-announcing no longer stops the agent's daemon; only a browser gone or
   in the other mode is relaunched. agent-browser's `stop` signals a pid only
   when its state files prove it the live daemon — a pid file from this
   boot, alive, beside a stream port that accepts, checked before `close` —
   and a state file older than the boot reads as absent.
2. `dor ab` again reads the stream port itself after every command that may
   bind and hands it over, so an agent-browser that writes no state files
   still opens a pane; the special case for a custom socket directory goes.
3. Every capture writes fresh files, one per caller: the shared capture is
   read into memory and its file removed, and the file transport's reader
   (Rust's `browser_screenshot`, the dev harness) deletes what it read. A
   browser's close or relaunch deletes its frames never read, and each new
   one any older than the request timeout.
4. A `--key` never mints a session a Surface anywhere in the Window holds
   (`WallHandle.browserSessions`) or another key's reservation of that
   provider holds; it numbers `.2`, `.3`, … instead.
5. `dor`'s `surface.browser` call waits past BROWSER_REQUEST_TIMEOUT_MS,
   now shared from dor-lib-common, since the host's attach can queue behind
   a launch or close of the browser.
6. Every provider call in a browser's lifecycle queue is bounded: a close at
   10 s (agent-browser's forwarded no timeout), the port read that ends a
   launch by its deadline, and shutdown waits on the queue at most 10 s.

Specs and rationale state each rule; the dor-browser budget is ratcheted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
main's alert rebuild left docs/specs/standalone.md at its budget, and the
read-then-delete wording pushed it one word over.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
nedtwigg added a commit that referenced this pull request Sep 24, 2026
- One proof of an agent-browser daemon, `liveDaemon` — a pid file from this
  boot naming a live process beside a stream port that accepts — now gates
  a stop's signal, every operation and capture, and the headed window's
  `get cdp-url`, where operations had checked only the pid.
- `dor ab` hands over the port it read after every command, so the viewer
  dials a handed-over loopback port for any bind; it captures that browser
  only when the same proof names that port, and otherwise sends every
  changed frame, never reading the caller's socket directory.
- Every capture writes a fresh, randomly named file, read into memory and
  deleted however the capture ends — a failed or killed one included — so
  no frame waits on disk; with the file transport gone, the per-reader
  copies and the pruning of unread frames are moot.
- The viewer's upstream dials end by 5 s and `get cdp-url` by 10 s, and the
  viewer listener closes every connection at shutdown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe
@nedtwigg
nedtwigg merged commit 53a1fc2 into main Sep 24, 2026
16 checks passed

This branch is waiting to be deployed

1 waiting deployment
hosted-preview — f24214fb Waiting Sep 24, 2026 by nedtwigg via cleanup #366
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants