mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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>
137 lines
3.7 KiB
TypeScript
137 lines
3.7 KiB
TypeScript
/**
|
||
* @fileoverview Pure utility functions for terminal pattern detection in respawn controller.
|
||
*
|
||
* Extracted from respawn-controller.ts for modularity. These are stateless functions
|
||
* and constants used to detect completion messages, working patterns, and token counts
|
||
* in terminal output.
|
||
*
|
||
* @module respawn-patterns
|
||
*/
|
||
|
||
import { TOKEN_PATTERN, CLAUDE_WORKING_LINE_PATTERN } from './utils/index.js';
|
||
|
||
// ========== Constants ==========
|
||
|
||
/**
|
||
* Pattern to detect completion messages from Claude Code.
|
||
* Requires "Worked for" prefix to avoid false positives from bare time durations
|
||
* in regular text (e.g., "wait for 5s", "run for 2m").
|
||
*
|
||
* Matches: "Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s"
|
||
* Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..."
|
||
*/
|
||
const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i;
|
||
|
||
/**
|
||
* Patterns indicating Claude is ready for input (legacy fallback).
|
||
* Used as secondary signals, not primary detection.
|
||
*/
|
||
export const PROMPT_PATTERNS = [
|
||
'❯', // Standard prompt
|
||
'\u276f', // Unicode variant
|
||
'⏵', // Claude Code prompt variant
|
||
];
|
||
|
||
/**
|
||
* Patterns indicating Claude is actively working.
|
||
* When detected, resets all idle detection timers.
|
||
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
||
*/
|
||
export const WORKING_PATTERNS = [
|
||
'Thinking',
|
||
'Writing',
|
||
'Reading',
|
||
'Running',
|
||
'Searching',
|
||
'Editing',
|
||
'Creating',
|
||
'Deleting',
|
||
'Analyzing',
|
||
'Executing',
|
||
'Synthesizing',
|
||
'Brewing', // Claude's processing indicators
|
||
'Compiling',
|
||
'Building',
|
||
'Installing',
|
||
'Fetching',
|
||
'Downloading',
|
||
'Processing',
|
||
'Generating',
|
||
'Loading',
|
||
'Starting',
|
||
'Updating',
|
||
'Checking',
|
||
'Validating',
|
||
'Testing',
|
||
'Formatting',
|
||
'Linting',
|
||
'⠋',
|
||
'⠙',
|
||
'⠹',
|
||
'⠸',
|
||
'⠼',
|
||
'⠴',
|
||
'⠦',
|
||
'⠧',
|
||
'⠇',
|
||
'⠏', // Spinner chars
|
||
'◐',
|
||
'◓',
|
||
'◑',
|
||
'◒', // Alternative spinners
|
||
'⣾',
|
||
'⣽',
|
||
'⣻',
|
||
'⢿',
|
||
'⡿',
|
||
'⣟',
|
||
'⣯',
|
||
'⣷', // Braille spinners
|
||
];
|
||
|
||
/**
|
||
* Check if data contains a completion message pattern.
|
||
* Matches "Worked for Xh Xm Xs" time duration patterns.
|
||
*
|
||
* @param data - Raw terminal output data
|
||
* @returns True if completion message pattern is found
|
||
*/
|
||
export function isCompletionMessage(data: string): boolean {
|
||
return COMPLETION_TIME_PATTERN.test(data);
|
||
}
|
||
|
||
/**
|
||
* Check if a rolling window of terminal output contains working patterns.
|
||
* The rolling window catches patterns split across chunks (e.g., "Thin" + "king").
|
||
*
|
||
* @param window - Rolling window of recent terminal output (already includes current data)
|
||
* @returns True if any working pattern is found in the window
|
||
*/
|
||
export function hasWorkingPattern(window: string): boolean {
|
||
// 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));
|
||
}
|
||
|
||
/**
|
||
* Extract token count from data if present.
|
||
* Parses patterns like "123.4k tokens" or "1.5M tokens".
|
||
*
|
||
* @param data - Raw terminal output data
|
||
* @returns Parsed token count, or null if no token pattern found
|
||
*/
|
||
export function extractTokenCount(data: string): number | null {
|
||
const match = data.match(TOKEN_PATTERN);
|
||
if (!match) return null;
|
||
|
||
let count = parseFloat(match[1]);
|
||
const suffix = match[2]?.toLowerCase();
|
||
if (suffix === 'k') count *= 1000;
|
||
else if (suffix === 'm') count *= 1000000;
|
||
|
||
return Math.round(count);
|
||
}
|