Take the status dots from Claude Code's hooks instead of guessing #21
No reviewers
Labels
No labels
bug
docs
enhancement
good first issue
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
pedromcaraujo/forge!21
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "status-dots-from-hooks"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #20.
Summary
The dots flickered because
/statusinferred state from two signals OR'dtogether, neither authoritative — one precise but unreliably sampled, the
other reliably sampled but imprecise. See #20 for the full breakdown.
This makes Claude Code report its own state.
web/forge-status-hook.shiswired into the hook events and writes
<statusDir>/<channel>.json:UserPromptSubmit,PreToolUse,PostToolUsebusyStop,Notification,SessionStartidleSessionEndNotification-> idle is deliberate: it fires when Claude wants a permissiondecision, which is exactly "waiting on you".
Hooks have one blind spot — Esc-interrupting a turn fires no
Stop— sothe pane hint survives as a decay path, but only after
PANE_MISS_LIMIT = 2consecutive misses. That's what buys determinism: no single mistimed capture
can flip a dot. Channels with no hook state (hooks disabled, or a session
predating the deploy) fall back to the previous logic untouched.
/statusgains asource: "hook" | "inferred"field so a misbehaving dot canbe diagnosed from the browser's network tab.
Notes
$TMUX_PANE, not$FORGE_CHANNEL.The channels share one long-lived tmux server, so every session inherits the
environment of whichever ttyd started it — verified live: a pane in session
forgereportsFORGE_CHANNEL=vault. (That also means the statusline usettyd.nixdocuments for that variable is broken; filing separately.)/etc/claude-code/managed-settings.json, gated on thenew
claudeHooks.enable(default true). It's the only declarative placehooks can go — there is no env var pointing Claude Code at an alternate
settings file, and the alternative was rewriting the user's own
~/.claude/settings.jsonon every deploy. Hooks there merge with theuser's rather than replacing them. Note it applies host-wide, not just to
the channels; the hook is a no-op outside tmux.
a turn for.
Test plan
nix flake checkpasses.Generated wrapper runs under
env -i(empty environment): resolves thechannel, writes the file, exits 0. Also exits 0 when the target dir is
unwritable, on a bogus argument, and with
$TMUX_PANEunset./statusexercised against the live 9-session tmux server._classifytransitions,[1,0,...]= pane hint present/absent per poll:Deploy and confirm the dots hold steady through a long tool-heavy turn.
The dots flickered green -> dim -> green on refresh with nothing actually changing. /status inferred state from two signals OR'd together, neither authoritative: * `esc to interrupt` in `capture-pane` — precise, but a snapshot of a continuously repainting TUI. A poll landing mid-redraw simply misses a hint that is really there, and it only ever sees the active pane. * a transcript mtime within 45s — reliably sampled, but Claude appends at message boundaries, so a long turn goes stale mid-flight. It is also per-directory, so two channels sharing a cwd light together. Replace the guessing with Claude Code's own hooks. forge-status-hook.sh brackets each turn (UserPromptSubmit/PreToolUse/PostToolUse -> busy, Stop/Notification/SessionStart -> idle, SessionEnd -> drop) and leaves the answer in a file /status reads. Notification counts as idle: it fires when Claude wants a permission decision, which is exactly "waiting on you". Hooks have one blind spot — Esc-interrupting a turn fires no Stop — so the pane hint stays on as a decay path, but only after two consecutive misses. That is what makes it deterministic: no single mistimed capture can flip a dot any more. Sessions with no hook state fall back to the old inference unchanged. The hook resolves its channel from $TMUX_PANE, not $FORGE_CHANNEL: the channels share one tmux server, so every session inherits the environment of whichever ttyd started it and that variable names the wrong channel almost every time. Provisioned via /etc/claude-code/managed-settings.json (claudeHooks.enable), the only declarative place hooks can go — it merges with, rather than replaces, any hooks the user already has. Closes #20Live-testing the previous commit against a real Claude Code session (2.1.217) showed the pane cross-check overriding correct hook data and blanking the dot during a genuinely running turn. Measured, both inferred signals are wrong mid-turn: * The pane renders `✻ Quantumizing… (6m 33s · ↓ 10.6k tokens)`. There is no `esc to interrupt` anywhere in it, so RUNNING_MARK had silently stopped matching anything at all. Worse, it still fired on any pane that happened to *display* the phrase — a channel discussing this code read as busy. * A 63s streaming turn wrote nothing to the transcript. Its mtime sailed past BUSY_WINDOW at 51s while the turn was still running, flipping the dot to idle mid-flight. That pair is the flicker in #20, and it means the pane is not fit to second-guess anything. Drop the hysteresis and let the hook state stand on its own, bounded only by HOOK_BUSY_TTL so an Esc (which fires no Stop) or a killed session can't leave a dot lit forever. The pane check survives only on the no-hook fallback path, with a regex for the parenthetical the current UI actually renders. It is best-effort by nature — this is UI text and it rots, as the dead mark above demonstrates. Verified end to end: dot held busy for a full 63s tool-free turn and dropped exactly at Stop, where the old logic went idle at 51s.Live-tested on nix-dev against a real Claude Code session (2.1.217)
Ran an isolated
hooktesttmux session with project-level settings (no/etc, no~/.claude— Pedro's channels untouched). Two findings changedthe design, both worse than #20 assumed.
1.
RUNNING_MARKmatches nothing. It has been dead code.A pane in a live turn renders:
No
esc to interruptanywhere — that string is gone from the current UI. Andit still produced false positives: session
forgematched only because thechannel was displaying a conversation quoting the phrase. So the signal was
simultaneously never-true-when-busy and sometimes-true-when-idle.
2. The transcript goes stale mid-turn. Measured through a 63s tool-free
turn,
transcript_ageclimbing while the turn was live:With both inferred signals wrong during a live turn, the pane is not fit to
second-guess the hook. The first commit's
PANE_MISS_LIMIThysteresis didexactly the wrong thing — it blanked a correct green after 2 polls. Dropped.
Now: hook state is authoritative, bounded only by
HOOK_BUSY_TTL(15m) soan Esc or a
SIGKILLed session can't leave a dot lit forever. The pane checksurvives only on the no-hook fallback path, with a regex for the parenthetical
the current UI renders.
Re-verified after the rewrite — dot held
busyfor the whole turn anddropped exactly at
Stop:Also confirmed live:
SessionStart-> idle,SessionEndremoves the file,Esc fires no
Stop(hook file unchanged — hence the TTL).Known limitation
After an Esc the dot stays green until the next prompt or the TTL. Deliberate:
expiring a turn that is still running is the worse failure, and after an Esc
you are sitting at that channel about to type, which resolves it.
Deployed and verified on nix-dev
nixos-rebuild testwith--override-input forge <local>. Activation wasminimal — only
forge-tmux+forge-uploadrestart, no ttyd, so live channelssurvived untouched.
Verified against the real
/statusendpoint through nginx, with thedeployed
/etc/claude-code/managed-settings.json(no test overrides):A 400-word tool-free turn, polled every 4s — one transition each way, no flapping:
A tool turn polled every 1 second — same, no flicker at any sampling rate:
Event sequence, traced directly
Notificationnever fired in any trace, so it cannot cause mid-turn flicker.Its two documented triggers (permission needed, prompt idle 60s+) both genuinely
mean "waiting on you", so the idle mapping stands.
Also confirmed: hooks from
/etcmerge with a project's own.claude/settings.jsonrather than replacing them — a session with onlylogging hooks in its project settings still produced a status file.
One honest caveat
When Claude backgrounds a shell command it emits a real
Stop, then resumeslater — so the dot legitimately blinks idle between those phases:
That's Claude genuinely stopping and restarting, not a detection artifact, and
the hooks report it faithfully. Normal turns (traced above) show exactly one
transition each way.
Not persisted
Applied with
test, so it reverts on reboot. The durable change needs theforgeinput bumped inpedromcaraujo/nixos.