Read the status dots from Claude's own terminal title #23

Merged
pedromcaraujo merged 2 commits from status-dots-from-terminal-title into main 2026-08-03 13:13:49 +01:00

Follow-up to #21, which was still not good enough.

The signal we were missing

Claude Code announces its state in the terminal title (OSC 0/2), and tmux
tracks it per pane as #{pane_title} with no configuration at all:

⠂ Debug non-deterministic session detection   <- turn running (braille spinner)
✳ Claude Code                                 <- waiting on you

Verified live across every channel on nix-dev:

altercos    idle  src=title    ✳ Claude Code
forge       busy  src=title    ⠂ Debug non-deterministic session detection
nixos       idle  src=title    ✳ Review and clean up old items starting at 80
wine-radar  idle  src=title    ✳ Re-enable and fix items 5 and 6

Why this beats what #21 shipped

pane scrape transcript hooks title
correct mid-turn no no yes yes
per-session (not per-cwd) yes no yes yes
works on a detached session no yes yes yes
works without installing anything yes yes no yes
survives Esc interrupt no yes
survives API error no yes
cost per poll 1 subprocess/channel 1 stat/channel 1 read free

The Esc case, measured live — hooks fire no Stop, so #21 held the dot lit
until the 15-minute TTL. Now:

before prompt   dot=idle  src=title
mid-turn        dot=busy  src=title
4s after Esc    dot=idle  src=title

