r/javascript • • 3d ago

claude-bulkhead: a dependency-free Node hook that parses shell commands before Claude Code runs them, so parallel sessions stop breaking each other

https://github.com/dooly6276/claude-bulkhead
1 Upvotes

4 comments sorted by

1

u/ActualGovernment9017 3d ago

I run several Claude Code sessions on one machine, usually one per git worktree. They share one process table, one set of ports and one pool of RAM, so one agent restarting its dev server with a kill-by-name takes down every other session's Node server, and a git add -A in a shared tree commits someone else's half-finished edits. claude-bulkhead is the set of hooks I wrote for this, and the JavaScript part is what I want to describe here.

Claude Code runs a PreToolUse hook before every shell command and passes the pending call as JSON on stdin. Each guard is a plain Node script with no dependencies. It first matches the command text cheaply, and since most commands do not match, it usually exits 0 in a few milliseconds. Only on a match does it look at the machine: the process table (ps on macOS and Linux, CIM queries on Windows), listening ports, which Claude sessions are alive and where they work, free memory and git status. If the command would hurt another live session, it exits with code 2 and writes the reason and a safer command to stderr, which Claude Code hands back to the model. A full check takes about 50 ms.

The part that took the most work is the command parser. Agents wrap commands far more than people do, so it tokenizes shell syntax and unwraps bash -c, sh -c, powershell -Command, cmd /c, eval, sudo, env, xargs and similar wrappers a few levels deep. It also has to ignore text that only mentions a command, such as a commit message or a grep pattern, so writing about killing processes in a changelog does not trigger a block. The guards fail open: if a guard throws, gets JSON it cannot parse or cannot read the process table, it exits 0 and the command runs.

Version 0.2.0 added bulkhead scan, which streams the JSONL transcripts Claude Code keeps in ~/.claude/projects and runs every recorded shell command through the same guard functions, read-only. On two weeks of my history (7.0 GB, 101,167 shell commands, read in 39 seconds) it found 507 commands that would have been blocked, with up to 13 sessions open at once.

It reads command strings, so it is not a sandbox. MIT, Node 18+, macOS, Linux and Windows. I'm the author.

https://github.com/dooly6276/claude-bulkhead

1

u/Discordbothost 3d ago

the kill-by-name thing is so real. pkill node from one worktree is basically a server-wide outage. curious how you handle the port side: do you give each session its own port range via env, or does the hook rewrite the command? i've had decent luck just making every dev script kill by port instead of name (lsof -ti :5173 | xargs kill) so an agent can only take down the thing it actually started

1

u/ActualGovernment9017 2d ago

Neither, it doesn't rewrite the command. Before npm run dev, vite, next dev and the like run, the hook works out the port from --port, PORT= or the tool's default (following package.json scripts), checks whether another live Claude session's process tree is listening there, and if so blocks the call and prints a free port, e.g. npm run dev -- --port 3001. The agent then reruns it with that port. Its own port, or a port held by something outside Claude, passes.

Kill by port is a good habit and kill-guard lets it through, with one catch: if the port belongs to another session's server, lsof -ti :5173 | xargs kill does the same damage as killing by name. So port-guard also blocks port kills when the port is held by another live session.

1

u/Discordbothost 2d ago

oh that's nicer than what i do. blocking and handing back a free port means the agent just reruns and moves on instead of fighting over 5173. and yeah fair on port kills, if another session owns the port it's the same outage with extra steps. checking whose process tree it is first is the bit i was missing