Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 118 additions & 41 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<task_id>.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/<task_id>.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.<agent_type> '#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/<session_id>.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
`<session_id>.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)

Expand Down
42 changes: 42 additions & 0 deletions docs/private-overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion dot_functions
Original file line number Diff line number Diff line change
Expand Up @@ -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.<alias>), whether open-as is there to tag, and
# whether a window can appear at all. Same helpers, same order as the
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading