Files
Codeman/src/session-activity.ts
T
Codeman maintainer b03780dfd2 fix(session): decide working/idle from the pane, not the composer redraw
Every working Claude session reported `status: "idle"` about two seconds
into its turn. Measured on live workers: two sessions mid-tool-call at 13
and 17 minutes both read `idle` while their panes showed
`✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`.

Two things had drifted apart:

1. The working indicator changed. Claude animates the glyph through
   `· ✢ ✳ ∗ ✻ ✽` and randomizes the gerund per turn, so neither
   SPINNER_PATTERN (braille, no longer drawn) nor the keyword list
   (Thinking/Writing/Reading/Running) matches a turn anymore.
2. A `❯` sighting is not the end of a turn. Claude redraws the composer
   roughly once a second all the way through one, and that redraw armed
   the "2s later, call it idle" timer.

Matching the new status line in the STREAM does not fix it either: tmux
ships partial repaints, so the complete line reached the PTY about once
every 20 seconds while the `❯` arrived every second.

So the decision moves off the stream:

- An unbroken run of repaints marks a turn as started. Sampled once a
  second for 12s over six live sessions, the two working ones produced
  output in 12/12 windows and the four idle ones in 0/12. Pure helpers in
  session-activity.ts carry the thresholds.
- Idle now needs the pane to go quiet AND the screen to agree.
  `_confirmIdle()` asks tmux what is rendered (new `capturePaneText()`,
  one plain `capture-pane`, floored at 1.5s per session and only ever at
  a transition) and re-checks every 5s while the screen still shows work.
  A turn can sit silent for tens of seconds inside one tool call, so
  silence alone proves nothing.
- The same screen check vetoes keystroke echo, which is a steady stream
  of repaints too but is not work.

CLAUDE_WORKING_LINE_PATTERN matches the `… (elapsed)` shape rather than
the glyph, because the FINISHED line (`✻ Cooked for 2m 49s`) carries the
same glyph and would otherwise pin a session at working forever.

Claude mode only. An external CLI has no `❯`, so nothing would arm the
confirmation and such a session would latch busy.

respawn-patterns.hasWorkingPattern() had the same blind spot (its gerund
list cannot see "Actualizing"), so it takes the pattern as an extra
signal. That can only make respawn less eager, never more.

Idle now lands about 3 to 5 seconds after a turn ends instead of 2
seconds into one. Verified end to end against a live worker, sampled
against the CLI's own "esc to interrupt" footer as independent ground
truth: busy for all 25s of a turn, idle 3s after it ended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:31:02 +02:00

94 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Pure working/idle heuristics for a Claude interactive pane.
*
* Split out of `session.ts` so the thresholds and the state math are unit
* testable without a PTY (same reasoning as `session-order.ts` /
* `usage-limit-patterns.ts`).
*
* **Why activity and not the status line.** Claude Code's working indicator is
* `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`, where the glyph animates through
* `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn. Neither the braille
* spinner (`SPINNER_PATTERN`) nor the old keyword list (`Thinking|Writing|
* Reading|Running`) matches any of that, so the pane looked idle for a whole
* turn. Matching the new line does not rescue the stream either: tmux ships
* PARTIAL repaints, so measured on a live worker the complete line reached the
* PTY roughly once every 20 seconds, while the composer's `❯` (which is what
* ARMS idle detection) arrived every single second.
*
* What is left is the one thing measured to separate the two states cleanly: a
* working pane repaints, an idle pane emits nothing at all. Sampled once per
* second for 12s across six live sessions, the two working ones produced output
* in 12/12 windows and the four idle ones in 0/12.
*/
/**
* A gap longer than this ends a run of continuous output. Claude repaints at
* least once a second while working, so this leaves generous headroom.
*/
export const ACTIVITY_GAP_MS = 2000;
/**
* Continuous output for this long means the pane is working. Long enough that a
* one-off repaint (an update-check line, a rotating tip) cannot reach it.
*/
export const WORKING_STREAK_MS = 2000;
/**
* Silence for this long is what confirms the pane really went idle. Must stay
* above ACTIVITY_GAP_MS, or a pause between two repaints of one turn would
* read as the end of the turn.
*/
export const IDLE_SILENCE_MS = 2500;
/** How often a pending idle confirmation re-checks a pane that is still noisy. */
export const IDLE_RECHECK_MS = 500;
/**
* Floor between two pane probes for one session. The probe shells out to tmux,
* so this is what keeps a screenful of busy sessions from turning idle detection
* into a subprocess storm.
*/
export const PANE_PROBE_MIN_INTERVAL_MS = 1500;
/**
* How long to wait before looking again at a pane the probe just called working.
* Claude can sit silent for tens of seconds inside one tool call, so this is the
* cadence that carries a long quiet turn, so it is deliberately slow.
*/
export const PANE_PROBE_RECHECK_MS = 5000;
/** An unbroken run of PTY output. */
export interface ActivityStreak {
/** When this run began. */
startedAt: number;
/** The most recent chunk in it. */
lastAt: number;
}
/**
* Fold one output chunk into the current streak, starting a new one when the
* pane has been quiet longer than `gapMs`.
*/
export function trackActivityStreak(
streak: ActivityStreak | null,
now: number,
gapMs: number = ACTIVITY_GAP_MS
): ActivityStreak {
if (!streak || now - streak.lastAt > gapMs) return { startedAt: now, lastAt: now };
return { startedAt: streak.startedAt, lastAt: now };
}
/**
* True once a streak has been running long enough to mean work rather than a
* single repaint. Measured on the streak's own span (`lastAt - startedAt`), not
* against the caller's clock, so a stale streak cannot age into a true.
*/
export function isSustainedActivity(streak: ActivityStreak | null, streakMs: number = WORKING_STREAK_MS): boolean {
return !!streak && streak.lastAt - streak.startedAt >= streakMs;
}
/** True when the pane has produced nothing for long enough to call it idle. */
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
return now - lastActivityAt >= silenceMs;
}