refactor: consolidate shared regex patterns and token validation

- Create src/utils/regex-patterns.ts with ANSI_ESCAPE_PATTERN_FULL,
  ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, and helper functions
- Create src/utils/token-validation.ts with MAX_SESSION_TOKENS and
  validation utilities
- Update session.ts, respawn-controller.ts, ai-checker-base.ts,
  ralph-tracker.ts, state-store.ts to use shared utilities
- Export all new utilities from src/utils/index.ts

Reduces code duplication while maintaining pre-compiled patterns
for performance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-29 10:55:49 +01:00
co-authored by Claude Opus 4.5
parent 451f0854eb
commit 6c07541288
10 changed files with 187 additions and 45 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+2 -4
View File
@@ -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<R> {
// ========== 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<R> {
// 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;
+3 -8
View File
@@ -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
+8 -13
View File
@@ -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;
+11 -16
View File
@@ -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;
+2 -2
View File
@@ -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;
+14
View File
@@ -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';
+73
View File
@@ -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, '');
}
+72
View File
@@ -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 };
}