One browser-automation provider abstraction: registry, typed request, one control pair, one host lifecycle - #777
Conversation
Deploying mouseterm with
|
| Latest commit: |
f24214f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ab206a6f.mouseterm.pages.dev |
| Branch Preview URL: | https://browser-providers.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
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:
joinInFlightdrops 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.forgetInFlightrotates 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.
|
The latest updates on your projects. Learn more about Argos notifications ↗︎
|
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
|
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.
2. Older agent-browser without state files. 3. Capture files.
4. 5. The false "could not open" warning. 6. An unbounded close. Every provider call a browser's lifecycle queue waits on is now bounded:
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 |
dormouse-bot
left a comment
There was a problem hiding this comment.
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."
reuseinbrowser-host.tsnow 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 customAGENT_BROWSER_SOCKET_DIR."callerStreamStatusis gone.dor abnow runsstream status --jsonafter every command that may bind, as base did, and the host's state-filefindserves 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.
|
Updated the PR description: both bullets now describe the new behaviour (a named launch navigates a live browser in the requested mode, |
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
43f3ade to
f24214f
Compare
- 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
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/renderModeFordecode the persistedrenderModestrings, which are unchanged (they are the publicrender_modeanddormouse.ymlrendervalues).One typed, provider-tagged request (A1)
browser(request: BrowserRequest)replaces the 8agentBrowser*PlatformAdapter methods andplaywright().browser:request, Tauribrowser_request+browser_screenshot, the sidecarbrowser:request, and the dev harness.base64crate are gone.browserHandle(provider, binding)gatesbinaryPath. The agent-browser provider renders each op to a fixed argv, and Playwright maps ops directly (its argv re-parse is deleted).browserProviders.One control pair, binding and identity (A3)
surface.browser/surface.resolveBrowserserve both providers.surface.agentBrowser/surface.resolveAgentBrowserremain as aliases for one release, for terminals still running an olderdor.{provider, session, cwd?, binaryPath?}. agent-browser launches now honourcwd, so a projectagent-browser.jsonapplies.nativeIdentityfor both providers.--keyfinds the Surface by key first, then usessessionForKey(key, scope). A bare Wall now gets a uniquew<8hex>scope, which fixes every VS Code webview sharingdormouse.1.default.runBrowserCli(descriptor).dor abreads 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.tsowns:nativeIdentityserialization with generations, and headed tracking;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:
The webview-side close bookkeeping from #775 is deleted.
Behaviour changes worth a look
stopsignals a daemon only when its state files prove it live: a pid file written since boot, naming a live process, beside a.streamport that accepts.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.mdis reorganized into Providers, then Automated Browser (identity, connection, pop-out, a Browser Host contract written once), then the genuine per-provider differences.dor-cli.mdhas one "Browser Surface Addressing" section. The glossary, transport, vscode, standalone, shortcuts and dor-tool specs and the rationale files are updated.Testing
pnpm testpasses (lib 3900+, dor 190, dor-lib-common 17, vscode-ext 181, standalone, relay, website, and every lint).createBrowserHost.🤖 Generated with Claude Code
https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe