Status dots flicker: session-active detection is nondeterministic #20

Closed
opened 2026-08-03 07:54:30 +01:00 by pedromcaraujo · 0 comments

Symptom

The sidebar status dot flips between green (busy) and dim (idle) across
refreshes with nothing actually changing in the channel.

Cause

GET /status in web/tmux_ctl.py derives state from two signals, OR'd
together in _classify():

  • Atmux capture-pane -p -t <name> grepped for esc to interrupt.
  • B — newest ~/.claude/projects/<slugged-cwd>/*.jsonl mtime within
    BUSY_WINDOW = 45s.

Neither is authoritative, and the OR flips:

  1. A is a snapshot of a repainting TUI. The spinner line is rewritten
    continuously. A poll landing during a redraw, or while a long tool output
    has scrolled the hint out of the visible region, simply misses it — next
    poll it's back. This alone produces green -> dim -> green on refresh.
  2. A only sees the session's active window/pane. A turn running in a
    split or background window is invisible to it.
  3. B doesn't cover a long turn. Claude appends at message boundaries, so
    a multi-minute thinking/tool stretch writes nothing and age > 45,
    leaving A as the sole signal — see (1). Symmetrically, the dot stays green
    for 45s after a turn ends.
  4. B is per-directory, not per-session. Two channels rooted at the same
    cwd light together; a claude -p from any shell in that cwd lights both.

So A is precise but unreliably sampled; B is reliably sampled but imprecise.

Fix

Take the state from Claude Code itself via hooks rather than inferring it
from the screen. A hook script brackets the turn (UserPromptSubmit -> busy,
Stop -> idle, PreToolUse/PostToolUse as heartbeat, Notification ->
idle since a permission prompt is "waiting on you") and writes a per-channel
state file the sidecar reads.

Caveats that keep this a hybrid rather than a pure replacement:

  • Stop does not fire on an Esc interrupt, nor when the session is
    killed. So the sidecar still needs liveness guards: pid check for the kill
    case, and pane-mark hysteresis (idle only after N consecutive misses) for
    the Esc case.
  • Sessions predating the hooks, or a deploy where hooks aren't wired, must
    fall back to today's logic.

Hysteresis on the pane signal is worth having regardless — it kills the
redraw-race flicker without changing anything else.

## Symptom The sidebar status dot flips between green (busy) and dim (idle) across refreshes with nothing actually changing in the channel. ## Cause `GET /status` in `web/tmux_ctl.py` derives state from two signals, OR'd together in `_classify()`: - **A** — `tmux capture-pane -p -t <name>` grepped for `esc to interrupt`. - **B** — newest `~/.claude/projects/<slugged-cwd>/*.jsonl` mtime within `BUSY_WINDOW = 45s`. Neither is authoritative, and the OR flips: 1. **A is a snapshot of a repainting TUI.** The spinner line is rewritten continuously. A poll landing during a redraw, or while a long tool output has scrolled the hint out of the visible region, simply misses it — next poll it's back. This alone produces green -> dim -> green on refresh. 2. **A only sees the session's active window/pane.** A turn running in a split or background window is invisible to it. 3. **B doesn't cover a long turn.** Claude appends at message boundaries, so a multi-minute thinking/tool stretch writes nothing and `age > 45`, leaving A as the sole signal — see (1). Symmetrically, the dot stays green for 45s after a turn ends. 4. **B is per-directory, not per-session.** Two channels rooted at the same cwd light together; a `claude -p` from any shell in that cwd lights both. So A is precise but unreliably sampled; B is reliably sampled but imprecise. ## Fix Take the state from Claude Code itself via hooks rather than inferring it from the screen. A hook script brackets the turn (`UserPromptSubmit` -> busy, `Stop` -> idle, `PreToolUse`/`PostToolUse` as heartbeat, `Notification` -> idle since a permission prompt is "waiting on you") and writes a per-channel state file the sidecar reads. Caveats that keep this a hybrid rather than a pure replacement: - `Stop` does **not** fire on an Esc interrupt, nor when the session is killed. So the sidecar still needs liveness guards: pid check for the kill case, and pane-mark hysteresis (idle only after N consecutive misses) for the Esc case. - Sessions predating the hooks, or a deploy where hooks aren't wired, must fall back to today's logic. Hysteresis on the pane signal is worth having regardless — it kills the redraw-race flicker without changing anything else.
Sign in to join this conversation.
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#20
No description provided.