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();