Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

chrome-multi-instance-warn

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.

The problem

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 --headless once 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.

What it does

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.

How it decides

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.

What a notice looks like

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.

What it matches

The patterns come from shapes that agents produce in practice. Each entry below corresponds to something observed in a real session transcript.

Launching a browser

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.

Attaching to one that exists

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.

Fanning out

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.

Weak signals

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.

What it stays quiet about

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/version with 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

Install

/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.

Configuration

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.

Changing the patterns

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.

Checking it works

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 $h

That 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.

Install without the plugin system

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.sh

install.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.

What it does not do

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.

Design notes

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.

Licence

MIT

About

Claude Code plugin: warns once per session when a tool payload is about to launch another browser (Chrome/Playwright/Puppeteer/CDP) or fan work out in parallel. Never blocks.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages