[BUG] `Monitor` `until`-loop self-matches when model uses `pgrep -f "<cmdline>"` as its check
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Monitor until-loop self-matches when model uses pgrep -f "<cmdline>" as its check
Environment
- Claude Code (Opus, but pattern is model-agnostic — generated bash is the same)
- Linux, bash
Monitortool with the documented polling pattern (until <check>; do sleep N; done)
What happens
When the model invokes Monitor with a pgrep-style check like:
until ! pgrep -f "dotnet test <args>" > /dev/null; do sleep 20; done
…the Monitor tool spawns the loop inside a bash -c '<the whole until expression>'. That bash's own argv contains the literal string the check is searching for. pgrep -f matches against the full command line, so it finds the Monitor's own bash process. pgrep returns 0, ! negates to false, and the loop never exits — even after the real background command has completed long ago.
Evidence from a hung session
$ ps --ppid <claude_pid> -o pid,cmd
PID CMD
N /usr/bin/bash -c ... eval 'until ! pgrep -f "dotnet test <args>" > /dev/null; do sleep 20; done; ...'
$ cat /proc/N/wchan
do_wait # blocked on the child sleep
$ pgrep -af "dotnet test <args>"
N /usr/bin/bash -c ... 'until ! pgrep -f "dotnet test <args>" ...'
# ^ the Monitor bash matches itself
# The actual background command finished hours ago; no other process matches.
Claude Code's parent process cannot exit because it's still awaiting the Monitor tool result, which will never arrive. In headless mode (claude -p), this hangs the entire invocation indefinitely.
Why it matters
This is a plausible default check for models instructed to "wait for a background command to finish." The self-match trap is subtle — it's not obvious from reading the generated bash, and the surface symptom ("claude hangs forever") is easy to misattribute to MCP teardown or network issues. I wasted time chasing those red herrings before tracing it.
Suggested directions
Any of these would fix it; they're not mutually exclusive:
- Monitor tool implementation: invoke the check through a wrapper that doesn't leak the pattern into the shell's
argv. e.g. write the check command to a temp file andbash <file>, or pass the pattern via an environment variable and have the check read it from$CHECK_PATTERN. Either preventspgrep -fself-matching. - Tool description: explicitly warn against
pgrep -f "<cmdline>"as a check and recommend alternatives:kill -0 <pid>,tail -f <output> | grep -m1 <marker>, or a sentinel file. Many models will default topgrep -fotherwise because it's the most natural phrasing. - Monitor tool safety: apply a reasonable max wall-clock timeout on the outer
untilloop so a self-matching check eventually fails rather than hanging forever.
Minimal repro
Ask an agent (any model) to run a short background command and then use Monitor to wait for it using pgrep -f "<the exact command>". The Monitor never exits even after the command completes.
What Should Happen?
Monitoring a process should not hang claude.
Error Messages/Logs
Steps to Reproduce
Minimal repro
Ask an agent (any model) to run a short background command and then use Monitor to wait for it using pgrep -f "<the exact command>". The Monitor never exits even after the command completes.
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.117 (Claude Code)
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Other
Additional Information
_No response_
Showing cached comments. Read the full discussion on GitHub ↗
5 Comments
After adding prompt-level guidance to avoid
pgrep -fand prefertail -f "$output_file" | grep -E <marker>instead, the next iteration hit a different hang with the same net effect.The model generated:
— no
-m1. The model was using the pipeline as a streaming notification source, which fits the Monitor tool's docstring ("each stdout line is a notification"). But when the backgrounddotnet testfinished, two things happened at once:tail -fon a regular file has no natural termination condition — it keeps polling the file for new bytes that will never come.-m1,grepkeeps reading from the now-idle pipe forever.Net result: Monitor never exits → Claude Code's parent blocks waiting for the tool result → session hangs. Symptom identical to the
pgrep -ftrap; root cause is a different footgun in the same family.Evidence:
This reinforces the underlying issue: Monitor has no fundamental guarantee that its check terminates, and models are prone to writing checks that don't. Both
pgrep -fandtail -f | grep(without-m1) look reasonable on first read.Additional suggested directions on top of the ones in the original report:
pgrep -f "<cmdline>"self-matches;tail -fon a regular file never exits naturally, so downstreamgrepmust use-m1to terminate the pipeline. If a model wants both streaming notifications and a termination condition, it should be guided to issue two Monitor invocations (one streaming, one for the terminal marker with-m1) rather than a single pipeline that tries to do both.untilloop (or the entire Monitor invocation) would cap the blast radius of every check pattern the model might pick — known traps and future ones.Minimal repro (second trap)
Ask an agent to
run_in_backgrounda command that writes to a file and finishes in a few seconds. Then ask it to useMonitorwithtail -f <file> | grep -E "<pattern>"— no-m1. Monitor will never exit after the command finishes, even though the matching line has already been emitted.What I observed
claude -phangs forever after it has finished its work.claude -pdoes its tool calls, emits a final assistant message stating the iteration is complete, and stops issuing tool calls. It does not exit. The process stays alive, holding the connection open indefinitely. There is no further output, no further tool activity — just a process that never returns control.The original report describes the in-loop hang while a
Monitorpoll-loop is active. What I observed is a hang afterclaude -phas stopped producing output entirely —claude -pitself never terminates.Why
A
bash -c '… until ! pgrep -f "<the pattern claude -p chose>" …'subprocess is still alive afterclaude -p's final message./proc/<pid>/wchanisdo_waiton its childsleep.pgrep -fis matching this bash itself — the search pattern is part of its argv via theevalwrapper — so the loop's exit condition is permanently false. Same self-match the original report describes.What's different from the original report's tool path:
In the original report, Claude Code reaches this loop via
Monitor. In what I observed,claude -preaches the same loop viaBash{run_in_background: true}followed byTaskOutput{block: true, timeout: 600000}.TaskOutputdoes return — at the 600 s mark, by timeout. But the underlying bash subprocess keeps running unsupervised.claude -pmoves on, does real work in subsequent tool calls, and emits its final message. The orphaned bash never exits. Andclaude -pwaits for it.Two effects layered on top of the
pgrepself-match:Bash{run_in_background: true}+TaskOutput{block: true}) inherits the same self-match characteristic asMonitor, because the wrapper bash's argv has the samebash -c '… eval "<command>" …'shape.TaskOutput's timeout returning does not stop the underlying task. The orphan keeps running, andclaude -pdoes not exit while it's alive.Sequence inside one repro
claude -pkicks off a long-running shell command viaBash{run_in_background: true}claude -pkicks off anuntil ! pgrep …waiter loop viaBash{run_in_background: true}and blocks on it viaTaskOutput{block: true, timeout: 600000}pgrep -fhit from this point is the waiter bash itselfTaskOutputtimes out at 600 s, returnsclaude -pignores the emptyTaskOutputresult, reads the long-running command's output by other means, continues with subsequent tool calls, emits its final assistant messageclaude -pis silent and alive; the orphaned waiter bash is still spinning; no exit+1 — hit the exact same trap in a Kubernetes Job running
claude -pnon-interactively (Claude Code 2.1.143, opus).The wrapper is:
Confirmed self-match with
pgrep -af:Process tree at hang time:
The real
make verifyhad finished — the captured output file ends withVerification passed. Only the polling wrapper hangs, claude blocks on its pidfd indo_epoll_wait, the Kubernetes Job never reachesphase: Succeeded, and the pod has to be killed manually. We've had to clean up several of these by hand.Note the wrapper self-match is sufficient on its own — the parent
claudeargv containing the pattern is just extra reinforcement. So the bug fires even when the user prompt doesn't mention the command being polled.Workaround applied on our side: an entry in
CLAUDE.mdtelling the model not to usepgrep -f 'CMD_NAME'for completion polls (kelos-dev/kelos#1165). Effective for our agents, but obviously not a general fix.Closing for now — inactive for too long. Please open a new issue if this is still relevant.
I had the same issue is it solved