Skip to content

Latest commit

 

History

611 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

The BrowserAI icon: a globe with a reading eye

BrowserAI

A Windows MCP server that gives an AI agent a real browser through Playwright's own MCP server, keeps each session in a directory that outlives the agent and can be resumed or handed to another, and ships as one installer that updates itself.

BrowserAI is a proxy. It ships the runtime, owns the lifecycle, and rewrites the tool surface. It does not reimplement Playwright, and it does not reimplement Playwright's MCP tool layer. That boundary is the single most important design constraint in this project and is spelled out in Scope below.

Windows only, and there is nothing to install alongside it. No Node, no .NET and no Chrome on the host: the installer carries a NativeAOT single-file binary, node.exe and the vendored @playwright/mcp tree, and provisions its own Chromium on first run. One MCP registration, at user scope, available in every repository -- no .mcp.json, no hooks, no per-repository files.

Why it exists, and every settled decision with the argument that settled it, is in DECISIONS.md.


Install

  1. Download BrowserAI.exe from the latest release and run it -- it is the installer. (Named BrowserAI-win-Setup.exe until 2026-09-15.) It installs per user into %LocalAppData%\BrowserAI.app and needs no elevation. Your data -- the browsers it downloads, the index of your session directories and its log -- goes in %LocalAppData%\BrowserAI beside it, and stays there across an update, a reinstall and an uninstall. Uninstalling asks before deleting it; a silent uninstall keeps it.
  2. That is the whole installation. The installer's own hook registers BrowserAI with Claude Code and with Codex, each through the client's own supported command -- claude mcp add --scope user and codex mcp add -- so it is available in every repository on the machine. A client that is not installed is skipped and named in the registration record below, and the install still succeeds. The uninstaller removes both registrations again. (Codex registration arrives in the first release after 1.1.0; 1.1.0 registers Claude Code only.)
  3. A small BrowserAI window opens when the install finishes, and there is a BrowserAI entry in your Start Menu that opens it again whenever you want it. It shows the installed version, where BrowserAI is installed and where its data lives, and whether it is registered with each client -- and it is the only place you need for what you might want to do: check for updates, and for each client separately register or unregister for all your projects, register in a specific project, or remove it from the project the window was opened in; and open the logs. When a registration is already correct the register link reads Register again, which writes it back the way BrowserAI would, and that is the way back after editing your own copy. It changes nothing unless you click something; opening it is safe.
  4. Restart the client so it picks up the new server. Claude Code reads its MCP configuration when a session starts, so sessions you already have open will not see BrowserAI until they are restarted. Codex does not pick the change up in a thread that is already open; start a new thread.

If registration did not happen -- a client was not found, or it is neither Claude Code nor Codex -- BrowserAI writes mcp-registration.json into %LocalAppData%\BrowserAI, with one entry per client carrying the exact command to run by hand. They are these:

claude mcp add browserai --scope user -- "<install root>\current\BrowserAI.Server.exe"
codex mcp add browserai -- "<install root>\current\BrowserAI.Server.exe"

Codex is looked for in four places, because its desktop app puts the command line on no search path at all: PATH, ~\.local\bin, the desktop app's own manifest under %LocalAppData%\OpenAI\Codex, and %AppData%\npm.

⚠️ The file name changed on 2026-09-15 (previously current\BrowserAI.exe). BrowserAI ships as two programs now, in one installer: BrowserAI.Server.exe is the MCP server, which is what Claude Code starts and what the command above names, and BrowserAI.exe is the small window described in step 3. If you have an older registration it still names the old path; updating repairs it, and so does clicking Register for all my Claude Code projects in the window.

Registration is never allowed to fail an install, and never allowed to fail silently: every outcome writes a log record and that file.

Registering BrowserAI in one project, not for all of them

The window's Register in a project... asks for a folder and writes a .mcp.json at its root -- the file Claude Code reads for project-scoped servers. It is meant to be committed: a teammate who clones the repository is then offered BrowserAI without configuring anything, and Claude Code will ask each of them to approve the server once, the first time they open a session there. The command written into it is portable -- ${LOCALAPPDATA}/BrowserAI.app/current/BrowserAI.Server.exe, which Claude Code expands on each machine -- so it is right on every teammate's machine and not just on the one that wrote it. (If you installed BrowserAI somewhere other than the default location, the entry gets that absolute path instead and the window tells you why.)

For Codex, the same link writes .codex\config.toml in the folder you choose, by running codex mcp add with CODEX_HOME pointed at that .codex folder, so the file is Codex's own. ⚠️ Codex reads a project's own configuration only in a project you have trusted, so the entry does nothing in a folder Codex has not been told to trust. The entry names BrowserAI.Server.exe and no folder, because Codex expands no variable in a server's command -- measured: none of four spellings of LOCALAPPDATA started anything in 48 attempts -- so the portable spelling above does not work there. Codex finds the name on your PATH instead, and the installer puts BrowserAI's own current folder on your user PATH and the uninstaller takes it off again, so the same committed file serves every machine with BrowserAI installed. ⚠️ Restart Codex after installing BrowserAI: a Codex that was already running keeps the PATH it started with and will not find the server until it is started again. Both follow from how Codex builds a server's environment, read in its source, and neither has been measured end to end. (Until 2026-09-24 the entry carried this machine's absolute path, which was right on the machine that wrote it and on no other.)

Removing a project registration is in the window too: each client offers Remove from a project..., which asks for the project folder and removes BrowserAI's entry from that folder's own .mcp.json or .codex\config.toml. Started from a terminal inside the repository, each client with a registration of ours at or above that folder also offers Remove BrowserAI from this project, naming the file. Opened from the Start Menu the window starts in its own install folder and finds none, so only the first link appears there. Neither removes another install's entry: one found in the folder you pick is reported and left alone.

This is an addition, not a replacement: registering for all your projects is still one entry in your own configuration with no file in any repository, and that is still what the installer does for you.

A redirected user profile is not supported, and BrowserAI refuses it by design instead of half-working on it. If %LocalAppData% resolves onto a UNC path or a mapped network drive -- a roaming or folder-redirected profile -- startup refuses the app root and says so by name; and a session directory on a network path is refused at the door for the same reason, in every spelling, including a drive letter that only resolves to a share once the filesystem is asked. The refusal is deliberate and not a restriction that can be lifted by asking: the session record is a SQLite database in WAL mode, whose shared-memory index has only ever been established on a local volume, and a refusal that names the path is a better answer than storage whose guarantees nobody has measured where you put it.

Updates are automatic and there is one track. No beta channel. BrowserAI checks its own feed and applies an update only when no other instance is live. The check happens once, when the server starts, and on no other schedule -- there is no polling timer, and opening a session or a hundred browsers inside one does not ask again, because they are all children of the one server your client started. A build that was not installed by the installer never checks at all.

The first session downloads a browser. Chromium is provisioned once per machine, not once per update -- measured at 207.3 MB down, 437.24 MiB on disk and about 10.8 s (chromium 1244, re-measured 2026-09-16; previously 203.8 MB, 430.48 MiB and about 12.6 s at chromium 1237). Nothing is downloaded at spawn after that, and nothing resolves from a registry at runtime: the client runs exactly the bytes the build froze into the artifact.


Using it

BrowserAI presents upstream's browser_* tools under upstream's own names, byte-for-byte, plus eight tools of its own. A session is a directory, and that is the only handle there is: every upstream tool has a required session parameter injected into its schema, carrying the absolute path of the session the call belongs to. A call that names no session is refused instead of reaching a browser.

Eight authored tools, all prefixed browserai_:

Tool What it does
browserai_init Creates a session. directory and purpose are required -- no defaults, no fallback, and an empty, relative or unusable path is refused and not turned into one that happens to work
browserai_resume Takes over a directory that is already a session, and replays what it was. browser is not an argument; it was bound at init and is read back out of the session's own record. It is also how a moved session comes back: there is no move tool and no copy tool, so the directory is moved by hand while no browser is open on it and resumed at its new path
browserai_list Every session beneath a directory you name -- browser, purpose, created and last-used stamps, size on disk, and whether it is in use right now: YES when this BrowserAI is driving it or something else holds its lock, no when nothing held it and that directory's own gate was free, UNKNOWN with the reason when either could not be settled. Corrected 2026-08-24 (previously "no when nothing held it at the instant of the look") -- a busy session's record is briefly present and unheld while it is being rewritten, and the look landed in that window and printed no about a session another agent was driving. The holder is deliberately not named -- the lock says the file is held, not by whom. There is no unscoped form: breadth is stated and not assumed
browserai_destroy Closes the browser and deletes the whole directory -- everything in it, screenshots and downloads included, so move out what must be kept first. Refuses anything that does not hold a valid session record, which is what stops it being aimed at Documents. Windows will not unlink a file a browser is still mapping, so a destroy that could not remove everything reports an error -- and that error names every survivor, says the session itself is gone, and says not to call this tool again on that directory because there is no longer a session there to destroy
browserai_catch_up Answers what were we doing here, and what is here now for one session, from two sources that routinely disagree: the session's own ordered log -- every browser call and every purpose change, with what the caller said each was for -- and a walk of the directory: age, last touched, total size, a breakdown by artifact kind, whether the profile holds a cookie store, and any HTTP Archive it finds. Read-only and takes no lock it can be refused by, so it answers for a session another BrowserAI is driving right now; it is the one session-scoped tool with no why, because a tool that told you what happened by adding to what happened would bury its own answer
browserai_set_purpose Rewrites what a session is for. The previous purpose is kept in the session's history and not lost
browserai_page_tool Calls a tool the page offers, if it offers any -- see Tools a page offers. name is the page's own name for it, arguments is an object that reaches the page's code unchanged, and the optional page refuses the call if the tab has navigated since you read the tool. Added 2026-09-21.
browserai_reinstall_browser Deletes and re-provisions one browser tree. browser is required and has no default -- with two families on disk, a defaulted one would re-download a healthy tree and report success while the broken one stayed broken. It refuses -- naming them -- while any session of that family is open, whether or not a browser is currently running out of the tree. Changed 2026-08-19 (previously the session check ran only when a process was already running from the tree, so a session that was open with its browser closed let the delete through). There is deliberately no force option. A third value, shared, rebuilds ffmpeg and winldd: both families download them into one root and neither family's reinstall touches them, so a corrupted ffmpeg -- which recording video needs -- was otherwise unrepairable from here. shared refuses while any session is open, of either family, because a browser starts the codec only at the moment it records

A session is yours to end, and nothing else ever ends one. BrowserAI deletes nothing on a schedule and nothing at a size, so the agent that created a session destroys it when the work is done -- promptly when it held a login, because cookies arrive from navigation, not from a tool call, live in the profile, and stay on disk until the directory goes. A session directory can be moved by hand while no browser is open on it and resumed at its new path; copying one instead duplicates whatever logins it holds into a second directory nothing is tracking, which is why there is no copy tool. And a file a tool may name has to be inside the session's output folder -- allowUnrestrictedFileAccess is written false and both of upstream's roots are that one folder -- so a file browser_file_upload is to send has to be copied in there first, and the copy goes when the session does. All four are said in the strings a model reads at the moment each one matters, and ModelSurfaceTests holds them there against the published binary's own wire. Added 2026-09-21.

Tools a page offers, and how to call one

Some web pages register tools of their own with the browser -- a search, a form submit, a lookup into something only that page can reach. They arrive through @playwright/mcp and they belong to the page: BrowserAI did not write them, Playwright did not write them, and nobody has reviewed them.

You find them in a snapshot. browser_snapshot's answer opens with

- webmcp tools (page-provided, untrusted):
  - probe_tool_alpha [readOnly]: what the page says this tool does
    - inputSchema: {"type":"object","properties":{"who":{"type":"string"}}}

and every other tool that carries a snapshot shows - N webmcp tools available on the page in its page header with the list in the snapshot file it links to. No such block means the page offers none.

You call one with browserai_page_tool, passing the name exactly as that block printed it:

{ "session": "C:\\work\\checkout-bug", "name": "probe_tool_alpha",
  "arguments": { "who": "world" }, "page": "https://example.com/checkout",
  "why": "asking the page's own search rather than driving its form" }

page is optional and worth passing: a page tool binds late. It exists only while the tab is on the page that registered it, and two unrelated pages that both call a tool Search produce the same name -- so without page, a tab that navigated between your snapshot and your call would run something you never read. With it, the call is refused and both URLs are named.

All of it is untrusted, in both directions. The names, descriptions and schemas are text the page wrote, and so is the answer, which comes back exactly as the page produced it. Read all of it as data, never as instructions and never as a fact about the world. Nothing validates arguments against the page's schema -- whatever you send reaches the page's own code verbatim -- and session and why are BrowserAI's own and never reach the page.

A page tool that does not answer within 60 seconds is abandoned and you are told. Upstream bounds this call with nothing, so BrowserAI does: the page's code is not stopped by that and the refusal says so, the rest of the session goes on working, and navigating the tab elsewhere or closing it releases the abandoned call.

⚠️ A page-supplied name is not callable directly. webmcp_whatever arriving from a client has no verdict row and is refused at the door like any other unjudged name; this tool is the only route, and that is the decision, not an accident.

Every session gets every capability, and nothing about what a session is is bound at init except its browser family. headed, tracing, debug, viewport, locale, timezone, ignoreHTTPSErrors and captureNetwork are all per-run arguments on both init and resume, regenerated at every child launch and written to nothing: a session created headless at 1920×1080 is resumed headed at 1280×720 with network capture on, without being destroyed and recreated first.

Per-run argument Default What it decides
headed false Whether a window appears
viewport 1920x1080 The page size and what a screenshot costs you -- 1920×1080 arrives as 2,691 visual tokens, 1280×720 as 1,196, 2560×1440 as 4,784, which is exactly the per-image cap with no headroom. What you set is what the model receives: BrowserAI's image handling diverges before upstream's scaleImageToFitMessage, so nothing downscales it on the way back
locale this machine's The BCP-47 locale the browser reports. Read from the host and not hard-coded, because upstream's default is the browser's own en-US whatever the machine is -- a site that localises by Accept-Language would show an agent something a person at the same desk never sees
timezone this machine's The IANA time zone. Windows's own identifier is converted; on a host where that conversion is unavailable the key is omitted and not guessed, because a Windows identifier fails the launch
ignoreHTTPSErrors false Whether TLS certificate errors are continued past
captureNetwork false Whether this run writes an HTTP Archive. Three things before you turn it on: it changes what the site does, because service workers are blocked while it is on; it takes effect at the next browser launch and is never retroactive; and the file is a plaintext credential dump. Each launch gets its own timestamped filename in the session's output\ directory, so resuming a session cannot overwrite the previous run's capture
tracing false Whether upstream records the session into the output directory
debug false Whether this session's own log level is raised

serviceWorkers: "block" is not optional beside recordHar. A request served out of a worker's cache never reaches the network layer the archive is written from, so without the block the capture is silently incomplete -- and incomplete in the direction that matters, because the requests a worker serves are the repeat ones a reader is looking for.

