Take the status dots from Claude Code's hooks instead of guessing #21

Merged
pedromcaraujo merged 2 commits from status-dots-from-hooks into main 2026-08-03 09:03:49 +01:00

Closes #20.

Summary

The dots flickered because /status inferred state from two signals OR'd
together, 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.sh is
wired into the hook events and writes <statusDir>/<channel>.json:

event state
UserPromptSubmit, PreToolUse, PostToolUse busy
Stop, Notification, SessionStart idle
SessionEnd file removed

Notification -> idle is deliberate: 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 survives as a decay path, but only after PANE_MISS_LIMIT = 2
consecutive 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.

/status gains a source: "hook" | "inferred" field so a misbehaving dot can
be diagnosed from the browser's network tab.

Notes

  • The hook resolves its channel from $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
    forge reports FORGE_CHANNEL=vault. (That also means the statusline use
    ttyd.nix documents for that variable is broken; filing separately.)
  • Provisioned through /etc/claude-code/managed-settings.json, gated on the
    new claudeHooks.enable (default true). It's the only declarative place
    hooks 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.json on every deploy. Hooks there merge with the
    user's rather than replacing them. Note it applies host-wide, not just to
    the channels; the hook is a no-op outside tmux.
  • Every failure path in the hook exits 0. A status dot is never worth blocking
    a turn for.

Test plan

  • nix flake check passes.

  • Generated wrapper runs under env -i (empty environment): resolves the
    channel, writes the file, exits 0. Also exits 0 when the target dir is
    unwritable, on a bogus argument, and with $TMUX_PANE unset.

  • /status exercised against the live 9-session tmux server.

  • _classify transitions, [1,0,...] = pane hint present/absent per poll:

    HOOK BUSY   pane agrees          [1,1,1,1] -> busy busy busy busy
    HOOK BUSY   single mid-redraw    [1,0,1,1] -> busy busy busy busy   <- flicker gone
    HOOK BUSY   Esc, never returns   [1,0,0,0] -> busy busy idle idle   <- still decays
    HOOK IDLE   30s transcript       [0,0]     -> idle idle             <- per-cwd false positive gone
    NO HOOK     single mid-redraw    [1,0,1,1] -> busy IDLE busy busy   <- old behaviour, reproduces the bug
    NOT CLAUDE  stale busy file      [0,0]     -> idle idle
    
  • Deploy and confirm the dots hold steady through a long tool-heavy turn.

Closes #20. ## Summary The dots flickered because `/status` inferred state from two signals OR'd together, 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.sh` is wired into the hook events and writes `<statusDir>/<channel>.json`: | event | state | |---|---| | `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `busy` | | `Stop`, `Notification`, `SessionStart` | `idle` | | `SessionEnd` | file removed | `Notification` -> idle is deliberate: 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 survives as a decay path, but only after `PANE_MISS_LIMIT = 2` consecutive 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. `/status` gains a `source: "hook" | "inferred"` field so a misbehaving dot can be diagnosed from the browser's network tab. ## Notes - The hook resolves its channel from `$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 `forge` reports `FORGE_CHANNEL=vault`. (That also means the statusline use `ttyd.nix` documents for that variable is broken; filing separately.) - Provisioned through `/etc/claude-code/managed-settings.json`, gated on the new `claudeHooks.enable` (default true). It's the only declarative place hooks 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.json` on every deploy. Hooks there *merge* with the user's rather than replacing them. Note it applies host-wide, not just to the channels; the hook is a no-op outside tmux. - Every failure path in the hook exits 0. A status dot is never worth blocking a turn for. ## Test plan - [x] `nix flake check` passes. - [x] Generated wrapper runs under `env -i` (empty environment): resolves the channel, writes the file, exits 0. Also exits 0 when the target dir is unwritable, on a bogus argument, and with `$TMUX_PANE` unset. - [x] `/status` exercised against the live 9-session tmux server. - [x] `_classify` transitions, `[1,0,...]` = pane hint present/absent per poll: HOOK BUSY pane agrees [1,1,1,1] -> busy busy busy busy HOOK BUSY single mid-redraw [1,0,1,1] -> busy busy busy busy <- flicker gone HOOK BUSY Esc, never returns [1,0,0,0] -> busy busy idle idle <- still decays HOOK IDLE 30s transcript [0,0] -> idle idle <- per-cwd false positive gone NO HOOK single mid-redraw [1,0,1,1] -> busy IDLE busy busy <- old behaviour, reproduces the bug NOT CLAUDE stale busy file [0,0] -> idle idle - [ ] Deploy and confirm the dots hold steady through a long tool-heavy turn.
forge: let Claude Code report the status dots instead of guessing
All checks were successful
CI / check (pull_request) Successful in 30s
3cca3a1686
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 #20
forge: make the hook state authoritative, stop cross-checking the pane
All checks were successful
CI / check (pull_request) Successful in 9s
8b49970919
Live-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.
Author
Owner

Live-tested on nix-dev against a real Claude Code session (2.1.217)