Changes

  • #{pane_title} added to the existing list-sessions format; title is now
    the primary signal, hooks the fallback, transcript the last resort.
  • Pane scraping removed entirely. It was wrong in both directions (see #21)
    and is now redundant. /status is a single tmux call regardless of channel
    count — previously one capture-pane per channel per poll.
  • source in the response is now title | hook | transcript.

Credit

Technique taken from herdr (Apache-2.0),
which uses the same OSC title as its highest-priority signal. Notably they
removed a hook-based design essentially identical to #21 — their changelog
still carries the uninstall list for UserPromptSubmit/PreToolUse/Stop/
SessionEnd hooks — citing escape interrupts and permission results as the
reason. No code taken, only the observation.

Test plan

  • nix flake check.
  • Classifier matrix: title wins over stale hook state in both directions;
    hook fallback + TTL intact; transcript last resort; shell guard intact.
  • Live across all 6 real channels — every one resolves src=title, with
    no hooks installed (a concurrent nixos-rebuild switch had reverted
    the test activation, which incidentally proves the zero-install claim).
  • Full turn on a detached session: -> braille for the whole turn ->
    exactly at the end.
  • Esc interrupt settles in ~4s.
  • API error (hit a content filter mid-test) also returned the title to .

Open question

With the title working this well and needing no install, claudeHooks.enable
may not be worth its footprint — it writes a host-wide, highest-precedence
/etc/claude-code/managed-settings.json for a fallback that may never fire.
Happy to drop it if you prefer; left in for now.

Follow-up to #21, which was still not good enough. ## The signal we were missing Claude Code announces its state in the **terminal title** (OSC 0/2), and tmux tracks it per pane as `#{pane_title}` with no configuration at all: ⠂ Debug non-deterministic session detection <- turn running (braille spinner) ✳ Claude Code <- waiting on you Verified live across every channel on nix-dev: altercos idle src=title ✳ Claude Code forge busy src=title ⠂ Debug non-deterministic session detection nixos idle src=title ✳ Review and clean up old items starting at 80 wine-radar idle src=title ✳ Re-enable and fix items 5 and 6 ## Why this beats what #21 shipped | | pane scrape | transcript | hooks | **title** | |---|---|---|---|---| | correct mid-turn | no | no | yes | **yes** | | per-session (not per-cwd) | yes | **no** | yes | **yes** | | works on a detached session | **no** | yes | yes | **yes** | | works without installing anything | yes | yes | **no** | **yes** | | survives Esc interrupt | — | — | **no** | **yes** | | survives API error | — | — | **no** | **yes** | | cost per poll | 1 subprocess/channel | 1 stat/channel | 1 read | **free** | The Esc case, measured live — hooks fire no `Stop`, so #21 held the dot lit until the 15-minute TTL. Now: before prompt dot=idle src=title mid-turn dot=busy src=title 4s after Esc dot=idle src=title ## Changes - `#{pane_title}` added to the existing `list-sessions` format; title is now the primary signal, hooks the fallback, transcript the last resort. - **Pane scraping removed entirely.** It was wrong in both directions (see #21) and is now redundant. `/status` is a *single* tmux call regardless of channel count — previously one `capture-pane` per channel per poll. - `source` in the response is now `title` | `hook` | `transcript`. ## Credit Technique taken from [herdr](https://github.com/herdrdev/herdr) (Apache-2.0), which uses the same OSC title as its highest-priority signal. Notably they *removed* a hook-based design essentially identical to #21 — their changelog still carries the uninstall list for `UserPromptSubmit`/`PreToolUse`/`Stop`/ `SessionEnd` hooks — citing escape interrupts and permission results as the reason. No code taken, only the observation. ## Test plan - [x] `nix flake check`. - [x] Classifier matrix: title wins over stale hook state in both directions; hook fallback + TTL intact; transcript last resort; shell guard intact. - [x] Live across all 6 real channels — every one resolves `src=title`, with **no hooks installed** (a concurrent `nixos-rebuild switch` had reverted the test activation, which incidentally proves the zero-install claim). - [x] Full turn on a **detached** session: `✳` -> braille for the whole turn -> `✳` exactly at the end. - [x] Esc interrupt settles in ~4s. - [x] API error (hit a content filter mid-test) also returned the title to `✳`. ## Open question With the title working this well and needing no install, `claudeHooks.enable` may not be worth its footprint — it writes a host-wide, highest-precedence `/etc/claude-code/managed-settings.json` for a fallback that may never fire. Happy to drop it if you prefer; left in for now.
forge: read the status dots from Claude's own terminal title
All checks were successful
CI / check (pull_request) Successful in 14s
7ccd14a38a
Claude Code announces its state in the terminal title (OSC 0/2), and tmux
tracks that per pane as #{pane_title} with no configuration:

    ⠂ Debug non-deterministic session detection   <- a turn is running
    ✳ Claude Code                                 <- waiting on the user

Make that the primary signal. It beats everything tried so far: it comes from
Claude itself rather than being inferred, it is per-pane rather than
per-directory, it needs nothing installed so it is already correct on sessions
that predate any deploy, and it lives in the byte stream — so unlike the
rendered pane it is just as correct on a detached session.

It also closes the hole hooks structurally cannot. Esc-interrupting a turn
fires no Stop event, and neither does an API error; both return the title to
the idle glyph at once. Measured on a live session, Esc now settles the dot in
about 4s where the hook path held it lit until HOOK_BUSY_TTL — fifteen
minutes.

Hooks drop to being the fallback for anything that sets no title, still
bounded by the TTL. The transcript mtime stays as a last resort.

Drop the pane scrape entirely. It was wrong in both directions — the mark it
keyed on had silently stopped matching anything, while still firing on any
pane that merely *displayed* the phrase — and the title supersedes it. That
also removes a capture-pane subprocess per channel per poll, so /status is now
a single tmux call regardless of channel count.

Credit for the technique: herdr (github.com/herdrdev/herdr, Apache-2.0), which
uses the same OSC title as its highest-priority signal after finding
hook-based lifecycle tracking insufficient for exactly these cases. No code
taken.
forge: drop the hook machinery, the terminal title covers it
All checks were successful
CI / check (pull_request) Successful in 7s
b1e8467a7c
The title is a strictly better signal than the hooks were, so keeping both
bought nothing but a second thing to maintain. Removed: the hook script, the
managed-settings.json rendering, the claudeHooks option, the state directory,
the TTL, and the whole hook-reading path in /status.

Worth being explicit that this is not a downgrade. The hooks could not observe
a turn ending by Esc interrupt or API error — neither fires a Stop event — so
they needed a 15-minute expiry to avoid stranding a lit dot. The title reports
both immediately. They also required deploying an /etc file that applied
host-wide at the highest settings precedence, and only took effect on sessions
started afterwards; the title needs nothing installed and was already correct
on every channel running at the time.

What's left is one signal with one fallback: Claude's terminal title, then a
transcript mtime for a session that sets no title at all.
Author
Owner

Hooks dropped

Removed the whole hook path: the script, the managed-settings.json
rendering, the claudeHooks option, the state directory, the TTL, and the
hook-reading branch in /status. Net −192 lines.

Not a downgrade — the hooks were strictly the weaker signal:

hooks title
sees a turn end by Esc no (needed a 15m TTL) yes, immediately
sees a turn end by API error no yes, immediately
works on already-running sessions no yes
needs an /etc file at highest precedence yes, host-wide no
cost per poll a file read per channel free

What's left is one signal plus one fallback: Claude's terminal title, then a
transcript mtime for anything that sets no title.

Re-verified after removal — all six live channels, still correct:

altercos    idle  src=title
forge       busy  src=title      <- the one actually working
nixos       idle  src=title
wine-radar  idle  src=title
xloora      idle  src=title
zendesk     idle  src=title

nix flake check passes; classifier matrix re-run (title both ways, transcript
fallback, empty-title case, plain-shell guard).

## Hooks dropped Removed the whole hook path: the script, the `managed-settings.json` rendering, the `claudeHooks` option, the state directory, the TTL, and the hook-reading branch in `/status`. **Net −192 lines.** Not a downgrade — the hooks were strictly the weaker signal: | | hooks | title | |---|---|---| | sees a turn end by Esc | no (needed a 15m TTL) | yes, immediately | | sees a turn end by API error | no | yes, immediately | | works on already-running sessions | no | yes | | needs an `/etc` file at highest precedence | yes, host-wide | no | | cost per poll | a file read per channel | free | What's left is one signal plus one fallback: Claude's terminal title, then a transcript mtime for anything that sets no title. Re-verified after removal — all six live channels, still correct: altercos idle src=title forge busy src=title <- the one actually working nixos idle src=title wine-radar idle src=title xloora idle src=title zendesk idle src=title `nix flake check` passes; classifier matrix re-run (title both ways, transcript fallback, empty-title case, plain-shell guard).
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!23
No description provided.