CI watcher: always-on background process that monitors CI and notifies on both failure and pass through the Monitor tool's stdout event stream.
Claude must never stop the watcher on its own initiative. The watcher itself may still exit on a terminal condition (PR closed without merge, no CI on the default branch, main-CI timeout, main CI resolved) — that is fine and is not a Claude-initiated kill.
Tool availability: Monitor and TaskStop are deferred tools in this harness. If they are not already available, load them first with ToolSearch query select:Monitor,TaskStop.
CRITICAL RULE: DO NOT KILL THE CI WATCHER AUTOMATICALLY
THE CI WATCHER MUST NEVER BE KILLED BY CLAUDE ON ITS OWN. This is an absolute rule with no exceptions:
- DO NOT kill the watcher when CI passes.
- DO NOT kill the watcher when CI fails.
- DO NOT kill the watcher when a PR is merged.
- DO NOT kill the watcher on merge to
main. - DO NOT kill the watcher when the feature looks "done".
- DO NOT kill the watcher on any other "natural completion" event.
- DO NOT call
/ci-watcher stop,TaskStopon the watcher's task id,kill <pid>,pkill ci_watch, or any equivalent on your own initiative.
THE ONLY WAY TO STOP THE WATCHER IS AN EXPLICIT USER REQUEST, such as the user typing /ci-watcher stop or giving a clear natural-language instruction like "stop the ci watcher" / "kill the ci watcher". If the user has not explicitly asked, leave it running.
stale task-id handling
/tmp/ci_watch_task_<slot> holds the Monitor task id of the watcher for this
session. The stored id can point at a task whose underlying process already
exited on its own (terminal condition) or crashed. Never trust the file's
contents without a liveness check first.
The liveness check is the PID lockfile — the same signal status_line.sh and
_notify.sh use:
# Guard: the lockfile is keyed on the full session UUID. Without it the path
# collapses to /tmp/ci_watch_lock_ and reports another session's state.
if [[ -z "${CLAUDE_CODE_SESSION_ID:-}" ]]; then
echo "Error: CLAUDE_CODE_SESSION_ID is unset; cannot check ci watcher liveness." >&2
exit 1
fi
# Read the PID the watcher wrote into its lockfile, then confirm that PID is
# still a live ci_watch process (ps args must contain "ci_watch"). Prints
# ALIVE, MUTE (alive, but its notifications reach nobody) or DEAD.
LOCK="/tmp/ci_watch_lock_${CLAUDE_CODE_SESSION_ID}"
STATE="/tmp/ci_watch_state_${CLAUDE_CODE_SESSION_ID}"
PID=$(cat "$LOCK" 2>/dev/null || echo "")
if [[ -n "$PID" ]] && ps -p "$PID" -o args= 2>/dev/null | grep -q ci_watch; then
if grep -q ':monitor-detached@' "$STATE" 2>/dev/null; then
echo "MUTE $PID"
else
echo "ALIVE $PID"
fi
else
echo "DEAD"
fi
Then:
- DEAD — the stored task id is stale. Call
TaskStopon it anyway (best effort). A "task not found" / "already finished" error is EXPECTED here: ignore it, do NOT treat it as a launch failure. Then delete the task-id file. - MUTE
<PID>— the process runs but its stdout writes fail, so no CI result will ever reach you again. Treat it exactly like ALIVE here, then relaunch it (step 2) and tell the user why; a relaunch is not a Claude- initiated kill. - ALIVE — call
TaskStopon the id for real, then confirm both the Monitor task reports stopped AND the PID from the lockfile is gone: re-run the check above at 1s intervals, at most 10 times. The loop is bounded on purpose — an unbounded wait would wedge/ci-watcher stopand every relaunch forever.- It prints
DEADwithin 10 tries — delete the task-id file and continue. - It still prints
ALIVE <PID>after 10 tries —TaskStopdid not kill the real process. Do NOT loop again and do NOT launch a second watcher. Report to the user that the watcher survivedTaskStop, give them the PID, and stop. Killing it is an explicit user decision (see the CRITICAL RULE).
- It prints
step 0: handle stop subcommand
If the first argument is stop, stop the watcher and exit — do NOT launch it:
# Guard: every /tmp file is keyed on the full session UUID.
if [[ -z "${CLAUDE_CODE_SESSION_ID:-}" ]]; then
echo "Error: CLAUDE_CODE_SESSION_ID is unset; cannot stop ci watcher." >&2
exit 1
fi
# Read the stored Monitor task id; NONE means no watcher was ever launched here.
cat "/tmp/ci_watch_task_${CLAUDE_CODE_SESSION_ID}" 2>/dev/null || echo NONE
If the output is NONE, the task id is missing — but a watcher may still be
running (the /tmp file can be reaped, or the session can have crashed between
Monitor returning and the id being persisted). Do NOT report "nothing to stop"
yet. Run the liveness check above first:
- DEAD — there really is nothing to stop. Report that and exit.
- ALIVE
<PID>— a watcher is running with no recoverable task id. Kill it by PID instead, then confirm with the same bounded re-check (1s intervals, at most 10 tries):
# PID is the number from the ALIVE line above. `:?` fails loud rather than
# running `kill ""` if this block is ever run on its own.
kill "${PID:?run the liveness check first and pass its PID}"
If it still reports ALIVE after 10 tries, report the surviving PID to the
user and stop; do not escalate to kill -9 on your own initiative.
Otherwise (the output is a task id) apply the stale-task-id handling above
(liveness check, TaskStop, confirm gone), then remove the file:
rm -f "/tmp/ci_watch_task_${CLAUDE_CODE_SESSION_ID}"
Exit without launching.
step 1: parse branch name from user input
user input
"$ARGUMENTS"
parse branch name
If user input is provided- determine branch name from it. If not, determine the current branch:
git branch --show-current
step 2: launch the CI watcher
First, collect the values that must be inlined into the Monitor command, and
read any existing task id:
# Guard: ci_watch.py exits 2 without a session id, so fail loud here instead.
if [[ -z "${CLAUDE_CODE_SESSION_ID:-}" ]]; then
echo "Error: CLAUDE_CODE_SESSION_ID is unset; cannot launch ci watcher." >&2
exit 1
fi
# SESSION and DIR get inlined as literals into the Monitor command below.
# The branch is deliberately NOT echoed here: it can be attacker-influenced text
# and this is a double-quoted shell string, where $(...) and backticks execute.
echo "SESSION=${CLAUDE_CODE_SESSION_ID} DIR=$(pwd)"
# Existing task id from a previous launch in this session, or NONE.
cat "/tmp/ci_watch_task_${CLAUDE_CODE_SESSION_ID}" 2>/dev/null || echo NONE
Take the branch name from step 1 (the output of git branch --show-current, or
the user's argument). Never re-echo it through another double-quoted shell
string — see the quoting rule below.
If the task id is not NONE, apply the stale-task-id handling above (liveness
check, TaskStop, confirm gone) BEFORE launching. This covers both a plain
relaunch and a branch switch.
Then make a single Monitor call:
command(template —<DIR>,<SESSION>and<BRANCH>are placeholders you replace with the literal values, NOT shell variables):cd '<DIR>' && exec env CLAUDE_CODE_SESSION_ID='<SESSION>' uv run ~/.claude/skills/ci-watcher/ci_watch.py '<BRANCH>' 2>>'/tmp/ci_watch_<SESSION>.log'description:CI status for branch <BRANCH>persistent:truetimeout_ms:3600000(required by the schema; ignored whenpersistentis true)
Fully substituted example — this is the shape the tool call must have:
cd '/Users/me/code/myrepo' && exec env CLAUDE_CODE_SESSION_ID='4f1c2b90-1c3d-4a55-9e21-7b6a0d5e8c11' uv run ~/.claude/skills/ci-watcher/ci_watch.py 'feat/my-branch' 2>>'/tmp/ci_watch_4f1c2b90-1c3d-4a55-9e21-7b6a0d5e8c11.log'
Never pass the template through verbatim. A shell expands an unset $DIR to the
empty string, cd '' succeeds silently, and the watcher then starts in an
arbitrary directory with an empty session id and exits 2 — to stderr, so you
would never see it.
Why the command looks like that:
- cwd and session id are inlined as literals, not inherited.
Monitorruns in the same shell environment as Bash, but neither its cwd nor its env inheritance is contractually guaranteed.ci_watch.pyexits 2 withoutCLAUDE_CODE_SESSION_ID, and it shells out togh repo viewandgit ls-remote, which need the repo directory. Neither can be left to chance. - Every interpolated value is SINGLE-quoted. This is the security-relevant
bullet. Git branch names may legally contain
$, backticks,;and&, and the command string is executed by a shell. Double quotes stop word splitting, globbing,;and&— they do not stop$VAR,`cmd`or$(cmd), so a branch namedx`touch /tmp/pwn`orx$(id)would execute inside double quotes. Only single quotes suppress every form of substitution. If a value itself contains a single quote, close, escape, reopen: write'as'''(soit'sbecomes'it'''s'). The log-redirect target is single-quoted for the same reason, even though it only holds a UUID. execreplaces the shell. Without it,Monitorwould track a parent shell whose child is the realuv/python process, andTaskStopcould kill the parent while orphaning the watcher. Withexec, the tracked process IS the watcher.- stderr goes to the log file. stdout is the Monitor event stream: one line
= one session notification.
ci_watch.pywrites every diagnostic to stderr, which is appended to/tmp/ci_watch_<slot>.logsotail -fdebugging still works. The cost is that stderr no longer shows up inTaskOutput. Redirect stream 2 ONLY. An all-streams redirect, or any redirect of stream 1, would swallow every notification and the watcher would go silent with no error.
Then persist the returned task id atomically so a concurrent reader never sees a half-written id:
# Write to a temp file, then rename — rename is atomic on the same filesystem.
printf '%s' "<TASK_ID>" > "/tmp/ci_watch_task_${CLAUDE_CODE_SESSION_ID}.tmp" \
&& mv "/tmp/ci_watch_task_${CLAUDE_CODE_SESSION_ID}.tmp" \
"/tmp/ci_watch_task_${CLAUDE_CODE_SESSION_ID}"
Persist the id BEFORE verifying the launch, never after: a watcher that is alive but slow to appear must still be stoppable, and an id pointing at a dead task is handled by the stale-task-id logic above.
Finally, confirm the watcher actually came up. ci_watch.py can die within a
second (branch not on the remote, missing gh auth, uv resolution failure),
and all of those messages go to the log file, not to TaskOutput — so without
this check a dead-on-arrival watcher is completely silent. Run the liveness
check above at 1s intervals, at most 10 times (uv may need a moment to start):
- ALIVE
<PID>— report to the user:CI watcher running for branch <BRANCH> (PID <PID>, log: /tmp/ci_watch_<slot>.log). - Still DEAD after 10 tries — the watcher failed to start. Show the user the tail of the log and stop:
tail -n 20 "/tmp/ci_watch_${CLAUDE_CODE_SESSION_ID}.log"
Note: state, PR-cache, lock, log, and task-id (/tmp/ci_watch_task_<slot>) files
in /tmp/ are all keyed on CLAUDE_CODE_SESSION_ID (full UUID). The watched
branch is recorded inside the state file as <branch>:<state>. To switch
branches mid-feature: read the old task id from /tmp/ci_watch_task_<slot>,
apply the stale-task-id handling above, launch a new Monitor with the new
branch, then atomically overwrite the task-id file with the new id.
behavior notes
repeated sync is BY DESIGN
The watcher will frequently surface "CI behind" / "branch behind" problems, and that means we will repeatedly need to sync. This is intentional, not a bug. Do NOT ignore any "CI behind" / branch-behind alert, and do NOT escalate it to the user. Just keep fixing and re-syncing every single time it comes up — however many times that is.
keep watching after merge
After a PR is merged, do NOT kill the watcher (see the CRITICAL RULE above). It keeps
watching the CI run triggered by the merge to main and will alert us if that post-merge
run fails. If the post-merge main CI fails, fix it in a separate/new PR — never reopen or
reuse the merged one.