diff --git a/CLAUDE.md b/CLAUDE.md index 16ecb43f..226d5060 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1417 (must match `package.json`) +**Version**: 0.1418 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index f7f83d81..91de7599 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1417", + "version": "0.1418", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts index c89862a2..ec2d53c1 100644 --- a/src/ai-checker-base.ts +++ b/src/ai-checker-base.ts @@ -29,6 +29,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { EventEmitter } from 'node:events'; import { getAugmentedPath } from './session.js'; +import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js'; // ========== Types ========== @@ -85,9 +86,6 @@ export interface AiCheckerEvents { // ========== Constants ========== -/** ANSI escape code pattern for stripping terminal formatting */ -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; - /** Poll interval for checking temp file completion */ const POLL_INTERVAL_MS = 500; @@ -354,7 +352,7 @@ export abstract class AiCheckerBase< private async runCheck(terminalBuffer: string): Promise { // Prepare the terminal buffer (strip ANSI, trim to maxContextChars) - const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN, ''); + const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); const trimmed = stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped; diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 3e807abc..b11e15b7 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -30,6 +30,7 @@ import { createInitialRalphTrackerState, createInitialCircuitBreakerStatus, } from './types.js'; +import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js'; // ========== Enhanced Plan Task Interface ========== @@ -287,12 +288,6 @@ const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is // ---------- Utility Patterns ---------- -/** - * Removes ANSI escape codes from terminal output for cleaner parsing. - * Matches: color codes (\x1b[...m), cursor movement (\x1b[...H, \x1b[...C), etc. - */ -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; - /** Maximum number of task number to content mappings to track */ const MAX_TASK_MAPPINGS = 100; @@ -909,7 +904,7 @@ export class RalphTracker extends EventEmitter { */ processTerminalData(data: string): void { // Remove ANSI escape codes for cleaner parsing - const cleanData = data.replace(ANSI_ESCAPE_PATTERN, ''); + const cleanData = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); // If tracker is disabled, only check for patterns that should auto-enable it if (!this._loopState.enabled) { @@ -1649,7 +1644,7 @@ export class RalphTracker extends EventEmitter { // Clean content: remove ANSI codes, collapse whitespace, trim const cleanContent = content - .replace(ANSI_ESCAPE_PATTERN, '') // Remove ANSI escape codes + .replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes .replace(/\s+/g, ' ') // Collapse whitespace .trim(); if (cleanContent.length < 5) return; // Skip very short content diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index b5d92832..ce19b5a2 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -39,6 +39,10 @@ import { Session } from './session.js'; import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js'; import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; +import { + ANSI_ESCAPE_PATTERN_SIMPLE, + TOKEN_PATTERN, +} from './utils/index.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE, @@ -56,21 +60,12 @@ import { */ const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i; -/** - * Pattern to extract token count from Claude's status line. - * Matches: "123.4k tokens", "5234 tokens", "1.2M tokens" - */ -const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; - /** Pre-filter: numbered option pattern for plan mode detection */ const PLAN_MODE_OPTION_PATTERN = /\d+\.\s+(Yes|No|Type|Cancel|Skip|Proceed|Approve|Reject)/i; /** Pre-filter: selection indicator arrow for plan mode detection */ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./; -/** Pattern to strip ANSI escape codes from terminal output */ -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; - // Note: The old '↵ send' indicator is no longer reliable in Claude Code 2024+ // Detection now uses completion message patterns ("for Xm Xs") instead. @@ -1223,8 +1218,8 @@ export class RespawnController extends EventEmitter { // This prevents false triggers when Claude pauses briefly mid-work. if (this._state === 'confirming_idle' || this._state === 'ai_checking') { // Strip ANSI escape codes to check if there's real content - ANSI_ESCAPE_PATTERN.lastIndex = 0; - const stripped = data.replace(ANSI_ESCAPE_PATTERN, '').trim(); + ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0; + const stripped = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').trim(); if (stripped.length > 2) { if (this._state === 'ai_checking') { this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`); @@ -1871,8 +1866,8 @@ export class RespawnController extends EventEmitter { const tail = buffer.slice(-2000); // Strip ANSI codes for pattern matching - ANSI_ESCAPE_PATTERN.lastIndex = 0; - const stripped = tail.replace(ANSI_ESCAPE_PATTERN, ''); + ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0; + const stripped = tail.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); // Must find numbered option pattern if (!PLAN_MODE_OPTION_PATTERN.test(stripped)) return false; diff --git a/src/session.ts b/src/session.ts index 2664e861..7ace03f7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -27,6 +27,11 @@ import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { ScreenManager } from './screen-manager.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; +import { + ANSI_ESCAPE_PATTERN_FULL, + TOKEN_PATTERN, + MAX_SESSION_TOKENS, +} from './utils/index.js'; import { MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, @@ -69,14 +74,6 @@ const GRACEFUL_SHUTDOWN_DELAY_MS = 100; // ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; -// Pre-compiled regex patterns for performance (avoid re-compilation on each call) -// Comprehensive ANSI escape pattern: -// - SGR (colors/styles): ESC [ params m -// - CSI sequences (cursor, scroll, etc.): ESC [ params letter -// - OSC sequences (title, etc.): ESC ] ... BEL or ESC ] ... ST -// - Single-char escapes: ESC = or ESC > -const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; -const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; // Pattern to match Task tool invocations in terminal output // Matches: "Explore(Description)", "Task(Description)", "Bash(Description)", etc. // The prefix characters vary (●, ·, ✶, etc.) so we don't require them @@ -610,8 +607,7 @@ export class Session extends EventEmitter { * Called when recovering sessions after server restart. */ restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void { - // Sanity check: reject absurdly large values (max 500k tokens per session) - const MAX_SESSION_TOKENS = 500_000; + // Sanity check: reject absurdly large values if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`); return; @@ -959,7 +955,7 @@ export class Session extends EventEmitter { // Detect when Claude starts working (thinking, writing, etc) // Strip ANSI/OSC sequences to avoid false positives from window titles like "3 File Reading Task" - const cleanDataForWorkingCheck = data.replace(ANSI_ESCAPE_PATTERN, ''); + const cleanDataForWorkingCheck = data.replace(ANSI_ESCAPE_PATTERN_FULL, ''); if (cleanDataForWorkingCheck.includes('Thinking') || cleanDataForWorkingCheck.includes('Writing') || cleanDataForWorkingCheck.includes('Reading') || cleanDataForWorkingCheck.includes('Running') || cleanDataForWorkingCheck.includes('⠋') || cleanDataForWorkingCheck.includes('⠙') || @@ -1349,7 +1345,7 @@ export class Session extends EventEmitter { for (const line of lines) { const trimmed = line.trim(); // Remove ANSI escape codes for JSON parsing (use pre-compiled pattern) - const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN, ''); + const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, ''); if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) { try { @@ -1438,7 +1434,7 @@ export class Session extends EventEmitter { if (!line.includes('(') || !line.includes(')')) return; // Strip ANSI codes before matching - terminal output has embedded codes like [1mExplore[0m - const cleanLine = line.replace(ANSI_ESCAPE_PATTERN, ''); + const cleanLine = line.replace(ANSI_ESCAPE_PATTERN_FULL, ''); // Reset regex lastIndex for global pattern TASK_TOOL_PATTERN.lastIndex = 0; @@ -1537,7 +1533,7 @@ export class Session extends EventEmitter { if (!data.includes('token')) return; // Remove ANSI escape codes for cleaner parsing (use pre-compiled pattern) - const cleanData = data.replace(ANSI_ESCAPE_PATTERN, ''); + const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, ''); // Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens" // The status line typically shows total tokens like "1.2k tokens" near the prompt @@ -1560,8 +1556,7 @@ export class Session extends EventEmitter { tokenCount *= 1000000; } - // Safety: Absolute maximum of 500k tokens per session - const MAX_SESSION_TOKENS = 500_000; + // Safety: Absolute maximum tokens per session if (tokenCount > MAX_SESSION_TOKENS) { console.warn(`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`); return; diff --git a/src/state-store.ts b/src/state-store.ts index f4f35209..2744d304 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -18,6 +18,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkS import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js'; +import { MAX_SESSION_TOKENS } from './utils/index.js'; /** Debounce delay for batching state writes (ms) */ const SAVE_DEBOUNCE_MS = 500; @@ -369,8 +370,7 @@ export class StateStore { * Call when a session is deleted to preserve its usage in lifetime stats. */ addToGlobalStats(inputTokens: number, outputTokens: number, cost: number): void { - // Sanity check: reject absurdly large values (max 500k tokens per session) - const MAX_SESSION_TOKENS = 500_000; + // Sanity check: reject absurdly large values if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`); return; diff --git a/src/utils/index.ts b/src/utils/index.ts index 1578f04f..a5539464 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,17 @@ export { BufferAccumulator } from './buffer-accumulator.js'; export { LRUMap, type LRUMapOptions } from './lru-map.js'; export { CleanupManager, type TimerOptions } from './cleanup-manager.js'; export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js'; +export { + ANSI_ESCAPE_PATTERN_FULL, + ANSI_ESCAPE_PATTERN_SIMPLE, + TOKEN_PATTERN, + createAnsiPatternFull, + createAnsiPatternSimple, + stripAnsi, + stripAnsiSimple, +} from './regex-patterns.js'; +export { + MAX_SESSION_TOKENS, + validateTokenCounts, + validateTokensAndCost, +} from './token-validation.js'; diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts new file mode 100644 index 00000000..172e583e --- /dev/null +++ b/src/utils/regex-patterns.ts @@ -0,0 +1,73 @@ +/** + * @fileoverview Shared regex patterns for terminal and token parsing. + * + * Pre-compiled patterns avoid re-compilation overhead on each use. + * Import these patterns instead of defining them locally. + * + * @module utils/regex-patterns + */ + +/** + * Comprehensive ANSI escape pattern that handles: + * - SGR (colors/styles): ESC [ params m + * - CSI sequences (cursor, scroll, etc.): ESC [ params letter + * - OSC sequences (title, etc.): ESC ] ... BEL or ESC ] ... ST + * - Single-char escapes: ESC = or ESC > + * + * Use this when you need complete ANSI stripping including OSC sequences. + * Note: Has global flag - reset lastIndex before exec() if reusing. + */ +export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; + +/** + * Simple ANSI CSI-only pattern for basic escape code stripping. + * Matches: ESC [ params letter (e.g., colors, cursor movement) + * + * Use this for faster stripping when OSC sequences aren't a concern. + * Note: Has global flag - reset lastIndex before exec() if reusing. + */ +export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g; + +/** + * Pattern to extract token count from Claude's status line. + * Matches: "123.4k tokens", "5234 tokens", "1.2M tokens" + * + * Capture groups: + * - Group 1: The numeric value (e.g., "123.4", "5234", "1.2") + * - Group 2: Optional suffix (k, K, m, M) or undefined + */ +export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; + +/** + * Creates a fresh copy of ANSI_ESCAPE_PATTERN_FULL. + * Use when you need a pattern without shared lastIndex state. + */ +export function createAnsiPatternFull(): RegExp { + return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; +} + +/** + * Creates a fresh copy of ANSI_ESCAPE_PATTERN_SIMPLE. + * Use when you need a pattern without shared lastIndex state. + */ +export function createAnsiPatternSimple(): RegExp { + return /\x1b\[[0-9;]*[A-Za-z]/g; +} + +/** + * Strips ANSI escape codes from text using the comprehensive pattern. + * @param text - Text containing ANSI escape codes + * @returns Text with ANSI codes removed + */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE_PATTERN_FULL, ''); +} + +/** + * Strips simple ANSI CSI codes from text (faster, less comprehensive). + * @param text - Text containing ANSI escape codes + * @returns Text with ANSI CSI codes removed + */ +export function stripAnsiSimple(text: string): string { + return text.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); +} diff --git a/src/utils/token-validation.ts b/src/utils/token-validation.ts new file mode 100644 index 00000000..5900d793 --- /dev/null +++ b/src/utils/token-validation.ts @@ -0,0 +1,72 @@ +/** + * @fileoverview Token validation utilities. + * + * Centralizes token count validation logic used across the codebase. + * Claude's context window is ~200k tokens, so 500k is a generous upper bound. + * + * @module utils/token-validation + */ + +/** + * Maximum tokens allowed per session. + * Claude's context is ~200k, so 500k is a safe upper bound for validation. + */ +export const MAX_SESSION_TOKENS = 500_000; + +/** + * Validates token counts are within acceptable bounds. + * Rejects negative values and values exceeding MAX_SESSION_TOKENS. + * + * @param inputTokens - Input token count to validate + * @param outputTokens - Output token count to validate + * @returns Object with isValid flag and optional error reason + */ +export function validateTokenCounts( + inputTokens: number, + outputTokens: number +): { isValid: boolean; reason?: string } { + if (inputTokens < 0 || outputTokens < 0) { + return { + isValid: false, + reason: `Negative token values: input=${inputTokens}, output=${outputTokens}`, + }; + } + + if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { + return { + isValid: false, + reason: `Token values exceed maximum (${MAX_SESSION_TOKENS}): input=${inputTokens}, output=${outputTokens}`, + }; + } + + return { isValid: true }; +} + +/** + * Validates token counts and cost for restoration/persistence. + * Returns true if all values are valid. + * + * @param inputTokens - Input token count + * @param outputTokens - Output token count + * @param cost - Cost value (must be non-negative) + * @returns Object with isValid flag and optional error reason + */ +export function validateTokensAndCost( + inputTokens: number, + outputTokens: number, + cost: number +): { isValid: boolean; reason?: string } { + const tokenValidation = validateTokenCounts(inputTokens, outputTokens); + if (!tokenValidation.isValid) { + return tokenValidation; + } + + if (cost < 0) { + return { + isValid: false, + reason: `Negative cost value: ${cost}`, + }; + } + + return { isValid: true }; +}