diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index 16303096..3744e68b 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -1,3 +1,18 @@ +/** + * @fileoverview Inner Loop Tracker for Ralph Wiggum detection + * + * This module parses terminal output from Claude Code sessions to detect: + * - Ralph Wiggum loop state (active, completion phrase, iteration count) + * - Todo list items from the TodoWrite tool + * - Completion phrases signaling loop completion + * + * The tracker is DISABLED by default and auto-enables when Ralph-related + * patterns are detected in the output stream, reducing overhead for + * sessions not using autonomous loops. + * + * @module inner-loop-tracker + */ + import { EventEmitter } from 'node:events'; import { InnerLoopState, @@ -6,111 +21,275 @@ import { createInitialInnerLoopState, } from './types.js'; -// Maximum number of todo items to track per session +// ========== Configuration Constants ========== + +/** + * Maximum number of todo items to track per session. + * Older items are removed when this limit is reached. + */ const MAX_TODO_ITEMS = 50; -// Todo items older than this will be auto-expired (1 hour) + +/** + * Todo items older than this duration (in milliseconds) will be auto-expired. + * Default: 1 hour (60 * 60 * 1000) + */ const TODO_EXPIRY_MS = 60 * 60 * 1000; -// Throttle cleanup checks (every 30 seconds) + +/** + * Minimum interval between cleanup checks (in milliseconds). + * Prevents running cleanup on every data chunk. + * Default: 30 seconds + */ const CLEANUP_THROTTLE_MS = 30 * 1000; -// Pre-compiled regex patterns for performance (avoid re-compilation on each call) +// ========== Pre-compiled Regex Patterns ========== +// Pre-compiled for performance (avoid re-compilation on each call) -// Completion phrase detection: PHRASE +/** + * Matches completion phrase tags: `PHRASE` + * Used to detect when Claude signals task completion. + * Capture group 1: The completion phrase text + */ const PROMISE_PATTERN = /([^<]+)<\/promise>/; -// Todo item patterns - multiple formats Claude Code uses -// Format 1: Checkbox format in markdown: "- [ ] Task" or "- [x] Task" +// ---------- Todo Item Patterns ---------- +// Claude Code outputs todos in multiple formats; we detect all of them + +/** + * Format 1: Markdown checkbox format + * Matches: "- [ ] Task" or "- [x] Task" (also with * bullet) + * Capture group 1: Checkbox state ('x', 'X', or ' ') + * Capture group 2: Task content + */ const TODO_CHECKBOX_PATTERN = /^[-*]\s*\[([xX ])\]\s+(.+)$/gm; -// Format 2: Todo with indicator icons: "Todo: ☐ Task", "Todo: ◐ Task", "Todo: ✓ Task" + +/** + * Format 2: Todo with indicator icons + * Matches: "Todo: ☐ Task", "Todo: ◐ Task", "Todo: ✓ Task" + * Capture group 1: Status icon + * Capture group 2: Task content + */ const TODO_INDICATOR_PATTERN = /Todo:\s*(☐|◐|✓|⏳|✅|⌛|🔄)\s+(.+)/g; -// Format 3: Status in parentheses: "(pending)", "(in_progress)", "(completed)" + +/** + * Format 3: Status in parentheses + * Matches: "- Task (pending)", "- Task (in_progress)", "- Task (completed)" + * Capture group 1: Task content + * Capture group 2: Status string + */ const TODO_STATUS_PATTERN = /[-*]\s*(.+?)\s+\((pending|in_progress|completed)\)/g; -// Format 4: Claude Code native TodoWrite output: "☐ Task", "☒ Task", "◐ Task" -// These appear in terminal with optional leading whitespace/brackets like "⎿ ☐ Task" -// Matches: start of line with optional whitespace/bracket, then checkbox, then task text + +/** + * Format 4: Claude Code native TodoWrite output + * Matches: "☐ Task", "☒ Task", "◐ Task" + * These appear with optional leading whitespace/brackets like "⎿ ☐ Task" + * Capture group 1: Checkbox icon (☐=pending, ☒=completed, ◐=in_progress) + * Capture group 2: Task content (min 3 chars, excludes checkbox icons) + */ const TODO_NATIVE_PATTERN = /^[\s⎿]*(☐|☒|◐)\s+([^☐☒◐\n]{3,})/gm; -// Patterns to exclude from todo detection (tool invocations, etc.) +/** + * Patterns to exclude from todo detection + * Prevents false positives from tool invocations and Claude commentary + */ const TODO_EXCLUDE_PATTERNS = [ /^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations - /^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary (with context) + /^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary /^\S+\([^)]+\)$/, // Generic function call pattern ]; -// Loop status patterns (does NOT include - that's handled by PROMISE_PATTERN) +// ---------- Loop Status Patterns ---------- +// Note: tags are handled separately by PROMISE_PATTERN + +/** + * Matches generic loop start messages + * Examples: "Loop started at", "Starting main loop", "Ralph loop started" + */ const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started/i; + +/** + * Matches elapsed time output + * Example: "Elapsed: 2.5 hours" + * Capture group 1: Hours as decimal number + */ const ELAPSED_TIME_PATTERN = /Elapsed:\s*(\d+(?:\.\d+)?)\s*hours?/i; + +/** + * Matches cycle count indicators (legacy format) + * Examples: "cycle #5", "respawn cycle #3" + * Capture groups 1 or 2: Cycle number + */ const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i; -// New patterns for improved Ralph detection (based on official Ralph Wiggum plugin) -// Iteration patterns: "Iteration 5/50", "[5/50]", "iteration #5" +// ---------- Ralph Wiggum Plugin Patterns ---------- +// Based on the official Ralph Wiggum plugin output format + +/** + * Matches iteration progress indicators + * Examples: "Iteration 5/50", "[5/50]", "iteration #5", "iter. 3 of 10" + * Capture groups: (1,2) for "Iteration X/Y" format, (3,4) for "[X/Y]" format + */ const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*[\/of]\s*(\d+))?|\[(\d+)\/(\d+)\]/i; -// Ralph loop start: "/ralph-loop:ralph-loop" command or "Starting Ralph loop" -// Pattern matches /ralph-loop anywhere to catch both skill invocations and output +/** + * Matches Ralph loop start command or announcement + * Examples: "/ralph-loop:ralph-loop", "Starting Ralph Wiggum loop", "ralph loop beginning" + */ const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i; -// Max iterations: "max-iterations 50" or "maxIterations: 50" or "max_iterations=50" +/** + * Matches max iterations configuration + * Examples: "max-iterations 50", "maxIterations: 50", "max_iterations=50" + * Capture group 1: Maximum iteration count + */ const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i; -// TodoWrite tool output - detect the tool being used +/** + * Matches TodoWrite tool usage indicators + * Examples: "TodoWrite", "todos updated", "Todos have been modified" + */ const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i; -// All tasks complete patterns - detect when Claude says all work is done -// Includes patterns like "All 8 files have been created", "All tasks completed" +// ---------- Task Completion Detection Patterns ---------- + +/** + * Matches "all tasks complete" announcements + * Examples: "All 8 files have been created", "All tasks completed", "Everything is done" + * Used to mark all tracked todos as complete at once + */ const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i; -// Pattern to extract count from "All 8 files created" type messages +/** + * Extracts count from "all N items" messages + * Example: "All 8 files created" → captures "8" + * Capture group 1: The count + */ const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i; -// Individual task completion patterns - detect when Claude marks a specific task done +/** + * Matches individual task completion messages + * Examples: "Task #5 is done", "marked as completed", "todo 3 finished" + * Used to update specific todo items by number + */ const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i; -// Generic completion signals (be careful with these - can be false positives) +/** + * Matches generic standalone completion signals + * Examples: "Done!", "Completed", "Finished", "All set" + * Note: Use with caution - high false positive potential + * @deprecated Currently unused due to false positive risk + */ const COMPLETION_SIGNAL_PATTERN = /^(?:done|completed?|finished|all\s+set)!?\s*$/i; -// ANSI escape code removal for cleaner parsing -// Matches color codes (\x1b[...m), cursor movement (\x1b[...H, \x1b[...C, etc.), and other sequences +// ---------- 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; +// ========== Event Types ========== + +/** + * Events emitted by InnerLoopTracker + * @event loopUpdate - Fired when loop state changes (active, iteration, completion phrase) + * @event todoUpdate - Fired when todo list changes (items added, status changed) + * @event completionDetected - Fired when completion phrase is detected (task complete) + * @event enabled - Fired when tracker auto-enables due to Ralph pattern detection + */ export interface InnerLoopTrackerEvents { + /** Emitted when loop state changes */ loopUpdate: (state: InnerLoopState) => void; + /** Emitted when todo list is modified */ todoUpdate: (todos: InnerTodoItem[]) => void; + /** Emitted when completion phrase detected (loop finished) */ completionDetected: (phrase: string) => void; - enabled: () => void; // Emitted when tracker auto-enables + /** Emitted when tracker auto-enables from disabled state */ + enabled: () => void; } /** - * InnerLoopTracker parses terminal output from Claude Code sessions to detect: - * 1. Ralph Wiggum loop state (active, completion phrase, cycle count) - * 2. Todo list items from the TodoWrite tool + * InnerLoopTracker - Parses terminal output to detect Ralph Wiggum loops and todos * - * The tracker is DISABLED by default and auto-enables when Ralph-related + * This class monitors Claude Code session output to detect: + * 1. **Ralph Wiggum loop state** - Active loops, completion phrases, iteration counts + * 2. **Todo list items** - From TodoWrite tool in various formats + * 3. **Completion signals** - `PHRASE` tags + * + * ## Lifecycle + * + * The tracker is **DISABLED by default** and auto-enables when Ralph-related * patterns are detected (e.g., /ralph-loop:ralph-loop, , todos). + * This reduces overhead for sessions not using autonomous loops. + * + * ## Completion Detection + * + * Uses occurrence-based detection to distinguish prompt from actual completion: + * - 1st occurrence of `X`: Stored as expected phrase (likely in prompt) + * - 2nd occurrence: Emits `completionDetected` event (actual completion) + * - If loop already active: Emits immediately on first occurrence + * + * ## Events + * + * - `loopUpdate` - Loop state changed (status, iteration, phrase) + * - `todoUpdate` - Todo list modified (add, status change) + * - `completionDetected` - Loop completion phrase detected + * - `enabled` - Tracker auto-enabled from disabled state + * + * @extends EventEmitter + * @example + * ```typescript + * const tracker = new InnerLoopTracker(); + * tracker.on('completionDetected', (phrase) => { + * console.log('Loop completed with phrase:', phrase); + * }); + * tracker.processTerminalData(ptyOutput); + * ``` */ export class InnerLoopTracker extends EventEmitter { + /** Current state of the detected loop */ private _loopState: InnerLoopState; + + /** Map of todo items by ID for O(1) lookup */ private _todos: Map = new Map(); + + /** Buffer for incomplete lines from terminal data */ private _lineBuffer: string = ''; - // Track occurrences of completion phrases to distinguish prompt from actual completion + + /** + * Tracks occurrences of completion phrases. + * Used to distinguish prompt echo (1st) from actual completion (2nd+). + */ private _completionPhraseCount: Map = new Map(); - // Throttle cleanup to avoid running on every data chunk + + /** Timestamp of last cleanup check for throttling */ private _lastCleanupTime: number = 0; + /** + * Creates a new InnerLoopTracker instance. + * Starts in disabled state until Ralph patterns are detected. + */ constructor() { super(); this._loopState = createInitialInnerLoopState(); } /** - * Whether the tracker is enabled and actively monitoring + * Whether the tracker is enabled and actively monitoring output. + * Disabled by default; auto-enables when Ralph patterns detected. + * @returns True if tracker is processing terminal data */ get enabled(): boolean { return this._loopState.enabled; } /** - * Enable the tracker (called automatically when Ralph patterns detected) + * Enable the tracker to start monitoring terminal output. + * Called automatically when Ralph patterns are detected. + * Emits 'enabled' event when transitioning from disabled state. + * @fires enabled + * @fires loopUpdate */ enable(): void { if (!this._loopState.enabled) { @@ -122,7 +301,9 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Disable the tracker + * Disable the tracker to stop monitoring terminal output. + * Terminal data will be ignored until re-enabled. + * @fires loopUpdate */ disable(): void { if (this._loopState.enabled) { @@ -133,8 +314,20 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Reset the tracker to initial state (for when a new task/loop starts) - * Clears all todos, completion phrase, and loop state while keeping enabled status + * Soft reset - clears state but keeps enabled status. + * Use when a new task/loop starts within the same session. + * + * Clears: + * - All todo items + * - Completion phrase tracking + * - Loop state (active, iterations) + * - Line buffer + * + * Preserves: + * - Enabled status + * + * @fires loopUpdate + * @fires todoUpdate */ reset(): void { const wasEnabled = this._loopState.enabled; @@ -148,7 +341,11 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Full reset including enabled state (for complete cleanup) + * Full reset - clears all state including enabled status. + * Use when session is closed or completely cleared. + * Returns tracker to initial disabled state. + * @fires loopUpdate + * @fires todoUpdate */ fullReset(): void { this._loopState = createInitialInnerLoopState(); @@ -159,16 +356,39 @@ export class InnerLoopTracker extends EventEmitter { this.emit('todoUpdate', this.todos); } + /** + * Get a copy of the current loop state. + * @returns Shallow copy of loop state (safe to modify) + */ get loopState(): InnerLoopState { return { ...this._loopState }; } + /** + * Get all tracked todo items as an array. + * @returns Array of todo items (copy, safe to modify) + */ get todos(): InnerTodoItem[] { return Array.from(this._todos.values()); } /** - * Process raw terminal data to detect inner loop patterns + * Process raw terminal data to detect inner loop patterns. + * + * This is the main entry point for parsing output. Call this with each + * chunk of data from the PTY. The tracker will: + * + * 1. Strip ANSI escape codes + * 2. Auto-enable if disabled and Ralph patterns detected + * 3. Buffer data and process complete lines + * 4. Detect loop status, todos, and completion phrases + * 5. Periodically clean up expired todos + * + * @param data - Raw terminal data (may include ANSI codes) + * @fires loopUpdate - When loop state changes + * @fires todoUpdate - When todos are detected or updated + * @fires completionDetected - When completion phrase found + * @fires enabled - When tracker auto-enables */ processTerminalData(data: string): void { // Remove ANSI escape codes for cleaner parsing @@ -203,7 +423,21 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Check if the data contains patterns that should auto-enable the tracker + * Check if data contains patterns that should auto-enable the tracker. + * + * The tracker auto-enables when any of these patterns are detected: + * - `/ralph-loop:ralph-loop` command + * - `PHRASE` completion tags + * - TodoWrite tool usage indicators + * - Iteration patterns (`Iteration 5/50`, `[5/50]`) + * - Todo checkboxes (`- [ ]`, `- [x]`) + * - Todo indicator icons (`☐`, `◐`, `☒`) + * - Loop start messages (`Loop started at`) + * - All tasks complete announcements + * - Task completion signals + * + * @param data - ANSI-cleaned terminal data + * @returns True if any Ralph-related pattern is detected */ private shouldAutoEnable(data: string): boolean { // Ralph loop command: /ralph-loop:ralph-loop @@ -264,7 +498,9 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Process a single line of terminal output + * Process a single line of terminal output. + * Runs all detection methods in sequence. + * @param line - Single line of ANSI-cleaned terminal output */ private processLine(line: string): void { const trimmed = line.trim(); @@ -287,8 +523,23 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Detect "all tasks complete" messages - * When detected: marks all todos as complete AND emits completion event + * Detect "all tasks complete" messages. + * + * When a valid "all complete" message is detected: + * 1. Marks all tracked todos as completed + * 2. Emits completion event if a completion phrase is set + * + * Validation criteria: + * - Line must match ALL_COMPLETE_PATTERN + * - Line must be reasonably short (<100 chars) to avoid matching commentary + * - Must not look like prompt text (no "output:" or ``) + * - Must have at least one tracked todo + * - If count is mentioned, should roughly match tracked todo count + * + * @param line - Single line to check + * @fires todoUpdate - If any todos marked complete + * @fires completionDetected - If completion phrase was set + * @fires loopUpdate - If loop state changes */ private detectAllTasksComplete(line: string): void { // Only trigger if line is a clear standalone completion message @@ -365,7 +616,9 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Check for multi-line patterns in the data chunk + * Check for multi-line patterns that might span line boundaries. + * Completion phrases can be split across PTY chunks. + * @param data - The full data chunk (may contain multiple lines) */ private checkMultiLinePatterns(data: string): void { // Completion phrase can span lines, so check the whole chunk @@ -376,8 +629,18 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Detect PHRASE completion phrases - * Also detects bare phrase (without tags) if we already know the expected phrase + * Detect completion phrases in a line. + * + * Handles two formats: + * 1. Tagged: `PHRASE` - Processed via handleCompletionPhrase + * 2. Bare: Just `PHRASE` - Only if we already know the expected phrase + * + * Bare phrase detection avoids false positives by requiring: + * - The phrase was previously seen in tagged form + * - Line is standalone or ends with the phrase + * - Line doesn't look like prompt context + * + * @param line - Single line to check */ private detectCompletionPhrase(line: string): void { // First check for tagged phrase: PHRASE @@ -404,9 +667,21 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Handle a bare completion phrase (without tags) - * Emits completion if we've already seen the tagged version in the prompt - * and this appears to be actual completion output (not prompt echo) + * Handle a bare completion phrase (without XML tags). + * + * Only fires completion if: + * 1. The phrase was previously seen in tagged form (from prompt) + * 2. This is the first bare occurrence (prevents double-firing) + * + * When triggered: + * - Marks all todos as complete + * - Emits completionDetected event + * - Sets loop to inactive + * + * @param phrase - The completion phrase text + * @fires todoUpdate - If any todos marked complete + * @fires completionDetected - When completion triggered + * @fires loopUpdate - When loop state changes */ private handleBareCompletionPhrase(phrase: string): void { // Only count if this phrase was already seen in tagged form (from the prompt) @@ -481,7 +756,13 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Helper: Activate the loop if not already active + * Activate the loop if not already active. + * + * Sets loop state to active and initializes counters. + * No-op if loop is already active. + * + * @returns True if loop was activated, false if already active + * @fires loopUpdate - When loop state changes */ private activateLoopIfNeeded(): boolean { if (this._loopState.active) return false; @@ -497,7 +778,19 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Detect loop start and status indicators + * Detect loop start and status indicators. + * + * Patterns detected: + * - Ralph loop start commands (`/ralph-loop:ralph-loop`) + * - Loop start messages (`Loop started at`, `Starting Ralph loop`) + * - Max iterations setting (`max-iterations 50`) + * - Iteration progress (`Iteration 5/50`, `[5/50]`) + * - Elapsed time (`Elapsed: 2.5 hours`) + * - Cycle count (`cycle #5`, `respawn cycle #3`) + * - TodoWrite tool usage + * + * @param line - Single line to check + * @fires loopUpdate - When any loop state changes */ private detectLoopStatus(line: string): void { // Check for Ralph loop start command (/ralph-loop:ralph-loop) @@ -562,7 +855,19 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Detect todo items in various formats + * Detect todo items in various formats from Claude Code output. + * + * Supported formats: + * - Format 1: Checkbox markdown (`- [ ] Task`, `- [x] Task`) + * - Format 2: Indicator icons (`Todo: ☐ Task`, `Todo: ✓ Task`) + * - Format 3: Status in parentheses (`- Task (pending)`) + * - Format 4: Native TodoWrite (`☐ Task`, `☒ Task`, `◐ Task`) + * + * Uses quick pre-check to skip lines that can't contain todos. + * Excludes tool invocations and Claude commentary patterns. + * + * @param line - Single line to check + * @fires todoUpdate - When any todos are detected or updated */ private detectTodoItems(line: string): void { // Quick check: skip lines that can't possibly contain todos @@ -629,7 +934,15 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Convert todo icon to status + * Convert a todo icon character to its corresponding status. + * + * Icon mappings: + * - Completed: `✓`, `✅`, `☒`, `◉`, `●` + * - In Progress: `◐`, `⏳`, `⌛`, `🔄` + * - Pending: `☐`, `○`, and anything else (default) + * + * @param icon - Single character icon + * @returns Corresponding InnerTodoStatus */ private iconToStatus(icon: string): InnerTodoStatus { switch (icon) { @@ -652,7 +965,17 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Add or update a todo item + * Add a new todo item or update an existing one. + * + * Behavior: + * - Content is cleaned (ANSI removed, whitespace collapsed) + * - Content under 5 chars is skipped + * - ID is generated from normalized content (stable hash) + * - Existing item: Updates status and timestamp + * - New item: Adds to map, evicts oldest if at MAX_TODO_ITEMS + * + * @param content - Raw todo content text + * @param status - Status to set */ private upsertTodo(content: string, status: InnerTodoStatus): void { // Skip empty or whitespace-only content @@ -693,10 +1016,18 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Normalize todo content for consistent matching - * - Collapse multiple whitespace to single space - * - Remove trailing garbage characters - * - Trim whitespace + * Normalize todo content for consistent matching. + * + * Normalization steps: + * 1. Collapse multiple whitespace to single space + * 2. Remove special characters (keep alphanumeric + basic punctuation) + * 3. Trim whitespace + * 4. Convert to lowercase + * + * This prevents duplicate todos from terminal rendering artifacts. + * + * @param content - Raw todo content + * @returns Normalized lowercase string */ private normalizeTodoContent(content: string): string { if (!content) return ''; @@ -708,8 +1039,13 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Generate a stable ID from todo content using djb2 hash - * Content is normalized first to prevent duplicates from terminal artifacts + * Generate a stable ID from todo content using djb2 hash. + * + * Uses the djb2 hash algorithm for good distribution across strings. + * Content is normalized first to prevent duplicates from terminal artifacts. + * + * @param content - Todo content text + * @returns Stable ID in format `todo-{hash}` (base36 encoded) */ private generateTodoId(content: string): string { if (!content) return 'todo-empty'; @@ -728,7 +1064,9 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Find the oldest todo item + * Find the todo item with the oldest detectedAt timestamp. + * Used for LRU eviction when at MAX_TODO_ITEMS limit. + * @returns Oldest todo item, or undefined if map is empty */ private findOldestTodo(): InnerTodoItem | undefined { let oldest: InnerTodoItem | undefined; @@ -741,7 +1079,8 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Throttled cleanup - only runs every CLEANUP_THROTTLE_MS + * Conditionally run cleanup, throttled to CLEANUP_THROTTLE_MS. + * Prevents cleanup from running on every data chunk (performance). */ private maybeCleanupExpiredTodos(): void { const now = Date.now(); @@ -753,7 +1092,9 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Remove expired todo items + * Remove todo items older than TODO_EXPIRY_MS. + * Emits todoUpdate if any items were removed. + * @fires todoUpdate - When expired items are removed */ private cleanupExpiredTodos(): void { const now = Date.now(); @@ -774,8 +1115,17 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Mark the loop as started (can be called externally) - * Also enables the tracker if not already enabled + * Programmatically start a loop (external API). + * + * Use when starting a loop from outside terminal detection, + * such as from a user action or API call. + * + * Automatically enables the tracker if not already enabled. + * + * @param completionPhrase - Optional phrase that signals completion + * @param maxIterations - Optional maximum iteration count + * @fires enabled - If tracker was disabled + * @fires loopUpdate - When loop state changes */ startLoop(completionPhrase?: string, maxIterations?: number): void { this.enable(); // Ensure tracker is enabled @@ -792,7 +1142,10 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Update max iterations (can be called externally) + * Update the maximum iteration count (external API). + * + * @param maxIterations - New max iterations, or null to remove limit + * @fires loopUpdate - When loop state changes */ setMaxIterations(maxIterations: number | null): void { this._loopState.maxIterations = maxIterations; @@ -801,7 +1154,12 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Mark the loop as stopped + * Programmatically stop the loop (external API). + * + * Sets loop to inactive. Does not disable the tracker + * or clear todos - use reset() or clear() for that. + * + * @fires loopUpdate - When loop state changes */ stopLoop(): void { this._loopState.active = false; @@ -810,8 +1168,13 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Clear all state (e.g., when session is cleared) - * Resets to disabled state + * Clear all state and disable the tracker. + * + * Use when the session is cleared or closed. + * Resets everything to initial disabled state. + * + * @fires loopUpdate - With initial state + * @fires todoUpdate - With empty array */ clear(): void { this._loopState = createInitialInnerLoopState(); // This sets enabled: false @@ -823,7 +1186,13 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Get todo completion stats + * Get aggregated statistics about tracked todos. + * + * @returns Object with counts by status: + * - total: Total number of tracked todos + * - pending: Todos not yet started + * - inProgress: Todos currently in progress + * - completed: Finished todos */ getTodoStats(): { total: number; pending: number; inProgress: number; completed: number } { let pending = 0; @@ -853,7 +1222,15 @@ export class InnerLoopTracker extends EventEmitter { } /** - * Restore state from persisted data + * Restore tracker state from persisted data. + * + * Use after loading state from StateStore. Handles backwards + * compatibility by defaulting missing `enabled` flag to false. + * + * Note: Does not emit events (caller should handle if needed). + * + * @param loopState - Persisted loop state object + * @param todos - Persisted todo items array */ restoreState(loopState: InnerLoopState, todos: InnerTodoItem[]): void { // Ensure enabled flag exists (backwards compatibility) diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index c482f9cb..1d604248 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -1,64 +1,192 @@ +/** + * @fileoverview Respawn Controller for autonomous Claude Code session cycling + * + * The RespawnController manages automatic respawning of Claude Code sessions. + * When Claude finishes working (detected by idle prompt), it automatically + * cycles through update → clear → init steps to keep the session productive. + * + * ## State Machine + * + * ``` + * WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR + * ↑ │ + * │ ▼ + * │ SENDING_INIT → WAITING_INIT → MONITORING_INIT ───────┘ + * │ │ + * │ ▼ (if no work triggered) + * └── SENDING_KICKSTART → WAITING_KICKSTART ──┘ + * ``` + * + * ## Configuration + * + * - `sendClear`: Whether to send /clear after update (default: true) + * - `sendInit`: Whether to send /init after clear (default: true) + * - `kickstartPrompt`: Optional prompt if /init doesn't trigger work + * + * @module respawn-controller + */ + import { EventEmitter } from 'node:events'; import { Session } from './session.js'; -// Maximum terminal buffer size for respawn controller (1MB) -const MAX_RESPAWN_BUFFER_SIZE = 1024 * 1024; -// Keep this much when trimming (512KB) -const RESPAWN_BUFFER_TRIM_SIZE = 512 * 1024; - -// Pre-compiled patterns for performance -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[HJKmsu?lh]/g; -const WHITESPACE_PATTERN = /\s+/g; - -// The definitive "ready for input" indicator - when Claude shows a suggestion -const READY_INDICATOR = '↵ send'; +// ========== Configuration Constants ========== /** - * Respawn sequence states + * Maximum terminal buffer size for respawn controller. + * Buffer is trimmed when this limit is exceeded to prevent memory issues. + */ +const MAX_RESPAWN_BUFFER_SIZE = 1024 * 1024; // 1MB + +/** + * Size to trim buffer to when MAX_RESPAWN_BUFFER_SIZE is exceeded. + * Keeps the most recent output for pattern detection. + */ +const RESPAWN_BUFFER_TRIM_SIZE = 512 * 1024; // 512KB + +// ========== Pre-compiled Regex Patterns ========== + +/** + * Matches ANSI escape codes for terminal control sequences. + * Used to strip formatting before pattern matching. + * @deprecated Currently unused but kept for potential future use + */ +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[HJKmsu?lh]/g; + +/** + * Matches whitespace sequences for normalization. + * @deprecated Currently unused but kept for potential future use + */ +const WHITESPACE_PATTERN = /\s+/g; + +/** + * The definitive "ready for input" indicator. + * When Claude shows a suggestion, this appears and indicates idle state. + */ +const READY_INDICATOR = '↵ send'; + +// ========== Type Definitions ========== + +/** + * Respawn sequence states. * * The controller cycles through these states: - * WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → MONITORING_INIT → (maybe SENDING_KICKSTART → WAITING_KICKSTART) → WATCHING + * ``` + * WATCHING → SENDING_UPDATE → WAITING_UPDATE → + * SENDING_CLEAR → WAITING_CLEAR → + * SENDING_INIT → WAITING_INIT → + * MONITORING_INIT → (maybe SENDING_KICKSTART → WAITING_KICKSTART) → + * WATCHING (repeat) + * ``` + * + * Steps can be skipped via config (`sendClear: false`, `sendInit: false`). */ export type RespawnState = - | 'watching' // Watching for idle, ready to start respawn sequence - | 'sending_update' // About to send the update docs prompt - | 'waiting_update' // Waiting for update to complete - | 'sending_clear' // About to send /clear - | 'waiting_clear' // Waiting for clear to complete - | 'sending_init' // About to send /init - | 'waiting_init' // Waiting for init to complete - | 'monitoring_init' // Monitoring if /init triggered work - | 'sending_kickstart' // About to send kickstart prompt - | 'waiting_kickstart' // Waiting for kickstart to complete - | 'stopped'; // Controller stopped + /** Watching for idle, ready to start respawn sequence */ + | 'watching' + /** About to send the update docs prompt */ + | 'sending_update' + /** Waiting for update to complete */ + | 'waiting_update' + /** About to send /clear command */ + | 'sending_clear' + /** Waiting for clear to complete */ + | 'waiting_clear' + /** About to send /init command */ + | 'sending_init' + /** Waiting for init to complete */ + | 'waiting_init' + /** Monitoring if /init triggered work */ + | 'monitoring_init' + /** About to send kickstart prompt */ + | 'sending_kickstart' + /** Waiting for kickstart to complete */ + | 'waiting_kickstart' + /** Controller stopped (not running) */ + | 'stopped'; +/** + * Configuration options for the RespawnController. + */ export interface RespawnConfig { - /** How long to wait after seeing prompt before considering truly idle (ms) */ + /** + * How long to wait after seeing prompt before considering truly idle. + * Prevents premature cycling when user is about to type. + * @default 10000 (10 seconds) + */ idleTimeoutMs: number; - /** The prompt to send for updating docs */ + + /** + * The prompt to send when updating docs. + * Sent at the start of each respawn cycle. + * @default 'update all the docs and CLAUDE.md' + */ updatePrompt: string; - /** Delay between sending steps (ms) */ + + /** + * Delay between sending steps (ms). + * Gives Claude time to process each command. + * @default 1000 (1 second) + */ interStepDelayMs: number; - /** Whether to enable respawn loop */ + + /** + * Whether the respawn loop is enabled. + * When false, start() will be a no-op. + * @default true + */ enabled: boolean; - /** Whether to send /clear after update prompt */ + + /** + * Whether to send /clear after update prompt completes. + * Resets Claude's context for fresh start. + * @default true + */ sendClear: boolean; - /** Whether to send /init after /clear */ + + /** + * Whether to send /init after /clear completes. + * Re-initializes Claude with CLAUDE.md context. + * @default true + */ sendInit: boolean; - /** Optional prompt to send if /init doesn't trigger work */ + + /** + * Optional prompt to send if /init doesn't trigger work. + * Used as a fallback when /init completes but Claude doesn't start working. + * @default undefined + */ kickstartPrompt?: string; } +/** + * Events emitted by RespawnController. + * + * @event stateChanged - Fired when state machine transitions + * @event respawnCycleStarted - Fired when a new cycle begins + * @event respawnCycleCompleted - Fired when a cycle finishes + * @event stepSent - Fired when a command is sent to the session + * @event stepCompleted - Fired when a step finishes (ready indicator detected) + * @event error - Fired on errors + * @event log - Fired for debug logging + */ export interface RespawnEvents { + /** State machine transition */ stateChanged: (state: RespawnState, prevState: RespawnState) => void; + /** New respawn cycle started */ respawnCycleStarted: (cycleNumber: number) => void; + /** Respawn cycle finished */ respawnCycleCompleted: (cycleNumber: number) => void; + /** Command sent to session */ stepSent: (step: string, input: string) => void; + /** Step completed (ready indicator detected) */ stepCompleted: (step: string) => void; + /** Error occurred */ error: (error: Error) => void; + /** Debug log message */ log: (message: string) => void; } +/** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt updatePrompt: 'update all the docs and CLAUDE.md', @@ -69,29 +197,90 @@ const DEFAULT_CONFIG: RespawnConfig = { }; /** - * RespawnController manages automatic respawning of Claude Code sessions + * RespawnController - Automatic session cycling for continuous Claude work. * - * When Claude finishes working (detected by idle prompt), it: - * 1. Sends an update docs prompt - * 2. Waits for completion - * 3. Sends /clear - * 4. Sends /init - * 5. Repeats + * Monitors a Claude Code session for idle state and automatically cycles + * through update → clear → init steps to keep the session productive. + * + * ## How It Works + * + * 1. **Idle Detection**: Watches terminal output for `↵ send` indicator + * 2. **Update**: Sends configured prompt (e.g., "update all docs") + * 3. **Clear**: Sends `/clear` to reset context (optional) + * 4. **Init**: Sends `/init` to re-initialize with CLAUDE.md (optional) + * 5. **Kickstart**: If /init doesn't trigger work, sends fallback prompt (optional) + * 6. **Repeat**: Returns to watching state for next cycle + * + * ## Idle Detection + * + * Primary indicator: `↵ send` - Claude's suggestion prompt + * Fallback indicators: Various prompt characters (❯, ⏵, etc.) + * + * Working indicators: Thinking, Writing, spinner characters, etc. + * + * ## Events + * + * - `stateChanged`: State machine transition + * - `respawnCycleStarted`: New cycle began + * - `respawnCycleCompleted`: Cycle finished + * - `stepSent`: Command sent to session + * - `stepCompleted`: Step finished + * - `log`: Debug messages + * + * @extends EventEmitter + * @example + * ```typescript + * const respawn = new RespawnController(session, { + * updatePrompt: 'continue working on the task', + * idleTimeoutMs: 5000, + * }); + * + * respawn.on('respawnCycleCompleted', (cycle) => { + * console.log(`Completed cycle ${cycle}`); + * }); + * + * respawn.start(); + * ``` */ export class RespawnController extends EventEmitter { + /** The session being controlled */ private session: Session; + + /** Current configuration */ private config: RespawnConfig; + + /** Current state machine state */ private _state: RespawnState = 'stopped'; + + /** Timer for idle detection timeout */ private idleTimer: NodeJS.Timeout | null = null; + + /** Timer for step delays */ private stepTimer: NodeJS.Timeout | null = null; + + /** Number of completed respawn cycles */ private cycleCount: number = 0; + + /** Timestamp of last terminal activity */ private lastActivityTime: number = 0; + + /** Buffer for recent terminal output */ private terminalBuffer: string = ''; + + /** Whether a prompt indicator was detected */ private promptDetected: boolean = false; + + /** Whether a working indicator was detected */ private workingDetected: boolean = false; + + /** Reference to terminal event handler (for cleanup) */ private terminalHandler: ((data: string) => void) | null = null; - // Terminal patterns - detect when Claude is ready for input + /** + * Patterns indicating Claude is ready for input. + * Primary: `↵ send` (suggestion prompt) + * Fallback: Various prompt characters + */ private readonly PROMPT_PATTERNS = [ '↵ send', // Suggestion ready to send (strongest indicator of idle) '❯', // Standard prompt @@ -100,6 +289,11 @@ export class RespawnController extends EventEmitter { '> ', // Fallback 'tokens', // The status line shows "X tokens" when at prompt ]; + + /** + * Patterns indicating Claude is actively working. + * When detected, resets idle detection. + */ private readonly WORKING_PATTERNS = [ 'Thinking', 'Writing', 'Reading', 'Running', 'Searching', 'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing', @@ -108,24 +302,51 @@ export class RespawnController extends EventEmitter { '✻', '✽', // Activity indicators (spinning star) ]; + /** + * Creates a new RespawnController. + * + * @param session - The Session instance to control + * @param config - Partial configuration (merged with defaults) + */ constructor(session: Session, config: Partial = {}) { super(); this.session = session; this.config = { ...DEFAULT_CONFIG, ...config }; } + /** + * Get the current state machine state. + * @returns Current RespawnState + */ get state(): RespawnState { return this._state; } + /** + * Get the current respawn cycle count. + * Increments each time a new cycle starts. + * @returns Number of cycles started + */ get currentCycle(): number { return this.cycleCount; } + /** + * Check if the controller is currently running. + * @returns True if state is not 'stopped' + */ get isRunning(): boolean { return this._state !== 'stopped'; } + /** + * Transition to a new state. + * Emits 'stateChanged' event with old and new states. + * No-op if already in the target state. + * + * @param newState - State to transition to + * @fires stateChanged + */ private setState(newState: RespawnState): void { if (newState === this._state) return; @@ -135,13 +356,27 @@ export class RespawnController extends EventEmitter { this.emit('stateChanged', newState, prevState); } + /** + * Emit a timestamped log message. + * @param message - Log message content + * @fires log + */ private log(message: string): void { const timestamp = new Date().toISOString(); this.emit('log', `[${timestamp}] [Respawn] ${message}`); } /** - * Start watching the session for idle state + * Start watching the session for idle state. + * + * Begins monitoring terminal output for idle indicators. + * When idle is detected, starts the respawn cycle. + * + * No-op if: + * - `config.enabled` is false + * - Already running (state !== 'stopped') + * + * @fires stateChanged - Transitions to 'watching' */ start(): void { if (!this.config.enabled) { @@ -161,7 +396,12 @@ export class RespawnController extends EventEmitter { } /** - * Stop the respawn controller + * Stop the respawn controller. + * + * Clears all timers, removes terminal listener, and sets state to 'stopped'. + * Safe to call multiple times. + * + * @fires stateChanged - Transitions to 'stopped' */ stop(): void { this.log('Stopping respawn controller'); @@ -174,7 +414,11 @@ export class RespawnController extends EventEmitter { } /** - * Pause respawn (keeps listening but won't trigger) + * Pause respawn without stopping. + * + * Clears timers but keeps listening to terminal. + * State is preserved; won't trigger idle detection while paused. + * Use resume() to continue. */ pause(): void { this.log('Pausing respawn'); @@ -183,7 +427,10 @@ export class RespawnController extends EventEmitter { } /** - * Resume respawn + * Resume respawn after pause. + * + * If in 'watching' state, immediately checks for idle condition. + * Otherwise, continues from current state. */ resume(): void { this.log('Resuming respawn'); @@ -192,6 +439,10 @@ export class RespawnController extends EventEmitter { } } + /** + * Set up terminal output listener on the session. + * Removes any previous listener first to avoid duplicates. + */ private setupTerminalListener(): void { // Remove our previous listener if any (don't remove other listeners!) if (this.terminalHandler) { @@ -204,6 +455,16 @@ export class RespawnController extends EventEmitter { this.session.on('terminal', this.terminalHandler); } + /** + * Process terminal data for idle/working detection. + * + * 1. Buffers data (with size limit) + * 2. Detects working indicators → resets idle + * 3. Detects ready indicator → triggers state-specific action + * 4. Detects prompt indicators → starts idle timer + * + * @param data - Raw terminal output data + */ private handleTerminalData(data: string): void { this.terminalBuffer += data; @@ -282,7 +543,12 @@ export class RespawnController extends EventEmitter { } } - // Step completion handlers - called when ready indicator is detected + /** + * Handle update step completion. + * Called when ready indicator detected in waiting_update state. + * Proceeds to clear, init, or completes cycle based on config. + * @fires stepCompleted - With step 'update' + */ private checkUpdateComplete(): void { this.clearIdleTimer(); this.log('Update completed (ready indicator)'); @@ -297,6 +563,11 @@ export class RespawnController extends EventEmitter { } } + /** + * Handle /clear step completion. + * Proceeds to init or completes cycle based on config. + * @fires stepCompleted - With step 'clear' + */ private checkClearComplete(): void { this.clearIdleTimer(); this.log('/clear completed (ready indicator)'); @@ -309,6 +580,12 @@ export class RespawnController extends EventEmitter { } } + /** + * Handle /init step completion. + * If kickstart is configured, monitors for work. + * Otherwise completes cycle. + * @fires stepCompleted - With step 'init' (if no kickstart) + */ private checkInitComplete(): void { this.clearIdleTimer(); this.log('/init completed (ready indicator)'); @@ -322,6 +599,11 @@ export class RespawnController extends EventEmitter { } } + /** + * Start monitoring to see if /init triggered work. + * Enters 'monitoring_init' state and waits 3s grace period. + * If no work detected, sends kickstart prompt. + */ private startMonitoringInit(): void { this.setState('monitoring_init'); this.terminalBuffer = ''; @@ -337,6 +619,11 @@ export class RespawnController extends EventEmitter { }, 3000); // 3 second grace period for /init to trigger work } + /** + * Handle monitoring timeout when /init didn't trigger work. + * Sends kickstart prompt as fallback. + * @fires stepCompleted - With step 'init' + */ private checkMonitoringInitIdle(): void { this.clearIdleTimer(); if (this.stepTimer) { @@ -348,6 +635,10 @@ export class RespawnController extends EventEmitter { this.sendKickstart(); } + /** + * Send the kickstart prompt to get Claude working. + * @fires stepSent - With step 'kickstart' + */ private sendKickstart(): void { this.setState('sending_kickstart'); this.terminalBuffer = ''; @@ -363,6 +654,10 @@ export class RespawnController extends EventEmitter { }, this.config.interStepDelayMs); } + /** + * Handle kickstart step completion. + * @fires stepCompleted - With step 'kickstart' + */ private checkKickstartComplete(): void { this.clearIdleTimer(); this.log('Kickstart completed (ready indicator)'); @@ -370,6 +665,10 @@ export class RespawnController extends EventEmitter { this.completeCycle(); } + /** + * Start the idle detection timer. + * After idleTimeoutMs, triggers onIdleDetected if still idle. + */ private startIdleTimer(): void { this.clearIdleTimer(); @@ -383,6 +682,7 @@ export class RespawnController extends EventEmitter { }, this.config.idleTimeoutMs); } + /** Clear the idle detection timer if running */ private clearIdleTimer(): void { if (this.idleTimer) { clearTimeout(this.idleTimer); @@ -390,6 +690,7 @@ export class RespawnController extends EventEmitter { } } + /** Clear all timers (idle and step) */ private clearTimers(): void { this.clearIdleTimer(); if (this.stepTimer) { @@ -398,6 +699,11 @@ export class RespawnController extends EventEmitter { } } + /** + * Handle confirmed idle detection. + * Starts a new respawn cycle. + * @fires respawnCycleStarted + */ private onIdleDetected(): void { if (this._state !== 'watching') { return; @@ -411,6 +717,10 @@ export class RespawnController extends EventEmitter { this.sendUpdateDocs(); } + /** + * Send the update docs prompt (first step of cycle). + * @fires stepSent - With step 'update' + */ private sendUpdateDocs(): void { this.setState('sending_update'); this.terminalBuffer = ''; // Clear buffer for fresh detection @@ -426,6 +736,10 @@ export class RespawnController extends EventEmitter { }, this.config.interStepDelayMs); } + /** + * Send /clear command. + * @fires stepSent - With step 'clear' + */ private sendClear(): void { this.setState('sending_clear'); this.terminalBuffer = ''; @@ -439,6 +753,10 @@ export class RespawnController extends EventEmitter { }, this.config.interStepDelayMs); } + /** + * Send /init command. + * @fires stepSent - With step 'init' + */ private sendInit(): void { this.setState('sending_init'); this.terminalBuffer = ''; @@ -453,6 +771,11 @@ export class RespawnController extends EventEmitter { }, this.config.interStepDelayMs); } + /** + * Complete the current respawn cycle. + * Returns to watching state for next cycle. + * @fires respawnCycleCompleted + */ private completeCycle(): void { this.log(`Respawn cycle #${this.cycleCount} completed`); this.emit('respawnCycleCompleted', this.cycleCount); @@ -464,6 +787,10 @@ export class RespawnController extends EventEmitter { this.workingDetected = false; } + /** + * Check if already idle and start cycle if so. + * Used when resuming from pause. + */ private checkIdleAndMaybeStart(): void { // Check if already idle const timeSinceActivity = Date.now() - this.lastActivityTime; @@ -473,7 +800,13 @@ export class RespawnController extends EventEmitter { } /** - * Update configuration + * Update configuration at runtime. + * + * Merges provided config with existing config. + * Takes effect immediately for new operations. + * + * @param config - Partial configuration to merge + * @fires log - With updated config details */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; @@ -481,14 +814,26 @@ export class RespawnController extends EventEmitter { } /** - * Get current configuration + * Get current configuration. + * @returns Copy of current config (safe to modify) */ getConfig(): RespawnConfig { return { ...this.config }; } /** - * Get status information + * Get comprehensive status information. + * + * Useful for debugging and monitoring. + * + * @returns Status object with: + * - state: Current state machine state + * - cycleCount: Number of cycles started + * - lastActivityTime: Timestamp of last activity + * - timeSinceActivity: Milliseconds since last activity + * - promptDetected: Whether prompt indicator seen + * - workingDetected: Whether working indicator seen + * - config: Current configuration */ getStatus() { return { diff --git a/src/task-tracker.ts b/src/task-tracker.ts index fe619b7b..01e0888d 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -1,60 +1,180 @@ +/** + * @fileoverview Background Task Tracker for Claude Code sessions + * + * This module tracks background tasks (subagents) spawned by Claude Code + * during session execution. It parses both JSON messages and terminal output + * to detect when tasks are created, updated, and completed. + * + * ## Task Hierarchy + * + * Tasks can be nested (parent-child relationships) when Claude spawns + * a subagent from within another subagent. The tracker maintains a stack + * to track nesting and a tree structure for visualization. + * + * ## Detection Methods + * + * 1. **JSON Messages**: Parses `tool_use` blocks for Task tool invocations + * and `tool_result` blocks for completion + * 2. **Terminal Output**: Fallback pattern matching for launch/complete messages + * + * @module task-tracker + */ + import { EventEmitter } from 'node:events'; -// Maximum number of completed tasks to keep in memory +// ========== Configuration Constants ========== + +/** + * Maximum number of completed tasks to keep in memory. + * Oldest completed tasks are removed when this limit is exceeded. + */ const MAX_COMPLETED_TASKS = 100; -// Pre-compiled patterns for performance +// ========== Pre-compiled Regex Patterns ========== + +/** + * Patterns that indicate a new task/agent is being launched. + * Used as fallback when JSON parsing doesn't capture the launch. + * Capture group 1: Agent/task type name + */ const LAUNCH_PATTERNS = [ /Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i, ]; + +/** + * Patterns that indicate a task has completed. + * Used as fallback when JSON parsing doesn't capture the result. + */ const COMPLETE_PATTERNS = [ /Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i, ]; +// ========== Type Definitions ========== + /** - * Represents a background task spawned by Claude Code + * Represents a background task spawned by Claude Code. + * + * Tasks form a tree structure where a parent task can spawn child tasks. + * This enables tracking of nested agent invocations. */ export interface BackgroundTask { + /** Unique task identifier (usually the tool_use ID from Claude) */ id: string; + + /** Parent task ID if this is a nested task, null for root tasks */ parentId: string | null; + + /** Human-readable description of what the task is doing */ description: string; + + /** Type of subagent (e.g., 'explore', 'bash', 'general-purpose') */ subagentType: string; + + /** Current execution status */ status: 'running' | 'completed' | 'failed'; + + /** Timestamp when task was created (milliseconds since epoch) */ startTime: number; + + /** Timestamp when task finished (milliseconds since epoch) */ endTime?: number; + + /** Output/result from the task execution */ output?: string; + + /** IDs of child tasks spawned by this task */ children: string[]; } +/** + * Events emitted by TaskTracker. + * + * @event taskCreated - New task detected and added + * @event taskUpdated - Task state changed (rarely used) + * @event taskCompleted - Task finished successfully + * @event taskFailed - Task finished with error + */ export interface TaskTrackerEvents { + /** New task created */ taskCreated: (task: BackgroundTask) => void; + /** Task state updated */ taskUpdated: (task: BackgroundTask) => void; + /** Task completed successfully */ taskCompleted: (task: BackgroundTask) => void; + /** Task failed with error */ taskFailed: (task: BackgroundTask, error: string) => void; } /** - * TaskTracker parses Claude Code's output to detect and track background tasks. + * TaskTracker - Detects and tracks background tasks in Claude Code sessions. * - * Claude Code outputs JSON messages. When it spawns a task via the Task tool, - * we see tool_use blocks with the task parameters. We track these and match - * them with tool_result blocks to track completion. + * ## How It Works + * + * Claude Code outputs JSON messages when executing. When it spawns a subagent + * via the Task tool, we see: + * + * 1. `tool_use` block with `name: "Task"` and input parameters + * 2. ... task execution output ... + * 3. `tool_result` block with the result or error + * + * The tracker maintains: + * - A map of all tasks by ID + * - A stack of currently running tasks (for nesting) + * - Parent-child relationships between tasks + * + * ## Usage + * + * ```typescript + * const tracker = new TaskTracker(); + * + * tracker.on('taskCreated', (task) => { + * console.log(`New task: ${task.description}`); + * }); + * + * tracker.on('taskCompleted', (task) => { + * console.log(`Task done: ${task.id}`); + * }); + * + * // Feed in Claude messages + * tracker.processMessage(claudeJsonMessage); + * + * // Or terminal output as fallback + * tracker.processTerminalOutput(ptyData); + * ``` + * + * @extends EventEmitter */ export class TaskTracker extends EventEmitter { + /** Map of task ID to task object */ private tasks: Map = new Map(); - private taskStack: string[] = []; // Stack of active task IDs for nesting + + /** Stack of active task IDs for tracking nesting depth */ + private taskStack: string[] = []; + + /** Pending tool_use blocks waiting for results */ private pendingToolUses: Map = new Map(); + /** + * Creates a new TaskTracker instance. + */ constructor() { super(); } /** - * Process a Claude message to detect task events + * Process a Claude JSON message to detect task events. + * + * Looks for `tool_use` blocks with `name: "Task"` and `tool_result` blocks + * to track task lifecycle. + * + * @param msg - Parsed Claude JSON message object + * @fires taskCreated - When a new task is detected + * @fires taskCompleted - When a task finishes successfully + * @fires taskFailed - When a task finishes with error */ processMessage(msg: any): void { if (!msg || !msg.message?.content) return; @@ -69,9 +189,17 @@ export class TaskTracker extends EventEmitter { } /** - * Process raw terminal output to detect task patterns - * This is a fallback for when JSON parsing doesn't capture everything - * Uses pre-compiled patterns for performance + * Process raw terminal output to detect task patterns. + * + * This is a fallback for when JSON parsing doesn't capture everything. + * Uses pre-compiled patterns to detect launch and completion messages. + * + * Note: May create duplicate tasks in some cases; deduplication is + * handled by checking for existing running tasks of the same type. + * + * @param data - Raw terminal output string + * @fires taskCreated - When a launch pattern is matched + * @fires taskCompleted - When a complete pattern is matched */ processTerminalOutput(data: string): void { // Detect task launch patterns in terminal output @@ -108,6 +236,13 @@ export class TaskTracker extends EventEmitter { } } + /** + * Handle a tool_use block for the Task tool. + * Creates a new task and pushes it onto the stack. + * + * @param block - The tool_use content block + * @fires taskCreated + */ private handleTaskToolUse(block: any): void { const toolUseId = block.id; const params = block.input || {}; @@ -146,6 +281,14 @@ export class TaskTracker extends EventEmitter { this.emit('taskCreated', task); } + /** + * Handle a tool_result block. + * Marks the corresponding task as completed or failed. + * + * @param block - The tool_result content block + * @fires taskCompleted - If result is success + * @fires taskFailed - If result is error + */ private handleToolResult(block: any): void { const toolUseId = block.tool_use_id; const task = this.tasks.get(toolUseId); @@ -216,6 +359,14 @@ export class TaskTracker extends EventEmitter { } } + /** + * Create a task from terminal pattern detection. + * Used as fallback when JSON messages aren't available. + * + * @param agentType - Type of agent detected + * @param context - Terminal context for debugging + * @fires taskCreated + */ private createTaskFromTerminal(agentType: string, context: string): void { const taskId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null; @@ -243,6 +394,12 @@ export class TaskTracker extends EventEmitter { this.emit('taskCreated', task); } + /** + * Mark a task as completed. + * + * @param taskId - ID of task to complete + * @fires taskCompleted - If task was running + */ private completeTask(taskId: string): void { const task = this.tasks.get(taskId); if (task && task.status === 'running') { @@ -258,6 +415,12 @@ export class TaskTracker extends EventEmitter { } } + /** + * Get the most recently started running task. + * Returns the task at the top of the stack (most nested). + * + * @returns Most recent running task, or undefined if none + */ private getMostRecentRunningTask(): BackgroundTask | undefined { // Return the task at the top of the stack if (this.taskStack.length > 0) { @@ -268,7 +431,12 @@ export class TaskTracker extends EventEmitter { } /** - * Get the task tree as a nested structure + * Get root-level tasks as a list. + * + * Child tasks can be accessed via the `children` array on each task. + * Use this for displaying a task tree in the UI. + * + * @returns Array of tasks without parents (root level) */ getTaskTree(): BackgroundTask[] { const rootTasks: BackgroundTask[] = []; @@ -283,21 +451,28 @@ export class TaskTracker extends EventEmitter { } /** - * Get all tasks as a flat map + * Get all tasks as a flat Map. + * + * @returns Copy of the internal tasks map (safe to modify) */ getAllTasks(): Map { return new Map(this.tasks); } /** - * Get a specific task by ID + * Get a specific task by its ID. + * + * @param taskId - The task ID to look up + * @returns The task if found, undefined otherwise */ getTask(taskId: string): BackgroundTask | undefined { return this.tasks.get(taskId); } /** - * Get count of currently running tasks + * Get the count of currently running tasks. + * + * @returns Number of tasks with status 'running' */ getRunningCount(): number { let count = 0; @@ -308,7 +483,13 @@ export class TaskTracker extends EventEmitter { } /** - * Get summary statistics + * Get aggregated statistics about all tracked tasks. + * + * @returns Object with counts: + * - total: Total number of tracked tasks + * - running: Tasks currently executing + * - completed: Successfully finished tasks + * - failed: Tasks that ended with errors */ getStats(): { total: number; running: number; completed: number; failed: number } { let running = 0, completed = 0, failed = 0; @@ -325,7 +506,10 @@ export class TaskTracker extends EventEmitter { } /** - * Clear all tasks (e.g., when session is cleared) + * Clear all tracked tasks. + * + * Use when the session is cleared or closed. + * Resets the task map, stack, and pending tool uses. */ clear(): void { this.tasks.clear();