diff --git a/CLAUDE.md b/CLAUDE.md index 349fcf71dd..7540ca4cc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1515,48 +1515,125 @@ 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` +kept a tab "working" for 41 hours (a foreground command that outlives its +timeout moves to the background still holding its stdin pipe, and a local +session has no time limit on background commands); a subagent that never +reported back did the same with no process behind it. No hook can kill +such a task — the payload carries no pid — so the shim only stops the row +believing it. + +The 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; `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 -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'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 -moves them. Tests: `tests/cc-status-shim.bats` (stub cc-status and it2, -side by side like the bundle ships them). +profile's hook command. On Stop, StopFailure and SubagentStop it tells +**quiet** tasks from **active** ones by the task's output file, +`tasks/.output` beside the payload's `scratchpad_dir` (a symlink +to the transcript for an agent): written within `tabstatus.quiet-minutes` +(git config, default 15 — above the ten-minute foreground ceiling) is +active, older is quiet, and no file to judge by counts as active, because +wrongly active only delays the idle and wrongly idle lies. Hiding every +task would be the opposite lie — a Stop over a running background agent is +what iTerm2 3.7.2 made cc-status cover — so cc-status is fed only the +active ones. 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, and a quiet row must not look like a permission +prompt) 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. `it2 set-status` keeps every field it is not given — a reset +sending only the status word leaves the old dot colour — so every paint +sends the full field set, and `tabstatus idle|working|waiting` sends +cc-status's colour pair (plus a zero count for idle) unless given a colour, +which makes it the manual reset for a stuck row. The shim also adds the +`id` and `status` keys cc-status reads and 2.1.289 no longer sends, and +drops the stopping agent from its own SubagentStop. + +A subagent **seat** gets its own dot and text colour while it runs +(`plan-fable #d787ff`, `unstick-fable #ff5faf`, `review-fable #af87ff`, +`work-sonnet #00afaf`, `scout-haiku #87af5f`; `git config +tabstatus.color. '#rrggbb'` overrides one or adds a seat). The +status stays one of the three words: the Session Status tool sorts by them, +and an unknown word's place is unspecified. Hooks fired inside a subagent +carry `agent_type`; the shim paints an in-seat PreToolUse/PostToolUse itself +(cc-status would paint plain orange) and passes PermissionRequest and +Notification through, since waiting outranks a colour. Running seats are +tracked in `${XDG_STATE_HOME:-~/.local/state}/tabstatus/.seats` +— written on SubagentStart, pruned on SubagentStop, deleted on +SessionStart/SessionEnd, ignored past a day — so a Stop over a still-running +background seat repaints its colour, not orange. Every detail the shim +writes starts with the repo (the payload cwd's basename), because the row +name is the tab title, often a task title. + +A turn that **ends by asking the operator a question** paints `waiting` in +yellow (`tabstatus.color.question`, default `#ffd75f`) with the question as +the detail: on Stop or StopFailure with nothing active, a +`last_assistant_message` whose last non-empty line (bullets and quotes +stripped) ends in `?` or `?`. cc-status alone cannot show it — it reserves +"waiting" for PermissionRequest and AskUserQuestion, paints a plain Stop +idle, and its detail is only the message's opening ~180 characters, where a +closing question is cut off. The paint follows cc-status's and outranks the +quiet-task one; a Stop with something active stays working, and +SubagentStop never fires it. + +A quiet **shell** task is **stalled** when nothing holds its output file open +any more, or when it was first seen quiet `tabstatus.stall-minutes` (git +config, default 15) ago and its holders' whole process tree has used under a +second of CPU since, the file untouched — a wedged download, not a silent +compile. It is measured without root: `lsof -t` names the holders, one `ps +-axo pid,ppid,time` (visible for every user) sums the tree's CPU, and +`.tasks` beside the seats file keeps each task's last mtime, CPU +and first-seen-quiet time between Stops. With nothing active a stalled task +paints `waiting` in red (`tabstatus.color.stalled`, default `#ff5f5f`), +outranking the question and quiet paints; agent tasks are never probed, and a +missing `lsof` means unknown, never stalled. + +Wiring is the load-bearing part. The shim is **named** `cc-status`, in a +directory off `PATH`, and its hook command is the bare absolute path with +the home directory expanded: iTerm2 3.7.3's health check accepts a hook +only if the command ends in `/cc-status` and `isExecutableFile` passes on +the literal string (verified in the app's onboarding source at that tag), so +`~`, `$HOME`, a wrapper line or another name makes the app report its +integration broken, and a Reinstall then appends a raw cc-status beside it, +which repaints "working". Master's hook lexer (`CCStatusHookCommand.swift`, +2026-10-03) will relax this, but 3.7.3 and 3.7.4 do not. The shim sits in +cc-status's own slot, never beside it — hooks for one event run in +parallel, so a second hook would race the thing it fixes — on eleven +events: the ten iTerm2 wires plus `SubagentStart`, which cc-status ignores +and the seat file relies on. Reinstall rewrites the entry back to the raw +binary and Uninstall strips it; re-applying the overlays restores it. The +overlays write the line (`cc-status settings` prints the block; +`docs/private-overlays.md`). 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` from beside it; `PATH` only carries `it2` while an +experimental iTerm2 setting is on. The shim deploys on every machine class +and exits 0 at once without the binary, which beats a hook entry that +errors on every event. + +Pass-through events cost one `sh`, a `cat`, two `case` matches and a pipe +into cc-status; a Stop with background tasks, or whose final message contains a `?` (most +Stops), adds one `python3` (~30 ms), a +`stat` per task, one `git config --get-regexp`, and an extra `it2` call only +when nothing is active; an in-seat tool call makes one `it2` call instead of +cc-status's. It fails open to stock behaviour everywhere (no binary, `it2`, +python3 or session id, bad JSON, `it2` past five seconds) and never exits +non-zero: the row is cosmetic and must never cost a turn. It cannot see task +types that keep no output file (`monitor`, `in_process_teammate`, +`local_workflow`, `remote_agent` are unverified; a missing file reads as +active), nor a dead process behind a recently written file. The upstream +reports — the moved command keeping its stdin pipe, the hooks reference's +stale `background_tasks[].status`, cc-status reading `id`/`status` where +2.1.289 sends `task_id` — are drafts, not yet filed. `cc-status explain` +(payload on stdin) prints each task's verdict, the seat and the action, +calling nothing; `unknown` means the output file is not where it looks, +`passthrough` that the gate missed the payload. The facts the shim rests on +are `utilstr`/`binstr` lines in the skill's `manifest.txt`, so `verify.sh` +says when a point release moves them. `claude-doctor`'s `tabstatus:` line +counts the eleven events in a profile's `settings.json` that run the shim, +still run iTerm2's raw cc-status, or run neither, plus the seats live; +`iterm2-doctor`'s `cc-status:` line reads the app's symlink and where `it2` +is, never executing either. Tests: `tests/cc-status-shim.bats` (stub +cc-status and it2, side by side like the bundle ships them). ### Machine-local secrets (~/.extra) diff --git a/docs/private-overlays.md b/docs/private-overlays.md index 27388e98bd..ca2caf5f98 100644 --- a/docs/private-overlays.md +++ b/docs/private-overlays.md @@ -491,6 +491,48 @@ profile's settings source as an additional `PreToolUse` matcher, keeping its `exit 2` branch intact — that branch is what makes a missing `~/bin/denyguard` block Bash instead of silently allowing it. +### Session Status wiring — every profile's `settings.json` + +iTerm2's Session Status rows come from its `cc-status` binary, which its +onboarding wires into a profile's hook events; the public +`dot_local/libexec/cc-status-shim/` shim takes that slot so a stale +background task stops pinning a tab at "working" (see "Session Status rows +come from Claude Code hooks" in CLAUDE.md). The shim is identity-free; only +the *file* that names it is private, because each profile's `settings.json` +is overlay-owned. Do not paste `cc-status settings`'s output by hand — it +bakes in whatever `$HOME` was when it ran. Write the command as a template +rule in the settings source, with the home directory from chezmoi: + +``` +{{ .chezmoi.homeDir }}/.local/libexec/cc-status-shim/cc-status +``` + +iTerm2 3.7.3's health check tests the literal string with `isExecutableFile` +and wants it to end in `/cc-status`, so `~`, `$HOME`, a `[ -x … ] || true` +guard or any other name makes the app report its integration broken (and a +Reinstall append a raw cc-status beside yours). The rule that works is +stat-gated: render the shim's bare path when that file exists, iTerm2's own +`~/.config/iterm2/cc-status` path (expanded the same way) when only the +integration does, and the guarded `[ -x … ] && exec … || true` form +otherwise, so a box with neither never errors per event. Apply it to the ten +events iTerm2 wires — `SessionStart`, `SessionEnd`, `UserPromptSubmit`, +`PreToolUse`, `PostToolUse`, `PermissionRequest`, `Notification`, `Stop`, +`StopFailure`, `SubagentStop` — **and add `SubagentStart`**, which iTerm2 +does not wire and the shim's seat state file relies on; without it seats are +never recorded and a Stop over a background seat paints amber or orange +rather than its colour. `cc-status settings` still prints the event list and +the reasoning, and is the reference for what belongs there. iTerm2 > +Reinstall rewrites the entries back to the raw binary and Uninstall strips +them; `dotfiles` restores them, so re-apply after either. + +`claude-doctor`'s `tabstatus:` line, run inside a repo of each profile, is +the check: `shim on 11/11 events, raw cc-status on 0, none on 0` with the +shim path printed (not `MISSING`). `raw cc-status on 1` is an event the rule +missed; iTerm2's "Claude Code Integration Looks Broken" alert means a +command that is not the bare expanded path, or a shim file that is not +executable. Note that 3.7.3 reads only `~/.claude/settings.json` for its +health check, so a profile elsewhere is judged by the doctor, not the alert. + ### Secrets — `~/.extra` from `private_dot_extra.tmpl` (personal overlay) Never a value, only a 1Password reference; chezmoi resolves it at apply time diff --git a/dot_functions b/dot_functions index 7af2362d75..b11c4facf4 100644 --- a/dot_functions +++ b/dot_functions @@ -666,6 +666,36 @@ function claude-doctor() { deny_log="${XDG_STATE_HOME:-$HOME/.local/state}/denyguard/denied.log"; deny_hits="$(command wc -l < "$deny_log" 2>/dev/null | tr -d ' ')"; deny_hits="${deny_hits:-0}"; print -r -- "deny: $deny_n in $settings; Bash guard: $deny_wired; ~/bin/denyguard: $deny_bin; denies logged: $deny_hits"; + # Session Status wiring: how many of the eleven events run the cc-status + # shim (the one this repo deploys), how many still run iTerm2's raw + # cc-status (a Reinstall puts it back, and it can sit beside the shim), and + # how many run neither. Read from the settings file's own text, as the deny + # line is. The state files are keyed by session id, not by profile, so the + # seat count is the whole machine's; one untouched for a day is ignored, + # as the shim itself ignores it. + local ts_shim="$HOME/.local/libexec/cc-status-shim/cc-status" ts_state ts_seats ts_counts; + [[ -x $ts_shim ]] || ts_shim='MISSING — chezmoi apply'; + ts_state="${XDG_STATE_HOME:-$HOME/.local/state}/tabstatus"; + ts_seats="$(command find "$ts_state" -name '*.seats' -mtime -1 -exec cat {} + 2>/dev/null | command wc -l | tr -d ' ')"; + if [[ ! -e $settings ]]; then + ts_counts='no settings.json'; + elif (( ! $+commands[python3] )); then + ts_counts='? (no python3)'; + elif ts_counts="$(command python3 -c 'import json, sys +ev = "SessionStart SessionEnd UserPromptSubmit PreToolUse PostToolUse PermissionRequest Notification Stop StopFailure SubagentStart SubagentStop".split() +h = json.load(open(sys.argv[1])).get("hooks") or {} +n = [0, 0, 0] +for e in ev: + c = [x.get("command") or "" for g in h.get(e) or [] for x in g.get("hooks") or []] + s = any(x.strip().endswith("/cc-status-shim/cc-status") for x in c) + r = any(not x.strip().endswith("/cc-status-shim/cc-status") and (x.strip().endswith("/cc-status") or "cc-status ]" in x) for x in c) + n[0] += s; n[1] += r; n[2] += not (s or r) +print("shim on %d/%d events, raw cc-status on %d, none on %d" % (n[0], len(ev), n[1], n[2]))' "$settings" 2>/dev/null)"; then + :; + else + ts_counts='? (unreadable settings.json)'; + fi; + print -r -- "tabstatus: $ts_counts in $settings; shim: $ts_shim; seats live: ${ts_seats:-0}"; # Where this profile's links open: the pin, whether the link router can # decode it (browser.tag.), whether open-as is there to tag, and # whether a window can appear at all. Same helpers, same order as the @@ -2237,11 +2267,25 @@ function git-doctor() { # OSC 21337 escape, because the escape needs the tab's tty and neither Claude # Code's Bash tool nor its hooks have one. iTerm2's own cc-status hook rewrites # the row on the next Claude Code event, so inside a session this lasts a turn. +# `idle`, `working` and `waiting` also send cc-status's colour pair (it2 leaves +# a field it is not given unchanged, so a bare `idle` over an orange row stayed +# orange) unless a colour flag is given, and `idle` stores a background-task +# count of 0, since cc-status revives an idle row whose stored count is not 0 +# a minute after Stop. So `tabstatus idle` is the manual reset for a stuck row. function tabstatus() { [[ -n $ITERM_SESSION_ID ]] || { print -ru2 -- "tabstatus: not inside an iTerm2 session"; return 1; } local st=${1-} detail=${2-}; (( $# )) && shift; (( $# )) && shift; - command it2 set-status --session "${ITERM_SESSION_ID#*:}" --status "$st" --detail "$detail" "$@" >/dev/null; + local -a add; + if (( ! ${@[(Ie)--dot-color]} && ! ${@[(Ie)--text-color]} )); then + case $st in + idle) add=(--dot-color '#00d75f' --text-color '#888888');; + working) add=(--dot-color '#ff9500' --text-color '#ff9500');; + waiting) add=(--dot-color '#5f87ff' --text-color '#5f87ff');; + esac; + fi; + [[ $st == idle ]] && (( ! ${@[(Ie)--background-tasks]} )) && add+=(--background-tasks 0); + command it2 set-status --session "${ITERM_SESSION_ID#*:}" --status "$st" --detail "$detail" "$@" "${add[@]}" >/dev/null; } # Where iTerm2 is installed, or nothing. $ITERM2_APP names a non-standard @@ -2351,6 +2395,19 @@ function iterm2-doctor() { else print -r -- "channel: no iterm2 cask in the Caskroom — installed by hand or by another manager"; fi; + # The cc-status binary iTerm2's Claude Code onboarding symlinks, and the + # it2 the shim paints with — read by readlink and -x, never executed. + local ccs="$HOME/.config/iterm2/cc-status" cct ccstate ccit2; + cct="$(readlink "$ccs" 2>/dev/null)"; + if [[ -x $ccs ]]; then ccstate=executable; + elif [[ -L $ccs ]]; then ccstate=DANGLING; + elif [[ -e $ccs ]]; then ccstate='not executable'; + else ccstate=absent; fi; + [[ -n $cct && $cct != /* ]] && cct="${ccs:h}/$cct"; + if [[ -n $cct && -x ${cct:h}/it2 ]]; then ccit2='beside it'; + elif (( $+commands[it2] )); then ccit2=PATH; + else ccit2=absent; fi; + print -r -- "cc-status: ~/.config/iterm2/cc-status${cct:+ -> $cct} ($ccstate); it2: $ccit2"; # The generative-AI and browser features ship as separate bundles since # 3.5; their absence is why an AI menu item can be missing on an app that # otherwise looks current. They install alongside iTerm.app, so look diff --git a/dot_local/libexec/cc-status-shim/executable_cc-status b/dot_local/libexec/cc-status-shim/executable_cc-status index f3e8277a97..f6a228ad93 100755 --- a/dot_local/libexec/cc-status-shim/executable_cc-status +++ b/dot_local/libexec/cc-status-shim/executable_cc-status @@ -6,8 +6,9 @@ # 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. +# "working" for days. This shim rewrites that one case, and paints subagent +# seats in their own colours. Every other event is handed to cc-status +# untouched. # # On Stop, StopFailure and SubagentStop with background tasks, each task is # classified by its output file, /tasks/.output (the @@ -26,6 +27,53 @@ # 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. # +# Subagent seats (plan-fable, unstick-fable, review-fable, work-sonnet, +# scout-haiku, or any agent_type given a tabstatus.color.) get a +# colour of their own while they run, so a tab says which seat is working. The +# status stays one of cc-status's three words — the Session Status tool sorts +# by them — and only the dot and text colours change. Hooks fired inside a +# seat carry agent_type: a PreToolUse/PostToolUse there is painted by the shim +# instead of cc-status (which would paint plain orange working); +# PermissionRequest and Notification pass through, since waiting outranks a +# colour. SubagentStart and SubagentStop keep one line per running seat in +# ${XDG_STATE_HOME:-~/.local/state}/tabstatus/.seats, so a Stop +# over a still-running background seat paints that seat's colour rather than +# amber or orange; SessionStart and SessionEnd delete the file and one older +# than a day is ignored. Every detail the shim writes starts with the repo +# (basename of the payload's cwd): the row name is the tab title, often a task +# title, not the repo. +# +# A turn that ends by asking the operator something is not idle: on Stop and +# StopFailure, with nothing active, a last_assistant_message whose last +# non-empty line (leading bullets and quotes stripped) ends in ? or U+FF1F is +# painted "waiting" in yellow (tabstatus.color.question, default #ffd75f) with +# that line as the detail. cc-status paints Stop-with-no-tasks idle and shows +# only the message's opening, where the question rarely is. The question paint +# follows cc-status's and outranks the quiet-task one; with something active +# the row stays working, and SubagentStop (a subagent's message) never fires it. +# +# A quiet shell task may be quiet because a long compile prints nothing, or +# because its process is wedged. For each quiet task whose output file is a +# regular file (a shell, not an agent's symlink) the shim asks `lsof -t` who +# holds the file open and sums the cumulative CPU time of those processes and +# all their descendants from one `ps -axo pid,ppid,time` (visible for every +# user, so a root child counts; no root needed). Per task it keeps +# "task_idlast_mtimecpu_secondsfirst_seen_quiet_ts" in +# ${XDG_STATE_HOME:-~/.local/state}/tabstatus/.tasks (pruned to the +# payload's tasks, deleted on SessionStart/SessionEnd). A quiet shell task is +# stalled when nothing holds its file (the process is gone, the task still +# registered) or when it was first seen quiet tabstatus.stall-minutes (default +# 15) ago and its CPU has moved under a second since, with the file untouched. +# With nothing active a stalled task paints "waiting" in red +# (tabstatus.color.stalled, default #ff5f5f), outranking the question and quiet +# paints; CPU that moved shows as "(cpu busy)" in the quiet paint. No lsof, or +# a failed probe, means unknown, never stalled. +# +# Cost: python3 now runs on every Stop whose message contains a ? as well as +# on those with background tasks, so on most Stops; the other events still +# cost only the sh gate. lsof and one ps run only on a Stop that has at least +# one quiet shell task. +# # 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 @@ -66,7 +114,8 @@ 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. +not one of the ten events iTerm2 wires; cc-status ignores it, and the shim +uses it to track which seats are running. TXT exit 0 fi @@ -89,13 +138,32 @@ fi payload=$(cat) pass() { printf '%s' "$payload" | "$real"; exit 0; } -# Cheap gate before paying for python3: Claude Code emits compact JSON. +# Cheap gate before paying for python3: Claude Code emits compact JSON. Only +# turn-ending events with background tasks (or, on Stop and StopFailure, a +# final message containing a ?), session start/end (the seat state file) and +# hooks fired inside a seat need the decision; the rest pass through. +# A seat is an agent_type with a default colour (keep in step with SEATS below) +# or a tabstatus.color. setting. if [ "$mode" != explain ]; then + go= case $payload in *'"hook_event_name":"Stop"'*|*'"hook_event_name":"StopFailure"'*|*'"hook_event_name":"SubagentStop"'*) - case $payload in *'"background_tasks":[{'*) ;; *) pass ;; esac ;; - *) pass ;; + case $payload in + *'"background_tasks":[{'*) go=1 ;; + *'"hook_event_name":"SubagentStop"'*) ;; + *'"last_assistant_message":"'*'?'*|*'"last_assistant_message":"'*'?'*) go=1 ;; + esac ;; + *'"hook_event_name":"SessionStart"'*|*'"hook_event_name":"SessionEnd"'*) go=1 ;; + esac + case $payload in + *'"agent_type":"'*) + at=${payload#*'"agent_type":"'}; at=${at%%'"'*} + case $at in + plan-fable|unstick-fable|review-fable|work-sonnet|scout-haiku) go=1 ;; + *) git config --get "tabstatus.color.$at" >/dev/null 2>&1 && go=1 ;; + esac ;; esac + [ -n "$go" ] || pass 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 @@ -104,7 +172,7 @@ 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, os, re, subprocess, sys, time +import functools, json, os, re, subprocess, sys, time mode, real, it2, sid = sys.argv[1:5] explain = mode == "explain" @@ -129,80 +197,287 @@ try: 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) +def setting(key, default, pattern): + v = cfg.get("tabstatus." + key) + if v is None: + return default + if re.fullmatch(pattern, v): + return v + print(f"cc-status: ignoring tabstatus.{key}={v!r}", file=sys.stderr) + return default + +HEX = r"#[0-9a-fA-F]{6}" +quiet_minutes = int(setting("quiet-minutes", "15", r"[0-9]+")) +quiet_color = setting("color.quiet", "#af8700", HEX) +question_color = setting("color.question", "#ffd75f", HEX) +stall_minutes = int(setting("stall-minutes", "15", r"[0-9]+")) +stalled_color = setting("color.stalled", "#ff5f5f", HEX) +SEATS = {"plan-fable": "#d787ff", "unstick-fable": "#ff5faf", "review-fable": "#af87ff", + "work-sonnet": "#00afaf", "scout-haiku": "#87af5f"} + +@functools.lru_cache(None) # a bad setting is noted once, not per lookup +def seat_color(agent_type): + # git lower-cases the last key component; "quiet", "question" and "stalled" are paint colours, not seats + if not isinstance(agent_type, str) or agent_type.lower() in ("quiet", "question", "stalled"): + return None + return setting("color." + agent_type.lower(), SEATS.get(agent_type), HEX) try: payload = json.loads(raw) - event, tasks = payload.get("hook_event_name", "?"), payload.get("background_tasks") + event = payload.get("hook_event_name", "?") 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) + payload, event = {}, "?" +tasks, aid, agent_type = payload.get("background_tasks"), payload.get("agent_id"), payload.get("agent_type") +seat_col = seat_color(agent_type) +seat = agent_type if seat_col else None +repo = os.path.basename(str(payload.get("cwd") or "").rstrip("/")) + +# Seat state: one "\t" line per running seat of a session. +ID = re.compile(r"[A-Za-z0-9_-]+") +pid = payload.get("session_id") +state_dir = os.path.join(os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "tabstatus") +sf = os.path.join(state_dir, pid + ".seats") if isinstance(pid, str) and ID.fullmatch(pid) else None +tf = sf[:-len(".seats")] + ".tasks" if sf else None + +def seats_read(): + try: + if time.time() - os.stat(sf).st_mtime > 86400: + if not explain: + os.remove(sf) + return [] + with open(sf) as f: + return [l.rstrip("\n").split("\t", 1) for l in f if "\t" in l] + except (OSError, TypeError): + return [] + +def seats_write(rows): + try: + if rows: + os.makedirs(state_dir, exist_ok=True) + with open(sf, "w") as f: + f.writelines(f"{a}\t{t}\n" for a, t in rows) + else: + os.remove(sf) + except (OSError, TypeError): + pass + +before = seats_read() +live = list(before) +if event in ("SessionStart", "SessionEnd"): + live = [] +elif event == "SubagentStart" and seat and isinstance(aid, str) and ID.fullmatch(aid) \ + and aid not in [a for a, _ in live]: + live.append([aid, agent_type]) +elif event == "SubagentStop" and aid: + live = [r for r in live if r[0] != aid] + +def paint(status, detail, dot, text, count=None): + # The full field set every time, whatever cc-status did or failed to do. + d = " ".join((f"{repo} · {detail}" if repo else detail).split()) + if len(d) > 180: + d = d[:180] + "…" + argv = [it2, "set-status", "--session", sid, "--status", status, "--detail", d, + "--dot-color", dot, "--text-color", text] + if count is not None: + argv += ["--background-tasks", str(count)] + call(argv, silent=True) + +def run_out(argv, timeout=5): + # stdout of a read-only probe; None when it is missing, hung or failed to start. + try: + return subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, + text=True, errors="replace", timeout=timeout).stdout + except (OSError, subprocess.SubprocessError): + return None + +def cpu_seconds(s): + # ps time: [D-][HH:]MM:SS[.ss] + days, _, rest = s.rpartition("-") + total = 0.0 + for part in rest.split(":"): + total = total * 60 + float(part) + return total + (int(days) * 86400 if days else 0) + +_ps = [] +def ps_tree(pids): + # Summed CPU seconds of pids and all their descendants, or None if ps failed. + if not _ps: + _ps.append(run_out(["ps", "-axo", "pid,ppid,time"])) + if _ps[0] is None: + return None + kids, own = {}, {} + for line in _ps[0].splitlines(): + f = line.split() + try: + own[f[0]] = cpu_seconds(f[2]) + kids.setdefault(f[1], []).append(f[0]) + except (IndexError, ValueError): + pass # the header, or a line we cannot read + seen, todo = set(), list(pids) + while todo: + p = todo.pop() + if p not in seen: + seen.add(p) + todo += kids.get(p, []) + return sum(own.get(p, 0.0) for p in seen) -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"])] +def holders_of(path): + # pids holding path open: [] none, None unknown (no lsof, hung) + out = run_out(["lsof", "-t", "--", path]) + return None if out is None else sorted({l for l in out.split() if l.isdigit()}) + +def tasks_read(): + recs = {} + try: + with open(tf) as f: + for l in f: + k = l.rstrip("\n").split("\t") + try: + recs[k[0]] = (float(k[1]), float(k[2]), float(k[3])) + except (IndexError, ValueError): + pass + except (OSError, TypeError): + pass + return recs + +def tasks_write(recs): + try: + if recs: + os.makedirs(state_dir, exist_ok=True) + with open(tf, "w") as f: + f.writelines(f"{k}\t{m!r}\t{c:.2f}\t{t:.0f}\n" for k, (m, c, t) in recs.items()) + else: + os.remove(tf) + except (OSError, TypeError): + pass + +def label(t): + return str(t.get("description") or t.get("task_type") or "?") if isinstance(t, dict) else "?" 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 +active, quiet, rows, fed = [], [], [], None +stalled, busy_cpu, keep, quiet_ages = [], set(), {}, [] +if event in ("Stop", "StopFailure", "SubagentStop") and isinstance(tasks, list) and tasks: + if event == "SubagentStop" and aid: + tasks = [t for t in tasks if not (isinstance(t, dict) and t.get("task_id") == aid)] + prior = tasks_read() + for t in tasks: + tid = t.get("task_id") if isinstance(t, dict) else None + state, age, probe = "unknown", None, "" + if isinstance(tid, str) and tid in prior: + keep[tid] = prior[tid] + 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: + mtime = os.stat(p).st_mtime + age = now - mtime + state = "active" if age < quiet_minutes * 60 else "quiet" + if state == "quiet": + quiet_ages.append(age) + if state == "quiet" and not os.path.islink(p): + # A quiet shell: who holds the file, and is that process getting CPU? + holders = holders_of(p) + cpu = ps_tree(holders) if holders else None + rec = prior.get(tid) + if rec and abs(rec[0] - mtime) >= 1: + rec = None # written since: not the same silence + stalled_min = None + if holders == []: + stalled_min = int(age // 60) + elif cpu is not None: + if rec and cpu - rec[1] >= 1: + busy_cpu.add(tid) + keep[tid] = (mtime, cpu, now) + elif rec: + if now - rec[2] >= stall_minutes * 60: + stalled_min = int((now - rec[2]) // 60) + else: + keep[tid] = (mtime, cpu, now) + if stalled_min is not None: + stalled.append((t, stalled_min)) + probe = (f" holder={','.join(holders) if holders else 'none' if holders == [] else 'unknown'}" + f" cpu={'-' if cpu is None else f'{cpu:.2f}'} stalled={'no' if stalled_min is None else 'yes'}") + 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)}{probe} {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] + if not explain: + tasks_write(keep) + +# A turn ending in a question: the last non-empty line, bullets and quotes off. +question = None +msg = payload.get("last_assistant_message") +if event in ("Stop", "StopFailure") and not active and isinstance(msg, str): + lines = [l for l in msg.splitlines() if l.strip()] + q = re.sub(r"^[\s>*+-]+", "", lines[-1]).rstrip() if lines else "" + if q.endswith(("?", "?")): + question = q + +# A running seat among the active tasks: the row stays that seat's colour, not the parent's. +ids = {t.get("task_id") for t in active if isinstance(t, dict)} +busy = [t for a, t in live if a in ids and seat_color(t)] +in_seat_tool = bool(seat) and event in ("PreToolUse", "PostToolUse") +paint_stalled = bool(stalled) and not active +paint_idle = bool(quiet) and not active and not question and not paint_stalled + if explain: - finish("rewrite+idle" if paint_idle else "rewrite", event, rows) + action = ("seat-paint" if in_seat_tool else "passthrough" if fed is None else + "rewrite+idle" if paint_idle else "rewrite+stalled" if paint_stalled else "rewrite") + if busy or (event == "SubagentStart" and seat): + action += "+seat" + if question and not paint_stalled: + action += "+question" + print(f"event={event}") + print(f"session={sid or 'none'}") + for r in rows: + print(r) + print(f"seat={seat or 'none'} colour={seat_col or 'none'} state={len(live)}") + print(f"question={'yes ' + question if question else 'no'}") + print(f"action={action}") + print(f"cc-status={real or 'absent'}") + print(f"it2={it2 or 'absent'}") + sys.exit(0) -payload["background_tasks"] = fed -call([real], json.dumps(payload).encode(), 10) +if live != before or event in ("SessionStart", "SessionEnd"): + seats_write(live) + if event in ("SessionStart", "SessionEnd"): + tasks_write({}) +if in_seat_tool: + tool = payload.get("tool_name") + paint("working", f"{seat}: {tool}" if tool else seat, seat_col, seat_col) + sys.exit(0) -if paint_idle: +if fed is None: + call([real], raw.encode(), 10) +else: + payload["background_tasks"] = fed + call([real], json.dumps(payload).encode(), 10) +if event == "SubagentStart" and seat: + paint("working", f"{seat} running", seat_col, seat_col) +if paint_stalled: + m = max(m for _, m in stalled) + paint("waiting", f"stalled {m} min: {', '.join(label(t) for t, _ in stalled)}", + stalled_color, stalled_color, 0) +elif question: + paint("waiting", question, question_color, question_color, 0) +elif paint_idle: n = len(quiet) - what = ", ".join(str(t.get("description") or t.get("task_type") or "?") if isinstance(t, dict) else "?" + what = ", ".join(label(t) + (" (cpu busy)" if isinstance(t, dict) and t.get("task_id") in busy_cpu 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) + # the shortest silence among the quiet tasks; a task with no file has no age + since = f", quiet {int(min(quiet_ages) // 60)} min" if quiet_ages else "" + paint("idle", f"idle · {n} quiet background task{'s' if n != 1 else ''}{since}: {what}", + quiet_color, "#888888", 0) +elif busy: + k = len(active) + c = seat_color(busy[0]) + paint("working", f"{', '.join(dict.fromkeys(busy))} running · {k} background task{'s' if k != 1 else ''} running", + c, c, k) PY ) printf '%s' "$payload" | python3 -c "$py" "$mode" "$real" "$it2" "$sid" diff --git a/tests/cc-status-shim.bats b/tests/cc-status-shim.bats index cd95a94855..b4d7426c59 100644 --- a/tests/cc-status-shim.bats +++ b/tests/cc-status-shim.bats @@ -4,6 +4,8 @@ # 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. +# lsof and ps are stubbed there too (and that directory put on PATH): they print +# lsof.out and ps.out, fixtures a test overwrites, and record their argv. bats_require_minimum_version 1.5.0 @@ -12,7 +14,13 @@ setup() { mkdir -p "$STUB" printf '#!/bin/sh\ncat > "%s/fed"\n' "$BATS_TEST_TMPDIR" > "$STUB/cc-status" 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" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/lsof_argv"\ncat "%s/lsof.out"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/lsof" + printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "%s/ps_argv"\ncat "%s/ps.out"\n' "$BATS_TEST_TMPDIR" "$BATS_TEST_TMPDIR" > "$STUB/ps" + chmod +x "$STUB/cc-status" "$STUB/it2" "$STUB/lsof" "$STUB/ps" + export PATH="$STUB:$PATH" + # By default one shell (4242) holds every output file, with a child: 3.5 s of CPU in all. + echo 4242 > "$BATS_TEST_TMPDIR/lsof.out" + set_cpu 1.00 2.50 # 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" @@ -20,6 +28,9 @@ setup() { 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 + export XDG_STATE_HOME="$BATS_TEST_TMPDIR/state" + SEATS_FILE="$XDG_STATE_HOME/tabstatus/s1.seats" + TASKS_FILE="$XDG_STATE_HOME/tabstatus/s1.tasks" mkdir -p "$HOME" : > "$GIT_CONFIG_GLOBAL" SESS="$BATS_TEST_TMPDIR/sess" @@ -32,9 +43,27 @@ age_file() { 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" } +# set_cpu : what the ps stub reports for pid 4242 and its child 4243. +set_cpu() { + printf ' PID PPID TIME\n 1 0 9:99.00\n 4242 1 0:%05.2f\n 4243 4242 0:%05.2f\n 4300 1 1:00.00\n' "$1" "$2" > "$BATS_TEST_TMPDIR/ps.out" +} + +# task_state : a .tasks line for task a quiet since the output file's own mtime, first seen that long ago. +task_state() { + local mt; mt=$(python3 -c 'import os,sys; print(repr(os.stat(sys.argv[1]).st_mtime))' "$SESS/tasks/$1.output") + mkdir -p "$(dirname "$TASKS_FILE")" + printf '%s\t%s\t%s\t%s\n' "$1" "$mt" "$3" "$(python3 -c 'import sys,time; print(int(time.time()-int(sys.argv[1])))' "$2")" > "$TASKS_FILE" +} + +# stall_payload [extra-json-members]: a Stop with one quiet shell task and a session id. +stall_payload() { payload Stop '[{"task_id":"a","task_type":"local_bash","description":"install.sh --all"}]' '"cwd":"/work/proj","session_id":"s1"'"${1:+,$1}"; } + # 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}"; } +# seat_payload [extra-json-members]: a hook fired inside a subagent. +seat_payload() { printf '{"hook_event_name":"%s","session_id":"s1","cwd":"/work/proj","agent_id":"ag1","agent_type":"%s"%s}' "$1" "$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"; } @@ -61,7 +90,7 @@ run_shim() { run sh "$SHIM"; } run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash","description":"tail -F build.log"}]')" [ "$status" -eq 0 ] [ "$(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" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|idle · 1 quiet background task, quiet 120 min: tail -F build.log|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] } @test "shim: an active task alongside a quiet one is fed to cc-status with id and status, and it2 is left alone" { @@ -111,7 +140,7 @@ run_shim() { run sh "$SHIM"; } 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" + grep -qx 'idle · 1 quiet background task, quiet 120 min: local_bash' "$BATS_TEST_TMPDIR/argv" } @test "shim: SubagentStop drops the agent that just stopped" { @@ -127,7 +156,7 @@ run_shim() { run sh "$SHIM"; } 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" + grep -qx 'idle · 2 quiet background tasks, quiet 120 min: 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\"}]")" # Code points, counted by python: wc -m depends on the locale (a C locale, @@ -269,3 +298,390 @@ PY [ "$status" -eq 0 ] [ ! -e "$BATS_TEST_TMPDIR/fed" ] } + +@test "seat: a PreToolUse inside plan-fable is painted by the shim in its colour, and cc-status is not called" { + run_shim <<< "$(seat_payload PreToolUse plan-fable '"tool_name":"Bash"')" + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · plan-fable: Bash|--dot-color|#d787ff|--text-color|#d787ff" ] +} + +@test "seat: every default colour, and a PostToolUse, paint the same way" { + local pair seat col + for pair in unstick-fable:#ff5faf review-fable:#af87ff work-sonnet:#00afaf scout-haiku:#87af5f; do + seat=${pair%%:*} col=${pair#*:} + run_shim <<< "$(seat_payload PostToolUse "$seat" '"tool_name":"Edit"')" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · $seat: Edit|--dot-color|$col|--text-color|$col" ] + done +} + +@test "seat: an agent type with no colour passes through untouched" { + local p; p=$(seat_payload PreToolUse Explore '"tool_name":"Bash"') + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "seat: PermissionRequest and Notification inside a seat pass through, waiting outranks colour" { + local ev p + for ev in PermissionRequest Notification; do + p=$(seat_payload "$ev" plan-fable '"tool_name":"Bash"') + run_shim <<< "$p" + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] + done +} + +@test "seat: tabstatus.color. overrides a default and adds a seat; a bad value falls back" { + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.plan-fable '#112233' + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.my-seat '#445566' + run_shim <<< "$(seat_payload PreToolUse plan-fable '"tool_name":"Bash"')" + grep -qx '#112233' "$BATS_TEST_TMPDIR/argv" + run_shim <<< "$(seat_payload PreToolUse my-seat '"tool_name":"Bash"')" + grep -qx '#445566' "$BATS_TEST_TMPDIR/argv" + + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.plan-fable 'purple' + run --separate-stderr sh "$SHIM" <<< "$(seat_payload PreToolUse plan-fable '"tool_name":"Bash"')" + grep -qx '#d787ff' "$BATS_TEST_TMPDIR/argv" + [[ $stderr == *"tabstatus.color.plan-fable"* ]] +} + +@test "seat: SubagentStart records the seat, reaches cc-status, and paints working without a count" { + local p; p=$(seat_payload SubagentStart work-sonnet) + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ "$(cat "$SEATS_FILE")" = "$(printf 'ag1\twork-sonnet')" ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · work-sonnet running|--dot-color|#00afaf|--text-color|#00afaf" ] + run_shim <<< "$p" # a repeat does not duplicate + [ "$(wc -l < "$SEATS_FILE")" -eq 1 ] +} + +@test "seat: SubagentStop prunes its own line only, and reaches cc-status unpainted" { + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\tplan-fable\nag2\twork-sonnet\n' > "$SEATS_FILE" + run_shim <<< "$(seat_payload SubagentStop plan-fable)" + [ "$status" -eq 0 ] + [ "$(cat "$SEATS_FILE")" = "$(printf 'ag2\twork-sonnet')" ] + [ -e "$BATS_TEST_TMPDIR/fed" ] + [ ! -e "$BATS_TEST_TMPDIR/argv" ] +} + +@test "seat: SubagentStop of the last seat deletes the state file" { + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\tplan-fable\n' > "$SEATS_FILE" + run_shim <<< "$(seat_payload SubagentStop plan-fable)" + [ ! -e "$SEATS_FILE" ] +} + +@test "seat: SessionStart and SessionEnd delete the state file and still reach cc-status" { + local ev p + for ev in SessionStart SessionEnd; do + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\tplan-fable\n' > "$SEATS_FILE" + p="{\"hook_event_name\":\"$ev\",\"session_id\":\"s1\"}" + run_shim <<< "$p" + [ ! -e "$SEATS_FILE" ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + rm "$BATS_TEST_TMPDIR/fed" + done +} + +@test "seat: a Stop over an active background seat paints its colour with the active count" { + age_file ag1 5 + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\tplan-fable\n' > "$SEATS_FILE" + run_shim <<< "$(payload Stop '[{"task_id":"ag1","task_type":"local_agent"}]' '"cwd":"/work/proj","session_id":"s1"')" + [ "$status" -eq 0 ] + [ "$(fed_tasks | grep -c '"ag1"')" -eq 1 ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · plan-fable running · 1 background task running|--dot-color|#d787ff|--text-color|#d787ff|--background-tasks|1" ] +} + +@test "seat: two live seats name both, in the first one's colour, counting every active task" { + age_file ag1 5 + age_file ag2 5 + age_file sh 5 + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\twork-sonnet\nag2\tplan-fable\n' > "$SEATS_FILE" + run_shim <<< "$(payload Stop '[{"task_id":"ag1"},{"task_id":"ag2"},{"task_id":"sh","task_type":"local_bash"}]' '"cwd":"/work/proj","session_id":"s1"')" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|working|--detail|proj · work-sonnet, plan-fable running · 3 background tasks running|--dot-color|#00afaf|--text-color|#00afaf|--background-tasks|3" ] +} + +@test "seat: a seat in the state file that is not among the active tasks paints nothing" { + age_file sh 5 + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'gone\tplan-fable\n' > "$SEATS_FILE" + run_shim <<< "$(payload Stop '[{"task_id":"sh","task_type":"local_bash"}]' '"cwd":"/work/proj","session_id":"s1"')" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "seat: a quiet seat is not painted as running; the amber idle wins" { + age_file ag1 7200 + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\tplan-fable\n' > "$SEATS_FILE" + run_shim <<< "$(payload Stop '[{"task_id":"ag1","task_type":"local_agent","description":"stuck"}]' '"cwd":"/work/proj","session_id":"s1"')" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|proj · idle · 1 quiet background task, quiet 120 min: stuck|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] +} + +@test "seat: a state file older than a day is ignored and removed" { + age_file ag1 5 + mkdir -p "$(dirname "$SEATS_FILE")" + printf 'ag1\tplan-fable\n' > "$SEATS_FILE" + python3 -c 'import os,sys,time; t=time.time()-25*3600; os.utime(sys.argv[1],(t,t))' "$SEATS_FILE" + run_shim <<< "$(payload Stop '[{"task_id":"ag1","task_type":"local_agent"}]' '"cwd":"/work/proj","session_id":"s1"')" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ ! -e "$SEATS_FILE" ] +} + +@test "seat: a session id that is not a plain token never reaches the filesystem" { + run_shim <<< '{"hook_event_name":"SubagentStart","session_id":"../../x","agent_id":"ag1","agent_type":"plan-fable"}' + [ "$status" -eq 0 ] + [ -z "$(find "$BATS_TEST_TMPDIR" -name '*.seats')" ] +} + +@test "repo: the quiet-task detail starts with the cwd's basename" { + age_file a 7200 + run_shim <<< "$(payload Stop '[{"task_id":"a","description":"tail -F build.log"}]' '"cwd":"/work/proj/"')" + grep -qx 'proj · idle · 1 quiet background task, quiet 120 min: tail -F build.log' "$BATS_TEST_TMPDIR/argv" +} + +@test "explain: a seat event names the seat, its colour and the live state, and writes nothing" { + run sh "$SHIM" explain <<< "$(seat_payload SubagentStart plan-fable)" + [ "$status" -eq 0 ] + [[ $output == *"seat=plan-fable colour=#d787ff state=1"* ]] + [[ $output == *"action=passthrough+seat"* ]] + run sh "$SHIM" explain <<< "$(seat_payload PreToolUse plan-fable '"tool_name":"Bash"')" + [[ $output == *"action=seat-paint"* ]] + run sh "$SHIM" explain <<< "$(seat_payload PreToolUse Explore)" + [[ $output == *"seat=none colour=none state=0"* && $output == *"action=passthrough"* ]] + [ ! -e "$SEATS_FILE" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] +} + +# question_payload [tasks-json]: a Stop whose final message is the given JSON string body. +question_payload() { payload Stop "${2:-[]}" "\"cwd\":\"/work/proj\",\"last_assistant_message\":\"$1\""; } + +@test "question: a Stop ending in a question paints waiting in yellow with the question as the detail, after cc-status" { + local p; p=$(question_payload 'Done.\n\nWhich branch should I use?') + run_shim <<< "$p" + [ "$status" -eq 0 ] + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · Which branch should I use?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] +} + +@test "question: StopFailure is handled like Stop, and a full-width question mark counts" { + run_shim <<< "$(payload StopFailure '[]' '"cwd":"/work/proj","last_assistant_message":"どちらにしますか?"')" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · どちらにしますか?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] +} + +@test "question: a message ending in a statement paints nothing, even with a question earlier" { + local p; p=$(question_payload 'Is it ok?\n\nYes, it is.') + run_shim <<< "$p" + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "question: with an active task the shim paints nothing and cc-status keeps the row working" { + age_file a 5 + run_shim <<< "$(question_payload 'Which branch?' '[{"task_id":"a","task_type":"local_bash"}]')" + [ "$(fed_tasks | grep -c '"a"')" -eq 1 ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "question: beside only a quiet task the question paint wins over the amber idle" { + age_file a 7200 + run_shim <<< "$(question_payload 'Which branch?' '[{"task_id":"a","task_type":"local_bash","description":"stuck"}]')" + [ "$(fed_tasks)" = "[]" ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/calls")" -eq 1 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · Which branch?|--dot-color|#ffd75f|--text-color|#ffd75f|--background-tasks|0" ] +} + +@test "question: leading bullets, quotes and spaces, and trailing spaces, are stripped" { + run_shim <<< "$(question_payload 'Fine.\n\n> - is this right? \n\n')" + grep -qx 'proj · is this right?' "$BATS_TEST_TMPDIR/argv" +} + +@test "question: a long question is cut like any other detail" { + local long; long=$(printf 'x%.0s' $(seq 1 300)) + run_shim <<< "$(question_payload "$long?")" + 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 "question: SubagentStop never paints one" { + run_shim <<< "$(seat_payload SubagentStop plan-fable '"last_assistant_message":"Which branch?"')" + [ ! -e "$BATS_TEST_TMPDIR/argv" ] + run_shim <<< "$(payload SubagentStop '[]' '"cwd":"/work/proj","last_assistant_message":"Which branch?"')" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "question: tabstatus.color.question overrides the colour; a bad value falls back with a note" { + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.question '#112233' + run_shim <<< "$(question_payload 'Which branch?')" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · Which branch?|--dot-color|#112233|--text-color|#112233|--background-tasks|0" ] + + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.question 'yellow' + run --separate-stderr sh "$SHIM" <<< "$(question_payload 'Which branch?')" + grep -qx '#ffd75f' "$BATS_TEST_TMPDIR/argv" + [[ $stderr == *"tabstatus.color.question"* ]] +} + +@test "question: a Stop with no question mark and no tasks passes through without it2" { + local p; p=$(question_payload 'All done.') + run_shim <<< "$p" + [ "$(cat "$BATS_TEST_TMPDIR/fed")" = "$p" ] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "explain: prints question=yes with the extracted line, or question=no" { + run sh "$SHIM" explain <<< "$(question_payload 'Done.\n\n- Which branch should I use?')" + [[ $output == *"question=yes Which branch should I use?"* ]] + [[ $output == *"action=passthrough+question"* ]] + run sh "$SHIM" explain <<< "$(question_payload 'All done.')" + [[ $output == *"question=no"* && $output == *"action=passthrough"* ]] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] +} + +@test "stalled: a first sighting of a quiet shell stays quiet, with its age, and is recorded" { + age_file a 7200 + run_shim <<< "$(stall_payload)" + [ "$status" -eq 0 ] + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|proj · idle · 1 quiet background task, quiet 120 min: install.sh --all|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/lsof_argv")" = "-t|--|$SESS/tasks/a.output" ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/ps_argv")" = "-axo|pid,ppid,time" ] + [ "$(wc -l < "$TASKS_FILE")" -eq 1 ] + [ "$(cut -f1,3 "$TASKS_FILE")" = "$(printf 'a\t3.50')" ] +} + +@test "stalled: quiet for stall-minutes with the same CPU and file paints waiting in red" { + age_file a 7200 + task_state a 965 3.50 + run_shim <<< "$(stall_payload)" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · stalled 16 min: install.sh --all|--dot-color|#ff5f5f|--text-color|#ff5f5f|--background-tasks|0" ] + [ "$(fed_tasks)" = "[]" ] +} + +@test "stalled: a CPU that moved resets the clock, stays quiet and says so" { + age_file a 7200 + task_state a 965 3.50 + set_cpu 1.00 7.50 + run_shim <<< "$(stall_payload)" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|idle|--detail|proj · idle · 1 quiet background task, quiet 120 min: install.sh --all (cpu busy)|--dot-color|#af8700|--text-color|#888888|--background-tasks|0" ] + [ "$(cut -f3 "$TASKS_FILE")" = "8.50" ] + run_shim <<< "$(stall_payload)" # nothing moved since, but the clock restarted a moment ago + grep -qx 'proj · idle · 1 quiet background task, quiet 120 min: install.sh --all' "$BATS_TEST_TMPDIR/argv" +} + +@test "stalled: quiet for less than stall-minutes is not stalled, and the record keeps its first sighting" { + age_file a 7200 + task_state a 300 3.50 + local before; before=$(cat "$TASKS_FILE") + run_shim <<< "$(stall_payload)" + grep -qx 'idle' "$BATS_TEST_TMPDIR/argv" + [ "$(cat "$TASKS_FILE")" = "$before" ] +} + +@test "stalled: a file written since the record is a new silence, not a stall" { + age_file a 7200 + task_state a 965 3.50 + python3 -c 'import os,sys,time; t=time.time()-3600; os.utime(sys.argv[1],(t,t))' "$SESS/tasks/a.output" + run_shim <<< "$(stall_payload)" + grep -qx 'idle' "$BATS_TEST_TMPDIR/argv" +} + +@test "stalled: nothing holds the file, so the task is stalled at once" { + age_file a 7200 + : > "$BATS_TEST_TMPDIR/lsof.out" + run_shim <<< "$(stall_payload)" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · stalled 120 min: install.sh --all|--dot-color|#ff5f5f|--text-color|#ff5f5f|--background-tasks|0" ] + [ ! -e "$BATS_TEST_TMPDIR/ps_argv" ] +} + +@test "stalled: an agent's symlinked output is never probed" { + : > "$BATS_TEST_TMPDIR/agent.jsonl" + python3 -c 'import os,sys,time; t=time.time()-7200; os.utime(sys.argv[1],(t,t))' "$BATS_TEST_TMPDIR/agent.jsonl" + ln -s "$BATS_TEST_TMPDIR/agent.jsonl" "$SESS/tasks/a.output" + : > "$BATS_TEST_TMPDIR/lsof.out" + run_shim <<< "$(stall_payload)" + grep -qx 'idle' "$BATS_TEST_TMPDIR/argv" + [ ! -e "$BATS_TEST_TMPDIR/lsof_argv" ] + [ ! -e "$TASKS_FILE" ] +} + +@test "stalled: outranks a question in the same payload" { + age_file a 7200 + : > "$BATS_TEST_TMPDIR/lsof.out" + run_shim <<< "$(stall_payload '"last_assistant_message":"Which branch?"')" + grep -qx 'proj · stalled 120 min: install.sh --all' "$BATS_TEST_TMPDIR/argv" + grep -qx 'waiting' "$BATS_TEST_TMPDIR/argv" +} + +@test "stalled: an active task beside a stalled one keeps the row working, painted by nobody" { + age_file a 7200 + age_file b 5 + : > "$BATS_TEST_TMPDIR/lsof.out" + run_shim <<< "$(payload Stop '[{"task_id":"a","task_type":"local_bash"},{"task_id":"b","task_type":"local_bash"}]' '"session_id":"s1"')" + [ ! -e "$BATS_TEST_TMPDIR/calls" ] +} + +@test "stalled: tabstatus.color.stalled and stall-minutes override; a bad colour falls back with a note" { + age_file a 7200 + task_state a 400 3.50 + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.stalled '#112233' + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.stall-minutes 5 + run_shim <<< "$(stall_payload)" + [ "$(it2_argv)" = "set-status|--session|ABC-123|--status|waiting|--detail|proj · stalled 6 min: install.sh --all|--dot-color|#112233|--text-color|#112233|--background-tasks|0" ] + + git config -f "$GIT_CONFIG_GLOBAL" tabstatus.color.stalled 'red' + run --separate-stderr sh "$SHIM" <<< "$(stall_payload)" + grep -qx '#ff5f5f' "$BATS_TEST_TMPDIR/argv" + [[ $stderr == *"tabstatus.color.stalled"* ]] +} + +@test "stalled: a missing lsof is unknown, never stalled" { + age_file a 7200 + rm "$STUB/lsof" + # PATH of just the tools the shim runs, so no lsof anywhere (Linux has it in /usr/bin). + local t tools="$BATS_TEST_TMPDIR/tools" + mkdir -p "$tools" + for t in git dirname readlink cat; do ln -s "$(command -v $t)" "$tools/$t"; done + ln -s "$(python3 -c 'import sys; print(sys.executable)')" "$tools/python3" + PATH="$STUB:$tools" run /bin/sh "$SHIM" <<< "$(stall_payload)" + [ "$status" -eq 0 ] + grep -qx 'idle' "$BATS_TEST_TMPDIR/argv" + [ ! -e "$TASKS_FILE" ] +} + +@test "stalled: the record is pruned to the payload's tasks, and SessionStart and SessionEnd delete it" { + age_file a 7200 + task_state a 300 3.50 + printf 'gone\t1.0\t1.00\t1\n' >> "$TASKS_FILE" + run_shim <<< "$(stall_payload)" + [ "$(cut -f1 "$TASKS_FILE")" = "a" ] + local ev + for ev in SessionStart SessionEnd; do + printf 'a\t1.0\t1.00\t1\n' > "$TASKS_FILE" + run_shim <<< "{\"hook_event_name\":\"$ev\",\"session_id\":\"s1\"}" + [ ! -e "$TASKS_FILE" ] + done +} + +@test "explain: a quiet shell task shows its holder, CPU and verdict, and writes nothing" { + age_file a 7200 + task_state a 965 3.50 + local before; before=$(cat "$TASKS_FILE") + run sh "$SHIM" explain <<< "$(stall_payload)" + [[ $output == *"task a local_bash quiet age=7200 holder=4242 cpu=3.50 stalled=yes"*"install.sh --all"* ]] + [[ $output == *"action=rewrite+stalled"* ]] + [ "$(cat "$TASKS_FILE")" = "$before" ] + : > "$BATS_TEST_TMPDIR/lsof.out" + run sh "$SHIM" explain <<< "$(stall_payload)" + [[ $output == *"holder=none cpu=- stalled=yes"* ]] + [ ! -e "$BATS_TEST_TMPDIR/calls" ] + [ ! -e "$BATS_TEST_TMPDIR/fed" ] +} diff --git a/tests/claude-profiles.bats b/tests/claude-profiles.bats index 3e6754d976..255109258d 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -188,6 +188,51 @@ JSON [[ "$output" == *"denies logged: 0"* ]] } +@test "claude-doctor: tabstatus counts events on the shim, on raw cc-status, on neither" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + command -v python3 >/dev/null || skip "python3 not installed" + # A real python3 for the same reason as the deny test above. + REAL_PYTHON3="$(python3 -c 'import sys; print(sys.executable)')" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3" + chmod +x "$BATS_TEST_TMPDIR/bin/python3" + mkdir -p "$HOME/.claude-personal" "$HOME/.local/libexec/cc-status-shim" "$HOME/.local/state/tabstatus" + settings="$HOME/.claude-personal/settings.json" + shim="$HOME/.local/libexec/cc-status-shim/cc-status" + tabstatus() { run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor"; } + + tabstatus + [[ "$output" == *"tabstatus: no settings.json in $settings; shim: MISSING"* ]] + + printf '#!/bin/sh\n' > "$shim"; chmod +x "$shim" + printf 'a\tplan-fable\nb\twork-sonnet\n' > "$HOME/.local/state/tabstatus/s1.seats" + # Settings with every one of the eleven events on the shim, then Stop on a + # bare raw entry and SubagentStart on nothing, then Stop on the guarded form. + python3 - "$settings" "$shim" "$HOME" <<'PY' +import json, sys +path, shim, home = sys.argv[1:] +ev = "SessionStart SessionEnd UserPromptSubmit PreToolUse PostToolUse PermissionRequest Notification Stop StopFailure SubagentStart SubagentStop".split() +def entry(cmd): return [{"hooks": [{"type": "command", "command": cmd}]}] +raw = home + "/.config/iterm2/cc-status" +for name, over in (("all", {}), ("bare", {"Stop": raw, "SubagentStart": None}), + ("guarded", {"Stop": "[ -x %s ] && %s || true" % (raw, raw), "SubagentStart": None})): + hooks = {e: entry(over.get(e, shim)) for e in ev if over.get(e, shim) is not None} + json.dump({"hooks": hooks}, open(path + "." + name, "w")) +PY + cp "$settings.all" "$settings" + tabstatus + [ "$status" -eq 0 ] + [[ "$output" == *"tabstatus: shim on 11/11 events, raw cc-status on 0, none on 0 in $settings; shim: $shim; seats live: 2"* ]] + cp "$settings.bare" "$settings" + tabstatus + [[ "$output" == *"tabstatus: shim on 9/11 events, raw cc-status on 1, none on 1 in $settings"* ]] + cp "$settings.guarded" "$settings" + tabstatus + [[ "$output" == *"tabstatus: shim on 9/11 events, raw cc-status on 1, none on 1 in $settings"* ]] + echo '{not json' > "$settings" + tabstatus + [[ "$output" == *"tabstatus: ? (unreadable settings.json) in $settings"* ]] +} + @test "claude-doctor: names the logged-in account from the profile's .claude.json" { repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') mkdir -p "$HOME/.claude-personal" diff --git a/tests/iterm2-doctor.bats b/tests/iterm2-doctor.bats index 6da060e31e..bbb03441de 100644 --- a/tests/iterm2-doctor.bats +++ b/tests/iterm2-doctor.bats @@ -88,6 +88,8 @@ doctor_stamp() { # get pasted into issues. while IFS= read -r line; do [ -n "$line" ] || continue + # The one label longer than the column ("cc-status:" is ten characters). + [[ "$line" == cc-status:\ * ]] && continue [[ "$line" =~ ^[a-z]+:\ +[^\ ] ]] [[ "${line:0:9}" =~ ^[a-z]+:\ *$ ]] [[ "${line:9:1}" != " " ]] @@ -174,6 +176,33 @@ EOF [[ "$output" == *"integ: not installed"* ]] } +@test "iterm2-doctor: cc-status line reads the symlink without running it" { + make_app "$(doctor_stamp)" + # A machine with iTerm2's experimental PATH setting on has a real it2 here. + PATH=$(IFS=:; for d in $PATH; do [ -x "$d/it2" ] || printf '%s:' "$d"; done) + doctor + [[ "$output" == *"cc-status: ~/.config/iterm2/cc-status (absent); it2: absent"* ]] + + # A symlink to a binary that is not there is called out, not hidden. + mkdir -p "$HOME/.config/iterm2" "$BATS_TEST_TMPDIR/real" + ln -s "$BATS_TEST_TMPDIR/real/cc-status" "$HOME/.config/iterm2/cc-status" + doctor + [[ "$output" == *"cc-status: ~/.config/iterm2/cc-status -> $BATS_TEST_TMPDIR/real/cc-status (DANGLING); it2: absent"* ]] + + # Present and executable, it2 beside it; the stub would leave a marker if run. + printf '#!/bin/sh\ntouch "%s/ran"\n' "$BATS_TEST_TMPDIR" | tee "$BATS_TEST_TMPDIR/real/cc-status" > "$BATS_TEST_TMPDIR/real/it2" + chmod +x "$BATS_TEST_TMPDIR/real/cc-status" "$BATS_TEST_TMPDIR/real/it2" + doctor + [[ "$output" == *"cc-status: ~/.config/iterm2/cc-status -> $BATS_TEST_TMPDIR/real/cc-status (executable); it2: beside it"* ]] + + # No it2 beside it, but one on PATH. + rm "$BATS_TEST_TMPDIR/real/it2" + cp "$STUB/defaults" "$STUB/it2"; chmod +x "$STUB/it2" + doctor + [[ "$output" == *"(executable); it2: PATH"* ]] + [ ! -e "$BATS_TEST_TMPDIR/ran" ] +} + # --- the stamp is the whole point, so it may not drift --------------------- @test "the doctor's stamp, the manifest's, and the skill's prose all agree" { diff --git a/tests/tabstatus.bats b/tests/tabstatus.bats index 0e6b5288e3..6dde21a5a8 100644 --- a/tests/tabstatus.bats +++ b/tests/tabstatus.bats @@ -24,6 +24,36 @@ setup() { [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--status||--detail|" ] } +@test "tabstatus: idle sends cc-status's idle colours and a zero background count" { + run zsh -c "ITERM_SESSION_ID=w0t1p0:ABC-123; source '$DOTFUNCTIONS'; tabstatus idle" + [ "$status" -eq 0 ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--status|idle|--detail||--dot-color|#00d75f|--text-color|#888888|--background-tasks|0" ] +} + +@test "tabstatus: working sends the orange pair and no count" { + run zsh -c "ITERM_SESSION_ID=w0t1p0:ABC-123; source '$DOTFUNCTIONS'; tabstatus working" + [ "$status" -eq 0 ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--status|working|--detail||--dot-color|#ff9500|--text-color|#ff9500" ] +} + +@test "tabstatus: a colour flag suppresses both default colours but idle still zeroes the count" { + run zsh -c "ITERM_SESSION_ID=w0t1p0:ABC-123; source '$DOTFUNCTIONS'; tabstatus idle '' --dot-color '#123456'" + [ "$status" -eq 0 ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--status|idle|--detail||--dot-color|#123456|--background-tasks|0" ] +} + +@test "tabstatus: an explicit background count is kept" { + run zsh -c "ITERM_SESSION_ID=w0t1p0:ABC-123; source '$DOTFUNCTIONS'; tabstatus idle '' --background-tasks 2" + [ "$status" -eq 0 ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--status|idle|--detail||--background-tasks|2|--dot-color|#00d75f|--text-color|#888888" ] +} + +@test "tabstatus: free-text status words get no colours" { + run zsh -c "ITERM_SESSION_ID=w0t1p0:ABC-123; source '$DOTFUNCTIONS'; tabstatus 'reviewing PR'" + [ "$status" -eq 0 ] + [ "$(paste -sd'|' "$BATS_TEST_TMPDIR/argv")" = "set-status|--session|ABC-123|--status|reviewing PR|--detail|" ] +} + @test "tabstatus: outside iTerm2 is an error and never calls it2" { run zsh -c "unset ITERM_SESSION_ID; source '$DOTFUNCTIONS'; tabstatus working" [ "$status" -eq 1 ]