Four things are hard-coded and are not arguments. The console level is debug always -- measured, error→debug costs +1 character on a navigation response and +5 otherwise, because the events line is a pointer (path#L1-L20), not the message text, and browser_console_messages already takes its own read level, which can be lowered at the moment of asking where a capture level chosen at init cannot be raised retroactively. (consoleLevel was an argument until 2026-08-20 and is deleted.) codegen: "none", which strips a ### Ran Playwright code block from every response for a feature this product does not have. snapshot.boxes: true, whose cost is deferred -- a response carries a link, not the snapshot -- and which the vision capability's six coordinate tools are unusable without. And permissions: ["clipboard-read"] -- clipboard-write is already granted without asking -- ⚠️ for Chromium only: measured 2026-08-20, Firefox fails at initializeServer with Unknown permission: clipboard-read and the browser exits, so writing it for both families makes every Firefox session unusable, not degraded.

⚠️ Corrected 2026-08-20. (Previously: "Three modes, bound at init and recorded in the session's browserai.json. A mode is two switches on the browser BrowserAI launches for that session -- whether a window appears, and whether the cookie and storage tools exist in that session's child" -- a table of headless / interactive / persistent against Window and Cookie-and-storage-tools reading No·No, Yes·No, Yes·Yes; then "A session without them has its child launched without upstream's storage capability, so the 17 cookie, localStorage and storageState tools do not exist in that process at all"; then a 2026-08-19 correction recording that all three modes persisted on disk and that storage was a tool filter , not a persistence switch; then "tracing is a boolean on any of the three, not a mode of its own. Headless-with-storage is deliberately not offered.")

The modes are gone. What they decided was a window and a capability set. The window is now an argument, and the capability set is everything -- the reason is the one the 2026-08-19 correction above had already established and the 2026-08-18 one before it: it was never a boundary against the caller, who owns the session directory, reads the profile inside it as the same Windows user, and could reach whatever a missing capability withheld by resuming the same directory as a different mode. What it cost was real: a headless session that needed to read one cookie had to be destroyed and recreated.

Ten tools became reachable in that change -- for the first time in this product's history and in the predecessor's. Upstream's network capability (browser_route, browser_route_list, browser_unroute, browser_network_state_set), its pdf capability (browser_pdf_save) and its testing capability (browser_generate_locator and four browser_verify_*) had never been named in a generated config at all. browser_run_code_unsafe is not among them and never was -- it is core, so it has been in every session this product has ever opened.

⚠️ browser_route can make a page lie to a human watching a headed window. A mocked response renders as if it came from the server: the address bar keeps the real origin, and nothing on screen says a rule is in force. That warning is in the server instructions, which BrowserAI writes, not appended to the tool's description, which passes through byte for byte.

Every call that names a session also carries a required why. It rides the same path session does -- injected into the JsonNode the child sent, appended to properties and to required, with everything upstream wrote left where it was -- and it is stripped again before the call is forwarded, so the child never sees it. It is on every upstream browser tool and on browserai_resume, browserai_destroy and browserai_set_purpose; not on browserai_list, which is directory-scoped, nor on browserai_reinstall_browser, which is machine-scoped, because neither has a session record to write into; and not on browserai_init, which asks for a purpose instead -- two mandatory free-text fields on one call gets one thoughtful answer and one restatement.

It asks for why, not what, and the descriptions do that work instead of leaving it to a model: "checking whether the login survived the redirect" beats "clicking the submit button", because the tool name already said the second one. A call that omits it is refused before anything is forwarded, with a sentence that says what to write, not only that something is missing -- a model told "'why' is required" retries with a restatement, which satisfies the schema and records nothing.

Where it goes is one time-ordered log inside browserai.data. init's purpose, every purpose change, every browserai_set_purpose, every browser call the session forwarded and every call it refused are rows in the same ordered table, so a reader sees the human changed the purpose here sitting between the calls it explains. A row is written in-flight immediately before a call is forwarded -- so a call that never returns still left one -- and settled to successful or failed with the time it settled, from which the duration comes for free. A call whose row cannot be written is refused, not forwarded.

⚠️ Corrected 2026-08-26 (previously "one time-ordered log inside browserai.json", and "a call BrowserAI refused leaves none, because this records what the session did and the refusals are in browserai.log beside it"). The record is a SQLite store now and the session's own log file is gone -- everything it carried is on stderr. So a refusal is a row: the record is the only place the agent reached for a tool this build will not forward survives at all.

purpose and why are different things and the schemas say so. A purpose is the session's standing description -- what browserai_list shows six weeks later. A why is disposable: why you are doing this right now, one entry in the log, shown in no listing. browserai_set_purpose takes both, and its descriptions carry the same example on both sides: "the original login bug turned out to be a redirect loop" is a why; "tracking the checkout redirect loop on staging" is a purpose. If what you are writing would still be true next week, it belongs in purpose.

browserai_catch_up is what reads it back, a page at a time -- oldest first, so a page number keeps meaning the same entries as the log grows -- and it holds the log against the directory instead of believing either alone. The log says what BrowserAI did; the directory says what is true now, and the two routinely disagree in one direction that matters: cookies arrive from navigation, not from tools, so a session whose log shows no cookie call at all can hold a live signed-in profile. It reports age, when it was last touched, total size and a breakdown by artifact kind, names the profile's cookie store when there is one, and calls out any .har by name -- a HAR records every request and response including headers, so every bearer token and session cookie that crossed the wire is in it in clear text.

⚠️ No argument is stored at all -- corrected 2026-08-26. Previously: "Every argument name, always -- a reader must be able to see that a password field was filled even when the value is not there. Then value and text are never stored, at any length, as <withheld, N characters>; an object or an array becomes a shape, <object, N keys>; and everything else is cut at 200 characters with a count of what was dropped, which is what turns a browser_evaluate body into a summary rather than a transcript." A row carries the tool's name, verbatim, and your why. Nothing sits between your call and the browser server, and an argument summary is BrowserAI reading your request and writing its own account of it -- the why answers the same question better, and it needs no list of upstream parameter names to stay honest. What is lost is worth naming: from the log alone you can no longer see which selector was typed into, or that a password field was filled at all.

⚠️ It was never a redaction boundary -- the record sits beside the browser profile whose cookie database holds the same credentials, readable by any process running as you. Defending against a caller that means harm is an explicit non-goal: the premise is that the model tries to behave, and what BrowserAI steers is honest mistakes.

⚠️ Corrected 2026-08-18 (previously "bound at init, recorded in the session's browserai.json, and enforced server-side on every call"). There was a (tool, mode) permission policy behind that phrase -- five tool classes, deny-by-default in both directions, plus a guard that refused a browser_get_config answer carrying "secrets" -- and it has been removed. It was described as a security boundary and was never one against the caller: the calling agent chooses the session directory, the profile and its cookie database are created inside it, and the agent runs as the same Windows user, so DPAPI decrypts for it. Any file tool the agent holds reads what the policy declined to return. Prompt injection is real and is not solved at this layer. That sentence was measured on 2026-08-18, after the removal, not before it -- a second process as the same user recovered a cookie from a session BrowserAI configured with CryptUnprotectData and AES-GCM alone, and App-Bound Encryption is not in force for the provisioned Chromium (kb).

Change control moved to the release gate, which covers more: four golden snapshots -- including tools-list.json with every tool's inputSchema -- are diffed against the resolved payload on every build, and upstream-review.json holds a release until a human adjudicates what moved.

One tool is withheld and one argument is mandatory; neither is a permission. session is mandatory, because that is routing. And browser_annotate is not in tools/list at all -- filtering the surface is in scope where renaming is not -- because it blocks with no self-timeout until a human draws, and its window belongs to a second, non-headless browser under a daemon that writes into %TEMP% and outlives the session. A caller that names it anyway is refused, not forwarded. Corrected 2026-08-18 (previously "Two refusals survive ... browser_annotate is refused on a mode that opens no window"). The measurement is in kb; what it would take to bring the tool back is in DECISIONS.

⚠️ Nothing is between your call and the browser server except the session and the reason. Corrected 2026-08-26 (previously a paragraph about BrowserAI reading each answer before it sorted, and leaving alone any file the answer mentioned -- "a download nothing named is still sorted, which is the control"). There is no sorting. output\ is flat: a file lands where the browser server put it, under the name your call gave, and the answer that names it comes back byte for byte as the browser server wrote it. What that removes is a whole class of wrong answer -- a pointer naming a file that had been moved out from under it, a console log recreated at the old path and landing as -2, an answer citing lines a 24-line file did not have. What it costs is stated and not hidden: a name you reuse overwrites, and a name Windows silently rewrites -- NUL.png, a trailing space -- is no longer refused. Both are open rows in HAZARDS.

The session directory is the identity. One directory holds browserai.lock and browserai.data at its root and profile/, output/ and downloads/ beneath it -- corrected 2026-08-26 (previously "holds browserai.json at its root"). browserai.lock says who owns this directory, and its open handle is the whole of that: it is written once and released by Windows however the holder dies. browserai.data says what happened here, and it is the file a copied session carries with it. There is no handle to keep, no token to store and no expiry: a session stays resumable against its recorded directory for as long as the directory exists, and a resume costs about 515 ms and loses only sessionStorage. Everything a tool writes lands in output/ under the name the call gave it. Corrected 2026-08-26 (previously "Artifacts are routed into typed subfolders on the way in, and every result carries the resolved absolute path").

Every session holds the machine's browsers root shared for its whole life, and a reinstall needs it to itself. Added 2026-08-19; reader/writer since 2026-08-20. Two agents on one machine could otherwise race: the reinstall establishes that nothing is running out of the tree, and the other one's init launches a browser into that tree while the delete is part way through it -- the check was right when it was asked. browserai_init and browserai_resume take the claim shared, any number at a time; browserai_reinstall_browser takes it exclusively, which Windows refuses while any session holds it. It knows nothing about browser families: a live Firefox session refuses a Chromium reinstall, because nothing may run out of the root while it is being replaced. A process that dies releases its claim, because the kernel closes the handle however the process ends. There is no drain, no wait and no intent marker. A reinstall that finds sessions open refuses at once and names them; it never holds the machine waiting for a browser a human may never close, and it does not stop new sessions starting meanwhile. Anything refused by a reinstall is told how far in that reinstall is -- what has been staged, how long it has been running, and the rate those two give.

BrowserAI refuses to start when either of its roots is not inside your own Windows profile. Added 2026-08-20; narrowed and then widened again 2026-09-15. %LocalAppData% gives every user their own browsers directory, session index and log, and their own install directory; BROWSERAI_ROOT can move the data root and Setup.exe --installto can move the install root, and both are now judged. (Previously "its app root ... BROWSERAI_ROOT and the installer's install-to flag both defeat that", then "its data root ... what --installto still moves -- the install root, and with it the live-instance markers keyed to it -- is not judged here". The first was written when there was one root; the second was true for a few hours, and the gap it named was closed the same day.) Two users sharing one root is unsafe in a way nothing reports at run time -- the file locks span users, but the machine-wide mutexes do not, so the second user's process cannot join the live set, creates no marker, and is invisible to the first user's census; that census then answers nothing else is running, and applying an update terminates every process under the install root, the other user's browsers included (kb). The refusal is a log line and a non-zero exit, and it names both roots -- because two different levers move them and neither can move the other's -- then offers the one that can move whichever is at fault: clear BROWSERAI_ROOT for the data root, install inside your profile for the install root. The check resolves through the filesystem instead of comparing strings, so a junction, a substed drive letter, an 8.3 short component or an extended-length spelling of a per-user root is served, and a junction inside your profile that points outside it is not.

Two browser families, chromium and firefox. The family is the one thing init binds permanently: a profile belongs to the browser that made it, so resume reads the family back out of the record and refuses to be told a different one. chromium is the default, because it is the most widely used browser -- the maintainer's reason, recorded in DECISIONS.md, and a decision, not a measurement. Each is downloaded once per machine on first use -- chromium 207.3 MB, firefox 129.5 MB, both measured from the CDN's own content-length (kb); corrected 2026-09-17 (previously "chromium 203.8 MB, firefox 127.2 MB"), re-measured 2026-09-16 at chromium 1244 and firefox 1544 -- and the first browser call on a family that is still downloading is refused with a progress report and not blocked -- bytes written so far against that measured total, elapsed, the observed rate, and what the remaining bytes come to at that rate. Changed 2026-08-19 (previously the size and "wait about ten seconds", which read the same at 8 s in and at 25 minutes in, so nothing told a caller whether the download was working). Upstream emits no progress notifications at all, so this refusal is the whole mechanism.

A download is stopped when it stops making progress, not when it has taken too long. Changed 2026-08-19 (previously a 45-minute ceiling on the whole install). A total-time cap can only ever fire on a link that is slow and working -- 207.3 MB in 45 minutes is 0.61 Mbps (corrected 2026-09-17, previously "203.8 MB in 45 minutes is 0.60 Mbps"; the same arithmetic over the 2026-09-16 figure) -- while a link that has died is caught by Playwright's own 30-second socket timeout twenty times sooner. What is watched now is bytes on disk under the browsers root, and the cap is ten minutes with nothing written at all: that is set by upstream's own installer lock, which legitimately writes nothing for up to 470 s while it queues behind another install. Corrected 2026-08-19 (previously "Chromium only, today. browserai_init refuses browser: "firefox""). What that sentence was waiting for was a measured Firefox download size and a decision about what browserai_reinstall_browser reinstalls when there are two trees; both are done.

The advertised tool surface does not depend on which family a session runs. Measured 2026-08-19 against real children of the resolved payload at both capability sets: 42 tools without storage and 59 with it, identical names, identical order, identical schemas under chromium and firefox (kb). Every tool-surface number in this repository is therefore a claim about both.

Any path is accepted, deliberately. BrowserAI does not constrain a session directory to a sanctioned root, so an agent may point one at a real browser profile and read live browser state. Correct use is the calling agent's responsibility -- BrowserAI logs the resolved absolute paths instead of enforcing a boundary. The reasoning is in DECISIONS.md.


Scope: proxy, not implementation

BrowserAI spawns @playwright/mcp as a child process and forwards JSON-RPC to it.

This is a hard boundary. It exists because the temptation to cross it is real and will present itself as a reasonable next step.

In scope

  • Spawning and supervising a pinned @playwright/mcp child over stdio
  • Forwarding tools/call to that child and returning its response verbatim
  • Fetching tools/list from the child at runtime and rewriting it -- filtering, re-describing, adding parameters. Not renaming: upstream names pass through byte-for-byte (Tool naming)
  • Generating the child's --config JSON at launch time from BrowserAI's own session state
  • Everything around the protocol: locking, lifecycle, directories, artifacts, diagnostics, updates

Out of scope -- explicitly forbidden

  • Driving Playwright directly (via Microsoft.Playwright / Playwright for .NET or any other binding)
  • Hand-writing tool schemas in C#. Every schema must originate from the child's tools/list response. If a tool definition is being typed into a .cs file, the boundary has been crossed.
  • Reimplementing the snapshot/ref system, the accessibility-tree serialization, response formatting, or error shaping

Why the boundary sits exactly there

@playwright/mcp 0.0.79 is a 20-line shim. The entire package is cli.js, index.js, and type definitions:

// node_modules/@playwright/mcp/index.js
const { tools } = require('playwright-core/lib/coreBundle');
module.exports = { createConnection: tools.createConnection };

The implementation lives in playwright-core/lib/coreBundle.js -- 3,536,648 bytes, esbuild-bundled, containing an 83-entry tool array. The numbers matter, because a golden test written against the wrong one fails on day one: 83 is the internal registry; 74 is the maximum ever exposed over MCP (9 are skillOnly and always stripped), and 27 is the default with no capabilities set -- all three in kb: tool surface, which also carries the per-capability breakdown, counted. Corrected 2026-09-17 @ playwright-core 1.64.0-alpha-2026-09-17 (previously "3,503,383 bytes ... an 82-entry tool array ... 82 is the internal registry; 73 is the maximum ever exposed over MCP ... and 26 is the default"). Re-measured off the resolved payload, not incremented: the dated override added one core tool, browser_emulate_media, and nothing else moved. core again, so the default moved again -- three bumps running now, and the only one that left the default alone was the 0.0.80 pair, which landed in devtools. Corrected 2026-09-15 @ playwright-core 1.64.0-alpha-2026-09-14 (previously "3,469,825 bytes ... an 80-entry tool array ... 80 is the internal registry; 71 is the maximum ever exposed over MCP ... and 24 is the default"). Re-measured off the resolved payload, not incremented: the roll inside @playwright/mcp 0.0.81 added two core tools and nothing else moved. The default moved this time and did not last time -- core is unconditional, so a tool arriving there is in the default surface, where the 0.0.80 pair landed in devtools and was not. Corrected 2026-09-14 @ playwright-core 1.63.0-alpha-2026-08-31 (previously "3.4 MB ... a 78-entry tool array ... 78 is the internal registry; 69 is the maximum ever exposed over MCP ... which also records that the per-capability breakdown was never counted"). Re-measured off the resolved payload, not incremented: the roll inside @playwright/mcp 0.0.80 added two devtools tools and nothing else moved. The last clause was separately stale -- that breakdown was counted on 2026-08-16 and the section it links to has said so since. Its value is not browser control; Playwright for .NET does browser control perfectly well. Its value is the ref-based accessibility snapshot system, the response formatting, and the error handling -- the layer that turns a browser into something a language model can operate. That layer is large, subtle, actively developed upstream, and would drift permanently the day it is forked.

"We don't want to reimplement Playwright" is the easy half of this rule. The half that matters: reimplementing the MCP tool layer is also reimplementation, even though it never touches a browser API.

The one sanctioned exception, if it is ever needed

playwright-core explicitly whitelists "./lib/coreBundle" in its exports map, so require('playwright-core/lib/coreBundle') is a supported import, not a blocked deep path. It exposes browserTools (a flat array of plain, inert objects), filteredTools, createConnection, and BrowserBackend. defineTool is literally the identity function -- there is no class, no registry, no side effect (kb: tool surface).

This means in-process tool manipulation is available if the proxy approach ever proves insufficient. It is not the plan, it carries no type definitions and no semver guarantee, and taking it requires pinning @playwright/mcp and playwright-core together and re-verifying the tool array on every bump. Documented here so it is a considered decision, not a discovery.


Where things are written down

File Holds Changes when
README.md (this file) What BrowserAI is: what it does, how to install it, how to use it, the scope boundary, the licence The product's surface moves
DECISIONS.md What we decided, and why -- the founding argument, the trade-offs taken, every settled decision with the reasoning attached We change our minds
ARCHITECTURE.md How the product is put together: each area, what it guarantees, and the code that implements it The code moves
HAZARDS.md Every known failure mode, in one checkable list, with the evidence that closed each one. Maintained by addition; rows are never deleted A new failure mode is found, or an open one is closed
RELEASING.md The checklist a release must pass, and the release gate it enforces. The only gate that exists An item proves unevidenceable, or the gate moves into automation
TESTING.md Why the suite is the only thing between a floating dependency and a shipped regression: the five layers, what the build itself must fail on, why the upstream-review gate is the suite and not a hook, and why the harness is ours The suite's shape changes, or a gate moves
STACK.md Which component was chosen and why, the MSVC prerequisite, where the version comes from, the build configuration, and the nine SDK deviations A component is replaced, or a deviation stops being needed
kb/ What we measured -- about Chromium, Firefox, Playwright, Node and Windows, one article per topic, with provenance and a re-verification hook Upstream ships, and a re-measurement says something different
TODO.md Work settled in intent but not yet done Something gets decided, or gets done
UPSTREAM-REVIEW.md The procedure for adopting a new upstream version The procedure proves insufficient

Different half-lives, which is the whole reason for the split. A decision stays true until we revisit it. An architecture stays true until the code moves. A measurement stays true until upstream ships, which is a clock nobody here controls. Mixing them means the whole document reads as equally settled, and the parts with the shortest half-life are exactly the ones that quietly stop being true. When a document here states a measured fact, it is a summary -- the article under kb/ carries the number, the date, the versions it held under, and how to re-establish it.

There was also an implementation plan, one file per section under plan/, plus a TODO.md full of closed items and a log of the first release run. All three were consumed: a plan section was spent the day the code it described existed, and the whole set was deleted on 2026-08-17 once every section was built and audited. What survived it is the documents above that were never part of it, plus everything the sections had put into kb/ and into the doc comments as they went. git log is the record of the build itself.


Status

Tagged v1.1.0 and published. No install is known beyond the maintainer's own.

⚠️ Corrected 2026-09-23 (previously "Tagged v1.0.0 and published.") -- v1.1.0 has been the standing release since 2026-09-23T11:50:58Z, gh release list reads it Latest, and until this correction the string 1.1.0 appeared nowhere in this file. The second sentence is unchanged and is the half that still matters: a download is not an install, and no session in the wild has ever been observed. The paragraph below is about v1.0.0 and is left as written, because it is the account of a sentence that was wrong, not a description of what is standing now.

⚠️ Corrected 2026-09-15 (previously "Tagged v1.0.0. Nothing has been distributed.", itself corrected 2026-08-24 from "Shipped. v1.0.0.") -- that sentence was false, and it was false in the direction that creates obligations, not the direction that relaxes them. A non-draft release has stood at releases/tag/v1.0.0 since 2026-08-17T01:54Z carrying an installer, a full package and the update feed, all publicly downloadable; GitHub's counters read 1 download of the installer, 0 of the package and 687 of releases.win.json, which is an installed BrowserAI polling for updates, not a person. The 2026-08-24 correction was derived from git tag --list and a gitignored Releases/, which are the two places that cannot see a GitHub release -- so a claim was re-checked, re-stamped and left wrong. The distinction that was load-bearing survives, narrowed: design arguments reasoned from "v1.0.0 shipped, so sessions exist in the wild" are still reasoning past the evidence, because a download is not an install and no session in the wild has ever been observed. The 2026-09-15 cut replaces that release object instead of adding to it; which one is standing is what gh release view v1.0.0 says. Nothing enforces this sentence -- the tag is what git tag --list says, the release is what gh release view v1.0.0 says, and the installed base is still what a person knows.

870 executed test cases, 0 failed, 0 skipped -- measured from the two-shell gate of 2026-09-24 at fcdb1c1: one full dotnet test run from PowerShell forcing C:\ at 3m 44.0s and one from Git Bash forcing c:\ at 3m 22.3s, both FULL RUN, both publish-FRESH with the binary and the tree both at 1.1.1-alpha.0.124, both with every capability PRESENT, no test on a degraded path, and both windows rows CLEAN (previously "853" from the two-shell gate earlier the same day, "841" from the two-shell gate earlier the same day, "831" from the two-shell gate earlier the same day, "817" from the two-shell gate earlier the same day, "814" from the two-shell gate earlier the same day, "807" from the two-shell gate earlier the same day, "788" from the two-shell gate of 2026-09-23, "787" earlier the same day, "784" from the six-run release gate of 2026-09-22, "783" from the two-shell gate earlier the same day, "781" from the first two-shell gate of 2026-09-22, "779" from the two-shell gate of 2026-09-21, "766", "770" and "766" earlier the same day, "765" on 2026-09-18 and "764" earlier that day, "762" from the six-run release gate of 2026-09-17, "761" from the two-shell gate earlier the same day, "755", "750" and "747" earlier the same day, "741" from the six-run release gate of 2026-09-16, "737", "720", "706", "685", "676" and "675" earlier the same day, "671" earlier still, "652", "651", "650", "647", "644", "643", "644", "641", "640", "637", "626", "624", "613", "604", "634", "648", "625", "622", "618", "614", "603", "601", "596", "593", "589", "585", "582", "576", "573", "571", "551", "548", "532", "531", "530", "514", "505", "506", "500", "501", "498", "497", "495", "493", "491", "478", "476", "461", "458", "436" and "419" before that; re-measured each time and not adjusted). The +1 is the packer's own asset list, later on 2026-09-23. One arm in ReleaseScriptTests drives build/Set-UploadAssets.ps1 over the list vpk pack really writes and holds that the portable archive is gone from it, that keeping it is what leaves it there, and that a list with no full package is refused. Planted red twice: with the filter removed the step refused itself and the arm named nothing, so the by-name assertion was moved ahead of the set comparison and the second red read "Expected to not contain" the archive by name. The +3 is the upload set and the evidence index, 2026-09-23. Two arms in ReleaseScriptTests hold what a release publishes -- that build/New-Release.ps1 declares the installer, the full package and releases.<channel>.json and nothing else, and that every other file a pack leaves behind is classified, not merely absent from the set. Both were planted red: a doctored declaration that published the portable archive again came back naming it, and dropping RELEASES from the classified list came back naming that against the real directory. One arm in HouseRuleTests holds that every batch under docs/evidence/ is a row in that directory's own index, in both directions -- planted red, and it named three batches that were already missing, the oldest of them two days old. The +13 is browserai_page_tool, 2026-09-21. Ten arms in PageToolTests drive it through the published binary against real pages that really register WebMCP tools -- a happy path whose page echoes back exactly the arguments the caller sent and nothing else, a name the page does not offer, a tab that navigated away, the same name on two pages with page given, two tools with one name on one page, a display title that is not the name, a page tool that never answers, and the door still refusing a page-supplied name outright. Three arms in ErrorCatalogueTests provoke the six new refusal rows. Watched red twice, and the second one is the interesting one. With the routing disabled and everything else in place, 8 of the 10 arms went red on their own first assertion, each reading "'browserai_page_tool' is not a BrowserAI session tool"; the two that stayed green are the two that are about existing behaviour, which is the control. Then, with the argument object taken from the caller's whole node instead of its arguments member, exactly 1 of the 10 went red -- and what it printed is the defect itself: the page's own handler echoed back {"session":"C:\Source\...\page-tool-session","name":"Do The Thing!","arguments":{"who":"world"},"why":"the suite exercising this call"}. The -4 is four tests RETIRED with the mechanism they guarded, 2026-09-21, and it is the first time this number has gone down. PayloadTests.TheDatedPlaywrightCoreOverrideIsStillNeeded, .TheExpiryComparisonFiresInBothDirections, .TheResolvedPlaywrightCoreIsWhateverTheOverrideSays and SessionPolicyTests.TheWebMcpCallIsWithheldOnLivenessAndTheWebMcpListIsNot were deleted, not skipped. The first three answered has the dated playwright-core override's exit fired; @playwright/mcp 0.0.82 shipped the fix the override was taken for, the overrides block went, and a mechanism that no longer exists cannot be tested. The fourth asserted that the WebMCP pair was judged in two directions, and upstream took both tools off the wire, so every premise it rested on is gone. Deleting a test for a mechanism that no longer exists is not a skip, and each deletion is named where the test was referenced and not removed without trace -- here, in TODO.md, in DECISIONS.md and in the source files themselves, which carry a RETIRED comment in the place the code stood. What the first three guarded came back to where it was before 2026-09-17: PayloadTests.TheLockRecordsUpstreamsOwnExactPinOfPlaywrightCore asserts the resolved playwright-core equals the declared one again, watched red against a doctored lock, and that is also what catches an override being added back.

The +17 is phase 1b, later on 2026-09-24, and the gate took three attempts, so every red is named here. At 417409a the PowerShell half was 869 of 870 and the Git Bash half 868 of 870; at 883e347 the PowerShell half was 869 again; at fcdb1c1 the PowerShell half was 869, then Git Bash 870 of 870 and PowerShell 870 of 870 on the same commit. Three reds were the suite's and are fixed, each watched red against a plant first: the pipe describe arm read the description the instant the navigate's answer arrived, and a call is counted until the handler that wrote its answer returns, so it now waits for the count to settle, red against a server planted to hold the count two seconds; the coverage-block arm classified a commit-charge reading of its own beside the block's, and at 75.0% of the limit the two fell either side of the band, so it now classifies the reading the block printed, red with the row planted at 76%; and the orphan-console rig launched the server through cmd /c start /b with nothing ordering cmd's exit before the server's look at it, so the server read its launcher alive, and the launcher is now the test probe's launch-suspended mode, which starts the server suspended and exits before the rig resumes it, red with a two-second linger planted after cmd's start in both shapes. Two reds were not fixed and are not this tree's: a Firefox launch that never finished, both in runs whose commit charge read TIGHT -- once in PasswordPromptTests, whose error was Playwright's own 180 s initializeServer timeout, and once in FirefoxSessionTests, whose navigate failed after 3m 01.8s with its text not kept. That is the stuck launch provisioning and timings records, and the second is read as the same one from its duration alone. The install hook's new environment broadcast was tried as a cause and one experiment did not implicate it: the four Firefox-launching classes ran eight times under a broadcast every 500 ms, 112 of 112 green. Five are the user PATH, in UserPathTests (Q294 b): each install root putting its own folder on the PATH and taking only that off, red with the PATH step planted out; an install and its uninstall leaving the value byte for byte, red with an append that skipped the separator after a value ending in one; the registry store keeping the kind and the unexpanded text, red with every write made REG_SZ; a bare name judged by the file it finds first on the PATH, red with the bare-name branch planted out; and a Codex project entry naming the server alone and saying what it finds, red with the absolute path written back. Two remove BrowserAI from a project you pick (Q289 b), in ConfigurationAppTests, driven through the dialog's own dispatch with a picker that opens nothing -- one holding the picker to its click and its owner and the removal to an entry of ours, one holding a Codex removal to the project's own home -- both red with the verb left out of the session's dispatch. Three are the installer lock (Q291 a), in InstallerLockTests: this run holding the lock, red with the session hook not taking it; the decision table and the drivers' half went in with the protocol they test and were not seen red on their own. Four read the gate or drive its script, in SuiteCoverageTests: every driver holding the lock for its whole run, red three times per driver; every driver packing the suite's installer before its run, red once per driver; the clearance comparing only each client's browserai entry (Q292 a), red against the whole-file hash; and the clearance reading the user PATH byte for byte, red against the script without that reading. One packs the suite's installer (Q287 a), ReleaseScriptTests.ATestPackOnlyRunPacksTheSuitesInstallerAndItsTwinAndNothingElse, red against the script without the mode, and one holds that installer to this tree's build, RealInstallerTests.TheSuitesInstallerIsTheBuildThisRunTested, red against a 1.1.0 test pack packed for the purpose. One is the Codex classifier, CodexRegistrationTests.ACodexEntrySpelledWithAVariableIsNeverOursBecauseCodexExpandsNothing, red while the classifier still expanded the variable. Arms that changed without moving the count: the real-installer arms now hold the per-client registration record and the user PATH, red against a test pack of a planted build; the pipe stop arm waits for each browser to exit, red with a single read planted before the server's exit; and WindowWatchTests holds that an unwatched run fails in every mode (Q290 a), red against the old decision.

The +12 is phase 1 of the coordinator work, later on 2026-09-24, and both halves of the gate were green on the first attempt. Eight are the pipe each server answers on, in ServerPipeTests, and each was watched red against a planted defect: a published server describing itself, planted red by reporting the publish directory as its working directory; the pipe's DACL holding one entry for the current user where the default holds more, red with an Everyone entry added; the remote-client flag read from the client's end, red with the flag removed; a second instance and a name somebody else created first both refused, red with the first-instance flag removed and again with a second instance allowed; a server dying mid-answer read as no answer through the length prefix, red when a short body was accepted; a hung listener costing the whole bound while the census still says held, red at a tenth of the bound and again with the census planted false; a free marker, a marker with no pipe and a pipe served by another pid, red with the census skipped, with the pid check skipped and with a missing name waited on; and a stop through the pipe ending a published server the graceful way, red when the stop exited the process outright and left its instance directory behind. Three are the update refusal, in UpdateInProgressTests, against the published binary: a stop with a call in flight answering that call, red when the stop refused nothing; a server started while its install's updater runs refusing calls, starting no child and ending with the updater, red with the updater never looked for and again with no watch on it; and the same server with no updater serving normally, red when every server read as updating. One is the catalogue row, ErrorCatalogueTests.TheUpdateRowIsEmittedByACallCutOffByAStopAndByACallMadeAfterIt, red when a second answer got through the door and again when a call cut off in flight was told nothing had run.

The +10 is the focus guard and the batch around it, later on 2026-09-24, and both halves of the gate were green on the first attempt. One is the residue scan, HouseRuleTests.NoCommentCarriesTheResidueOfTheScriptThatWroteIt, watched red naming eight comments a patch script had written its own source into. Five are the window watch, in WindowWatchTests: the classifier driven both ways over synthetic sightings; the row and the refusal in all three states; the premise that this run's hooks went in; a child test host on a private desktop that shows one window through the probe's new window-show mode and must exit 10 naming its class while the parent never sees it, watched red with the watch's show events planted out; and the planted arm that child runs. The watch itself was watched red against the real offender first: RealInstallerTests.TheInstalledMainExecutableOpensOneDialogAndNoConsoleWindow, unmodified, in a host on a private desktop, exited 10 naming its #32770, and moved onto a desktop of its own it is green, which moved no count. One refuses a real toast in any test, HouseRuleTests.NoTestRaisesARealToast, with its synthetic control both ways. One holds the gate's clearance to a parse of ~/.claude.json, SuiteCoverageTests.TheClearanceSnapshotReadsTheRegistrationWithoutStartingTheClient, watched red against the script that started the client. Two are installer facts made arms, in RealInstallerTests: two roots of one pack id sharing one Add/Remove key that either uninstall deletes, and an installed server that never sees the installer's variable beside a copy outside any install that does, each watched red against a planted wrong expectation. Three arms were rewritten in place and moved no count: ForbiddenDependencyTests.AProjectUnderSrcThatReferencesTheCodeGeneratorShipsTheMetadatasNotice replaces the arm that refused any generated code, watched red naming three metadata packages; InstallerHandoffTests.ThePublishedBinaryServesTheClientThatStartedItWithTheInstallersVariableSet replaces the arm that required the deleted installer exit, watched red against a build that still carried it; and the variable arm of ThePublishedBinaryExitsWhenItsLauncherIsGoneAndStdinIsAConsole now requires the general exit.

The +14 is Q258's Codex steps 2 to 6, later on 2026-09-24, in four groups, and both halves of the gate were green on the first attempt. Two are the hooks, in RegistrationTests: one hook registers every client and the record carries an entry for each, and a client the machine does not have is named in the record without failing the hook -- both planted red by a hook that registered only the first client. Six are the window and the registrar's project path: every registration action belongs to exactly one named client, planted red by one identifier block for both clients, which also reddened the older NoCommandCanBeMistakenForTheCloseButton on its distinct-identifier check; removing a project registration is offered only where there is one of ours; and four arms over McpRegistrar.ApplyToProject against the double -- the project's home and never the user's (planted red by an empty environment), a stale entry of ours removed before it is written again (planted red by deleting the remove-first), another install's entry refused (planted red by skipping the gate on a remove), and the residue removed only while empty (planted red by the File.Delete it was first written with). The re-register affordance was planted red in the renamed status arm, which is why the rename moved no count. One is an uninstall over nothing, which now runs nothing for either client, watched red against the code before the fix. Five drive the real Codex under a scratch CODEX_HOME: its exit-zero dialect, the user-scope round trip through the registrar, a project registration written into the repository and nowhere else -- the arm that found every codex mcp verb refusing a home that is not there, which the double then modelled and was watched red on received Refused -- the CLI located as an executable, and the update effect: a BrowserAI registered in Codex serves one call through a real codex app-server and leaves nothing that holds an update, with a HELD control inside the arm. N16 added assertions and no arms: the version half of the freshness reading, planted red by a doctored comparison and then seen live refusing a binary one version behind, and the reconnect arm's wire version, planted red by a shim that did not record it.

The +7 is Q261, later on 2026-09-24, and it is three groups. Four are the mechanism: StaleToolListTests holds that a tools/call arriving before any tools/list is refused ONCE with the list-changed notification and that the next call is forwarded, that a connection which listed first is never refused and is sent no notification, and that browserai_resume and browserai_catch_up each say when a session's record was last written by another build and stay silent when it was not. Planted red twice: with the connection flag forced set at the handshake four arms went red, one of them quoting the whole missing sentence; with the once-guard changed so it fires every time, the refusal arm went red on the SECOND call, which is the wall the decision rejected. One is the catalogue: ErrorCatalogueTests provokes the new row over three separate connections, because the refusal is once per connection and a version that reused one would assert the first client's remedy three times and pass. And two are the real clients: ClientReconnectTests drives the real Claude Code against the published binary with its server made to exit mid-session -- the refusal reaches the model byte for byte and the retry goes through -- and the real Codex CLI, which lists before it calls and therefore never meets the refusal at all. Planted red against the product, by forcing the flag set: the Claude Code arm went red on the notification count. Both skip loudly when their binary is absent, and SuiteCapability.CodexCommandLine is new for the second of them.

The +3 is T7, later on 2026-09-24: the reap at session close. One arm drives the whole thing: ServerRegistryReapTests opens two sessions with a real Chromium each through the published binary, plants a thousand dead descriptors in a scratch registry, destroys one session, finds the reaper's pid and creation time in the machine's process log, waits for that process to exit, and then holds that every dead descriptor is gone, that the closed session's own went with its browser, and that the live browser's still stands. Planted red: with the teardown's reap made unreachable and the slice republished, it failed on the close should have recorded the reaper it started -- 1 failed, 1 succeeded -- and green 2 of 2 with the call restored. A second arm holds the script -- upstream's serverRegistry.list() against the module it was given, forward slashes, an escaped apostrophe, and no cli-client anywhere in it -- and a third, in ChildEnvironmentTests, holds the one forwarded test hook both ways: inherited when this process carries PWTEST_SERVER_REGISTRY, absent when it does not, refused to nobody.

And one assertion was removed the same day, with no count behind it, because it was a promptness claim in disguise: the reaper was still running when the destroy answered holds only while the test host observes the answer inside the reap's own window, and at full parallelism this host is starved for seconds -- it passed alone and failed in the suite with the product behaving identically. What survives is the ordering read out of the log. The same gate surfaced a stray the suite had been handing the sweep for as long as that file existed: PasswordPromptTests took a session's guard and released it on the spot, so the browser it launched into that profile was a stray by the product's own definition, and the sweep's record names the pid the arm's own Playwright log carried. The guard is held for the child's life now, planted red and watched in both directions under one real --sweep a second: 3 of 3 red with it dropped, 3 of 3 green with it held.

⚠️ Twenty-two arms went red on the first gate run of that change and not one was a product defect. Every hand-written client in this suite that drives a published server was calling a tool without ever asking for the tool list, which no real client does -- so the refusal was correct and landed on the rig's own browserai_init. The fix is one line in each of the two client stand-ins, and it is recorded because the shape is worth knowing: a mechanism aimed at a client's habit will find every place the suite did not share that habit.

The +19 is one session's worth, 2026-09-23 into 2026-09-24, and it is four groups. Eight are Codex: CodexRegistrationTests holds the command shapes and the -- separator, the project scope through the CODEX_HOME lever, the four-place discovery order with a refusal that names every place it looked, the idempotence answers that differ between the two clients, a foreign entry neither intent may touch, and both clients listed in a stable order with distinct keys. Three are the password prompt: PasswordPromptTests opens no window on a sign-in POST and proves the probe can see one, and the Firefox preference reaches the child. Four are the never- again rules the no-trace-of-AI directive needed: HouseRuleTests.NoMaintainedProseCarriesATell over commentary and over the product's own strings, .NoTextFileCarriesACharacterAPersonDoesNotType widened to five classes over every non-binary file, ChangelogTests.NoEntrysDetailOpensByRestatingItsHeadline, and SuiteCoverageTests.EveryGateDriverDeclaresTheDriveLetterSpellingItForces, which found a defect in the drivers on its first run and a defect in itself in the same breath. And four are the triage: the assumed-justification count held at zero, no project under src/ referencing the code generator, every review entry adjudicating every golden snapshot by name, and a burst of child notifications reaching the caller in the order the child wrote it.

⚠️ One of the nineteen went red in the first full run it ever met and the claim was never wrong. UpdateTests.ACheckThatNeverAnswersEndsOnItsOwnBudgetAndSaysSo divided the product's four update budgets by 4,500, which puts the outer bound at 600 ms -- below the scheduling noise of this machine running the whole suite, so it passed every filtered run and reported 1,054 ms against 600 ms under load, with every other assertion in it green. The factor is 450 now and the arm was watched red at it. A hang detector whose bound is smaller than the jitter is a promptness test wearing the right words, which is the failure the house rule about duration assertions is written against and the one thing that rule's scan cannot see.

The +1 is the full-packages-only arm, 2026-09-22. ReleaseScriptTests.EveryReleasePacksFullPackagesOnlyAndTheFeedCarriesNoDeltaRow holds both halves of a decision of record: that build/New-Release.ps1 passes --delta None, which a scan can see, and that the argument means what the script assumes, which only vpk can say. It packs a real 162 KB executable twice into a feed that already holds the previous full package. The positive control is what makes it worth anything -- the same two packs without the argument do produce a delta, so "no delta" is the option working, not vpk having nothing to compare against. It was watched red on the script scan before the argument went in.

The +2 is the event-id scan and the relaunch-note guard, 2026-09-22. ProxyLogTests.EveryLogEventIdIsUniqueInItsClassAndNoRetiredIdIsInUse reads every [LoggerMessage] under src/ as text and refuses a repeated id inside one class, or any id a class's own RETIRED-EVENT-IDS: marker names. It was written for a collision found by reading and it found a second one by running: on its first run against the real tree it reported ClientLivenessLog declaring id 76 twice, which nobody had noticed. Both events left that id and 76 is retired (previously "the later of the two events moved to 77", which is what the batch did before the maintainer answered Q226 c): a log from any v1.0.0 binary carries 76 for two events, and retiring it ends that ambiguity at v1.0.0 instead of carrying it into every release after. DeadChildTests.TheRelaunchNoteDoesNotPromiseThatStoredStateSurvived is a guard on the TEXT of SessionManager.ChildWasRelaunched, not on the constant, which is the point: every other arm in that file asserts against the constant and stayed green through a sentence that claimed cookies and stored state survived a relaunch when nine measured runs say they may not. Both were watched red -- the first against the real tree, the second against the shipped string -- and one further test was WIDENED and not added: RealInstallerTests.TheSuitesPackAndTheShippingPackDifferOnlyWhereTheIdAppears went red on the first gate run after Velopack 1.2.158, because upstream now names the embedded stub from the pack title, and it took two narrowings with controls in five directions before it was green again.

The +2 is the onboarding guard, 2026-09-22. HouseRuleTests.EveryScratchClientConfigurationIsSeededAsOnboardedBeforeTheClientRuns refuses a site that hands the real claude.exe a scratch CLAUDE_CONFIG_DIR without first seeding hasCompletedOnboarding into it, and RegistrationTests.TheScratchConfigurationIsSeededWithWhatTheClientReadsAsOnboarded holds that the file exists and carries the marker before the scope is anything a child could inherit. Both were planted red: the scan named all three sites verbatim -- two in RealInstallerTests and RegistrationTests' own factory -- and the second arm failed on File.Exists(seeded). The guard is asserted and never measured against the flow, because measuring it means running the sign-in flow on the maintainer's desktop, which is the event being guarded against; its hazard row is open saying so. And planting it found a blind spot in a neighbour: wrapping the registration factory across two lines made HouseRuleTests.EveryArmInAFileThatOverridesTheEnvironmentRunsBesideNothing stop seeing that file at all, its tree count falling 2 → 1 and the arm going red on its own non-vacuity floor -- which is the only reason it was noticed. The factory went back on one line; the scan was not widened and the floor was not lowered, and the line-based limit is now stated on it by addition.

The +4 before that are the four arms holding the session-lifetime rules on the published binary's own wire, earlier the same day -- who destroys a session, what a destroy takes, where an uploadable file has to live, and that a session moves by hand. Each was planted and watched red first: 18 of the 19 required phrases named as absent, and the other two present already, which is the positive control that the scan can find a phrase that is there. The +1 before that is the arm holding the charter version-chain example to the payload lock and the committed browsers snapshot, earlier the same day. ThirdPartyNoticeTests.TheReadmeTablesNameEveryPackageThatShipsAndEveryFamilyThatIsProvisioned holds the two licensing tables under Third-party components to the sources the arm below already holds the shipped notices to, plus the committed upstream-snapshots/browsers.json for the revision -- which the build regenerates from the resolved payload, so all three sources are committed and this one runs whole on a clean clone. It was watched red on four offences at once, which is what the same correction left behind one document across: "the payload ships 'playwright' and README.md's third-party components section does not name it", "'firefox' is a provisioned family and README.md's 'What the user's machine downloads' table has no row of its own naming it", "README.md's 'What the user's machine downloads' table says 'chromium 1237' and the committed browsers.json snapshot says 1245", and a fourth saying no revision stood beside firefox at all. The positive control is inside the arm, not beside it: each provisioned family must state a revision of its own, so a pattern that has stopped matching -- or a previously "..." cut that swallowed a live cell -- is red, not quietly empty.

The +2 is the two omissions nothing could see, 2026-09-18. ReleaseScriptTests.APayloadWithNoOverrideInForceSaysSoRatherThanSayingNothing is the control on the manifest's new pulledForward key -- watched red at "Expected to contain "pulledForward": null", and it differs from the populated arm in exactly one file, so a field derived from the payload lock instead of the payload manifest goes red there. ThirdPartyNoticeTests.TheNoticesNameEveryPackageThatShipsAndEveryFamilyThatIsProvisioned is the one worth naming: it enumerates build/payload/package-lock.json and ProvisionedBrowsers.Families, not a typed list, because a typed list can only be wrong in the direction of naming a path that is not there -- it cannot notice a package that ships and is in nobody's list, which is exactly what playwright had been doing since the first payload build. It was watched red naming both omissions and the three paths under them, and its first shape was wrong in a way the red showed: a plain Contains passed on the very omission it was written for, because playwright is a substring of both @playwright/mcp and playwright-core. The existing eight-file manifest arm and three rows in Obligations moved with them and are not counted here, because neither is a new test case.

The +1 is a read-only file, 2026-09-17. TreeDeleteTests.AReadOnlyFileIsRemovedRatherThanReportedAsANodeThatWouldNotGo, planted red and watched at Assert.That(File.Exists(loose)).IsFalse() -- "Expected to be false but found True" -- against the shape that found the defect: a read-only git loose object under two ordinary directories, a read-only directory beside it, and a held file in the same tree as the control, so that clearing an attribute cannot become swallowing a sharing violation. It was not an idea: this release's own gate went red at the head of run 1 on a tree nobody had changed, and the defect was in Runtime/TreeDelete, not in the rig that provoked it.

The +6 is the dead browser server, in both directions, 2026-09-17. Four arms in DeadChildTests and two in ErrorCatalogueTests, each planted red before the change that makes it pass. ACallForwardedAfterTheChildDiedComesBackRatherThanWaitingForever is the one worth naming: it was watched red at 5 m 00.924 s, the whole of TestDefaults.InProcessHang, against a forward handed to a child whose transport had already closed -- "No frame arrived on this pipe in 5 minutes while waiting for the answer to 'tools/call' (id 3)" -- and it answers in 2.1 s with the door check in. AResumeRelaunchesAChildThatHasDiedAndSaysSo was red on the relaunch sentence being absent from an answer that said nothing had changed. The other two are the controls, and they are the reason the first two cannot be satisfied cheaply: a resume of a healthy session still changes nothing, and a call held open on a healthy child still gets the child's own answer, so a liveness question answered too readily is a red build, not a refusal on every slow page action. The two catalogue arms provoke the two new refusals through real conditions, which is what stops a sentence nobody can reach being written.

The +5 before that is the dated playwright-core override and the config key it was taken for, 2026-09-17. Four arms in PayloadTests and one in ConfigRoundTripTests, each planted red before the change that makes it pass. ⚠️ Three of the five are gone as of 2026-09-21 and the record of why they existed stays here -- the paragraph below is left exactly as it was written, because a test retired when its mechanism ends is not a test that was wrong. TheDatedPlaywrightCoreOverrideIsStillNeeded, TheExpiryComparisonFiresInBothDirections and TheResolvedPlaywrightCoreIsWhateverTheOverrideSays were deleted when the override was retired: @playwright/mcp 0.0.82 shipped a playwright-core carrying the fix, the overrides block went, and deleting a test for a mechanism that no longer exists is not a skip. The property they guarded came back to where it was before 2026-09-17 -- TheLockRecordsUpstreamsOwnExactPinOfPlaywrightCore asserts the resolved playwright-core equals the declared one again, planted red against a doctored lock, and that is also what catches an override being added back. The fifth was renamed and not deleted, because 0.0.82's own config.d.ts declares filePaths and the old name asserted otherwise; it is ConfigRoundTripTests.TheChildHonoursFilePathsAndHandsBackAbsoluteWhereUpstreamDefaultsToRelative now, measuring the same running child for the same reason -- loadConfig validates nothing, so a declaration in the typings was never the evidence. TheDatedPlaywrightCoreOverrideIsStillNeeded is the exit: it reads the committed lock, so it runs from a clean clone, and it goes red the day @playwright/mcp pins a playwright-core at or above the override, naming the file and the key to delete. It was watched red against a lock doctored so the wrapper already pinned it, with TheAssembledManifestDeclaresWhatTheCommittedLockRecords going red beside it on the same doctoring -- which is the arm that catches a payload built from something other than the committed record. TheExpiryComparisonFiresInBothDirections is its positive control, because the exit asserts on every ordinary day that nothing happened: it drives nine orderings through the same comparison, including a release outranking every prerelease of its own triple, and requires a shape the comparison cannot order to throw and not answer. TheResolvedPlaywrightCoreIsWhateverTheOverrideSays holds the manifest and the lock together, because npm writes no overrides block into the lock it produces and the manifest is therefore the only record that one is in force. And ConfigRoundTripTests.TheChildHonoursFilePathsEvenThoughItsOwnTypingsDoNotDeclareIt is the measurement the typings cannot give: filePaths is the one generated key @playwright/mcp's own config.d.ts does not declare, so a running child saying the value back is the whole of the evidence. Planted red and reporting an empty string where absolute belongs.

The +3 before that was three checks, earlier the same day, one per rule that batch turned from a habit into a mechanism. Change by change, each named with what it was watched red against: HouseRuleTests.NothingAsksAVolumeHowMuchRoomItHas, a tree-wide scan that named four files before the free-space check was removed and none after ("Expected to be empty but received src\BrowserAI\Sessions\SessionEnvironment.cs: asks a volume for its free space (AvailableFreeSpace) ..."), with controls in both directions -- three real spellings caught, and DriveInfo.GetDrives, which enumerates mounted volumes and says nothing about free space, deliberately not. A second red was planted for the same change and deliberately did not survive it: the literal inversion of the arm that used to assert the refusal, a volume reporting 12 MiB free asserted not to refuse ("Expected to not be equal to True but received True"). Its seam is SessionEnvironment.FreeBytesOn, which is part of what was removed, so there is no condition left for it to arrange -- said here and not quietly dropped. ChangelogTests.NothingThatReachesAReleaseBodyCarriesACharacterAPersonWouldNotType, red against the 1.0.0 preamble ("an em dash (U+2014); write two hyphens, or a comma, or two sentences"), which is the character half of the maintainer's no trace of AI directive; and ChangelogTests.ALegendThatIsNotATableRefusesTheBody, red against a generator that accepted a one-paragraph legend and flattened it ("Expected to contain table"), with the accepted fixture beside it as the control. Eleven arms of ChangelogTests went red together on the way to that last one, which is what a shape change looks like when the shape is read in four places.

The +6 before that is five checks the previous batch added and one the 2026-09-16 re-measurement added without the count being re-stamped. Change by change, each named with what it was watched red against: ReleaseScriptTests.TheSecondFeedIsClearedBeforeItIsPackedIntoAndNothingElseIs, red three ways -- with no script at all ("Expected to be 0 but found 64"), with a script that finds the files and deletes none ("Expected to be equal to "" ... but received "BrowserAI.app.test-1.0.1-alpha.0.19-full.nupkg, ...""), and with one that reaches a directory up ("releases.win.json is not the test pack's regenerated output and nothing may delete it"); JobContainmentTests.AWalkedPidThatVanishedBeforeTheQueryIsExitedAndAnyOtherFailureIsStillAFailure, red at the classification level against a stub carrying the old behaviour ("Expected to be equal to Exited but received Unreadable"), with the two Win32 error numbers measured and not quoted and both host branches watched red separately; NeverByImageNameTests.TheScanReadsTheFilterRatherThanTheApi, whose first run over the real tree returned four offenders nobody had ever called a violation -- two release scripts and three test files, caught by WHERE matching Where-Object -- which is the control working in the direction a narrowing most needs one; FirstRunCacheTests.TheDownloadFigureInTheCoverageLineComesFromTheConstant, red at "Expected to contain \"207.3 MB\" ... but received \"downloaded 203.8 MB from the CDN because some reason\"" against the literal every run of this suite had been printing for a day; RecordedCountTests.TheUpstreamVariableCountInTheDocCommentIsWhatRowSeventeenSays, red at "Expected to be equal to 45 but received 43" against two records that had each been correct about a different version of upstream. And the sixth is ProvisioningTests.TheQuotedFirstRunDownloadSizeIsTheFigureTheKnowledgeBasePublishes from 2026-09-16, which landed with its own red and whose arrival never reached this paragraph -- recorded here and not absorbed, because a count that moves without a sentence is the thing this paragraph exists to prevent.

The +9 before that is the orphan first run of the published v1.0.0 and the three checks that could not fail, 2026-09-15. Change by change, each named with what it was watched red against: DirectStdioServerTransportTests.DisposingDoesNotWaitForAReadTheCallerWillNeverEnd, red at 5 m 00 s -- the whole of TestDefaults.InProcessHang -- against a transport whose DisposeAsync awaited a read loop parked on a stream that never returns; InstallerHandoffTests.ThePublishedBinaryExitsWhenItsLauncherIsGoneAndStdinIsAConsole, two arms, red at 10 m 00 s on the variable-less one -- the whole of TestDefaults.ProcessHang -- against the shipped binary started with a launcher already gone and a console stdin; InstallerHandoffTests.ARunWithNobodyToServeStartsNothingAndCreatesNothingButItsLog, red in 650 ms against the same binary, naming the playwright-mcp child, the stray sweep and the update check it had already run; ReleaseScriptTests.APublishLogWithNoIlcPassIsRefusedAndOneWithAPassIsAccepted and .TheReleaseScriptRemovesTheIlcIntermediatesAndThenRequiresAFullPass, whose subject is a release check that could not fail -- the refusal was watched live against a real incremental publish log before the arms existed; .TheSuitesInstallerIsPackedUnderATestIdIntoADirectoryOfItsOwn and RealInstallerTests.TheSuitesPackAndTheShippingPackDifferOnlyWhereTheIdAppears, which are the two halves of the installer arm no longer installing under the shipping pack id -- the identity comparison carries a synthetic both-directions control, because a real pair that happens to agree is indistinguishable from a comparison that stopped looking; and ReleaseScriptTests.ARealInstallIsNeverDanglingAndTheSuitesOwnLeftoversAlwaysAre, over constructed inputs, not a planted registry key, because planting one would be the suite doing the thing the refusal exists to prevent. ⚠️ Two of the nine were watched red against the PUBLISHED v1.0.0, not against a perturbation of this tree, which is the strongest form the rule has and the only one available here: the defect was in a shipped artifact. The +4 before them is the 0.0.81 adoption of 2026-09-15: one arm holding the two WebMCP tools upstream added to opposite verdicts -- the call withheld on liveness, the list allowed -- and three over judging the install root as well as the data one, which is the half the layout split had left unjudged. The +19 before it, the same day, is the install-layout split, and the shape of that was: one structural scan that no data path resolves under an install root (planted red against the wiring it replaced, which named both offenders), four arms over what an uninstall does with the data root and six more over how it decides, two over the installer's own start of a freshly installed binary, one over the release script's pack id and the rename back, and one that runs a real Setup.exe twice over one install root. The +1 is the scan that closes the suite race the packed release let loose -- HouseRuleTests.EveryArmInAFileThatOverridesTheEnvironmentRunsBesideNothing, holding that every arm in a file that constructs an EnvironmentScope carries the keyless [NotInParallel], not a key, and watched red twice against this tree: once with the keyed attribute the race was measured through, and once with RegistrationTests' class attribute removed, where it named all twenty of its arms. ⚠️ The skipped count moved 2 → 0, and nothing about the suite changed to do it. Corrected 2026-09-15 (previously "The 2 skipped are the two arms that need a release packed from this tree's own layout -- the real-installer one, and the notice check that reads the packed .nupkg -- and they are the gate working, not a rule being broken: this machine's Releases/ holds artefacts from before the pack id and the download names changed, so both capabilities read ABSENT..."). A release was packed from this tree's own layout, so Releases/ now holds BrowserAI.app-1.0.0-full.nupkg and BrowserAI.exe, both capabilities read PRESENT and both arms run. That is the same gate reporting the same thing about a different machine state, which is why the release checklist packs before it runs the gate -- and it is also how the race was found: the installer arm had never once executed inside a full suite before 2026-09-15. The +1 before that was the coverage block learning to state the publish freshness the suite had been establishing in silence, and the red was watched four times, one per direction the row can be wrong in. PublishedSlice.EnsureFresh compares the published NativeAOT binary against every input that goes into it and has done since the beginning; what it did on success was nothing, and the block's published slice row says PRESENT, which is a claim about existence. So twelve green gate logs held no sentence about whether the binary they drove belonged to the tree they were reading -- and on 2026-08-30 a runner with a staleness suspicion reached for the nearest figure to hand, a commit date, putting 56383c9's 01:20:40 touching src/BrowserAI/Sessions/SessionLock.cs beside the binary's 01:14:16.500 and reporting four gate sets -- twelve full runs -- as having driven a stale binary while passing the check. Every reading in that account was true and the conclusion was false: that file's own timestamp was 01:12:22.665, one minute 53.8 seconds before the publish, and git commit records when it ran instead of touching a working-tree file. Dissolving it took an investigation that one printed line would have ended, and the line now exists: publish freshness FRESH exe 2026-08-30T02:03:58.876Z is 2h51m36s newer than the newest of 95 inputs (src\BrowserAI\Sessions\SessionLock.cs, 2026-08-29T23:12:22.665Z). Both timestamps are UTC to the millisecond, and both halves of that are the incident: the gap that settled it was 113.8 seconds, so a row printed to the minute would have made things worse, and the two figures compared that day were a local-time file stamp and a commit date with no zone named in either. The row and the refusal are one comparison, not two -- Measure walks the inputs once, RefusalFor renders that reading as the exception and RowFor renders it as the row -- which is asserted live in both directions and not left to the construction, because the construction is the whole of the guarantee and a second enumeration is exactly how the corpus scan came to disagree with git ls-files by 520 files under a remark saying the two matched. The four reds are the four ways the row can lie: the row absent from the block, where both this arm and the older block test named the missing title; a stale reading rendered without its warning; a fresh reading rendered with one; and the guard's own refusal decoupled from the verdict the row prints. The STALE rendering is driven from synthetic readings and never from the tree, because a healthy tree publishes and then runs, so the branch that matters would otherwise first execute on a day somebody is already confused -- and arranging a genuinely stale publish inside a test would leave the tree needing a re-publish to go green again. No hazard row was added, on this file's own precedent: a row records a blind spot and a test closes it. The +1 before that is the hazard index's parser learning to say when it has dropped a line, and it was watched red on the shape that provoked it. HazardIndex.Rows keeps a pipe-leading line only when it splits into exactly eight fields and discards every other one in silence -- which is right for the header, for the separator and for the three-column table above the index explaining what each column is for, and catastrophic for a row somebody wrote a bare | into. On 2026-08-30 FileShare.ReadWrite | FileShare.Delete, written into an evidence cell to record what a fix had opened a file with, split its line into nine fields and deleted the row the same commit had just re-opened. A dropped line leaves the open tally and the closed tally in the same instant, which is the one failure this table's counting mechanism cannot describe: RecordedCountTests can see that a number moved and never which row went missing. The red established that it is worse than that. With the guard stashed and the offending row planted, all nine tests of the two classes that read this table passed -- the tally included -- because a row that arrives malformed never registers in either direction, so no number moves at all and there is nothing to disagree with. With the guard in, the same file fails naming HAZARDS.md:299, the field count, the leading cell and the three ways out. The escape it recommends had to be made true and not suggested: \| is GitHub-Flavoured Markdown's own literal pipe, and a parser that went on splitting at it would have moved the row from one silent skip to another -- so HazardIndex.SplitRow honours it, and the second red is the same planted row written that way, which parses into eight fields and is counted, taking the tally to 46 open against a published 45. The guard is a second walk of the table, not a second reading of it. Rows scans the whole file and keeps what looks like a row; HazardIndex.TableLines walks the contiguous run of pipe-leading lines below the header; they share the split and nothing else, and the two counts are asserted equal -- which is the half that catches a skip the field count cannot see, such as a row whose Area cell is all dashes, or one written with a leading space that ends the region early and takes every row below it out of the guard's sight. No hazard row was added, and that is this file's own precedent, not an omission: a row records a blind spot, and a test closes it. The +3 is the failure dump learning to read the files it exists to inline, and every red was watched against the real body and not planted around it. The dump LauncherWait.Evidence produces is the whole account of a containment failure, because the scratch tree it names is deleted when the test unwinds -- and on the 2026-08-30 release gate's sixth run, the Firefox stall the stderr tee had been armed for two hours earlier, it printed (unreadable: ... used by another process) for all three capture files and (0 bytes) beside each. The reason was the reader, and it was the reader's own share mode. File.ReadAllText asks FileShare.Read; Windows checks a reader's share mode against the accesses a live writer already holds, so a reader that does not permit writing is refused however permissive the writer was -- node's fs.openSync shares read, write and delete precisely so a log can be tailed, and lost the argument anyway. It opens FileShare.ReadWrite | FileShare.Delete now. The red reproduced the phantom as well as the refusal, which was not the expectation: written to assert only that a printed byte count had been measured, it came back --- cli-stderr.log (0 bytes) --- against 63 bytes written and flushed -- character for character the line the gate had printed -- so FileInfo.Length, the size the enumeration cached, was wrong and not merely unmeasured. The number comes off the open stream now, and a file nothing could open carries no number at all: the second arm holds an exclusively-held file, where (unreadable: ...) is the correct answer and must stay one, and requires no byte count beside it. That arm is what the fix cannot satisfy by accident, and the first is its control -- if lengths ever stopped being printed, that one goes red. The third is the other half of the pair and is deliberately described as the smaller half. The driver closes its cli-stderr.log tee once the child it was teeing ends, on 'close', not 'exit' because 'exit' fires while stderr may still have chunks to deliver and the tidy-up would drop the last thing upstream said. It was watched red at 10 m 00.4 s, the whole of the arm's hang detector, reporting "the driver was still holding ... with the child it was teeing long gone" -- and by hand outside the suite first, where the held file's enumerated length also read 0, against 61 real bytes. ⚠️ What no test here reaches is said plainly and not implied: the launcher kills the driver with TerminateProcess, where no handler of any kind runs, and a browser that stalls forever never ends its child -- so on the failure actually worth diagnosing the file is still open when the dump reads it. That is why the reader had to be the fix and the close is the tidy-up, and it is why the arm asserts the driver is still alive when the file goes free: a driver that died would release the handle too, and a green built on that would be saying nothing. The +3 before that is the release-preparation batch, and each was watched red before its fix went in. The first is the session guard's record: a process re-taking a directory it itself last held was logged "previous holder was PID n, still running: True" -- true word by word, and read by anyone scanning the log as a live stranger being evicted, which is the one event on that path worth waking up for and had happened five times in eight thousand. Counted 2026-08-30 over the machine-wide process log, predicate quoted: since the 2026-08-26 logging cutover that file holds 8,423 Session lock reclaimed lines, 8,418 of them carrying still running: True, and zero Session lock acquired lines -- in the two 2026-08-29 files alone it is 2,081 of 2,081 -- because destroy and set_purpose both dispose the live session and re-acquire, and are the only acquisitions that reach that file at all. AProcessReTakingItsOwnGuardSaysSoRatherThanReportingAReclaim was watched red with the branch removed, coming back with event id 2 where 12 is required; its negative arm is the load-bearing half, refusing the old sentence anywhere in the capture, because asserting only that the new record is present would pass just as well if a well-meant "log both" left the false one exactly where it was. The pinning test one method above it -- Reclaimed and HolderRunning: true for exactly this shape -- is unmodified and still green, because both are answers about the directory, not about who is asking and both are still right. The second is build/payload/package-lock.json joining the published slice's freshness inputs: a payload re-resolve left every slice arm driving a published tree carrying the old one and reading as fresh, and the red needed no plant on the day it landed -- the publish was seven hours older than the lock, so the check fired unprompted and named it. The counterfactual was planted anyway, because the interesting claim is that the walk cannot see the file at all: it prunes every directory called payload, so the corpus arm goes red with the entry removed however the enumeration is widened. The third is the coverage block's commit charge row, which exists because the hazard index's six-run-gate row closed on 2026-08-24 by naming the commit charge beside the run as the one reading that separates its cause from a live one -- and then nothing took it, so for six days no gate could ask that row's own question. Red watched with the row removed from the block. Its bands are exercised from synthetic readings and never from the machine, which is MachineLoad's standing rule: a bound on a live commit figure is a test that passes or fails depending on the developer's other windows, and a healthy machine sits in one band for ever, so the two bands that matter would otherwise first run on the day something is already wrong. A fourth change moved no test and was watched red in both directions anyway. The browser-containment driver spawned cli.js with all three streams piped and read only stdout, so upstream's account of every launch that did not happen went into a pipe nobody drained -- which is why the 2026-08-29 stall could be named and not explained. It is teed to cli-stderr.log in the scratch directory the failure dump already walks: provoked into failure, the dump now carries cli-stderr.log (60 bytes) with upstream's own sentence beside the child-stderr.log (0 bytes) and child-stdout.log (0 bytes) that were all it could say before, and with the tee removed the file is absent altogether. No test asserts it, and that is deliberate, not an omission: what a browser writes to stderr is upstream's business and changes between revisions, so it is evidence for a reader and never an assertion. The +1 before that is the harness's process-log reader, which could answer with a stranger's records. ProcessLogRecords selected a writer by matching pid=<n>@ -- the pid alone, with the creation FILETIME behind the @ read past and never compared -- while the type's own remarks said in as many words that a bare pid does not identify a writer. The machine-wide log is kept for thirty days and Windows reuses pids well inside that window, so the scope was answerable by whoever last wore the number: demonstrated live on 2026-08-29, a read scoped to the running test host's own pid came back holding records written on 2026-08-24 by a different process wearing it. The reader takes (pid, creationFileTime) now and matches both halves, the pid-only entry point is gone and not caveated, and neither caller had to be taught anything -- both already knew the identity they were asking about. The red was planted and not watched live, and that is a property of the subject, not a shortcut: whether this machine's log happens to hold a stranger wearing this run's pid depends on the box and on the last thirty days, so a live arm would pass by matching nothing on most machines and go red only by luck. ARecordWearingThisPidWithAnotherCreationTimeIsAStrangersAndIsNotReturned hands the reader a directory holding three records that differ in nothing but the FILETIME, and with the old marker restored it returned all three -- ours, the stranger's, and one whose FILETIME merely begins with ours, which is the case the marker's trailing separator exists for and which a prefix match would have collected in silence. The three planted lines go through the same WriterHeader expression the file's live arms use, so the control cannot outlive the record format it is written against. The -1 before that is a deletion, and a deletion carries no planted red. browserai-sessions.json -- the per-root roll-up, written to the parent of the directory the caller named and read by nothing -- is gone, and the two tests that were about the file went with it: the one that blocked the write with a directory and required the answer to say so, and the half of the other that parsed the file back to prove it covered one root only. One of the two came back narrower instead of dying, which is why this is -1 and not -2. The rest of TheRollUpCoversOnlyTheRootInPlay asserted the sibling-sessions line in the init answer -- which survives the deletion, and which that test was the only assertion in the tree over -- so it is TheSiblingSessionsLineCoversOnlyTheRootInPlay now, and it is stronger than the sentences it replaces: the two roots are populated in an order that makes each DoesNotContain name a session that already existed when the answer was written, which the old ordering did not. The one property that genuinely died with the file says so in its own remark: a roll-up sat at a session's own root and was never propagated to an ancestor, which is a fact about which file got rewritten, and the walk behind the surviving line is a path prefix that does see what is nested below it. The +3 is the harness's own reclaim pass, which could terminate a live run's processes and never said so. The record that names a killed run's leftovers carried a subject and no owner, so a second harness process reading a live run's file ended that run's browsers, probes and slices with exit code 1 and then deleted the tree they were using -- 18 of 18, and the mechanism behind an exit code chased for eleven days as a crash. A row now names the process that started it and holds the job containing it, and the pass acts only on rows whose owner is neither this process nor any process still running; rows it declines are written back instead of the file being emptied, because sparing the process and blanking the record would take the recovery with it. Each of the three was watched red, and one of them twice: the kill with the owner gate removed, reporting the measured line verbatim about an owner that was still running; the recovery with the check inverted, against an owner that was really started and really killed; and the new WARN the pass writes to the machine's process log -- the pid, the owner, the record it honoured and the exit code read from the constant the call hands it -- watched red both suppressed and firing on a pass that ended nothing. The +1 before that is a diagnostic for the one ceiling this suite could name and never read. A desktop heap spent to the byte kills a Chromium before it creates a single window, silently -- reproduced deliberately over eighty launches -- and GetPerformanceInfo cannot see it, because UOI_HEAPSIZE reports a desktop heap's size and no API reports its usage. So the attribution failure now tries the allocation instead of asking about it: one CreateWindowExW with a 2,048-character title, on the same desktop, at the instant the browser is found gone, destroyed again immediately, with the verdict in the message. The 2,048 is the measured regime, not a round number -- window text lives in that heap, so the title length is the allocation, and a probe asking for the smallest one would report a clean create on a desktop already killing browsers. A refusal that sets no last error at all is the signature and the message says so, which is the one reading that would otherwise be thrown away as an unread error. StraySweepTests.ABrowserGoneBeforeItsWindowAppearedIsAskedWhetherTheDesktopHeapWasSpent was watched red before the probe was wired in -- the message carried the exit code, the streams, the log and the machine and said nothing whatever about a window -- and it provokes the death with the test probe run with no arguments at all, so the branch is deterministic and needs neither a browser nor a rig. What it asserts is that the reading was taken, never what it says: the verdict is a property of the developer's other windows, which is the bar MachineLoad is already held to, so it requires the heading and exactly one of the five verdicts. The +3 before that is the three behaviour changes the maintainer released, each watched red first and each the option the review called the honest one. A server start opened the SQLite store of every session on the machine, leaving a -shm and a -wal beside each -- the sweep goes probe-first now, and the red is the review's own measured shape at the mechanism: a cleanly-closed session's directory at two files, not four. An autonomous idle browser close left no trace in the only record there is, under a heading that tells its reader "this is what BrowserAI did" -- it writes an in-flight row before the call reaches the child and settles it from the child's answer, and the red was the settle timing out against a row that did not exist. And the short-circuit in front of the verdict door was a prefix test, so browserai_zzz was refused by the session manager and recorded nowhere while an unjudged upstream name was recorded on the session it named -- it is an exact match now, which also makes the answer rows of tool-verdicts.json load-bearing at run time, not build-time only. Two residuals are asserted and not described: a browserai.data that will not parse reads as Session at guard depth (kept either way, so the sweep is unchanged), and a call with no resolvable session still writes no row, because there is no directory to write one into. The +11 before that is the post-course-correction re-review, and ten of the eleven were watched red before the fix went in. Six are model-facing answers that were wrong, not merely thin: a purpose written by one agent carried invisible TAG-block text into another's context, because the sanitiser iterated char and char.GetUnicodeCategory never answers Format for half of a supplementary-plane character; browserai_catch_up served page=4294967297 as "page 1 of 1" with no error, an unchecked truncation that also made the mirror case quote a number the caller never sent; a refusal named U+0007 in words and then carried the byte twice into the model reading it; another leaked the C# identifier (Parameter 'canonical') into the one sentence a model is supposed to act on; browserai_list on an unmounted drive letter answered "No BrowserAI sessions under 'Q:'" confidently and in 1 ms; and a session directory deep enough to leave no room for browserai.data was accepted, created and locked before failing with a message about the browser. That last red found a longer suffix than the review had named -- SQLite composes browserai.data-shm from the store's path and its Win32 VFS is MAX_PATH-bound too, so the store failed before the child's working directory ever did, and the budget is derived from the measurement, not from the report. Two more are refusals that were reaching a person, not a caller: a tool name written twice inside one half of tool-verdicts.json exited the process with a bare dictionary exception naming neither the file nor the row -- planted in the text, because a JsonObject cannot hold two properties under one name, which is part of why it went unnoticed -- and the five PLAYWRIGHT_MCP_* variables that override a generated config key, ALLOW_UNRESTRICTED_FILE_ACCESS first among them, were absent from a child's block by construction and absent from ChildEnvironment.Refused by omission. Two are the release manifest gaining the field that can express a crunch override, which two documents had been claiming it already had. The eleventh had no red and says so: CanonicalPathTests.TheAncestorWalkGivesUpExactlyPastItsLimitAndSaysSoWhileStillAnsweringAPath is the control PathVerdict.Unestablished has never had -- reachable by depth alone, with a successful create, against a record that called it unreachable -- and the behaviour it asserts was already correct, so there was nothing to plant. Four tests changed shape without moving the count: ReVerificationIndexTests.Exists was harmonised with HazardIndexTests.Missing on the three axes where it had silently been narrower, with the fourth axis left different and the reason written into the method; ChildEnvironmentTests and ToolVerdictTests each gained a denominator; and AppendOnlyRecordTests seals a fifth review. The +2 is the reconciliation pass, and both are mechanisms, not behaviours. HouseRuleTests.TheSessionGuardsTwoLoadBearingLiteralsAreTheOnesItIsWrittenWith reads LockFile.cs as text and holds the two literals the session guard is made of -- Hold's FileShare.Read and the probe's FileAccess.ReadWrite. Both were watched red on the real file, one perturbation at a time, and the point of the rule is that neither perturbation reddens anything else: sharing writes and probing read-only both leave a file that opens, so every behavioural arm in the suite stays green while one BrowserAI drives another's profile or every driven session reports as free. StraySweepTests.OnlyTheSuiteEverWaitsForTheSweepGate is the second, and it arrived with a fix, not on its own: an arm that needed its own sweep pass to have RUN was losing the machine-wide gate to a real BrowserAI another test had just started, once in five full runs. The flake was reproduced deterministically -- hold Global\BrowserAI-Sweep from another process and the arm fails with the identical message -- and the fix is that a suite pass now waits on the gate instead of asking again in a loop, which is the serialisation a mutex is for. The scan is what keeps the product at zero wait, where ninety-nine peers queueing to redo one pass is the thundering herd the design removes. One more test changed shape without moving the count: ReVerificationIndexTests.EveryRowIsEitherManualOrNamesSomethingThatExists now reads around a previously "..." clause the way HazardIndexTests already did, with both gates asking one definition of the clause; it was watched red against the two index rows that had been left quoting dead test names without backticks to work around the asymmetry. The +11 is one path function, and it is a net of 21 arrivals against 14 departures: SessionDirectoryGuardTests is deleted and CanonicalPathTests stands where it stood, asserting a canonical FORM where the old file asserted a refusal. Every alias this machine can build - a \\?\ prefix, a substed drive letter, a junction, a directory symlink, an 8.3 short name - is now resolved into the spelling the filesystem itself uses, and the invariant test is strictly stronger for it: EverySpellingOfOneDirectoryIsAdmittedUnderTheOneIdentity requires each spelling to be admitted and to hash to one identity, where the arm it replaced could be satisfied by a refusal. Six of the new cases are behaviours that were open defects, each watched red first: a listing pointed at a junction over the tree its sessions are in answered "No BrowserAI sessions under '...'" with no error at all; browserai_destroy on a UNC path took 21 seconds to answer that is not a session, because the boundary refusals ran at init and resume only; a subst onto a mapped drive was refused with a spelling that earns the network refusal on the next turn; a trailing dot, a trailing space and a bare NUL were accepted and silently rewritten by Windows; an index entry pointing at a non-canonical spelling was followed and not swept; and a stray-sweep title on a letter substed onto a mapped drive passed the guard that exists to keep the sweep off a redirector. One is a mechanism, not a behaviour: HouseRuleTests.ThePrefixIsDerivedInOnePlaceAndTheRestOfTheTreeAsksForIt reads src/ as text and fails a second derivation of the subtree prefix, which is what closes W8 for good, not for now - watched red against the two real offenders in SessionManager. And one measurement inverted an ordering the whole type was built around: GetDriveTypeW through a substed letter resolves the substitution and blocks, 21,035 ms against a dead share, then answers DRIVE_NO_ROOT_DIR - so VolumeIdentity asks QueryDosDeviceW first now, and only asks GetDriveTypeW about a letter that is not a substitution. What moved for every session on this machine is the spelling and not the identity: the answer and the record carry the drive letter upper-case, because that is what Windows reports, and nothing hashed changed. The +9 is the verdicts file, and what it replaced was one C# constant. Which tools BrowserAI forwards is now tool-verdicts.json -- one row per tool, allow / deny / answer, tracked at the repository root, copied into the payload it describes and read at startup -- and a name with no row is refused at the door. Seven of the nine are ToolVerdictTests: the file against the golden tools-list.json snapshot in both directions, each direction planted red by hand before the check went in and then held by a doctored-file control that asserts the text of the disagreement; the seven authored rows against SessionToolSurface.Names, both ways; every deny carrying the reason a caller reads and an ISO date; judgedAgainst against the committed payload lock; fifteen malformed shapes and a missing file, each refused with the file named, against the undoctored file as the positive control; and the payload copy compared byte for byte with the tracked one. The other two drive the door and the advertised list through rig copies of the file -- a denial the product does not ship, so the shipped deny set stays at exactly one and the four documented surface counts do not move -- and they were watched red against the hardcoded predicate before it was deleted. One test was inverted and not added, and it is the one whose own comment said it existed to prove a removal: AToolThisBuildHasNeverHeardOfIsForwardedRatherThanRefused is now AToolThisBuildHasNeverJudgedIsRefusedRatherThanForwarded. What went on 2026-08-18 was a (tool, mode) permission matrix and every word of why it went still holds; a verdict is a different question -- whether a name this build has never been told about is worth starting a browser for, given that upstream creates the browser context before it looks a tool name up. browser_annotate's withhold dissolved into its own deny row: the refusal a caller reads is byte-identical, because the four measured facts that were a doc comment beside a C# constant are now that row's why, and the error catalogue went from 25 rows to 26 as AnnotationIsNotInTheSurface split into ToolIsDenied(tool, why) and ToolHasNoVerdict() -- two rows because a denial has no fix and a gap is answered by tools/list. SessionToolPolicy.cs is deleted and eight test files read the shipped file instead. And one flake was found by the full run, not by review, and it was not this batch's: four arms in SessionLogTests read a forwarded call's row the instant the round trip returned, and BrowserProxy settles that row in a finally that runs after the answer goes out -- so one read in-flight where it expected failed. They now wait for the row to settle, bounded by the suite's own hang detector, not by any number a test invented. The product ordering is deliberately unchanged: the finally is what covers the ways out the child never hears about, and a neighbouring arm asserts that the in-flight window is genuinely visible to a second reader while a call is outstanding -- so what was wrong was a test asserting a promptness the product never offered. The +14 is a corpse read as a live client, and a release body nobody could reproduce -- 2026-09-15. Change by change, each named with what it was watched red against: ProcessLivenessTests.AWatchIsRefusedWhenThePidOpensAndItsProcessHasAlreadyExited, red in 254 ms at "Expected to be null but found BrowserAI.Interop.ClientLivenessWatcher" against a watcher that read OpenProcess succeeding as there is somebody there -- a launcher that has exited goes on answering while anything holds a handle to it, and the console host does; InstallerHandoffTests.ThePublishedBinaryTreatsALauncherThatExitedButStillOpensAsNobodyToServe, red against the published pre-fix binary at 'received "Watching the MCP client"', and .ARunWithNobodyToServeStartsNothingAndCreatesNothingButItsLog red the same way -- the arm that had failed once in four full runs that morning and cost the whole of TestDefaults.ProcessHang when it did, now failing every time in under 400 ms, because OrphanedConsoleStart produces the openable corpse by construction instead of leaving it to the machine; ChangelogTests.AVersionSectionBecomesHeadlinesWithTheDetailFolded and .ABodyThatDoesNotFitFallsBackToHeadlinesAndSaysSo, each watched red against its own mutation of the new generator -- the fold removed, and the size guard removed, which read "is FOLDED: 291 characters against a limit of 400"; .TheReleaseNotesAnchorIsTheOneTheLinkCheckerComputes, three arguments, red on the one_off heading alone when the PowerShell slug was made to strip underscores, which is the mistake a hand-written slug rule actually makes; .AnEntryNotInTheHeadlineShapeRefusesTheBody and .AVersionWithNoSectionRefusesRatherThanWritingAnEmptyBody, both refusals, each with the accepted fixture beside it as the control; .EveryEntryOpensWithOnePaletteIconAndABoldOneSentenceHeadline, .TheLegendAtTheTopListsExactlyTheApprovedPalette (renamed 2026-09-17 to .TheLegendAtTheTopIsATableListingExactlyTheApprovedPalette when the legend became a table), .EverySectionsGroupsAreTheKeepAChangelogSetInItsFixedOrder and .TheNewestReleasedSectionOpensWithAPreamble, whose subject is the changelog's own shape and whose controls are synthetic because the tree can only ever be in the passing state; and ReleaseScriptTests.TheReleaseBodyIsGeneratedFromTheSectionRatherThanCutFromIt, red at "Expected to be greater than -1" before the wiring went in. DocumentationLinkTests lost no coverage when Slug moved into MarkdownAnchor: the same method, called from two places now, because a link in a published release is the one link here nobody can re-check afterwards.

The +4 is the icon batch, 2026-09-16, and each was watched red against the thing it names. HouseRuleTests.NoTextFileInTheTreeCarriesAControlByte, red naming three files by line -- HAZARDS.md:145, BrowserIdleTimerTests.cs:994 and SessionToolTests.cs:95 -- each a \b that something expanded into a literal backspace before the file was written, and one of them had turned BrowserIdleTimerTests.TheShippedClockIsTheRealOneAndNothingInTheProductReplacesIt into a scan that could not match anything: it asked for a backspace before Clock, so the arm asserting that no shipped file assigns the clock seam was passing over a result set nothing could enter. That arm now carries a positive control asserted before the emptiness is, and it was watched red at "Expected to be true but found False" with the defect put back. DocumentationLinkTests.EveryAssetReferenceInTheProseResolvesToTheFileItNames, red at "README.md:4: 'assets/icon-127.png' names a file that is not there" -- the first image this project has published, and the exclusion that would have hidden a broken link to it had already gone quiet, because TheAssetExclusionHidesNothing never walked assets\; that arm now asserts the property the exclusion needs and was watched red against a stray asset planted outside the directory. ReleaseScriptTests.TheShippedIconIsTheOneTheMaintainerChose, red at "the directory declares 3 entries and this icon must carry 4 entries: 16, 32, 48, 256" against the real file with its count doctored -- its controls are doctored files, not the icon it replaced, because candidate 1 was packed by the same script and has the identical directory shape, and a check that cannot fail against the file it replaced is not evidence. ChangelogTests.ABodyIsGeneratedOnlyFromTheChangelogTheTagCarries, red at "Expected to be 1 but found 0" with the generator's refusals taken out, over a real repository built in scratch; the same plant took AVersionSectionBecomesHeadlinesEachLinkedToItsOwnLineRange and ABodyThatDoesNotFitFallsBackToHeadlinesAndSaysSo red with it. And one arm caught the batch's own tooling: the control-byte scan named two backspaces that a documentation edit made in this very batch had written into RELEASING.md and TESTING.md, in the same shape it exists for.

The count did not move on 2026-09-16 when the scratch directory was retired, and one arm changed behaviour anyway, so it is recorded here and not left out for want of a number. DocumentationLinkTests.EveryAssetReferenceInTheProseResolvesToTheFileItNames resolves a reference written in a quoted record -- anything under docs/ledger/, which is a verbatim snapshot of a file written at the repository root and sealed against editing -- from the root as well as from beside the file. It was watched red twice: first by the tree, at "docs\ledger...:2576: 'assets/icon-128.png' names a file that is not there", which is the front page's own icon quoted inside a ledger; then by planting the new predicate false and watching the arm's own control fail at "Expected to be true but found False". The fallback is one named directory and not a skip, and the arm asserts in both directions that a reference resolving from neither place is still an offender.

The +17 is one review batch, 2026-09-16, and every arm below was watched red against the defect it names. ReleaseScriptTests.TheSuitesInstallerIsPackedUnderATestIdIntoADirectoryOfItsOwn, red at "build/New-Release.ps1 no longer assigns $packTitle as a single-quoted literal" against two packs that shared one Start Menu .lnk; RegistrationTests.NeitherAnInstallNorAnUninstallTouchesAnEntryThisInstallDidNotWrite, red at "Expected to be equal to Refused but received Registered" against an install hook that overwrote another BrowserAI's registration -- and the same change turned six existing arms red, which is how it was established that they had been asking the machine's own ~/.claude.json a question; ConfigurationAppTests.NothingAClickDoesCanThrowOutOfTheDialogsCallback, red at "InvalidOperationException: the link threw" escaping a reverse P/Invoke, where an escaped exception is a FailFast; .AFolderThatCouldNotBeTurnedIntoAPathIsNotACancel, red at "Expected to be equal to Failed but received Cancelled"; HouseRuleTests.EveryFolderPickerIsOwnedByTheDialogThatOpenedIt, red naming Program.cs:186 and its literal 0 owner -- and red a second time, correctly, the moment the fixed call wrapped across lines and the first version of the scanner read an empty argument; .NoUpdateCallIsMadeWithAnUnboundedToken, red naming all three CancellationToken.None sites against a check whose only bound was Velopack's thirty-minute HttpClient default; ReleaseScriptTests.AReleaseCutOverLocalPreReleasePacksIsRefusedAndNamesWhatToClear, red against the script calling a stale local feed a rollback; AppendOnlyRecordTests.ADateSetAtTheCutIsReportedAsAHeadingRatherThanAsARewrite, red at REWRITTEN ... revert it for a release date the checklist requires changing; ChangelogTests.AnEntryAboveTheFirstGroupAndAParagraphInsideOneAreBothRefused, red at PowerShell's "The property 'Entries' cannot be found on this object"; InstallerHandoffTests.TheInstallersVariablesAreClearedAfterVelopackReadsThemAndBeforeAnythingStarts, red against a clear that ran before the call whose behaviour it decides; and ConfigurationAppTests.TheDialogsIconIsAskedForAtTheDialogsDpi, red against a 32-pixel icon stretched under Per-Monitor-V2. Four arms are new assertions about behaviour that was already correct and unread, and they carry controls, not a red: the three BackgroundWork arms, and TaskDialogLayoutTests.TheAppsEmbeddedManifestDeclaresCommonControlsLongPathsAndPerMonitorV2, whose control is the server's own manifest. .ThePublishedConfigurationAppRunsInASingleThreadedApartment is a measurement -- [STAThread] under NativeAOT had been assumed since the configuration app was written, and it is STA.

⚠️ The gate found what the review did not, and it is recorded here because it is the strongest thing in this paragraph: the DPI fix's first form called LoadIconWithScaleSize, which comctl32 exports by ordinal only, so the published app threw EntryPointNotFoundException out of Show() -- outside the callback boundary added in the same batch -- and exited 0xC0000409 without ever drawing a window. RealInstallerTests.TheInstalledMainExecutableOpensOneDialogAndNoConsoleWindow polled ten minutes for a dialog that was never going to appear.

The +21 is the two-binary split, 2026-09-15, and each is named with what it was watched red against. RegistrationTests gains seven: three over the composed sibling -- the server is registered, not the app that composed it (red as "expected ...\current\BrowserAI.Server.exe, received ...\current\BrowserAI.exe, which differs at index 120"), an install with no server beside the app is refused by name, and a file at the server's name that is not a console binary is refused (both red as "expected to be false but found True"), each over an install layout constructed out of PE headers the test writes; three over the update hook's repair -- ours-and-stale re-pointed, ours-and-valid untouched, foreign reported and never touched (red as "expected AlreadyRegistered, received Refused" once the foreign arm existed); and one over the portable project-scope command. TaskDialogLayoutTests gains four, holding the hand-written TASKDIALOGCONFIG against Microsoft's own metadata -- 160 bytes, 22 offsets, with a naturally packed copy at 184 as the positive control -- and reading the PE subsystem out of both executables. ConfigurationAppTests gains six, which assert every sentence, link and button of the window without opening one; two of them found something as they were written, and both are recorded in the log and not quietly fixed. RealInstallerTests gains two: the installed main executable opens exactly one visible top-level window of class #32770, owns no console window and exits 0 on WM_CLOSE, and the packed release names the app as its mainExe and carries both binaries. ReleaseScriptTests and BuildConfigurationTests gain one each -- both publishes behind their own ILC gate, and a configuration app that must declare the version 6 common controls, the one entry whose absence has no compile-time signal at all.

Corrected 2026-08-17 (previously "Design phase. Nothing is built."). That sentence outlived the design phase by a full build and was the first thing a reader of the public repository met. It is recorded and not quietly replaced because the failure is worth keeping: a status line is written once, at the moment it is true, and nothing anywhere goes red when it stops being true.

What is still owed is in TODO.md; what is still undecided is under Still open.


License

BrowserAI is source-available under a bespoke variant of the Functional Source License 1.1 (MIT Future License), modified so the Change Date is the fifth anniversary of each release, not the canonical second. On that date the release additionally becomes available under the MIT License. In spirit: read it, run it, modify it, deploy it inside your organisation -- but do not ship a commercial product or service that competes with it, for five years, after which it becomes MIT.

This is not the canonical FSL and must not be referred to by, or distributed under, the SPDX identifier FSL-1.1-MIT. Where an SPDX expression is required, use LicenseRef-BrowserAI-FSL-1.1-MIT-5yr. The authoritative terms are in LICENSE and prevail over this summary.

Copyright 2026 Jori Huisman.

Source files carry the two-line SPDX header -- SPDX-FileCopyrightText plus SPDX-License-Identifier, always in the LicenseRef-BrowserAI-FSL-1.1-MIT-5yr form, as CLAUDE.md requires and as every article under kb/, CLAUDE.md and UPSTREAM-REVIEW.md already do.

The licence does not demand it -- LICENSE is the notice and shipping it satisfies the Redistribution clause -- so this is a house rule, kept for a different reason: a file that names its own licence cannot be copied out of the repository and quietly become unlicensed, which is exactly what happened to the launcher this project replaces, thirteen times.

Formats with no comment syntax carry it as data where they can (upstream-review.json has a _license key) and not at all where they cannot. Vendored third-party files keep their upstream headers instead, which Apache-2.0 §4 requires.

Third-party components

The license above covers BrowserAI's own code and its documentation. It does not cover the bundled payload, which keeps its own terms. Shipping that payload creates obligations that attach at first installer handoff, independent of BrowserAI's own license. Verified 2026-08-14 against the versions pinned in the runtime, re-read 2026-09-17 @ @playwright/mcp 0.0.81 · Node v24.21.0 and the browser revisions the second table's rows state, and corrected here 2026-09-18; what is actually present in each shipped tree is recorded in kb: payload licensing:

Two columns of this table used to be one, and merging them was the error. What BrowserAI redistributes is the installer payload, and only that creates obligations for us. What the user's machine downloads on first run comes from Playwright's CDN, direct to that machine, and carries no redistribution duty for us at all -- we ship no copy of it, so there is nothing to accompany with a licence. That is not a side benefit of first-run provisioning; it is the reason for it.

What we redistribute -- obligations are ours:

Component Terms Obligation on redistribution
@playwright/mcp Apache-2.0 Keeping the vendored node_modules tree intact ships the package's LICENSE and satisfies §4. It carries no NOTICE of its own, which is true of this package and was never true of the other two. Scope forbids modification, so §4(b) is clean by construction.
playwright-core, playwright Apache-2.0 The same vendored tree and the same §4 -- but §4(d) does bite: each of the two ships a NOTICE of its own, a ThirdPartyNotices.txt and three per-bundle *.LICENSE sidecars, and shipping the tree intact is what propagates them. playwright is @playwright/mcp's other exact dependency and has been in the payload since the first build of one; its LICENSE and NOTICE are byte for byte playwright-core's, so what was ever missing here was a name and never a licence. Corrected 2026-09-18 @ @playwright/mcp 0.0.81 (previously "@playwright/mcp, playwright-core 0.0.79"), and the §4(d) sentence with it (previously "Upstream publishes no NOTICE file, so §4(d) has nothing to propagate") -- measured 2026-09-17 against a real publish: 254 bytes each, byte-identical to one another. The resolved versions are deliberately not repeated here. THIRD-PARTY-NOTICES.txt states them and ThirdPartyNoticeTests reads them back out of build/payload/package-lock.json, which is the difference between a number that goes red when it goes stale and the one this cell carried for a month.
ModelContextProtocol, ModelContextProtocol.Core 2.2.0 Apache-2.0 §4(a): a copy of the licence must reach every recipient. Compiled into BrowserAI.exe, so nothing carries it unless we do -- it ships in THIRD-PARTY-NOTICES.txt. Upstream's own LICENSE grants three licences, not one: Apache-2.0, MIT for contributions whose authors have not consented to relicensing, and CC-BY-4.0 for documentation. It is reproduced whole for that reason, and because upstream's copy ends at END OF TERMS AND CONDITIONS and omits the appendix its own §4 refers to, which is upstream's file as published and is not completed here. Vendored fixture files keep their upstream headers.
Microsoft.Extensions.* -- 17 assemblies MIT Notice, same as Velopack's and for the same reason. Two referenced directly (Logging, Logging.Console), the rest transitive; the list is derived from src/BrowserAI/packages.lock.json by ThirdPartyNoticeTests and not typed, so a package entering the closure on a later bump is a red build. Two copyright lines, because sixteen come from dotnet/dotnet and Microsoft.Extensions.AI.Abstractions from dotnet/extensions.
Velopack 1.2.0 MIT Notice.
Node.js v24 MIT, plus aggregate terms for OpenSSL, ICU, V8, zlib, c-ares Ship Node's full LICENSE. "A single node.exe, nothing else" drops it. Not optional.

What the user's machine downloads -- no obligation on us:

Component Terms Position
full chromium 1246 (154.0.8037.0) Google Chrome for Testing -- Google-branded, no OSS license file anywhere in the tree Provisioned on first use, fetched by playwright-core from Playwright's CDN into %LocalAppData%\BrowserAI\browsers\chromium-<revision>\. chrome.exe reports CompanyName "Google LLC" and "Copyright 2026 Google LLC. All rights reserved."; its ABOUT points at Google's Chrome Terms of Service, and the only on-point public statement is a Google representative naming which terms apply: Mathias Bynens, a GoogleChromeLabs member, in chrome-for-testing issue #21 on 2023-06-16 -- "Chrome for Testing is a flavor of Google Chrome, so https://www.google.com/chrome/terms/ applies" -- and, in the same thread, that the repository's Apache-2.0 file "applies to the code in this repository" and not to the binary. Cited 2026-09-23. Corrected 2026-09-23 (previously "a Google engineer, 2023 -- reads those terms as forbidding redistribution"): Google stated the pointer and nothing further; the "so unfortunately no redistribution is possible" sentence belongs to the person who asked, and Google did not answer it. Those terms defer to Google's Terms of Service, whose Software in Google services section reads "You may not copy, modify, distribute, sell, or lease any part of our services or software" -- and reading that against this product is a legal question nobody here may answer, which is why we do not redistribute it and provisioning stays a first-run download. We do not redistribute it, which is precisely why the provisioning decision was taken; the row that used to read "Unresolved" described a blocker that decision already closed. What remains open is only the bundled-build fallback, and it stays closed until someone gets a different answer from Google. Corrected 2026-09-21 (previously "full chromium 1245"), and corrected 2026-09-18 before that (previously "full chromium 1237") -- 1237 is what the payload resolved on 2026-08-14 and the revision has rolled three times since, always at the same browserVersion 154.0.8037.0, so the archive behind every one of them is the same archive. The revision moves with the payload and this cell moves with it: ThirdPartyNoticeTests reads it back out of upstream-snapshots/browsers.json, which the build regenerates from the resolved payload, so a roll that leaves this number behind is a red build, not a cell nobody re-read. The reading itself was re-taken at 1245 on 2026-09-17 and not carried forward: the only licence-adjacent file among the tree's 308 is ABOUT, 257 bytes.
firefox 1549 (156.0) MPL-2.0 headline, with Apache and BSD terms besides Provisioned on first use exactly as Chromium is, from the same CDN into %LocalAppData%\BrowserAI\browsers\firefox-<revision>\. There is no standalone licence file in the tree at all -- 63 files, enumerated and not searched (corrected 2026-09-21, previously "61 files"). The terms travel inside the archive: chrome/toolkit/content/global/license.html at 320,546 bytes within firefox\omni.ja, and chrome/browser/content/browser/license.html at 320,988 bytes within firefox\browser\omni.ja, which is what about:license renders. A bundled build would have to extract and carry them, which is harder than accompanying a file. Added 2026-09-18, measured the day before (previously "firefox 1548 (155.0)") (kb): Firefox has been a provisioned family since 2026-08-19 and this table did not list it -- the same omission THIRD-PARTY-NOTICES.txt carried until the day before, one document across. ✅ RE-MEASURED 2026-09-22 AT 1549 / 156.0, AND THE SIZES MOVED -- corrected 2026-09-22 (previously "320,171 bytes ... 320,613 bytes", and before that the warning that the revision had moved and the reading had not: "the byte counts above are the previous reading and are owed, marked [STALE] on row 26"). +375 B on each, and the 375 bytes are three additions to existing licences' file lists: gfx/graphite2 under LGPL-2.1, third_party/dav1d under BSD 2-Clause, and Microsoft.WindowsAppRuntime.dll with Microsoft.WindowsAppRuntime.Insights.Resource.dll under MIT -- the last two being exactly the two new files that took this tree from 61 to 63. No licence was added, removed or changed and the MPL-2.0 headline is unmoved. This is the case the [STALE] marker was put there for: a new browserVersion is where this file can change, it did, and nothing was carried forward as though it had not (kb).
chromium-headless-shell 1237 BSD-3-Clause + 40,178-line credits file Not shipped and not provisioned -- full Chromium always. LICENSE.headless_shell and the credits file would have to accompany a bundled build; neither is our problem today. Struck 2026-09-18: nothing downloads this, so a table about what the user's machine downloads has nothing to say about it. BrowserProvisioner passes --no-shell to upstream's own install-browser and has since a45749b on 2026-08-16, two days after this row was written; the browsers root held no chromium_headless_shell-* directory at any revision when it was read on 2026-09-17. Struck and not deleted, and its revision left at 1237 and not rolled: 1237 is what the tree it describes was pinned at, the bundled-build total still counts its 268.49 MB, and a number about a tree nobody has must not be updated as though somebody did.
ffmpeg 1011 LGPL-2.1 Arrives beside either browser from the CDN -- both families fetch it, into the same root. COPYING.LGPLv2.1 ships in that directory as Playwright lays it down, 26,526 bytes of a 4-file tree. Spawned as an unmodified separate executable by playwright-core, so §6's relink requirement does not bite and it never reaches BrowserAI's own code. A bundled build would owe version identification and an offer of corresponding source. Corrected 2026-09-18 (previously "Arrives beside Chromium from the CDN"), verified 2026-09-17 unmoved at revision 1011 -- it read as Chromium's because Chromium was the only family when it was written.
winldd 1007 no license file shipped Same: downloaded by either family, never redistributed, and the tree is small enough to state exhaustively and not search -- 3 files, DEPENDENCIES_VALIDATED, INSTALLATION_COMPLETE and PrintDeps.exe, verified 2026-09-17 unmoved at revision 1007. A bundled build would have to source a licence from microsoft/playwright first -- which is a reason not to bundle, not an outstanding task.

Under a bundled build the four live rows above move into the first table and their obligations become live; the struck one does not, because there is nothing anybody has to bundle. Nothing in the second table blocks a release today. Corrected 2026-09-18 (previously "all four rows above") -- the table is five rows now, one of them struck and one of them new.

Playwright is a trademark of Microsoft Corporation. Chrome and Chromium are trademarks of Google LLC. BrowserAI is not affiliated with, endorsed by, or sponsored by either. Apache-2.0 §6 grants no trademark rights, and the inherited browser_* tool names surface upstream branding directly in BrowserAI's own API -- ship a short disclaimer in the installed artifact.

✅ It ships, since 2026-08-16, in THIRD-PARTY-NOTICES.txt beside the binary, together with Velopack's MIT licence -- two of the obligations with no upstream file of their own, and the two that the first run of the release checklist found absent from an otherwise releasable package. ThirdPartyNoticeTests asserts them against the repository file, the publish output and the packed .nupkg's entry list, so an obligation added to the table above is a red build, not a discovery at the next release.

✅ Both tables above are held to those same sources from 2026-09-18, which is what the two corrections dated that day are: ThirdPartyNoticeTests.TheReadmeTablesNameEveryPackageThatShipsAndEveryFamilyThatIsProvisioned reads build/payload/package-lock.json, ProvisionedBrowsers.Families and the committed upstream-snapshots/browsers.json -- so a package that ships and is named nowhere here, a provisioned family with no row of its own, and a browser revision a roll left behind are each a red build, not something a reader has to notice. What no test here reads is the prose inside a cell. Whether the terms a row names are the terms actually in that tree is a measurement, it is dated in the row that states it, and re-taking it is row 26 of the re-verification index. A previously "..." span is cut out before the revisions are read, because a correction stamp is a record of what a cell used to say and holding it to today's manifest would demand the record be rewritten at every roll.

Corrected 2026-08-16 at the plan's final audit: four such obligations, not two (previously "the two obligations with no upstream file of their own" and "asserts all four"). The MCP SDK and the Microsoft.Extensions.* family are compiled into BrowserAI.exe on exactly Velopack's terms, and a NuGet package's licence stays in the machine's package cache -- it is never copied to a publish output, so linked in and its notice ships are independent facts and the second was false for both. Apache-2.0 §4(a) is the stricter of the two clauses, not the looser, and this product is publicly distributed. The Microsoft.Extensions.* list is derived from src/BrowserAI/packages.lock.json and not typed, so a package entering the closure on a later bump is red here, not a licence nobody noticed had arrived.

About

Self-contained MCP server that proxies opinionated Playwright instances behind managed handles, with centralized versioning and updates.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages