diff --git a/CLAUDE.md b/CLAUDE.md index 1502b558..9f9693e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. +⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no `❯`, so nothing would ever arm the confirmation and the session would latch busy. + **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) **Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 769705ab..08e95fdb 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -276,4 +276,13 @@ export interface TerminalMultiplexer extends EventEmitter { * Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47). */ captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null; + + /** + * Plain text of the visible frame: no styles, no cursor query, no repaint + * reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle + * detection calls it on a timer: it only needs to read what the CLI is + * currently rendering, never to replay it into an xterm. Returns null when the + * pane cannot be read. + */ + capturePaneText?(muxName: string, paneTarget?: string): string | null; } diff --git a/src/respawn-patterns.ts b/src/respawn-patterns.ts index cad91e49..9505fea9 100644 --- a/src/respawn-patterns.ts +++ b/src/respawn-patterns.ts @@ -8,7 +8,7 @@ * @module respawn-patterns */ -import { TOKEN_PATTERN } from './utils/index.js'; +import { TOKEN_PATTERN, CLAUDE_WORKING_LINE_PATTERN } from './utils/index.js'; // ========== Constants ========== @@ -108,7 +108,12 @@ export function isCompletionMessage(data: string): boolean { * @returns True if any working pattern is found in the window */ export function hasWorkingPattern(window: string): boolean { - return WORKING_PATTERNS.some((pattern) => window.includes(pattern)); + // Current Claude randomizes the gerund ("Actualizing…", "Finagling…"), so the + // list above catches only a fraction of turns. The live status line's own shape + // (`… (13m 23s · ↓ 47.5k tokens)`) is what identifies the rest. Kept as an + // extra signal rather than a replacement: this window is RAW terminal data, and + // a partial repaint can split the line across chunks. + return CLAUDE_WORKING_LINE_PATTERN.test(window) || WORKING_PATTERNS.some((pattern) => window.includes(pattern)); } /** diff --git a/src/session-activity.ts b/src/session-activity.ts new file mode 100644 index 00000000..324aff1b --- /dev/null +++ b/src/session-activity.ts @@ -0,0 +1,93 @@ +/** + * @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; +} diff --git a/src/session.ts b/src/session.ts index 985e74b2..1d2a0364 100644 --- a/src/session.ts +++ b/src/session.ts @@ -59,11 +59,21 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; +import { + trackActivityStreak, + isSustainedActivity, + isPaneQuiet, + IDLE_RECHECK_MS, + PANE_PROBE_MIN_INTERVAL_MS, + PANE_PROBE_RECHECK_MS, + type ActivityStreak, +} from './session-activity.js'; import { BufferAccumulator, ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, + CLAUDE_WORKING_LINE_PATTERN, MAX_SESSION_TOKENS, execPattern, getClaudeCliVersion, @@ -376,6 +386,9 @@ export class Session extends EventEmitter { private _lastPromptTime: number = 0; private activityTimeout: NodeJS.Timeout | null = null; private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection + private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection) + private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe + private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read) private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept private _taskTracker: TaskTracker; @@ -1762,44 +1775,7 @@ export class Session extends EventEmitter { } // === Idle/working detection runs on every chunk (latency-sensitive) === - // Detect if Claude is working or at prompt - // The prompt line contains "❯" when waiting for input - if (data.includes('❯') || data.includes('\u276f')) { - // Only start a new timeout if we're not already awaiting idle confirmation - // This prevents status bar redraws (which include ❯) from resetting the timer - if (!this._awaitingIdleConfirmation) { - if (this.activityTimeout) clearTimeout(this.activityTimeout); - this._awaitingIdleConfirmation = true; - this.activityTimeout = setTimeout(() => { - this._awaitingIdleConfirmation = false; - // Emit idle if either: - // 1. Claude was working and is now at prompt (normal case) - // 2. Session just started and is ready (status is 'busy' but _isWorking is false) - const wasWorking = this._isWorking; - const isInitialReady = this._status === 'busy' && !this._isWorking; - if (wasWorking || isInitialReady) { - this._isWorking = false; - this._status = 'idle'; - this._lastPromptTime = Date.now(); - this.emit('idle'); - } - }, IDLE_DETECTION_DELAY_MS); - } - } - - // Detect when Claude starts working (thinking, writing, etc) - // Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences) - const hasSpinner = SPINNER_PATTERN.test(data); - if (hasSpinner) { - if (!this._isWorking) { - this._isWorking = true; - this._status = 'busy'; - this.emit('working'); - this._autoOps.notifyWorking(); - } - this._awaitingIdleConfirmation = false; - if (this.activityTimeout) clearTimeout(this.activityTimeout); - } + this._detectInteractiveActivity(data); // === Expensive processing (ANSI strip, Ralph, bash parser) is throttled === // Instead of running regex-heavy parsers on every PTY chunk, we accumulate @@ -1848,6 +1824,7 @@ export class Session extends EventEmitter { this._pid = null; this._status = 'idle'; this._awaitingIdleConfirmation = false; + this._activityStreak = null; // Clear all timers to prevent memory leaks if (this.activityTimeout) { clearTimeout(this.activityTimeout); @@ -1903,6 +1880,134 @@ export class Session extends EventEmitter { return this._respawnBlocked; } + /** + * Per-chunk working/idle detection for an interactive pane. Split out of the + * PTY `onData` handler so it can be unit tested without spawning one. + * + * @param data raw PTY chunk, ANSI included + */ + private _detectInteractiveActivity(data: string): void { + // The prompt line contains "❯" when Claude is waiting for input. It only ARMS + // the check and is NOT evidence the turn ended: Claude redraws the composer + // about once a second all the way through a turn, which is exactly how a + // working session used to flip to idle two seconds in. _confirmIdle() waits + // for the pane to actually go quiet before believing it. + if (data.includes('❯')) { + // Only start a new timeout if we're not already awaiting idle confirmation. + // This prevents status bar redraws (which include the prompt) from resetting it. + if (!this._awaitingIdleConfirmation) { + if (this.activityTimeout) clearTimeout(this.activityTimeout); + this._awaitingIdleConfirmation = true; + this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_DETECTION_DELAY_MS); + } + } + + // Detect when Claude starts working (thinking, writing, etc). + // Fast path: spinner characters on raw data (Unicode, never inside ANSI sequences). + if (SPINNER_PATTERN.test(data)) this._markWorking(); + + // Activity fallback: current Claude Code animates `✻ Actualizing…` instead of a + // braille spinner, so the fast path above misses entire turns, and matching the + // new status line does not rescue it either (tmux repaints partially, so the + // complete line reaches the PTY only every few tens of seconds). An unbroken run + // of repaints is the signal that survives. See session-activity.ts for the + // measurement. Claude only: an external CLI's TUI has no ❯, so nothing would + // ever arm the idle confirmation and such a session would latch busy forever. + if (!isExternalCliMode(this.mode)) { + this._activityStreak = trackActivityStreak(this._activityStreak, Date.now()); + // A streak is the TRIGGER to look, not the verdict: typing into the composer + // also produces a steady stream of repaints. The screen settles it, and only + // an explicit "no working line" vetoes; a probe that cannot read the pane + // (null) leaves the streak in charge. + if (!this._isWorking && isSustainedActivity(this._activityStreak) && this._probePaneWorking() !== false) { + this._markWorking(); + } + } + } + + /** + * Ask the pane what it is rendering right now. + * + * The PTY stream cannot answer this on its own: measured on a live worker, + * Claude repaints roughly once a second for most of a turn but can then sit + * completely silent for tens of seconds inside a single tool call, while the + * `✻ Elucidating… (39s · ↓ 2.0k tokens)` line stays on screen the whole time. + * Silence therefore proves nothing, and the rendered frame is the only cheap + * source that is right in both directions. + * + * Costs one `capture-pane`, floored at PANE_PROBE_MIN_INTERVAL_MS per session + * and only ever called at a transition, never on the output hot path. + * + * @returns true/false when the screen could be read, null when it could not + * (no mux, capture failed, tests). Callers must treat null as "no evidence" + * and fall back to their stream heuristics. + */ + private _probePaneWorking(): boolean | null { + if (!this._mux || !this._muxSession) return null; + const now = Date.now(); + if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking; + this._lastPaneProbeAt = now; + const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null; + this._lastPaneProbeWorking = text === null ? null : CLAUDE_WORKING_LINE_PATTERN.test(text); + return this._lastPaneProbeWorking; + } + + /** + * Mark the pane as working. Idempotent: `working` is emitted on the transition + * only, so the per-chunk detectors can all call it freely. + * + * Deliberately does NOT cancel a pending idle confirmation. That confirmation + * is what eventually notices the turn ended, and it already refuses to fire + * while the pane is noisy, and cancelling it here would leave a session that + * finished during a lull with nothing armed to ever call it idle. + */ + private _markWorking(): void { + if (this._isWorking) return; + this._isWorking = true; + this._status = 'busy'; + this.emit('working'); + this._autoOps.notifyWorking(); + } + + /** + * Decide whether the armed idle confirmation is real. + * + * A ❯ sighting alone means nothing (Claude redraws the composer through the + * whole turn), so the pane must ALSO have gone quiet. While output is still + * flowing the check re-arms instead of concluding. That loop is a timestamp + * compare every IDLE_RECHECK_MS and ends the moment the pane falls silent. + */ + private _confirmIdle(): void { + if (this._isStopped) { + this._awaitingIdleConfirmation = false; + return; + } + if (!isPaneQuiet(this._lastActivityAt, Date.now())) { + this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_RECHECK_MS); + return; // stays _awaitingIdleConfirmation, so ❯ redraws do not pile up timers + } + // Quiet is necessary but NOT sufficient: a turn can go silent mid-tool-call. + // Ask the screen before concluding, and keep asking on a slow cadence. + if (this._probePaneWorking() === true) { + this._markWorking(); + this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS); + return; + } + this._awaitingIdleConfirmation = false; + this.activityTimeout = null; + // Emit idle if either: + // 1. Claude was working and is now at prompt (normal case) + // 2. Session just started and is ready (status is 'busy' but _isWorking is false) + const wasWorking = this._isWorking; + const isInitialReady = this._status === 'busy' && !this._isWorking; + if (wasWorking || isInitialReady) { + this._isWorking = false; + this._status = 'idle'; + this._lastPromptTime = Date.now(); + this.emit('idle'); + } + } + /** * Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions). * Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every @@ -1953,22 +2058,22 @@ export class Session extends EventEmitter { this.parseTaskDescriptionsFromTerminalData(getCleanData()); } - // Work keyword detection (text-based, needs clean data) - // Only check if spinner didn't already trigger working state + // Work detection (text-based, needs clean data: the status line is coloured, + // so raw data has escape sequences between the `…` and the elapsed timer). + // Only check if a faster path didn't already trigger working state. if (!this._isWorking) { const cleanData = getCleanData(); if ( + CLAUDE_WORKING_LINE_PATTERN.test(cleanData) || + // Legacy gerunds. Current Claude randomizes the word ("Actualizing…", + // "Finagling…"), so these catch only a fraction of turns; the pattern + // above and the activity streak carry the rest. cleanData.includes('Thinking') || cleanData.includes('Writing') || cleanData.includes('Reading') || cleanData.includes('Running') ) { - this._isWorking = true; - this._status = 'busy'; - this.emit('working'); - this._autoOps.notifyWorking(); - this._awaitingIdleConfirmation = false; - if (this.activityTimeout) clearTimeout(this.activityTimeout); + this._markWorking(); } } } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index aa186b5a..3d83ce04 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3175,6 +3175,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Used for full page reloads so the user gets back their scroll history. * Caveat: lines tmux has already evicted past its history-limit are gone. */ + /** + * Plain visible-frame text for the working/idle probe (see `session.ts`). + * + * One `capture-pane` and nothing else: no `-e` styles, no `display-message` + * cursor query, no repaint reconstruction: this feeds a regex, not a + * terminal. Returns null in tests (no tmux) so callers fall back to their + * stream heuristics rather than reading an empty screen as "not working". + */ + capturePaneText(muxName: string, paneTarget?: string): string | null { + if (IS_TEST_MODE) return null; + const target = resolveTmuxPaneTarget(muxName, paneTarget); + if (!target) return null; + try { + return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); + } catch { + // A dead/renamed pane is an ordinary outcome here, not an error worth logging + // on a timer; the caller treats null as "no evidence either way". + return null; + } + } + capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null { if (IS_TEST_MODE) return ''; const target = resolveTmuxPaneTarget(muxName, paneTarget); diff --git a/src/utils/index.ts b/src/utils/index.ts index 4c813f42..45ffb9f8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -17,6 +17,7 @@ export { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, SPINNER_PATTERN, + CLAUDE_WORKING_LINE_PATTERN, stripAnsi, SAFE_PATH_PATTERN, execPattern, diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 6cf28fc1..228dcfc6 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -60,6 +60,24 @@ export function stripAnsi(text: string): string { */ export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; +/** + * Claude Code's live working status line, e.g. + * `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)` + * `✽ Herding… (3s · esc to interrupt)` + * + * Matched on the ELLIPSIS + elapsed timer, never on the leading glyph: the + * animation cycles through `· ✢ ✳ ∗ ✻ ✽` (two of those are ordinary punctuation) + * and the gerund is randomized per turn, while the finished line (`✻ Cooked for + * 2m 49s`) carries the same glyph with no `…` and no parenthesis. Feed this + * ANSI-STRIPPED data: tmux colours the timer separately, so the raw stream has + * escape sequences sitting between the `…` and the `(`. + * + * A sighting is proof the pane is working; its ABSENCE proves nothing, because + * tmux repaints partially and the whole line reaches the PTY only occasionally + * (see `session-activity.ts` for what carries the idle decision instead). + */ +export const CLAUDE_WORKING_LINE_PATTERN = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt/; + export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u; /** diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 72f17aab..8177e262 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2522,6 +2522,51 @@ html.mobile-init .file-browser-panel { border-color: var(--red); } + /* Working is not an alert, so it gets a calm green breathing edge rather than a + blink: at a glance the row reads "this one is moving", without competing with + the two states that actually want you. Slower than both of them on purpose. */ + .mobile-overview-row--working { + border-color: var(--green); + animation: mobile-overview-breathe-green 2.2s ease-in-out infinite; + } + + @keyframes mobile-overview-breathe-green { + 0%, + 100% { + background: var(--bg-card); + border-color: var(--border); + } + 50% { + background: rgba(34, 197, 94, 0.1); + border-color: var(--green); + } + } + + /* The pill picks up a three-dot ellipsis that fills in and empties, so the row + still reads as active on a skin where the border tint is subtle. */ + .mobile-overview-pill--working::after { + content: ''; + display: inline-block; + width: 0.75em; + text-align: left; + animation: mobile-overview-pill-dots 1.5s steps(1, end) infinite; + } + + @keyframes mobile-overview-pill-dots { + 0% { + content: ''; + } + 25% { + content: '.'; + } + 50% { + content: '..'; + } + 75% { + content: '...'; + } + } + @keyframes mobile-overview-blink-red { 0%, 100% { @@ -2603,6 +2648,25 @@ html.mobile-init .file-browser-panel { will-change: opacity; } + /* Ring the pulsing dot with the SAME spinner a tab shows while it loads: same + 2px ring, same bright leading edge, same `tab-load-spin` keyframes from + styles.css (reused, not re-declared, so the two can never drift). Green + rather than the tab's blue because here it means "running", not "loading": + the motion is the shared part, the color still belongs to the state. */ + .mobile-overview-dot { + position: relative; + } + + .mobile-overview-dot--working::after { + content: ''; + position: absolute; + inset: -4px; + border: 2px solid rgba(34, 197, 94, 0.25); + border-top-color: var(--green); + border-radius: 50%; + animation: tab-load-spin 0.7s linear infinite; + } + .mobile-overview-dot--idle { background: var(--green); } @@ -2713,6 +2777,24 @@ html.mobile-init .file-browser-panel { .mobile-overview-dot--working { animation: none; } + + /* The ring stays as a static full circle: it still marks the row, it just + stops turning. */ + .mobile-overview-dot--working::after { + border-color: var(--green); + animation: none; + } + + /* Working is only informational, so it drops to a static green edge and a + static ellipsis rather than holding a tint the way the alerts do. */ + .mobile-overview-row--working { + animation: none; + } + + .mobile-overview-pill--working::after { + content: '...'; + animation: none; + } } /* Light-skin compatibility for mobile-only chrome. These components predate diff --git a/test/respawn-patterns.test.ts b/test/respawn-patterns.test.ts index 3060d8cb..60442c1f 100644 --- a/test/respawn-patterns.test.ts +++ b/test/respawn-patterns.test.ts @@ -115,6 +115,21 @@ describe('hasWorkingPattern', () => { }); }); + describe('current Claude status line', () => { + it('should detect the randomized gerund by the elapsed timer', () => { + // Live captures on Claude Code 2.1.220. The word changes every turn, so the + // WORKING_PATTERNS list above cannot see any of these. + expect(hasWorkingPattern('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true); + expect(hasWorkingPattern('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true); + expect(hasWorkingPattern('✽ Herding… (3s · esc to interrupt)')).toBe(true); + }); + + it('should NOT treat the completion line as working', () => { + expect(hasWorkingPattern('✻ Cooked for 2m 49s')).toBe(false); + expect(hasWorkingPattern('✻ Brewed for 18m 41s')).toBe(false); + }); + }); + describe('spinner characters', () => { it('should detect braille spinner characters', () => { expect(hasWorkingPattern('Loading... \u280B')).toBe(true); diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts new file mode 100644 index 00000000..a95337f9 --- /dev/null +++ b/test/session-activity.test.ts @@ -0,0 +1,248 @@ +/** + * Working/idle detection for an interactive Claude pane. + * + * The bug this pins: Claude redraws the composer (`❯`) about once a second all + * the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule + * flipped a busy session to idle two seconds into every turn. Measured on a live + * worker: `GET /api/sessions` reported `idle` for a session that had been + * running for 17 minutes and was mid-tool-call. + * + * The status-line fixtures below are verbatim captures from live panes + * (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220. + */ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { Session } from '../src/session.js'; +import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js'; +import { + trackActivityStreak, + isSustainedActivity, + isPaneQuiet, + ACTIVITY_GAP_MS, + WORKING_STREAK_MS, + IDLE_SILENCE_MS, +} from '../src/session-activity.js'; + +type SessionInternals = { + _handleTerminalOutput(data: string): void; + _detectInteractiveActivity(data: string): void; +}; + +/** One PTY chunk: what the pane emitted, exactly as the interactive handler sees it. */ +function feed(session: Session, data: string): void { + const internals = session as unknown as SessionInternals; + internals._handleTerminalOutput(data); + internals._detectInteractiveActivity(data); +} + +/** + * A session whose mux reports a fixed (or scripted) screen, so the pane probe has + * something to read. Only `capturePaneText` is exercised by these paths. + */ +function withFakePane(screen: string | (() => string)): Session { + const read = typeof screen === 'function' ? screen : () => screen; + const mux = { + isAvailable: () => true, + capturePaneText: () => read(), + } as unknown as NonNullable[0]>['mux']; + return new Session({ + workingDir: '/tmp', + mode: 'claude', + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() }, + } as ConstructorParameters[0]); +} + +/** A composer repaint: the frame Claude ships roughly once a second while working. */ +const COMPOSER_REPAINT = + '\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m'; + +describe('CLAUDE_WORKING_LINE_PATTERN', () => { + it('matches the live status line, whatever the glyph and gerund are', () => { + // Captured from three different live panes: the glyph animates through + // `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn, so neither is matchable. + expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true); + expect(CLAUDE_WORKING_LINE_PATTERN.test('* Implementing the backend… (18m 59s · ↓ 69.9k tokens)')).toBe(true); + expect(CLAUDE_WORKING_LINE_PATTERN.test('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true); + expect(CLAUDE_WORKING_LINE_PATTERN.test('✽ Herding… (3s · esc to interrupt)')).toBe(true); + }); + + it('does not match the FINISHED line, which carries the same glyph', () => { + // `✻ Cooked for 2m 49s` sits on screen for the whole idle period afterwards. + // Matching the glyph alone would pin such a session at "working" forever. + expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Cooked for 2m 49s')).toBe(false); + expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Brewed for 18m 41s')).toBe(false); + expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Worked for 2m 46s')).toBe(false); + }); + + it('ignores ordinary prose and the idle footer', () => { + expect(CLAUDE_WORKING_LINE_PATTERN.test(COMPOSER_REPAINT)).toBe(false); + expect(CLAUDE_WORKING_LINE_PATTERN.test(' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents')).toBe( + false + ); + expect(CLAUDE_WORKING_LINE_PATTERN.test('the build took 45s to finish')).toBe(false); + }); +}); + +describe('activity streak helpers', () => { + it('extends a streak while chunks keep arriving', () => { + let streak = trackActivityStreak(null, 1000); + streak = trackActivityStreak(streak, 2000); + streak = trackActivityStreak(streak, 3000); + expect(streak).toEqual({ startedAt: 1000, lastAt: 3000 }); + }); + + it('restarts the streak after a gap', () => { + const first = trackActivityStreak(null, 1000); + const after = trackActivityStreak(first, 1000 + ACTIVITY_GAP_MS + 1); + expect(after.startedAt).toBe(1000 + ACTIVITY_GAP_MS + 1); + }); + + it('calls it working only once the streak spans the threshold', () => { + expect(isSustainedActivity(null)).toBe(false); + expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS - 1 })).toBe(false); + expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS })).toBe(true); + }); + + it('measures the streak on its own span, so a stale streak cannot age into working', () => { + // A single old chunk stays a single chunk no matter how much later we ask. + const oneChunk = { startedAt: 0, lastAt: 0 }; + expect(isSustainedActivity(oneChunk)).toBe(false); + }); + + it('calls the pane quiet only after the silence window', () => { + expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS - 1)).toBe(false); + expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS)).toBe(true); + }); +}); + +describe('Session interactive idle detection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('stays busy through a long turn of composer repaints', () => { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const events: string[] = []; + session.on('idle', () => events.push('idle')); + session.on('working', () => events.push('working')); + + // 30 seconds of the once-a-second repaint a working pane emits. Every one of + // these carries a ❯; the old rule went idle after the first two seconds. + for (let i = 0; i < 30; i++) { + feed(session, COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + + expect(events).toEqual(['working']); + expect(session.status).toBe('busy'); + }); + + it('goes idle once the pane falls silent', () => { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const events: string[] = []; + session.on('idle', () => events.push('idle')); + + for (let i = 0; i < 5; i++) { + feed(session, COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + expect(events).toEqual([]); + + // Turn over: nothing more is emitted. + vi.advanceTimersByTime(IDLE_SILENCE_MS + 1000); + + expect(events).toEqual(['idle']); + expect(session.status).toBe('idle'); + }); + + it('emits idle once, not once per re-check', () => { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const events: string[] = []; + session.on('idle', () => events.push('idle')); + + for (let i = 0; i < 4; i++) { + feed(session, COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + vi.advanceTimersByTime(60_000); + + expect(events).toEqual(['idle']); + }); + + it('refuses to go idle while the screen still shows the working line', () => { + vi.useFakeTimers(); + // A turn can go completely silent inside one tool call (measured at 20+ + // seconds on a live worker) while `✻ Elucidating… (39s · ↓ 2.0k tokens)` + // sits on screen the whole time. Silence alone must not end the turn. + const session = withFakePane('✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n'); + const events: string[] = []; + session.on('idle', () => events.push('idle')); + + for (let i = 0; i < 3; i++) { + feed(session, COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + vi.advanceTimersByTime(60_000); // silent for a minute + + expect(events).toEqual([]); + expect(session.status).toBe('busy'); + }); + + it('goes idle once the working line leaves the screen', () => { + vi.useFakeTimers(); + const pane = { text: '✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n' }; + const session = withFakePane(() => pane.text); + const events: string[] = []; + session.on('idle', () => events.push('idle')); + + for (let i = 0; i < 3; i++) { + feed(session, COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + vi.advanceTimersByTime(20_000); + expect(events).toEqual([]); + + // Turn over: the same glyph remains, on the FINISHED line this time. + pane.text = '✻ Cooked for 2m 49s\n❯ \n'; + vi.advanceTimersByTime(20_000); + + expect(events).toEqual(['idle']); + expect(session.status).toBe('idle'); + }); + + it('does not call typing into the composer "working"', () => { + vi.useFakeTimers(); + // Keystroke echo is a steady stream of repaints too, so the streak alone + // would call it work. The screen has no working line, which vetoes it. + const session = withFakePane('❯ some prompt being typed\n'); + const events: string[] = []; + session.on('working', () => events.push('working')); + + for (let i = 0; i < 10; i++) { + feed(session, '\x1b[31;3Hx'); + vi.advanceTimersByTime(300); + } + + expect(events).toEqual([]); + expect(session.status).toBe('idle'); + }); + + it('does not mark an external CLI pane working off raw activity', () => { + vi.useFakeTimers(); + // Codex/Gemini/OpenCode render their own TUIs and have no ❯, so nothing would + // arm the idle confirmation, so a session marked working here would never recover. + const session = new Session({ workingDir: '/tmp', mode: 'codex' }); + const events: string[] = []; + session.on('working', () => events.push('working')); + + for (let i = 0; i < 10; i++) { + feed(session, '\x1b[2K▌ Working (12s)'); + vi.advanceTimersByTime(1000); + } + + expect(events).toEqual([]); + }); +});