From 3ec3a008e475103b42a371e6d714b6c6ea2c4225 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 6 Oct 2026 14:09:00 -0500 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=96=A5=EF=B8=8F?= =?UTF-8?q?=20cc-status=20shim,=20so=20a=20stale=20background=20task=20no?= =?UTF-8?q?=20longer=20pins=20a=20tab=20at=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iTerm2 3.7's cc-status keeps a Session Status row at "working" whenever the Stop payload's background_tasks array is non-empty, and Claude Code never ages that registry out: a backgrounded shell blocked on stdin kept a tab "working" for 41 hours, a subagent that never reported did the same with no process behind it. The shim takes cc-status's place in each profile's hook command and changes that one case: cc-status gets the Stop payload with the array emptied and paints idle, then one it2 set-status call puts what is still registered in the detail, keeps the stored count truthful, and turns the dot blue. Every other event passes through untouched. Fails open. Deployed as ~/.local/libexec/cc-status-shim/cc-status: iTerm2 3.7.3's health check accepts a hook only if its command is a bare executable path ending in /cc-status, so the shim keeps the name, off PATH. it2 is taken from beside the real binary, where iTerm2 ships it; PATH carries it only while an experimental iTerm2 setting is on. macOS-only, like worktabs. Co-Authored-By: Claude Fable 5.1 --- .chezmoiignore | 2 + CLAUDE.md | 38 ++++++ .../cc-status-shim/executable_cc-status | 98 +++++++++++++++ tests/cc-status-shim.bats | 112 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100755 dot_local/libexec/cc-status-shim/executable_cc-status create mode 100644 tests/cc-status-shim.bats diff --git a/.chezmoiignore b/.chezmoiignore index e1683678577..aca6b8b5357 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -49,6 +49,8 @@ com.googlecode.iterm2.plist .config/finicky # The worktabs launcher drives iTerm2, which is macOS-only bin/worktabs +# So does the cc-status shim: it fronts a binary iTerm2 ships +.local/libexec/cc-status-shim {{ end -}} {{ if eq .machineClass "ephemeral" -}} diff --git a/CLAUDE.md b/CLAUDE.md index 19f35e63157..d2b954af6c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1507,6 +1507,44 @@ disagreement is half of what makes it worth keeping. Tests: `tests/iterm2-doctor.bats` (fake `iTerm.app` bundles and a stub `defaults`; nothing ever touches the real iTerm2 or opens a dialog). +**Session Status rows come from Claude Code hooks, through a shim.** iTerm2 +3.7 ships `cc-status`, a binary its onboarding symlinks at +`~/.config/iterm2/cc-status` and wires into every hook event of a profile's +`settings.json`; it turns each event into a row (name, status word, +optional detail of up to three lines, dot and text colours). On Stop it +reads the payload's `background_tasks` — the session's whole task registry, +which Claude Code never ages out — and keeps the row at "working" while +that array is non-empty. Measured: a backgrounded shell blocked on stdin +kept a tab "working" for 41 hours; a subagent that never reported back did +the same with no process behind it. A shim, deployed as +`~/.local/libexec/cc-status-shim/cc-status` +(`dot_local/libexec/cc-status-shim/`), takes cc-status's place in each +profile's `settings.json` hook command (here, the overlays write that +line) and changes exactly that case: cc-status gets the Stop payload with +the array emptied and paints idle, then one `it2 set-status` call writes +what is still registered into the detail, keeps the stored count truthful, +and turns the dot blue. Every other event passes through untouched. Two +details are load-bearing. The shim is **named** `cc-status`, in a +directory off `PATH`, because iTerm2 3.7.3's health check accepts a hook +only if its command is a bare executable path ending in `/cc-status` +(verified in the app's onboarding source at that tag): a wrapper line or +any other name makes the app report its integration broken, and a +Reinstall then appends a raw cc-status beside it, which repaints "working" +on Stop. And iTerm2's own symlink at `~/.config/iterm2/cc-status` is left +alone — `ensureCCStatusSymlink` removes anything else put there — and is +where the shim finds the real binary, with `it2` taken from beside it; +`PATH` only carries `it2` while an experimental iTerm2 setting is on. +`it2 set-status` keeps every field it is not given — a reset that sends +only the status word leaves the old colours in place — so anything +repainting a row sends the full field set. The shim fails open everywhere +(no binary, no it2, no python3, no session id, bad JSON): the row is +cosmetic and must never cost a turn. The facts it rests on — the payload +keys cc-status reads, the it2 flag it stores the count under, its detail +text and colours, the app's symlink upkeep — are `utilstr`/`binstr` lines +in the skill's `manifest.txt`, so `verify.sh` says when a point release +moves them. Tests: `tests/cc-status-shim.bats` (stub cc-status and it2, +side by side like the bundle ships them). + ### Machine-local secrets (~/.extra) `~/.extra` is rendered by chezmoi from `private_dot_extra.tmpl` (mode 0600) diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status new file mode 100755 index 00000000000..b0c1031c809 --- /dev/null +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -0,0 +1,98 @@ +#!/bin/sh +# cc-status (shim) — stands between Claude Code's hooks and iTerm2's cc-status. +# +# iTerm2 3.7 paints a Session Status row per tab from Claude Code hook events +# through its bundled cc-status binary. On Stop it keeps the row at "working" +# whenever the payload's background_tasks array is non-empty — and that array +# is the session's whole task registry, which nothing ages out: a backgrounded +# shell blocked on stdin, or a subagent that never reported back, keeps a tab +# "working" for days. This shim rewrites that one case. Every other event is +# handed to cc-status untouched. +# +# On Stop with background tasks: cc-status gets the payload with the array +# emptied, so it paints idle like any finished turn; then one it2 call sets +# the detail to what is still registered ("1 background task: …"), keeps the +# stored count truthful for `it2 session get-background-tasks`, and turns the +# dot blue — idle, but something is still in flight. set-status keeps every +# field it is not given, so the status word and text colour cc-status just +# painted stay. +# +# Wiring: each profile's settings.json runs cc-status on every hook event; +# point that command at this file instead: +# ~/.local/libexec/cc-status-shim/cc-status +# The file is deliberately NAMED cc-status, in a directory off PATH. iTerm2 +# 3.7.3's own health check accepts a hook only if the command is a bare +# executable path ending in /cc-status (a wrapper line, or any other name, +# makes the app report its integration broken and a Reinstall append a raw +# cc-status beside it). The symlink iTerm2 maintains at +# ~/.config/iterm2/cc-status is left alone — the app recreates it over +# anything else put there — and is where the real binary is found; +# CC_STATUS_BIN overrides that (tests). it2 is taken from beside the real +# binary, where iTerm2 ships it, before PATH is tried: PATH only has it while +# an experimental iTerm2 setting is on. +# +# Fails open: no cc-status, no python3, no it2, no session id, unparsable +# JSON — the payload passes through or the shim exits 0. The status is +# cosmetic and must never cost a turn. +set -u + +real=${CC_STATUS_BIN:-$HOME/.config/iterm2/cc-status} +[ -x "$real" ] || exit 0 +target=$(readlink "$real" 2>/dev/null || printf '%s' "$real") +case $target in "$0"|*/cc-status-shim/cc-status) exit 0 ;; esac # never recurse into ourselves + +payload=$(cat) + +# Cheap gate before paying for python3: Claude Code emits compact JSON. +case $payload in + *'"hook_event_name":"Stop"'*) ;; + *) printf '%s' "$payload" | "$real"; exit 0 ;; +esac + +it2="$(dirname "$target")/it2" +[ -x "$it2" ] || it2=$(command -v it2 2>/dev/null || true) + +if [ -z "${ITERM_SESSION_ID:-}" ] || [ -z "$it2" ] || ! command -v python3 >/dev/null 2>&1; then + printf '%s' "$payload" | "$real"; exit 0 +fi + +# The script goes in via -c so stdin stays free for the payload: `python3 -` +# with a heredoc would make the heredoc the stdin and the payload vanish. +py=$(cat <<'PY' +import json, subprocess, sys + +raw = sys.stdin.read() +real, it2, sid = sys.argv[1], sys.argv[2], sys.argv[3] + +def passthrough(): + subprocess.run([real], input=raw.encode()) + sys.exit(0) + +try: + payload = json.loads(raw) +except ValueError: + passthrough() +if payload.get("hook_event_name") != "Stop": + passthrough() +tasks = payload.get("background_tasks") or [] +if not isinstance(tasks, list) or not tasks: + passthrough() + +payload["background_tasks"] = [] +subprocess.run([real], input=json.dumps(payload).encode()) + +n = len(tasks) +what = ", ".join( + str(t.get("description") or t.get("task_type") or "?") if isinstance(t, dict) else "?" + for t in tasks +) +detail = f"{n} background task{'s' if n != 1 else ''}: {what}" +subprocess.run( + [it2, "set-status", "--session", sid, "--detail", detail[:240], + "--dot-color", "#5f87ff", "--background-tasks", str(n)], + stdout=subprocess.DEVNULL, +) +PY +) +printf '%s' "$payload" | python3 -c "$py" "$real" "$it2" "${ITERM_SESSION_ID#*:}" +exit 0 diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats new file mode 100644 index 00000000000..2607dd05381 --- /dev/null +++ b/tests/cc-status-shim.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +# The cc-status shim (dot_local/libexec/cc-status-shim/) sits between Claude +# Code's hooks and iTerm2's cc-status. Both cc-status and it2 are stubbed in +# one directory, the way iTerm2 ships them side by side: cc-status records +# the payload it was fed, it2 records its argv. No real iTerm2 is touched. + +setup() { + STUB="$BATS_TEST_TMPDIR/utilities" + mkdir -p "$STUB" + printf '#!/bin/sh\ncat > "%s/fed"\n' "$BATS_TEST_TMPDIR" > "$STUB/cc-status" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/argv"\n' "$BATS_TEST_TMPDIR" > "$STUB/it2" + chmod +x "$STUB/cc-status" "$STUB/it2" + # The real wiring is a symlink to the bundle; it2 is found beside its target. + ln -s "$STUB/cc-status" "$BATS_TEST_TMPDIR/cc-status-link" + export CC_STATUS_BIN="$BATS_TEST_TMPDIR/cc-status-link" + export ITERM_SESSION_ID="w0t1p0:ABC-123" + SHIM="$BATS_TEST_DIRNAME/../dot_local/libexec/cc-status-shim/executable_cc-status" +} + +run_shim() { run sh "$SHIM"; } + +@test "shim: a non-Stop event passes through byte for byte and never calls it2" { + local p='{"hook_event_name":"PreToolUse","tool_name":"Bash","background_tasks":[{"task_id":"t1"}]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: Stop with nothing in flight passes through unchanged" { + local p='{"hook_event_name":"Stop","stop_hook_active":false,"background_tasks":[]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: Stop with background tasks paints idle, then details the tasks" { + local p='{"hook_event_name":"Stop","stop_hook_active":false,"background_tasks":[{"task_id":"a","task_type":"local_bash","description":"tail -F build.log"},{"task_id":"b","task_type":"local_agent","description":"review PR"}]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + # cc-status saw an empty array, so it paints the row like any finished turn. + python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d["background_tasks"]==[] and d["hook_event_name"]=="Stop"' "$BATS_TEST_TMPDIR/fed" + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--detail|2 background tasks: tail -F build.log, review PR|--dot-color|#5f87ff|--background-tasks|2" ] +} + +@test "shim: one task is singular" { + run_shim <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a","task_type":"local_bash"}]}' + [ "$status" -eq 0 ] + grep -qx '1 background task: local_bash' "$BATS_TEST_TMPDIR/argv" +} + +@test "shim: it2 is taken from beside the real binary, not from PATH" { + # A different it2 on PATH must lose to the one shipped beside cc-status. + mkdir -p "$BATS_TEST_TMPDIR/path" + printf '#!/bin/sh\necho wrong > "%s/argv"\n' "$BATS_TEST_TMPDIR" > "$BATS_TEST_TMPDIR/path/it2" + chmod +x "$BATS_TEST_TMPDIR/path/it2" + PATH="$BATS_TEST_TMPDIR/path:$PATH" run sh "$SHIM" <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + [ "$status" -eq 0 ] + grep -q '^set-status$' "$BATS_TEST_TMPDIR/argv" +} + +@test "shim: without any it2 the payload passes through and nothing else is tried" { + rm "$STUB/it2" + local p='{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + PATH="/usr/bin:/bin" run sh "$SHIM" <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] +} + +@test "shim: the event name only inside message text does not count as Stop" { + local p='{"hook_event_name":"PostToolUse","tool_response":"the \"hook_event_name\":\"Stop\" payload","background_tasks":[{"task_id":"a"}]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: without an iTerm2 session id it passes through" { + unset ITERM_SESSION_ID + local p='{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: unparsable JSON passes through" { + run_shim <<< '{"hook_event_name":"Stop","background_tasks":[oops' + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = '{"hook_event_name":"Stop","background_tasks":[oops' ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: no cc-status at all exits 0 and touches nothing" { + export CC_STATUS_BIN="$BATS_TEST_TMPDIR/absent" + run_shim <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "shim: pointed at itself it exits 0 instead of recursing" { + mkdir -p "$BATS_TEST_TMPDIR/cc-status-shim" + cp "$SHIM" "$BATS_TEST_TMPDIR/cc-status-shim/cc-status" + chmod +x "$BATS_TEST_TMPDIR/cc-status-shim/cc-status" + export CC_STATUS_BIN="$BATS_TEST_TMPDIR/cc-status-shim/cc-status" + run "$BATS_TEST_TMPDIR/cc-status-shim/cc-status" <<< '{"hook_event_name":"Stop","background_tasks":[]}' + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] +} From df5c192b03dd33f311cba853c2b8f4e06d9bbd22 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 6 Oct 2026 14:09:01 -0500 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=85=20test:=20=F0=9F=94=8D=20record?= =?UTF-8?q?=20the=20cc-status=20contract=20in=20the=20iTerm2=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new utilstr verb checks a string inside one bundled utility, the way binstr checks the main executable. The manifest now records what the shim rests on: the payload keys cc-status reads, the it2 flag it stores the task count under, its detail text and colours, and the app's own symlink upkeep. Strings of 15 bytes or fewer live inline in a Swift binary, so only longer literals are recorded. Not grep -q under pipefail: its early exit hands strings(1) a SIGPIPE on a binary still printing, failing a fact that is present. Co-Authored-By: Claude Fable 5.1 --- .claude/skills/iterm2/scripts/manifest.txt | 34 ++++++++++++++++++++++ .claude/skills/iterm2/scripts/verify.sh | 18 +++++++++++- tests/iterm2-doctor.bats | 13 +++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/.claude/skills/iterm2/scripts/manifest.txt b/.claude/skills/iterm2/scripts/manifest.txt index 7bfb6f4c053..3811056f1dc 100644 --- a/.claude/skills/iterm2/scripts/manifest.txt +++ b/.claude/skills/iterm2/scripts/manifest.txt @@ -175,3 +175,37 @@ info SUFeedURLForTesting https://iterm2.com/appcasts/testing_modern.xml # --- Separate bundles since 3.5 --------------------------------------- bundle iTermAI.app bundle iTermBrowserPlugin.app + +# --- Claude Code integration (3.7) ------------------------------------- +# iTerm2 3.7 ships cc-status, a hook binary that turns Claude Code hook +# payloads into Session Status rows, and it2, the CLI it drives. Both are +# undocumented on iterm2.com; bin/cc-status-shim sits in front of cc-status +# and depends on exactly these facts: the payload keys cc-status reads, the +# it2 flag it stores the task count under, the detail text and colours it +# paints, and the app's own symlink upkeep. Swift keeps strings of 15 bytes +# or fewer inline, so short literals (--status, --detail, set-status) are +# not findable here; everything below is 16 bytes or longer, or a colour. +util cc-status +util it2 +utilstr cc-status background_tasks +utilstr cc-status notification_type +utilstr cc-status permission_prompt +utilstr cc-status last_assistant_message +utilstr cc-status --background-tasks +utilstr cc-status 1 background task running +utilstr cc-status #00d75f +utilstr cc-status #5f87ff +utilstr cc-status #888888 +utilstr cc-status #ff9500 +utilstr it2 --background-tasks +utilstr it2 ITERM_SESSION_ID +binstr ccStatusSymlinkIsHealthy +binstr ensureCCStatusSymlink +binstr utilities/cc-status +binstr com.iterm2.set-status +binstr Toolbelt.Session Status +binstr * `status=TEXT` +binstr * `indicator=COLOR` +binstr * `status-color=COLOR` +binstr * `detail=TEXT` +binstr Sets optional detail text shown below the status (wraps up to 3 lines). diff --git a/.claude/skills/iterm2/scripts/verify.sh b/.claude/skills/iterm2/scripts/verify.sh index 11c6c0ed102..63b05c876a8 100755 --- a/.claude/skills/iterm2/scripts/verify.sh +++ b/.claude/skills/iterm2/scripts/verify.sh @@ -143,6 +143,22 @@ while IFS= read -r line || [ -n "$line" ]; do record util fail "$rest" fi ;; + utilstr) + # A string inside one bundled utility — what cc-status reads from a + # hook payload, what it passes to it2. The utilities are small, so + # strings(1) runs per fact rather than once like the main executable. + name=${rest%% *}; want=${rest#* } + if ! command -v strings >/dev/null 2>&1; then + record utilstr skip + # Not grep -q: under pipefail its early exit hands strings(1) a + # SIGPIPE on any binary big enough to still be printing, and the + # pipeline fails on a fact that is present. + elif [ -r "$utils/$name" ] && strings -a "$utils/$name" 2>/dev/null | grep -xF -- "$want" >/dev/null; then + record utilstr ok + else + record utilstr fail "$name: $want" + fi + ;; info) key=${rest%% *}; want=${rest#* } got=$(plist_get "$key") @@ -185,7 +201,7 @@ else say "stamp: $stamp — INSTALLED IS $installed" fi -for class in sdef binstr util info tipcount; do +for class in sdef binstr util utilstr info tipcount; do n=${total[$class]:-0} [ "$n" -eq 0 ] && continue s=${skipped[$class]:-0} diff --git a/tests/iterm2-doctor.bats b/tests/iterm2-doctor.bats index bac103f8001..6da060e31ef 100644 --- a/tests/iterm2-doctor.bats +++ b/tests/iterm2-doctor.bats @@ -220,6 +220,10 @@ make_verifiable_app() { sed -n 's/^sdef //p' "$manifest" > "$APP/Contents/Resources/iTerm2.sdef" while read -r u; do : > "$APP/Contents/Resources/utilities/$u"; done \ < <(sed -n 's/^util //p' "$manifest") + # utilstr facts are strings inside a named utility; a text file stands in + # for the binary, one recorded string per line. + while read -r u s; do printf '%s\n' "$s" >> "$APP/Contents/Resources/utilities/$u"; done \ + < <(sed -n 's/^utilstr //p' "$manifest") # it2tip carries the app's own feature tour; the manifest records how many # entries it has, so the fake needs exactly that many. local n @@ -241,7 +245,7 @@ make_verifiable_app() { [[ "$output" == *"— matches"* ]] [[ "$output" == *"OK - every recorded fact still holds"* ]] # Each class of fact was actually exercised, not silently skipped. - for class in sdef binstr util info; do + for class in sdef binstr util utilstr info; do [[ "$output" == *"$class:"*"/"*" verified"* ]] done } @@ -256,6 +260,9 @@ binstr KeptKey binstr RemovedKey util it2kept util it2removed +util it2strs +utilstr it2strs KeptUtilString +utilstr it2strs RemovedUtilString tipcount 4 info SUFeedURLForFinal https://example.invalid/kept EOF @@ -266,7 +273,7 @@ EOF head -2 "$APP/Contents/Resources/utilities/it2tip" > "$APP/it2tip.short" mv "$APP/it2tip.short" "$APP/Contents/Resources/utilities/it2tip" sed -i.bak '/removed/d;/Removed/d' "$APP/Contents/MacOS/iTerm2" \ - "$APP/Contents/Resources/iTerm2.sdef" + "$APP/Contents/Resources/iTerm2.sdef" "$APP/Contents/Resources/utilities/it2strs" write_info_plist "$APP" 9.9.9 "SUFeedURLForFinal https://example.invalid/moved" run bash "$SKILL/scripts/verify.sh" --manifest "$M" @@ -279,6 +286,8 @@ EOF # there reports a missing package as a broken verifier. if command -v strings > /dev/null 2>&1; then [[ "$output" == *"binstr: RemovedKey"* ]] + [[ "$output" == *"utilstr: it2strs: RemovedUtilString"* ]] + [[ "$output" != *"KeptUtilString"* ]] fi [[ "$output" == *"util: it2removed"* ]] [[ "$output" == *"tipcount: it2tip lists 2 features, recorded as 4"* ]] From 29439b7c8ec63aad5c74f48b1af33e583dfc9f4b Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 6 Oct 2026 14:20:59 -0500 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=96=A5=EF=B8=8F?= =?UTF-8?q?=20cc-status=20shim=20tells=20quiet=20tasks=20from=20active=20o?= =?UTF-8?q?nes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut treated every background task at Stop as stale: it emptied the array and stored the task count back into iTerm2. That hides real work (a Stop while a background subagent is mid-edit read as idle) and the stored count undid itself: cc-status's Notification(idle_prompt) handler, a minute after Stop, reads the count back with get-background-tasks and repaints "working · N background tasks running". Now each task is classified by its output file under the session's tasks/ directory (the sibling of scratchpad_dir; for an agent a symlink to its transcript). Written within tabstatus.quiet-minutes (default 15) is active; older, or a dangling symlink, is quiet; no file to judge by counts as active, since wrongly active only delays the idle. cc-status is fed only the active tasks, with the id and status keys its older schema reads. When nothing is active but something is quiet, one it2 call paints the full field set: idle, amber (blue is cc-status's waiting colour), the quiet tasks in the detail, and a stored count of 0, never the quiet count. StopFailure and SubagentStop are handled like Stop, and the stopping agent is dropped from its own SubagentStop. Adds `cc-status explain` (prints each verdict, calls nothing) and `cc-status settings` (the hooks block for a profile's settings.json, the ten iTerm2 events plus SubagentStart, with the home path expanded). Drops the off-mac chezmoi ignore: the hook command is a bare path, so the file must exist wherever a settings.json names it, and the shim already exits 0 without iTerm2's binary. Co-Authored-By: Claude Fable 5.1 --- .chezmoiignore | 2 - CLAUDE.md | 31 ++- .../cc-status-shim/executable_cc-status | 207 ++++++++++++++---- tests/cc-status-shim.bats | 179 ++++++++++++++- 4 files changed, 348 insertions(+), 71 deletions(-) diff --git a/.chezmoiignore b/.chezmoiignore index aca6b8b5357..e1683678577 100644 --- a/.chezmoiignore +++ b/.chezmoiignore @@ -49,8 +49,6 @@ com.googlecode.iterm2.plist .config/finicky # The worktabs launcher drives iTerm2, which is macOS-only bin/worktabs -# So does the cc-status shim: it fronts a binary iTerm2 ships -.local/libexec/cc-status-shim {{ end -}} {{ if eq .machineClass "ephemeral" -}} diff --git a/CLAUDE.md b/CLAUDE.md index d2b954af6c4..349fcf71ddd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1520,12 +1520,25 @@ the same with no process behind it. A shim, deployed as `~/.local/libexec/cc-status-shim/cc-status` (`dot_local/libexec/cc-status-shim/`), takes cc-status's place in each profile's `settings.json` hook command (here, the overlays write that -line) and changes exactly that case: cc-status gets the Stop payload with -the array emptied and paints idle, then one `it2 set-status` call writes -what is still registered into the detail, keeps the stored count truthful, -and turns the dot blue. Every other event passes through untouched. Two -details are load-bearing. The shim is **named** `cc-status`, in a -directory off `PATH`, because iTerm2 3.7.3's health check accepts a hook +line; `cc-status settings` prints the block, the ten events iTerm2 wires +plus `SubagentStart`, with the home path expanded — 3.7.3 tests the literal +string, so `~` fails it) and, on Stop, StopFailure and SubagentStop, tells +**quiet** from **active** tasks. A task's output file, `tasks/.output` +beside the payload's `scratchpad_dir` (a symlink to the transcript for an +agent), is its last sign of life: written within `tabstatus.quiet-minutes` +(git config, default 15) is active, older is quiet, and no file to judge by +counts as active — wrongly active only delays the idle, wrongly idle lies. +cc-status is fed only the active tasks, so a Stop over a running background +agent still reads "working"; when nothing is active, one `it2 set-status` +paints the row idle in amber (`tabstatus.color.quiet`, default `#af8700` — +cc-status's blue is its "waiting" colour) with the quiet tasks in the +detail. The count stored back is **0** on purpose: cc-status's +`Notification(idle_prompt)` handler, a minute after Stop, reads the stored +count with `get-background-tasks` and repaints "working · N background tasks +running", so storing the quiet N would revive the lie. `cc-status explain` +(payload on stdin) prints each task's verdict without calling anything. +Every other event passes through untouched. Two details are load-bearing. +The shim is **named** `cc-status`, in a directory off `PATH`, because iTerm2 3.7.3's health check accepts a hook only if its command is a bare executable path ending in `/cc-status` (verified in the app's onboarding source at that tag): a wrapper line or any other name makes the app report its integration broken, and a @@ -1536,9 +1549,9 @@ where the shim finds the real binary, with `it2` taken from beside it; `PATH` only carries `it2` while an experimental iTerm2 setting is on. `it2 set-status` keeps every field it is not given — a reset that sends only the status word leaves the old colours in place — so anything -repainting a row sends the full field set. The shim fails open everywhere -(no binary, no it2, no python3, no session id, bad JSON): the row is -cosmetic and must never cost a turn. The facts it rests on — the payload +repainting a row sends the full field set (the shim's paint does). The +shim fails open everywhere (no binary, no it2, no python3, no session id, +bad JSON): the row is cosmetic and must never cost a turn. The facts it rests on — the payload keys cc-status reads, the it2 flag it stores the count under, its detail text and colours, the app's symlink upkeep — are `utilstr`/`binstr` lines in the skill's `manifest.txt`, so `verify.sh` says when a point release diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index b0c1031c809..b2d9a8a3f9a 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -9,17 +9,29 @@ # "working" for days. This shim rewrites that one case. Every other event is # handed to cc-status untouched. # -# On Stop with background tasks: cc-status gets the payload with the array -# emptied, so it paints idle like any finished turn; then one it2 call sets -# the detail to what is still registered ("1 background task: …"), keeps the -# stored count truthful for `it2 session get-background-tasks`, and turns the -# dot blue — idle, but something is still in flight. set-status keeps every -# field it is not given, so the status word and text colour cc-status just -# painted stay. +# On Stop, StopFailure and SubagentStop with background tasks, each task is +# classified by its output file, /tasks/.output (the +# sibling of the payload's scratchpad_dir; for an agent it is a symlink to the +# transcript): modified within tabstatus.quiet-minutes (default 15) it is +# active, older it is quiet, and a task with no file to judge by counts as +# active — wrongly active only delays the idle, wrongly idle lies. cc-status +# is fed only the active ones, so a Stop over a running background agent still +# paints "working". When nothing is active but something is quiet, one it2 +# call then paints the row idle with the quiet tasks in the detail, in amber +# (tabstatus.color.quiet, default #af8700; cc-status's own blue is its +# "waiting" colour). It stores 0 as the background-task count, never the +# quiet ones: cc-status re-reads the stored count on Notification(idle_prompt) +# a minute after Stop and repaints "working · N background tasks running". +# The paint sends the full field set, so a cc-status that failed cannot leave +# a half-painted row. Items also get the id and status keys that cc-status +# (written against an older Claude Code) reads and 2.1.289 no longer sends. +# +# cc-status hook mode: payload on stdin, exit 0 always +# cc-status explain payload on stdin; print the decision, call nothing +# cc-status settings print the hooks block for a profile's settings.json # # Wiring: each profile's settings.json runs cc-status on every hook event; -# point that command at this file instead: -# ~/.local/libexec/cc-status-shim/cc-status +# point that command at this file instead (`cc-status settings` prints it). # The file is deliberately NAMED cc-status, in a directory off PATH. iTerm2 # 3.7.3's own health check accepts a hook only if the command is a bare # executable path ending in /cc-status (a wrapper line, or any other name, @@ -37,62 +49,161 @@ set -u real=${CC_STATUS_BIN:-$HOME/.config/iterm2/cc-status} -[ -x "$real" ] || exit 0 -target=$(readlink "$real" 2>/dev/null || printf '%s' "$real") -case $target in "$0"|*/cc-status-shim/cc-status) exit 0 ;; esac # never recurse into ourselves - -payload=$(cat) +mode=${1:-} -# Cheap gate before paying for python3: Claude Code emits compact JSON. -case $payload in - *'"hook_event_name":"Stop"'*) ;; - *) printf '%s' "$payload" | "$real"; exit 0 ;; -esac +if [ "$mode" = settings ]; then + cmd="$HOME/.local/libexec/cc-status-shim/cc-status" + printf 'cc-status settings: the shim does not edit settings.json — each profile'"'"'s\nfile is owned by its overlay. Put this in that file, replacing iTerm2'"'"'s own\ncc-status entries (never beside them):\n\n{\n "hooks": {\n' + sep= + for ev in SessionStart SessionEnd UserPromptSubmit PreToolUse PostToolUse PermissionRequest Notification Stop StopFailure SubagentStart SubagentStop; do + printf '%s "%s": [\n { "hooks": [{ "type": "command", "command": "%s" }] }\n ]' "$sep" "$ev" "$cmd" + sep=,' +' + done + printf '\n }\n}\n\n' + cat <<'TXT' +The command is a bare absolute path: iTerm2 3.7.3's health check tests the +literal string with isExecutableFile, so "~" and "$HOME" fail it and the app +reports the integration broken. iTerm2 > Reinstall rewrites the entry back to +the raw cc-status (re-paste afterwards); Uninstall strips it. SubagentStart is +not one of the ten events iTerm2 wires; cc-status ignores it. +TXT + exit 0 +fi +target=$(readlink "$real" 2>/dev/null || printf '%s' "$real") it2="$(dirname "$target")/it2" [ -x "$it2" ] || it2=$(command -v it2 2>/dev/null || true) +sid=${ITERM_SESSION_ID#*:} +[ -x "$real" ] || real= +[ -x "$it2" ] || it2= + +# A leading dash is cc-status's own flag (--agent codex): not ours to read. +case $mode in -*) [ -n "$real" ] && exec "$real" "$@"; exit 0 ;; esac + +if [ "$mode" != explain ]; then + [ -n "$real" ] || exit 0 + case $target in "$0"|*/cc-status-shim/cc-status) exit 0 ;; esac # never recurse into ourselves +fi + +payload=$(cat) +pass() { printf '%s' "$payload" | "$real"; exit 0; } -if [ -z "${ITERM_SESSION_ID:-}" ] || [ -z "$it2" ] || ! command -v python3 >/dev/null 2>&1; then - printf '%s' "$payload" | "$real"; exit 0 +# Cheap gate before paying for python3: Claude Code emits compact JSON. +if [ "$mode" != explain ]; then + case $payload in + *'"hook_event_name":"Stop"'*|*'"hook_event_name":"StopFailure"'*|*'"hook_event_name":"SubagentStop"'*) + case $payload in *'"background_tasks":[{'*) ;; *) pass ;; esac ;; + *) pass ;; + esac + if [ -z "$sid" ] || [ -z "$it2" ] || ! command -v python3 >/dev/null 2>&1; then pass; fi +elif ! command -v python3 >/dev/null 2>&1; then + echo 'cc-status explain: needs python3' >&2; exit 0 fi # The script goes in via -c so stdin stays free for the payload: `python3 -` # with a heredoc would make the heredoc the stdin and the payload vanish. py=$(cat <<'PY' -import json, subprocess, sys +import json, os, re, subprocess, sys, time +mode, real, it2, sid = sys.argv[1:5] +explain = mode == "explain" raw = sys.stdin.read() -real, it2, sid = sys.argv[1], sys.argv[2], sys.argv[3] -def passthrough(): - subprocess.run([real], input=raw.encode()) +def call(argv, data=None, timeout=5, silent=False): + # A hung or missing helper must never cost the turn. + kw = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} if silent else {} + kw["input" if data else "stdin"] = data or subprocess.DEVNULL + try: + subprocess.run(argv, timeout=timeout, **kw) + except (OSError, subprocess.SubprocessError): + pass + +cfg = {} +try: + out = subprocess.run(["git", "config", "--get-regexp", r"^tabstatus\."], + capture_output=True, text=True, timeout=5).stdout + for line in out.splitlines(): + k, _, v = line.partition(" ") + cfg[k] = v.strip() +except (OSError, subprocess.SubprocessError): + pass + +quiet_minutes, quiet_color = 15, "#af8700" +v = cfg.get("tabstatus.quiet-minutes") +if v is not None: + if re.fullmatch(r"[0-9]+", v): + quiet_minutes = int(v) + else: + print(f"cc-status: ignoring tabstatus.quiet-minutes={v!r}", file=sys.stderr) +v = cfg.get("tabstatus.color.quiet") +if v is not None: + if re.fullmatch(r"#[0-9a-fA-F]{6}", v): + quiet_color = v + else: + print(f"cc-status: ignoring tabstatus.color.quiet={v!r}", file=sys.stderr) + +def finish(action, event="?", rows=()): + if explain: + print(f"event={event}") + print(f"session={sid or 'none'}") + for r in rows: + print(r) + print(f"action={action}") + print(f"cc-status={real or 'absent'}") + print(f"it2={it2 or 'absent'}") sys.exit(0) try: payload = json.loads(raw) -except ValueError: - passthrough() -if payload.get("hook_event_name") != "Stop": - passthrough() -tasks = payload.get("background_tasks") or [] -if not isinstance(tasks, list) or not tasks: - passthrough() - -payload["background_tasks"] = [] -subprocess.run([real], input=json.dumps(payload).encode()) - -n = len(tasks) -what = ", ".join( - str(t.get("description") or t.get("task_type") or "?") if isinstance(t, dict) else "?" - for t in tasks -) -detail = f"{n} background task{'s' if n != 1 else ''}: {what}" -subprocess.run( - [it2, "set-status", "--session", sid, "--detail", detail[:240], - "--dot-color", "#5f87ff", "--background-tasks", str(n)], - stdout=subprocess.DEVNULL, -) + event, tasks = payload.get("hook_event_name", "?"), payload.get("background_tasks") +except (ValueError, AttributeError): + event, tasks = "?", None +if event not in ("Stop", "StopFailure", "SubagentStop") or not isinstance(tasks, list) or not tasks: + if not explain: + call([real], raw.encode(), 10) + finish("passthrough", event) + +if event == "SubagentStop" and payload.get("agent_id"): + tasks = [t for t in tasks if not (isinstance(t, dict) and t.get("task_id") == payload["agent_id"])] + +now = time.time() +scratch = payload.get("scratchpad_dir") +active, quiet, rows = [], [], [] +for t in tasks: + tid = t.get("task_id") if isinstance(t, dict) else None + state, age = "unknown", None + if tid and isinstance(tid, str) and "/" not in tid and isinstance(scratch, str) and scratch: + p = os.path.join(os.path.dirname(scratch.rstrip("/")), "tasks", tid + ".output") + try: + age = now - os.stat(p).st_mtime + state = "active" if age < quiet_minutes * 60 else "quiet" + except OSError: + if os.path.islink(p): # an agent whose transcript is gone + state = "quiet" + (quiet if state == "quiet" else active).append(t) + kind, d = (t.get("task_type"), t.get("description")) if isinstance(t, dict) else (None, None) + rows.append(f"task {tid} {kind} {state} age={'-' if age is None else int(age)} {d or ''}") + +fed = [{**t, "id": t["task_id"], "status": "running"} if isinstance(t, dict) and "task_id" in t else t + for t in active] +paint_idle = bool(quiet) and not active +if explain: + finish("rewrite+idle" if paint_idle else "rewrite", event, rows) + +payload["background_tasks"] = fed +call([real], json.dumps(payload).encode(), 10) + +if paint_idle: + n = len(quiet) + what = ", ".join(str(t.get("description") or t.get("task_type") or "?") if isinstance(t, dict) else "?" + for t in quiet) + detail = " ".join(f"idle · {n} quiet background task{'s' if n != 1 else ''}: {what}".split()) + if len(detail) > 180: + detail = detail[:180] + "…" + call([it2, "set-status", "--session", sid, "--status", "idle", "--detail", detail, + "--dot-color", quiet_color, "--text-color", "#888888", "--background-tasks", "0"], silent=True) PY ) -printf '%s' "$payload" | python3 -c "$py" "$real" "$it2" "${ITERM_SESSION_ID#*:}" +printf '%s' "$payload" | python3 -c "$py" "$mode" "$real" "$it2" "$sid" exit 0 diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats index 2607dd05381..773ccf54275 100644 --- a/tests/cc-status-shim.bats +++ b/tests/cc-status-shim.bats @@ -5,19 +5,39 @@ # one directory, the way iTerm2 ships them side by side: cc-status records # the payload it was fed, it2 records its argv. No real iTerm2 is touched. +bats_require_minimum_version 1.5.0 + setup() { STUB="$BATS_TEST_TMPDIR/utilities" mkdir -p "$STUB" printf '#!/bin/sh\ncat > "%s/fed"\n' "$BATS_TEST_TMPDIR" > "$STUB/cc-status" - printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/argv"\n' "$BATS_TEST_TMPDIR" > "$STUB/it2" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/argv"\necho x >> "%s/calls"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/it2" chmod +x "$STUB/cc-status" "$STUB/it2" # The real wiring is a symlink to the bundle; it2 is found beside its target. ln -s "$STUB/cc-status" "$BATS_TEST_TMPDIR/cc-status-link" export CC_STATUS_BIN="$BATS_TEST_TMPDIR/cc-status-link" export ITERM_SESSION_ID="w0t1p0:ABC-123" SHIM="$BATS_TEST_DIRNAME/../dot_local/libexec/cc-status-shim/executable_cc-status" + # Config comes from git; keep the machine's own out of it. + export HOME="$BATS_TEST_TMPDIR/home" GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" GIT_CONFIG_NOSYSTEM=1 + mkdir -p "$HOME" + : > "$GIT_CONFIG_GLOBAL" + SESS="$BATS_TEST_TMPDIR/sess" + mkdir -p "$SESS/scratchpad" "$SESS/tasks" +} + +# age_file : a task output file last written that long ago. +age_file() { + : > "$SESS/tasks/$1.output" + python3 -c 'import os,sys,time; t=time.time()-int(sys.argv[2]); os.utime(sys.argv[1],(t,t))' "$SESS/tasks/$1.output" "$2" } +# payload [extra-json-members]: a compact payload, as Claude Code writes it. +payload() { printf '{"hook_event_name":"%s","scratchpad_dir":"%s/scratchpad","background_tasks":%s%s}' "$1" "$SESS" "$2" "${3:+,$3}"; } + +it2_argv() { paste -sd'|' "$BATS_TEST_TMPDIR/argv"; } +fed_tasks() { python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["background_tasks"],sort_keys=True))' "$BATS_TEST_TMPDIR/fed"; } + run_shim() { run sh "$SHIM"; } @test "shim: a non-Stop event passes through byte for byte and never calls it2" { @@ -36,19 +56,151 @@ run_shim() { run sh "$SHIM"; } [ ! -e "$BATS_TEST_TMPDIR/argv" ] } -@test "shim: Stop with background tasks paints idle, then details the tasks" { - local p='{"hook_event_name":"Stop","stop_hook_active":false,"background_tasks":[{"task_id":"a","task_type":"local_bash","description":"tail -F build.log"},{"task_id":"b","task_type":"local_agent","description":"review PR"}]}' - run_shim <<< "$p" +@test "shim: a quiet shell paints idle in amber, stores 0, and feeds cc-status an empty array" { + age_file a 7200 + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"tail -F build.log"}]')" [ "$status" -eq 0 ] - # cc-status saw an empty array, so it paints the row like any finished turn. - python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d["background_tasks"]==[] and d["hook_event_name"]=="Stop"' "$BATS_TEST_TMPDIR/fed" - [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--detail|2 background tasks: tail -F build.log, review PR|--dot-color|#5f87ff|--background-tasks|2" ] + [ "$(fed_tasks)" = "[]" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|idle · 1 quiet background task: tail -F build.log|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] } -@test "shim: one task is singular" { +@test "shim: an active task alongside a quiet one is fed to cc-status with id and status, and it2 is left alone" { + age_file a 7200 + age_file b 30 + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"old"},{"task_id":"b","task_type":"local_agent","description":"busy"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks)" = '[{"description": "busy", "id": "b", "status": "running", "task_id": "b", "task_type": "local_agent"}]' ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "shim: an agent's symlinked output follows the transcript: fresh active, old quiet, dangling quiet" { + : > "$BATS_TEST_TMPDIR/agent.jsonl" + ln -s "$BATS_TEST_TMPDIR/agent.jsonl" "$SESS/tasks/ag.output" + run_shim <<< "$(payload Stop '[{"task_id":"ag","task_type":"local_agent"}]')" + [ "$(fed_tasks | grep -c '"ag"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + + python3 -c 'import os,sys,time; t=time.time()-7200; os.utime(sys.argv[1],(t,t))' "$BATS_TEST_TMPDIR/agent.jsonl" + run_shim <<< "$(payload Stop '[{"task_id":"ag","task_type":"local_agent"}]')" + [ "$(fed_tasks)" = "[]" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + + rm "$BATS_TEST_TMPDIR/agent.jsonl" + rm "$BATS_TEST_TMPDIR/calls" + run_shim <<< "$(payload Stop '[{"task_id":"ag","task_type":"local_agent"}]')" + [ "$(fed_tasks)" = "[]" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] +} + +@test "shim: a task with no output file is unknown and counts as active" { + run_shim <<< "$(payload Stop '[{"task_id":"ghost","task_type":"monitor"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks | grep -c '"ghost"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "shim: a payload with no scratchpad_dir leaves every task active" { run_shim <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a","task_type":"local_bash"}]}' [ "$status" -eq 0 ] - grep -qx '1 background task: local_bash' "$BATS_TEST_TMPDIR/argv" + [ "$(fed_tasks | grep -c '"a"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "shim: StopFailure is handled like Stop" { + age_file a 7200 + run_shim <<< "$(payload StopFailure '[{"task_id":"a","task_type":"local_bash"}]')" + [ "$status" -eq 0 ] + [ "$(fed_tasks)" = "[]" ] + grep -qx 'idle · 1 quiet background task: local_bash' "$BATS_TEST_TMPDIR/argv" +} + +@test "shim: SubagentStop drops the agent that just stopped" { + age_file ag1 5 + age_file b 5 + run_shim <<< "$(payload SubagentStop '[{"task_id":"ag1","task_type":"local_agent"},{"task_id":"b","task_type":"local_bash"}]' '"agent_id":"ag1"')" + [ "$status" -eq 0 ] + [ "$(fed_tasks | grep -c '"b"')" -eq 1 ] + [ "$(fed_tasks | grep -c '"ag1"')" -eq 0 ] +} + +@test "shim: several quiet tasks are plural, and a long detail is cut" { + age_file a 7200 + age_file b 7200 + run_shim <<< "$(payload Stop '[{"task_id":"a","description":"first"},{"task_id":"b","task_type":"local_agent"}]')" + grep -qx 'idle · 2 quiet background tasks: first, local_agent' "$BATS_TEST_TMPDIR/argv" + local long; long=$(printf 'x%.0s' $(seq 1 300)) + run_shim <<< "$(payload Stop "[{\"task_id\":\"a\",\"description\":\"$long\"}]")" + [ "$(grep -A1 '^--detail$' "$BATS_TEST_TMPDIR/argv" | tail -1 | wc -m)" -eq 182 ] # 180 + … + newline +} + +@test "shim: tabstatus config overrides the window and the colour; an invalid colour falls back with a note" { + age_file a 600 + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.quiet-minutes 5 + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.quiet '#112233' + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + grep -qx '#112233' "$BATS_TEST_TMPDIR/argv" + + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.quiet 'blue' + run --separate-stderr sh "$SHIM" <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + grep -qx '#af8700' "$BATS_TEST_TMPDIR/argv" + [[ $stderr == *"tabstatus.color.quiet"* ]] +} + +@test "shim: a hung it2 costs about its timeout, and the shim still exits 0" { + age_file a 7200 + printf '#!/bin/sh\nexec sleep 10\n' > "$STUB/it2" + local t0=$SECONDS + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + [ "$status" -eq 0 ] + [ $((SECONDS - t0)) -lt 9 ] +} + +@test "shim: a flag argument goes to cc-status untouched, with the payload" { + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/ccargv"; cat > "%s/fed"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/cc-status" + run sh "$SHIM" --agent codex <<< '{"hook_event_name":"Stop"}' + [ "$status" -eq 0 ] + [ "$(paste -sd' ' "$BATS_TEST_TMPDIR/ccargv")" = "--agent codex" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = '{"hook_event_name":"Stop"}' ] +} + +@test "explain: prints the decision and calls neither cc-status nor it2" { + age_file a 7200 + age_file b 5 + run sh "$SHIM" explain <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"old"},{"task_id":"b","task_type":"local_agent","description":"busy"},{"task_id":"c","task_type":"monitor","description":"ghost"}]')" + [ "$status" -eq 0 ] + [[ $output == *"event=Stop"* ]] + [[ $output == *"session=ABC-123"* ]] + [[ $output == *"task a local_bash quiet age=7200"*"old"* ]] + [[ $output == *"task b local_agent active age=5"*"busy"* ]] + [[ $output == *"task c monitor unknown age=-"*"ghost"* ]] + [[ $output == *"action=rewrite"* && $output != *"idle"* ]] + [[ $output == *"cc-status=$CC_STATUS_BIN"* ]] + [[ $output == *"it2=$STUB/it2"* ]] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "explain: all quiet says rewrite+idle; a non-Stop event says passthrough" { + age_file a 7200 + run sh "$SHIM" explain <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"}]')" + [[ $output == *"action=rewrite+idle"* ]] + run sh "$SHIM" explain <<< '{"hook_event_name":"PreToolUse"}' + [[ $output == *"event=PreToolUse"* && $output == *"action=passthrough"* ]] +} + +@test "settings: valid JSON naming all eleven events, each with the bare expanded shim path" { + run sh "$SHIM" settings + [ "$status" -eq 0 ] + printf '%s\n' "$output" | sed -n '/^{/,/^}/p' > "$BATS_TEST_TMPDIR/settings.json" + python3 - "$BATS_TEST_TMPDIR/settings.json" "$HOME" <<'PY' +import json, sys +h = json.load(open(sys.argv[1]))["hooks"] +assert len(h) == 11 and "SubagentStart" in h and "SubagentStop" in h, sorted(h) +for ev, entries in h.items(): + for e in entries: + for hook in e["hooks"]: + assert hook["command"] == sys.argv[2] + "/.local/libexec/cc-status-shim/cc-status", (ev, hook) +PY } @test "shim: it2 is taken from beside the real binary, not from PATH" { @@ -56,14 +208,16 @@ run_shim() { run sh "$SHIM"; } mkdir -p "$BATS_TEST_TMPDIR/path" printf '#!/bin/sh\necho wrong > "%s/argv"\n' "$BATS_TEST_TMPDIR" > "$BATS_TEST_TMPDIR/path/it2" chmod +x "$BATS_TEST_TMPDIR/path/it2" - PATH="$BATS_TEST_TMPDIR/path:$PATH" run sh "$SHIM" <<< '{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + age_file a 7200 + PATH="$BATS_TEST_TMPDIR/path:$PATH" run sh "$SHIM" <<< "$(payload Stop '[{"task_id":"a"}]')" [ "$status" -eq 0 ] grep -q '^set-status$' "$BATS_TEST_TMPDIR/argv" } @test "shim: without any it2 the payload passes through and nothing else is tried" { rm "$STUB/it2" - local p='{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + age_file a 7200 + local p; p=$(payload Stop '[{"task_id":"a"}]') PATH="/usr/bin:/bin" run sh "$SHIM" <<< "$p" [ "$status" -eq 0 ] [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] @@ -79,7 +233,8 @@ run_shim() { run sh "$SHIM"; } @test "shim: without an iTerm2 session id it passes through" { unset ITERM_SESSION_ID - local p='{"hook_event_name":"Stop","background_tasks":[{"task_id":"a"}]}' + age_file a 7200 + local p; p=$(payload Stop '[{"task_id":"a"}]') run_shim <<< "$p" [ "$status" -eq 0 ] [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] From d181ed40ae836d957db5ce676d9594fd842b79a7 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 6 Oct 2026 14:35:20 -0500 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=90=9A=20cc-sta?= =?UTF-8?q?tus=20shim=20survives=20an=20unset=20session=20id=20under=20das?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under set -u, dash rejects ${ITERM_SESSION_ID#*:} when the variable is unset ("parameter not set", exit 2), where macOS's bash lets it through. Ubuntu's /bin/sh is dash, so the "no session id" pass-through failed there. Default the variable first, then strip. Co-Authored-By: Claude Fable 5.1 --- dot_local/libexec/cc-status-shim/executable_cc-status | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index b2d9a8a3f9a..f3e8277a977 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -74,7 +74,7 @@ fi target=$(readlink "$real" 2>/dev/null || printf '%s' "$real") it2="$(dirname "$target")/it2" [ -x "$it2" ] || it2=$(command -v it2 2>/dev/null || true) -sid=${ITERM_SESSION_ID#*:} +sid=${ITERM_SESSION_ID:-}; sid=${sid#*:} # two steps: dash under set -u rejects ${unset#pat} [ -x "$real" ] || real= [ -x "$it2" ] || it2= From be557385192ce76f3c72d2079c42e586b37c5080 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 6 Oct 2026 14:46:22 -0500 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85=20test:=20=F0=9F=8C=90=20measure?= =?UTF-8?q?=20the=20cut=20detail=20in=20code=20points,=20so=20any=20locale?= =?UTF-8?q?=20agrees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wc -m counts the three-byte ellipsis as three characters under a C locale, which is what the Rocky CI container runs, and the detail's "·" makes a byte count wrong everywhere. python counts code points the same way on every runner. Co-Authored-By: Claude Fable 5.1 --- tests/cc-status-shim.bats | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats index 773ccf54275..cd95a948551 100644 --- a/tests/cc-status-shim.bats +++ b/tests/cc-status-shim.bats @@ -130,7 +130,11 @@ run_shim() { run sh "$SHIM"; } grep -qx 'idle · 2 quiet background tasks: first, local_agent' "$BATS_TEST_TMPDIR/argv" local long; long=$(printf 'x%.0s' $(seq 1 300)) run_shim <<< "$(payload Stop "[{\"task_id\":\"a\",\"description\":\"$long\"}]")" - [ "$(grep -A1 '^--detail$' "$BATS_TEST_TMPDIR/argv" | tail -1 | wc -m)" -eq 182 ] # 180 + … + newline + # Code points, counted by python: wc -m depends on the locale (a C locale, + # as in the Rocky CI container, counts the ellipsis as three) and the + # detail's "·" makes a byte count wrong everywhere. + grep -A1 '^--detail$' "$BATS_TEST_TMPDIR/argv" | tail -1 \ + | python3 -c 'import sys; s=sys.stdin.buffer.read().decode().rstrip("\n"); assert len(s)==181 and s.endswith("…"), len(s)' } @test "shim: tabstatus config overrides the window and the colour; an invalid colour falls back with a note" { From 6e9d503df001b60ce9e7f4205c0c92821da097d7 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Wed, 7 Oct 2026 00:47:20 -0500 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=93=9D=20docs:=20=F0=9F=92=AC=20unsee?= =?UTF-8?q?n=20repos=20are=20sessions=20to=20message,=20and=20the=20main?= =?UTF-8?q?=20conversation=20proxies=20for=20seats=20that=20cannot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rules for every profile, under "Talking to other Claude Code sessions". When the operator names a repo this session has no access to, the answer "I lack access" is wrong when a Claude Code session is running there: the request is to find that session and put the question to it, and the mechanics of how (ListAgents, postbox) are already written down below. plan-fable and the other seats have no messaging tools at all, so anything the operator wants carried to or from one is the main conversation's to relay, not a limitation to report. And "postbox" in the operator's mouth means session messaging in general, whichever transport the rules pick; while it works, replies say what was sent and what came back, not which tool carried it. Co-Authored-By: Claude Fable 5.1 --- dot_claude-shared/CLAUDE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/dot_claude-shared/CLAUDE.md b/dot_claude-shared/CLAUDE.md index 939325a6fa0..42135c8e589 100644 --- a/dot_claude-shared/CLAUDE.md +++ b/dot_claude-shared/CLAUDE.md @@ -181,6 +181,26 @@ employer-specific — profile-specific memory belongs in that profile's own ## Talking to other Claude Code sessions +- **A repo you cannot see is a session you can talk to.** When I name a + repo, codebase, or directory this session has no access to, I already + know that. I am asking you to find the Claude Code session running + there (`ListAgents`, then `postbox list_agents` / `postbox name `) + and put the question or task to it — not to tell me you lack access. + Say you lack access only when no session is reachable there either. +- **You are the proxy for seats that cannot message.** `plan-fable` and + the other subagents have no `SendMessage` or postbox tools. When I say + something must reach plan-fable, or plan-fable needs an answer from + another session, you carry it: put the inbound message in the brief + (or `SendMessage` to the running agent), and relay its questions and + answers to the other session yourself. Do not report that the agent + cannot reach them. +- When I say "postbox" I mean session-to-session messaging in general, + whichever transport applies. Pick the route by the rules below (native + `SendMessage` first, mail only where it cannot reach) — do not switch to + mail because I used the word, and do not correct the term. While it + works, keep the transport out of your replies: tell me what was sent + and what came back, not which tool carried it. Surface the mechanics + only when a message could not be delivered. - To reach ONE other session, look first with `ListAgents` and use `SendMessage` if it is listed — that is the native path and always the first choice. Sessions in this same profile are listed there.