Ran an isolated hooktest tmux session with project-level settings (no
/etc, no ~/.claude — Pedro's channels untouched). Two findings changed
the design, both worse than #20 assumed.

1. RUNNING_MARK matches nothing. It has been dead code.

A pane in a live turn renders:

✻ Quantumizing… (6m 33s · ↓ 10.6k tokens)

No esc to interrupt anywhere — that string is gone from the current UI. And
it still produced false positives: session forge matched only because the
channel 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_age climbing while the turn was live:

51s  hook=busy  transcript_age=47s   <-- old logic: IDLE (mid-turn!)
63s  hook=busy  transcript_age=59s   <-- old logic: IDLE
66s  hook=idle  transcript_age=62s   <-- turn actually ended here

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_LIMIT hysteresis did
exactly the wrong thing — it blanked a correct green after 2 polls. Dropped.

Now: hook state is authoritative, bounded only by HOOK_BUSY_TTL (15m) so
an Esc or a SIGKILLed 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 renders.

Re-verified after the rewrite — dot held busy for the whole turn and
dropped exactly at Stop:

0s..63s   dot=busy  src=hook
66s       dot=idle  src=hook

Also confirmed live: SessionStart -> idle, SessionEnd removes 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.

## Live-tested on nix-dev against a real Claude Code session (2.1.217) Ran an isolated `hooktest` tmux session with project-level settings (no `/etc`, no `~/.claude` — Pedro's channels untouched). Two findings changed the design, both worse than #20 assumed. **1. `RUNNING_MARK` matches nothing. It has been dead code.** A pane in a live turn renders: ✻ Quantumizing… (6m 33s · ↓ 10.6k tokens) No `esc to interrupt` anywhere — that string is gone from the current UI. And it still produced *false positives*: session `forge` matched only because the channel 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_age` climbing while the turn was live: 51s hook=busy transcript_age=47s <-- old logic: IDLE (mid-turn!) 63s hook=busy transcript_age=59s <-- old logic: IDLE 66s hook=idle transcript_age=62s <-- turn actually ended here 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_LIMIT` hysteresis did exactly the wrong thing — it blanked a correct green after 2 polls. Dropped. **Now:** hook state is authoritative, bounded only by `HOOK_BUSY_TTL` (15m) so an Esc or a `SIGKILL`ed 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 renders. **Re-verified after the rewrite** — dot held `busy` for the whole turn and dropped exactly at `Stop`: 0s..63s dot=busy src=hook 66s dot=idle src=hook Also confirmed live: `SessionStart` -> idle, `SessionEnd` removes 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.
Author
Owner

Deployed and verified on nix-dev

nixos-rebuild test with --override-input forge <local>. Activation was
minimal — only forge-tmux + forge-upload restart, no ttyd, so live channels
survived untouched.

Verified against the real /status endpoint through nginx, with the
deployed /etc/claude-code/managed-settings.json (no test overrides):

deploycheck: idle  source=hook      <- fresh session, hooks live
forge:       busy  source=inferred  <- predates the deploy, fallback

A 400-word tool-free turn, polled every 4s — one transition each way, no flapping:

 0s dot=idle src=hook
 4s dot=busy src=hook   ... held ...
32s dot=busy src=hook
36s dot=idle src=hook   ... held to 100s

A tool turn polled every 1 second — same, no flicker at any sampling rate:

 1s idle -> 2s busy -> 12s idle -> held to 45s

Event sequence, traced directly

SessionStart
UserPromptSubmit / PreToolUse / PostToolUse / PreToolUse / PostToolUse / Stop
SubagentStop

Notification never 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 /etc merge with a project's own
.claude/settings.json rather than replacing them — a session with only
logging 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 resumes
later — so the dot legitimately blinks idle between those phases:

busy -> idle -> busy -> idle -> busy -> idle

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 the
forge input bumped in pedromcaraujo/nixos.

## Deployed and verified on nix-dev `nixos-rebuild test` with `--override-input forge <local>`. Activation was minimal — only `forge-tmux` + `forge-upload` restart, no ttyd, so live channels survived untouched. Verified against the **real** `/status` endpoint through nginx, with the deployed `/etc/claude-code/managed-settings.json` (no test overrides): deploycheck: idle source=hook <- fresh session, hooks live forge: busy source=inferred <- predates the deploy, fallback A 400-word tool-free turn, polled every 4s — one transition each way, no flapping: 0s dot=idle src=hook 4s dot=busy src=hook ... held ... 32s dot=busy src=hook 36s dot=idle src=hook ... held to 100s A tool turn polled every **1 second** — same, no flicker at any sampling rate: 1s idle -> 2s busy -> 12s idle -> held to 45s ### Event sequence, traced directly SessionStart UserPromptSubmit / PreToolUse / PostToolUse / PreToolUse / PostToolUse / Stop SubagentStop `Notification` never 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 `/etc` **merge** with a project's own `.claude/settings.json` rather than replacing them — a session with only logging 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 resumes later — so the dot legitimately blinks idle between those phases: busy -> idle -> busy -> idle -> busy -> idle 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 the `forge` input bumped in `pedromcaraujo/nixos`.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pedromcaraujo/forge!21
No description provided.