A Claude Code plugin. It warns you once per session when a tool payload is about to start another browser, or fan work out across the machine.
It never blocks anything.
An agent driving a browser starts a new one instead of using the one already open. Each decision is locally reasonable, and nothing counts them:
- a script with
chromium.launch()inside it, run four times in a row, which is four browsers - a loop that screenshots every frame by invoking
firefox --headlessonce per file playwright test, which defaults to one worker per two cores and gives each worker its own browser- a probe-then-spawn helper: attach to
127.0.0.1:9222, and quietly launch a fresh Chrome when nothing answers
Then the runs that time out throw before close(), so those browsers never
exit. The leftovers hold their memory and their sockets. The next run is slower,
and the flakiness that follows gets blamed on the site, the router, or the test
itself. Two Chromes hammering a small embedded HTTP server will starve it, and
the resulting truncated pages look exactly like a bug in the server. The end of
that path is a machine deep in swap.
The same reflex shows up with no browser involved: xargs -P 8,
make -j$(nproc), a thread pool sized from the core count. A machine having
eight cores is not an instruction to use eight of them.
On every tool call it flattens tool_input to text and matches that text
against a sectioned pattern list. It raises at most two notices per session, one
about browsers and one about parallelism, then stays quiet until the session
ends.
It does not block. A browser launch is often exactly the right thing to do, the cost of a wrong one is a few seconds, and a hook that refuses tool calls costs more than it saves. It returns a message and no permission decision, so the call proceeds either way.
It fires once per topic. The agent keeps the note in context from then on. Repeating it on every call would burn tokens and teach the model to skim past it.
A payload that launches a browser produces the notice when any one of these holds:
| Condition | Reasoning |
|---|---|
| A fan-out pattern sits in the same payload | A launch inside a loop or a pool is one browser per iteration |
| A browser is already running | That is what a second instance is |
| It is the Nth launch this session (default 2) | Nobody decided to have two of them; it accumulated |
The third rule is the interesting one. Most multi-instance situations are not a single reckless command. They are four sensible commands in a row, each of which starts a browser and none of which mentions the other three. A stateless matcher cannot see that, so the hook keeps a counter per session.
It also remembers files. A Write whose content contains chromium.launch()
registers that filename as a browser launcher, so a later node scrape.mjs is
recognised as the launch it really is. Without that, the most common shape in
practice is invisible: the launch lives in a file written twenty minutes ago,
and the command that triggers it names no browser at all.
When something is already listening on a remote debugging port, the notice names the port and includes the line to attach to it.
If the payload also contains an attach pattern, the wording changes to say so. A payload holding both usually means "probe the endpoint, and spawn one if it is dead", and the fix there is to fail loudly instead of falling back to launching.
The one-line message shown in the transcript:
chrome-multi-instance-warn: a browser launch while 1 browser process(es) are
already running (allowed, shown once). Prefer attaching to the browser already
open; one instance for the whole task.
Alongside it, the hook passes the agent a longer note giving the situation
(which pattern matched, how many browsers are running, which debugging ports are
open) and the practice to follow: attach rather than spawn, one browser and one
context for the whole task, new pages per item, close() in a finally block,
--workers=1 for the Playwright runner, and a pgrep check for leftovers
before deciding the environment is broken.
The parallelism notice is separate and shorter. It asks for the work to be done one item at a time, and points out that a saturated machine is slower than a serial one once it starts swapping, and that several connections at once can starve a small server into producing failures that look like bugs.
The patterns come from shapes that agents produce in practice. Each entry below corresponds to something observed in a real session transcript.
chromium.launch, launchPersistentContext,
launchServer, puppeteer.launch, pyppeteer, sync_playwright(),
webdriver.Chrome(), undetected_chromedriver, chromedp.NewContext, rod.
A browser binary followed by a flag, which covers
google-chrome --remote-debugging-port=9222,
firefox --headless --screenshot out.png, and the versioned
chrome-linux64/chrome binaries that Puppeteer downloads. Launch configuration
wherever it appears: --user-data-dir, --remote-debugging-port,
headless: true, headless=True, executablePath, channel: 'chrome',
args: ['--no-sandbox', xvfb-run. The Playwright CLI: playwright test,
playwright screenshot, playwright codegen, playwright pdf.
connectOverCDP, connect_over_cdp,
puppeteer.connect, browserWSEndpoint, browserURL, /json/version,
--no-launch. These never fire anything on their own. They change the wording
of a notice raised for another reason.
xargs -P, GNU parallel, -j8, -j$(nproc), a for loop
ending in & done, nohup … &, --workers=4, concurrency: 8,
ThreadPoolExecutor, multiprocessing.Pool, joblib.Parallel,
worker_threads, cluster.fork, tokio::spawn, and two or more 92xx
debugging ports in one payload. Also the words in parallel, concurrently,
meanwhile, simultaneously, all at once. Tool payloads carry a description
field, and the intent tends to show up there in prose before the command that
implements it does.
nproc, os.cpus(), cpu_count(), /proc/cpuinfo,
Promise.all, asyncio.gather, tmux new-window, a trailing &, and loop
constructs (for … do, while … do, .forEach, .map(x => …)). All of these
are ordinary on their own and none of them fires a notice by itself. Next to a
browser launch they decide the matter, because a loop with a launch inside it is
one browser per iteration whether or not the iterations overlap.
Detection is worth nothing if the notice becomes background noise, so these are all silent:
which google-chrome,ls /usr/bin/google-chrome*, and other capability probing- a bare
curl http://127.0.0.1:9222/json/versionwith no launch attached playwright test --workers=1, since the flag is the fix- any loop, pool or background job with no browser in it, up to the point where it is doing genuine fan-out
- the second, third and fourth time a condition holds, because the notice has already been given
/plugin marketplace add didvc/claude-chrome-multi-instance-warn
/plugin install chrome-multi-instance-warn@didvc-browser-plugins
That writes extraKnownMarketplaces and enabledPlugins into your settings for
you, and there is nothing to unpick by hand later:
/plugin uninstall chrome-multi-instance-warn@didvc-browser-plugins
Requires jq and bash. If jq is missing, the hook exits quietly rather than
interfering with your session. ps is used when a launch is detected. Where it is
unavailable, the "already running" condition simply never holds and the other
two still work.
| Variable | Effect |
|---|---|
CHROME_NOTICE_OFF=1 |
Disable entirely |
CHROME_NOTICE_BUDGET=n |
Launches per session before the notice (default 2) |
CHROME_NOTICE_PATTERNS |
Path to your own patterns file |
CHROME_NOTICE_DEBUG=1 |
Explain on stderr why it did or did not fire |
CHROME_NOTICE_STATE_DIR |
Where the per-session markers live |
Set these in the environment that launches claude. A shell command run by
Claude cannot set them, because the hook is spawned by Claude Code and does not
inherit that command's environment.
CHROME_NOTICE_BUDGET=1 warns on the first launch of every session, which suits
work where a browser is already open and attaching is always correct. Raising it
to 3 or 4 suits a project whose test suite legitimately restarts a browser
between phases.
Do not edit hooks/patterns.txt where it sits in the plugin cache. That path is
version-pinned and gets replaced on every update, taking your edits with it.
Copy it to the plugin's data directory, which survives updates:
d=~/.claude/plugins/data/chrome-multi-instance-warn-didvc-browser-plugins
mkdir -p "$d"
cp ~/.claude/plugins/cache/didvc-browser-plugins/chrome-multi-instance-warn/*/hooks/patterns.txt \
"$d/patterns.txt"The file holds one extended regex per line, matched case-insensitively, grouped under four section headers:
| Section | Role |
|---|---|
[browser-launch] |
Starting a browser. Counted, and checked against the three conditions |
[browser-attach] |
Talking to one that is open. Changes wording only |
[fanout] |
Doing N things at once. Raises its own notice |
[fanout-weak] |
Might be doing N things at once. Counts only beside a launch |
Whole-line # comments are supported. Inline comments are not, because # is
legal inside a regex. Lookup order is $CHROME_NOTICE_PATTERNS, then the data
directory, then the bundled defaults.
One rule worth keeping: a pattern that has never fired on real work is a pattern that can only produce false positives. Take new entries from something that actually happened, and delete entries that fire on things you do not care about.
Feed the hook a payload directly. It reads the same JSON on stdin that Claude Code sends it:
h=~/.claude/plugins/cache/didvc-browser-plugins/chrome-multi-instance-warn/*/hooks/chrome-multi-instance-warn.sh
echo '{"session_id":"t1","tool_name":"Bash","tool_input":
{"command":"for f in *.svg; do firefox --headless --screenshot $f.png $f; done"}}' \
| CHROME_NOTICE_DEBUG=1 bash $hThat prints the JSON the agent would receive, and the debug line explaining the
decision. Re-running it with the same session_id prints nothing, which is the
once-per-session marker doing its job. Change the id to see it again, or clear
the state directory.
If you would rather wire it into settings.json yourself:
git clone https://github.com/didvc/claude-chrome-multi-instance-warn
cd claude-chrome-multi-instance-warn && ./install.shinstall.sh copies the hook to ~/.claude/hooks/ and merges the PreToolUse
entry into ~/.claude/settings.json with jq, preserving everything already
there. It backs the file up first and is idempotent, so re-running it replaces
this hook's entry instead of stacking copies. ./install.sh --uninstall
reverses it.
It does not count processes for you, kill anything, or manage a browser pool. It
reads the payload it is handed, takes a ps snapshot when a launch is in play,
and says one sentence.
It cannot see inside a compiled binary, a container, or a remote host reached over SSH. A launcher script written before the plugin was installed is invisible to the file-memory rule until the file is written again.
It is regex matching over text, so a launch behind an indirection it has no pattern for passes silently. The counting rule is the backstop for exactly that case: the second launch of a session gets noticed however it was spelled, as long as one pattern somewhere recognised it as a launch.
It sees tool payloads only. Nothing in the hook system intercepts assistant prose, so an agent can describe a plan to run eight browsers without the hook seeing anything until a tool call carries it out.
Three choices are deliberate and worth stating, since each could reasonably have gone the other way.
Never blocking. The harm here is load and orphaned processes, both of which are recoverable. Blocking would turn a recoverable cost into a broken session whenever the matcher is wrong, and the matcher will sometimes be wrong.
Once per session. A guard that speaks on every matching call gets ignored by the third repetition, and the agent has the note in context anyway.
Two notices instead of one. Browser instances and machine-wide parallelism are
the same underlying habit, but they get fixed differently and often occur hours
apart in a session. Sharing one marker between them means an early make -j8
silences the browser warning for the rest of the day.
MIT