mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
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>
94 lines
3.7 KiB
TypeScript
94 lines
3.7 KiB
TypeScript
/**
|
||
* @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;
|
||
}
|