mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #246 from Ark0N/fix/idle-detection-working-state
fix: sessions reported idle while working, plus a working state you can see
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+151
-46
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
CLAUDE_WORKING_LINE_PATTERN,
|
||||
stripAnsi,
|
||||
SAFE_PATH_PATTERN,
|
||||
execPattern,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Parameters<typeof Session.prototype.constructor>[0]>['mux'];
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user