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
34 changes: 34 additions & 0 deletions .claude/skills/iterm2/scripts/manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
18 changes: 17 additions & 1 deletion .claude/skills/iterm2/scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}
Expand Down
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1507,6 +1507,57 @@ 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; `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).

### Machine-local secrets (~/.extra)

`~/.extra` is rendered by chezmoi from `private_dot_extra.tmpl` (mode 0600)
Expand Down
20 changes: 20 additions & 0 deletions dot_claude-shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>`)
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.
Expand Down
209 changes: 209 additions & 0 deletions dot_local/libexec/cc-status-shim/executable_cc-status
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#!/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, StopFailure and SubagentStop with background tasks, each task is
# classified by its output file, <session dir>/tasks/<task_id>.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 (`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,
# 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}
mode=${1:-}

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:-}; sid=${sid#*:} # two steps: dash under set -u rejects ${unset#pat}
[ -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; }

# 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, os, re, subprocess, sys, time

mode, real, it2, sid = sys.argv[1:5]
explain = mode == "explain"
raw = sys.stdin.read()

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)
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" "$mode" "$real" "$it2" "$sid"
exit 0
Loading
Loading