diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts new file mode 100644 index 00000000..0092f34d --- /dev/null +++ b/src/inner-loop-tracker.ts @@ -0,0 +1,396 @@ +import { EventEmitter } from 'node:events'; +import { + InnerLoopState, + InnerTodoItem, + InnerTodoStatus, + createInitialInnerLoopState, +} from './types.js'; + +// Maximum number of todo items to track per session +const MAX_TODO_ITEMS = 50; +// Todo items older than this will be auto-expired (1 hour) +const TODO_EXPIRY_MS = 60 * 60 * 1000; + +// Pre-compiled regex patterns for performance (avoid re-compilation on each call) + +// Completion phrase detection: PHRASE +const PROMISE_PATTERN = /([^<]+)<\/promise>/; + +// Todo item patterns - multiple formats Claude Code uses +// Format 1: Checkbox format in markdown: "- [ ] Task" or "- [x] Task" +const TODO_CHECKBOX_PATTERN = /^[-*]\s*\[([xX ])\]\s+(.+)$/gm; +// Format 2: Todo with indicator icons: "Todo: ☐ Task", "Todo: ◐ Task", "Todo: ✓ Task" +const TODO_INDICATOR_PATTERN = /Todo:\s*(☐|◐|✓|⏳|✅|⌛|🔄)\s+(.+)/g; +// Format 3: Status in parentheses: "(pending)", "(in_progress)", "(completed)" +const TODO_STATUS_PATTERN = /[-*]\s*(.+?)\s+\((pending|in_progress|completed)\)/g; + +// Loop status patterns +const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started|([^<]+)<\/promise>/i; +const ELAPSED_TIME_PATTERN = /Elapsed:\s*(\d+(?:\.\d+)?)\s*hours?/i; +const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i; + +// ANSI escape code removal for cleaner parsing +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; + +export interface InnerLoopTrackerEvents { + loopUpdate: (state: InnerLoopState) => void; + todoUpdate: (todos: InnerTodoItem[]) => void; + completionDetected: (phrase: string) => 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 + */ +export class InnerLoopTracker extends EventEmitter { + private _loopState: InnerLoopState; + private _todos: Map = new Map(); + private _lineBuffer: string = ''; + + constructor() { + super(); + this._loopState = createInitialInnerLoopState(); + } + + get loopState(): InnerLoopState { + return { ...this._loopState }; + } + + get todos(): InnerTodoItem[] { + return Array.from(this._todos.values()); + } + + /** + * Process raw terminal data to detect inner loop patterns + */ + processTerminalData(data: string): void { + // Remove ANSI escape codes for cleaner parsing + const cleanData = data.replace(ANSI_ESCAPE_PATTERN, ''); + + // Buffer data for line-based processing + this._lineBuffer += cleanData; + + // Process complete lines + const lines = this._lineBuffer.split('\n'); + this._lineBuffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + this.processLine(line); + } + + // Also check the current buffer for multi-line patterns + this.checkMultiLinePatterns(cleanData); + + // Cleanup expired todos + this.cleanupExpiredTodos(); + } + + /** + * Process a single line of terminal output + */ + private processLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) return; + + // Check for completion phrase + this.detectCompletionPhrase(trimmed); + + // Check for loop start/status + this.detectLoopStatus(trimmed); + + // Check for todo items + this.detectTodoItems(trimmed); + } + + /** + * Check for multi-line patterns in the data chunk + */ + private checkMultiLinePatterns(data: string): void { + // Completion phrase can span lines, so check the whole chunk + const promiseMatch = data.match(PROMISE_PATTERN); + if (promiseMatch) { + this.handleCompletionPhrase(promiseMatch[1]); + } + } + + /** + * Detect PHRASE completion phrases + */ + private detectCompletionPhrase(line: string): void { + const match = line.match(PROMISE_PATTERN); + if (match) { + this.handleCompletionPhrase(match[1]); + } + } + + /** + * Handle a detected completion phrase + */ + private handleCompletionPhrase(phrase: string): void { + const wasActive = this._loopState.active; + + // If we see a completion phrase, mark loop as completed + this._loopState.completionPhrase = phrase; + this._loopState.active = false; + this._loopState.lastActivity = Date.now(); + + this.emit('completionDetected', phrase); + this.emit('loopUpdate', this.loopState); + } + + /** + * Detect loop start and status indicators + */ + private detectLoopStatus(line: string): void { + // Check for loop start + if (LOOP_START_PATTERN.test(line)) { + // Check if this is a promise match (loop ending) vs loop start + if (!PROMISE_PATTERN.test(line)) { + // This is a loop start indicator + if (!this._loopState.active) { + this._loopState.active = true; + this._loopState.startedAt = Date.now(); + this._loopState.cycleCount = 0; + this._loopState.elapsedHours = null; + this._loopState.lastActivity = Date.now(); + this.emit('loopUpdate', this.loopState); + } + } + } + + // Check for elapsed time + const elapsedMatch = line.match(ELAPSED_TIME_PATTERN); + if (elapsedMatch) { + this._loopState.elapsedHours = parseFloat(elapsedMatch[1]); + this._loopState.lastActivity = Date.now(); + this.emit('loopUpdate', this.loopState); + } + + // Check for cycle count + const cycleMatch = line.match(CYCLE_PATTERN); + if (cycleMatch) { + const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2]); + if (!isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) { + this._loopState.cycleCount = cycleNum; + this._loopState.lastActivity = Date.now(); + this.emit('loopUpdate', this.loopState); + } + } + } + + /** + * Detect todo items in various formats + */ + private detectTodoItems(line: string): void { + let updated = false; + + // Format 1: Checkbox format "- [ ] Task" or "- [x] Task" + TODO_CHECKBOX_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) { + const checked = match[1].toLowerCase() === 'x'; + const content = match[2].trim(); + const status: InnerTodoStatus = checked ? 'completed' : 'pending'; + this.upsertTodo(content, status); + updated = true; + } + + // Format 2: Todo with indicator icons + TODO_INDICATOR_PATTERN.lastIndex = 0; + while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) { + const icon = match[1]; + const content = match[2].trim(); + const status = this.iconToStatus(icon); + this.upsertTodo(content, status); + updated = true; + } + + // Format 3: Status in parentheses + TODO_STATUS_PATTERN.lastIndex = 0; + while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) { + const content = match[1].trim(); + const status = match[2] as InnerTodoStatus; + this.upsertTodo(content, status); + updated = true; + } + + if (updated) { + this.emit('todoUpdate', this.todos); + } + } + + /** + * Convert todo icon to status + */ + private iconToStatus(icon: string): InnerTodoStatus { + switch (icon) { + case '✓': + case '✅': + return 'completed'; + case '◐': + case '⏳': + case '⌛': + case '🔄': + return 'in_progress'; + case '☐': + default: + return 'pending'; + } + } + + /** + * Add or update a todo item + */ + private upsertTodo(content: string, status: InnerTodoStatus): void { + // Generate a stable ID from content (simple hash) + const id = this.generateTodoId(content); + + const existing = this._todos.get(id); + if (existing) { + // Update existing todo + existing.status = status; + existing.detectedAt = Date.now(); + } else { + // Add new todo + if (this._todos.size >= MAX_TODO_ITEMS) { + // Remove oldest todo to make room + const oldest = this.findOldestTodo(); + if (oldest) { + this._todos.delete(oldest.id); + } + } + + this._todos.set(id, { + id, + content, + status, + detectedAt: Date.now(), + }); + } + } + + /** + * Generate a stable ID from todo content + */ + private generateTodoId(content: string): string { + // Simple hash based on content + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return `todo-${Math.abs(hash).toString(36)}`; + } + + /** + * Find the oldest todo item + */ + private findOldestTodo(): InnerTodoItem | undefined { + let oldest: InnerTodoItem | undefined; + for (const todo of this._todos.values()) { + if (!oldest || todo.detectedAt < oldest.detectedAt) { + oldest = todo; + } + } + return oldest; + } + + /** + * Remove expired todo items + */ + private cleanupExpiredTodos(): void { + const now = Date.now(); + const toDelete: string[] = []; + + for (const [id, todo] of this._todos) { + if (now - todo.detectedAt > TODO_EXPIRY_MS) { + toDelete.push(id); + } + } + + if (toDelete.length > 0) { + for (const id of toDelete) { + this._todos.delete(id); + } + this.emit('todoUpdate', this.todos); + } + } + + /** + * Mark the loop as started (can be called externally) + */ + startLoop(completionPhrase?: string): void { + this._loopState.active = true; + this._loopState.startedAt = Date.now(); + this._loopState.cycleCount = 0; + this._loopState.elapsedHours = null; + this._loopState.lastActivity = Date.now(); + if (completionPhrase) { + this._loopState.completionPhrase = completionPhrase; + } + this.emit('loopUpdate', this.loopState); + } + + /** + * Mark the loop as stopped + */ + stopLoop(): void { + this._loopState.active = false; + this._loopState.lastActivity = Date.now(); + this.emit('loopUpdate', this.loopState); + } + + /** + * Clear all state (e.g., when session is cleared) + */ + clear(): void { + this._loopState = createInitialInnerLoopState(); + this._todos.clear(); + this._lineBuffer = ''; + this.emit('loopUpdate', this.loopState); + this.emit('todoUpdate', this.todos); + } + + /** + * Get todo completion stats + */ + getTodoStats(): { total: number; pending: number; inProgress: number; completed: number } { + let pending = 0; + let inProgress = 0; + let completed = 0; + + for (const todo of this._todos.values()) { + switch (todo.status) { + case 'pending': + pending++; + break; + case 'in_progress': + inProgress++; + break; + case 'completed': + completed++; + break; + } + } + + return { + total: this._todos.size, + pending, + inProgress, + completed, + }; + } + + /** + * Restore state from persisted data + */ + restoreState(loopState: InnerLoopState, todos: InnerTodoItem[]): void { + this._loopState = { ...loopState }; + this._todos.clear(); + for (const todo of todos) { + this._todos.set(todo.id, { ...todo }); + } + } +} diff --git a/src/session.ts b/src/session.ts index 9b9388b6..6b7f9634 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,11 +2,13 @@ import { EventEmitter } from 'node:events'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; import { execSync } from 'node:child_process'; -import { SessionState, SessionStatus, SessionConfig, ScreenSession } from './types.js'; +import { SessionState, SessionStatus, SessionConfig, ScreenSession, InnerLoopState, InnerTodoItem } from './types.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; +import { InnerLoopTracker } from './inner-loop-tracker.js'; import { ScreenManager } from './screen-manager.js'; export type { BackgroundTask } from './task-tracker.js'; +export type { InnerLoopState, InnerTodoItem } from './types.js'; // Maximum terminal buffer size in characters (default 5MB of text) const MAX_TERMINAL_BUFFER_SIZE = 5 * 1024 * 1024; @@ -64,6 +66,10 @@ export interface SessionEvents { autoClear: (data: { tokens: number; threshold: number }) => void; // Auto-compact event autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void; + // Inner loop tracking events (Claude Code running inside this session) + innerLoopUpdate: (state: InnerLoopState) => void; + innerTodoUpdate: (todos: InnerTodoItem[]) => void; + innerCompletionDetected: (phrase: string) => void; } export type SessionMode = 'claude' | 'shell'; @@ -114,6 +120,9 @@ export class Session extends EventEmitter { private _screenSession: ScreenSession | null = null; private _useScreen: boolean = false; + // Inner loop tracking (Ralph Wiggum loops and todo lists inside Claude Code) + private _innerLoopTracker: InnerLoopTracker; + constructor(config: Partial & { workingDir: string; mode?: SessionMode; @@ -137,6 +146,12 @@ export class Session extends EventEmitter { this._taskTracker.on('taskUpdated', (task) => this.emit('taskUpdated', task)); this._taskTracker.on('taskCompleted', (task) => this.emit('taskCompleted', task)); this._taskTracker.on('taskFailed', (task, error) => this.emit('taskFailed', task, error)); + + // Initialize inner loop tracker and forward events + this._innerLoopTracker = new InnerLoopTracker(); + this._innerLoopTracker.on('loopUpdate', (state) => this.emit('innerLoopUpdate', state)); + this._innerLoopTracker.on('todoUpdate', (todos) => this.emit('innerTodoUpdate', todos)); + this._innerLoopTracker.on('completionDetected', (phrase) => this.emit('innerCompletionDetected', phrase)); } get status(): SessionStatus { @@ -207,6 +222,23 @@ export class Session extends EventEmitter { return this._taskTracker.getStats(); } + // Inner loop tracking getters + get innerLoopTracker(): InnerLoopTracker { + return this._innerLoopTracker; + } + + get innerLoopState(): InnerLoopState { + return this._innerLoopTracker.loopState; + } + + get innerTodos(): InnerTodoItem[] { + return this._innerLoopTracker.todos; + } + + get innerTodoStats(): { total: number; pending: number; inProgress: number; completed: number } { + return this._innerLoopTracker.getTodoStats(); + } + // Token tracking getters and setters get totalTokens(): number { return this._totalInputTokens + this._totalOutputTokens; @@ -323,6 +355,10 @@ export class Session extends EventEmitter { enabled: this._autoClearEnabled, threshold: this._autoClearThreshold, }, + // Inner loop tracking state + innerLoop: this._innerLoopTracker.loopState, + innerTodos: this._innerLoopTracker.todos, + innerTodoStats: this._innerLoopTracker.getTodoStats(), }; } @@ -408,6 +444,9 @@ export class Session extends EventEmitter { this.emit('terminal', data); this.emit('output', data); + // Forward to inner loop tracker to detect Ralph loops and todos + this._innerLoopTracker.processTerminalData(data); + // Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens") this.parseTokensFromStatusLine(data); @@ -996,5 +1035,6 @@ export class Session extends EventEmitter { this._errorBuffer = ''; this._messages = []; this._taskTracker.clear(); + this._innerLoopTracker.clear(); } } diff --git a/src/state-store.ts b/src/state-store.ts index 40bfd958..a24a20bd 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { AppState, createInitialState } from './types.js'; +import { AppState, createInitialState, InnerSessionState, createInitialInnerSessionState } from './types.js'; // Debounce delay for batching state writes (ms) const SAVE_DEBOUNCE_MS = 500; @@ -12,10 +12,18 @@ export class StateStore { private saveTimeout: NodeJS.Timeout | null = null; private dirty: boolean = false; + // Inner state storage (separate from main state to reduce write frequency) + private innerStates: Map = new Map(); + private innerStatePath: string; + private innerStateSaveTimeout: NodeJS.Timeout | null = null; + private innerStateDirty: boolean = false; + constructor(filePath?: string) { this.filePath = filePath || join(homedir(), '.claudeman', 'state.json'); + this.innerStatePath = this.filePath.replace('.json', '-inner.json'); this.state = this.load(); this.state.config.stateFilePath = this.filePath; + this.loadInnerStates(); } private ensureDir(): void { @@ -138,7 +146,91 @@ export class StateStore { reset(): void { this.state = createInitialState(); this.state.config.stateFilePath = this.filePath; + this.innerStates.clear(); this.saveNow(); // Immediate save for reset operations + this.saveInnerStatesNow(); + } + + // ========== Inner State Methods ========== + + private loadInnerStates(): void { + try { + if (existsSync(this.innerStatePath)) { + const data = readFileSync(this.innerStatePath, 'utf-8'); + const parsed = JSON.parse(data) as Record; + for (const [sessionId, state] of Object.entries(parsed)) { + this.innerStates.set(sessionId, state); + } + } + } catch (err) { + console.error('Failed to load inner states:', err); + } + } + + // Debounced save for inner states + private saveInnerStates(): void { + this.innerStateDirty = true; + if (this.innerStateSaveTimeout) { + return; // Already scheduled + } + this.innerStateSaveTimeout = setTimeout(() => { + this.saveInnerStatesNow(); + }, SAVE_DEBOUNCE_MS); + } + + // Immediate save for inner states + private saveInnerStatesNow(): void { + if (this.innerStateSaveTimeout) { + clearTimeout(this.innerStateSaveTimeout); + this.innerStateSaveTimeout = null; + } + if (!this.innerStateDirty) { + return; + } + this.innerStateDirty = false; + this.ensureDir(); + const data: Record = {}; + for (const [sessionId, state] of this.innerStates) { + data[sessionId] = state; + } + writeFileSync(this.innerStatePath, JSON.stringify(data, null, 2), 'utf-8'); + } + + getInnerState(sessionId: string): InnerSessionState | null { + return this.innerStates.get(sessionId) || null; + } + + setInnerState(sessionId: string, state: InnerSessionState): void { + this.innerStates.set(sessionId, state); + this.saveInnerStates(); + } + + updateInnerState(sessionId: string, updates: Partial): InnerSessionState { + let state = this.innerStates.get(sessionId); + if (!state) { + state = createInitialInnerSessionState(sessionId); + } + state = { ...state, ...updates, lastUpdated: Date.now() }; + this.innerStates.set(sessionId, state); + this.saveInnerStates(); + return state; + } + + removeInnerState(sessionId: string): void { + if (this.innerStates.has(sessionId)) { + this.innerStates.delete(sessionId); + this.saveInnerStates(); + } + } + + getAllInnerStates(): Map { + return new Map(this.innerStates); + } + + // Flush all pending saves (call before shutdown) + flushAll(): void { + this.saveNow(); + this.saveInnerStatesNow(); } } diff --git a/src/types.ts b/src/types.ts index e6d6ec31..33b48f1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -245,6 +245,58 @@ export const DEFAULT_CONFIG: AppConfig = { }, }; +// ========== Inner Loop Tracking Types ========== +// Track Ralph Wiggum loops and todo lists running inside Claude Code sessions + +export type InnerTodoStatus = 'pending' | 'in_progress' | 'completed'; + +export interface InnerLoopState { + active: boolean; + completionPhrase: string | null; + startedAt: number | null; + cycleCount: number; + lastActivity: number; + elapsedHours: number | null; +} + +export interface InnerTodoItem { + id: string; + content: string; + status: InnerTodoStatus; + detectedAt: number; +} + +export interface InnerSessionState { + sessionId: string; + loop: InnerLoopState; + todos: InnerTodoItem[]; + lastUpdated: number; +} + +export interface InnerStateRecord { + [sessionId: string]: InnerSessionState; +} + +export function createInitialInnerLoopState(): InnerLoopState { + return { + active: false, + completionPhrase: null, + startedAt: null, + cycleCount: 0, + lastActivity: Date.now(), + elapsedHours: null, + }; +} + +export function createInitialInnerSessionState(sessionId: string): InnerSessionState { + return { + sessionId, + loop: createInitialInnerLoopState(), + todos: [], + lastUpdated: Date.now(), + }; +} + export function createInitialState(): AppState { return { sessions: {}, diff --git a/src/web/public/app.js b/src/web/public/app.js index a73bd7b6..0ece7ac8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -19,6 +19,10 @@ class ClaudemanApp { this.pendingCloseSessionId = null; // Session pending close confirmation this.screenSessions = []; // Screen sessions for process monitor + // Inner loop/todo state per session + this.innerStates = new Map(); // Map + this.innerStatePanelCollapsed = true; // Default to collapsed + // Terminal write batching this.pendingWrites = ''; this.writeFrameScheduled = false; @@ -480,6 +484,28 @@ class ClaudemanApp { this.renderScreenSessions(); } }); + + // Inner loop/todo events + this.eventSource.addEventListener('session:innerLoopUpdate', (e) => { + const data = JSON.parse(e.data); + this.updateInnerState(data.sessionId, { loop: data.state }); + }); + + this.eventSource.addEventListener('session:innerTodoUpdate', (e) => { + const data = JSON.parse(e.data); + this.updateInnerState(data.sessionId, { todos: data.todos }); + }); + + this.eventSource.addEventListener('session:innerCompletionDetected', (e) => { + const data = JSON.parse(e.data); + this.showToast(`Loop completed: ${data.phrase}`, 'success'); + // Update inner state to mark loop as inactive + const existing = this.innerStates.get(data.sessionId) || {}; + if (existing.loop) { + existing.loop.active = false; + this.updateInnerState(data.sessionId, existing); + } + }); } setConnectionStatus(status) { @@ -491,7 +517,17 @@ class ClaudemanApp { handleInit(data) { this.sessions.clear(); - data.sessions.forEach(s => this.sessions.set(s.id, s)); + this.innerStates.clear(); + data.sessions.forEach(s => { + this.sessions.set(s.id, s); + // Load inner state from session data + if (s.innerLoop || s.innerTodos) { + this.innerStates.set(s.id, { + loop: s.innerLoop || null, + todos: s.innerTodos || [] + }); + } + }); if (data.respawnStatus) { this.respawnStatus = data.respawnStatus; @@ -627,6 +663,16 @@ class ClaudemanApp { this.renderTaskPanel(); } + // Update inner state panel for this session + const session = this.sessions.get(sessionId); + if (session && (session.innerLoop || session.innerTodos)) { + this.updateInnerState(sessionId, { + loop: session.innerLoop, + todos: session.innerTodos + }); + } + this.renderInnerStatePanel(); + this.terminal.focus(); } catch (err) { console.error('Failed to load session terminal:', err); @@ -638,6 +684,7 @@ class ClaudemanApp { await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' }); this.sessions.delete(sessionId); this.terminalBuffers.delete(sessionId); + this.innerStates.delete(sessionId); if (this.activeSessionId === sessionId) { this.activeSessionId = null; @@ -1723,6 +1770,146 @@ class ClaudemanApp { return result; } + // ========== Inner State (Ralph Loop & Todo List) ========== + + updateInnerState(sessionId, updates) { + const existing = this.innerStates.get(sessionId) || { loop: null, todos: [] }; + const updated = { ...existing, ...updates }; + this.innerStates.set(sessionId, updated); + + // Re-render if this is the active session + if (sessionId === this.activeSessionId) { + this.renderInnerStatePanel(); + } + } + + toggleInnerStatePanel() { + this.innerStatePanelCollapsed = !this.innerStatePanelCollapsed; + this.renderInnerStatePanel(); + } + + renderInnerStatePanel() { + const panel = this.$('innerStatePanel'); + const content = this.$('innerStateContent'); + const toggle = this.$('innerStateToggle'); + + if (!panel) return; // Panel not in DOM yet + + const state = this.innerStates.get(this.activeSessionId); + + // Check if there's anything to show + const hasLoop = state?.loop?.active || state?.loop?.completionPhrase; + const hasTodos = state?.todos?.length > 0; + + if (!hasLoop && !hasTodos) { + panel.style.display = 'none'; + return; + } + + panel.style.display = ''; + + if (this.innerStatePanelCollapsed) { + panel.classList.add('collapsed'); + toggle.innerHTML = '▶'; // Right arrow + content.innerHTML = this.renderInnerStateSummary(state); + } else { + panel.classList.remove('collapsed'); + toggle.innerHTML = '▼'; // Down arrow + content.innerHTML = this.renderInnerStateExpanded(state); + } + } + + renderInnerStateSummary(state) { + const parts = []; + + // Loop status + if (state?.loop) { + const loop = state.loop; + if (loop.active) { + let loopText = '🔄 Loop'; + if (loop.completionPhrase) { + loopText += `: "${this.escapeHtml(loop.completionPhrase)}"`; + } + if (loop.elapsedHours !== null) { + loopText += ` (${loop.elapsedHours.toFixed(1)}h)`; + } + parts.push(`${loopText}`); + } else if (loop.completionPhrase) { + parts.push(`✓ ${this.escapeHtml(loop.completionPhrase)}`); + } + } + + // Todo summary + if (state?.todos?.length > 0) { + const completed = state.todos.filter(t => t.status === 'completed').length; + const total = state.todos.length; + const inProgress = state.todos.filter(t => t.status === 'in_progress').length; + let todoText = `Tasks: ${completed}/${total}`; + if (inProgress > 0) { + todoText += ` (${inProgress} in progress)`; + } + parts.push(`${todoText}`); + } + + return parts.join(' | '); + } + + renderInnerStateExpanded(state) { + let html = ''; + + // Loop details + if (state?.loop) { + const loop = state.loop; + html += '
'; + if (loop.active) { + html += `
🔄 Loop Active`; + if (loop.completionPhrase) { + html += ` - waiting for: ${this.escapeHtml(loop.completionPhrase)}`; + } + html += '
'; + if (loop.elapsedHours !== null) { + html += `
Elapsed: ${loop.elapsedHours.toFixed(2)} hours
`; + } + if (loop.cycleCount > 0) { + html += `
Cycle: #${loop.cycleCount}
`; + } + } else if (loop.completionPhrase) { + html += `
✓ Completed: ${this.escapeHtml(loop.completionPhrase)}
`; + } + html += '
'; + } + + // Todo list + if (state?.todos?.length > 0) { + const completed = state.todos.filter(t => t.status === 'completed').length; + const total = state.todos.length; + + html += '
'; + html += `
Tasks (${completed}/${total} complete)
`; + html += '
    '; + + for (const todo of state.todos) { + const icon = this.getTodoIcon(todo.status); + const statusClass = `todo-${todo.status.replace('_', '-')}`; + html += `
  • ${icon} ${this.escapeHtml(todo.content)}
  • `; + } + + html += '
'; + html += '
'; + } + + return html || '
No inner state data
'; + } + + getTodoIcon(status) { + switch (status) { + case 'completed': return '✓'; + case 'in_progress': return '◐'; + case 'pending': + default: return '☐'; + } + } + // ========== Screen Sessions (in Monitor Panel) ========== async loadScreens() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 61b0a8d4..18339582 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -72,6 +72,15 @@ + + +
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ea5d9aa6..7de21f81 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -447,6 +447,168 @@ body { to { transform: rotate(360deg); } } +/* Inner State Panel (Ralph Loop + Todo List) */ +.inner-state-panel { + background: var(--bg-card); + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + flex-shrink: 0; +} + +.inner-state-panel.collapsed .inner-state-content { + max-height: 28px; + overflow: hidden; +} + +.inner-state-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + cursor: pointer; + user-select: none; +} + +.inner-state-header:hover { + background: var(--bg-hover); +} + +.inner-state-toggle { + color: var(--text-dim); + font-size: 0.6rem; + transition: transform 0.2s; +} + +.inner-state-title { + color: var(--text-dim); + font-weight: 500; +} + +.inner-state-content { + padding: 0 0.75rem 0.5rem; + transition: max-height 0.2s; +} + +.inner-state-panel.collapsed .inner-state-content { + padding: 0 0.75rem; + display: flex; + align-items: center; + gap: 1rem; +} + +/* Loop status in summary view */ +.inner-loop-status { + padding: 0.15rem 0.5rem; + border-radius: 3px; + font-size: 0.7rem; +} + +.inner-loop-status.active { + background: rgba(59, 130, 246, 0.2); + color: var(--accent-hover); +} + +.inner-loop-status.completed { + background: rgba(34, 197, 94, 0.2); + color: var(--green); +} + +.inner-todo-summary { + color: var(--text-dim); +} + +/* Loop details in expanded view */ +.inner-loop-details { + margin-bottom: 0.5rem; + padding: 0.5rem; + background: rgba(59, 130, 246, 0.1); + border-radius: 4px; + border-left: 2px solid var(--accent); +} + +.inner-loop-active { + color: var(--accent-hover); +} + +.inner-loop-active code { + background: var(--bg-input); + padding: 0.1rem 0.3rem; + border-radius: 2px; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; +} + +.inner-loop-elapsed, +.inner-loop-cycles { + color: var(--text-dim); + font-size: 0.7rem; + margin-top: 0.25rem; +} + +.inner-loop-completed { + color: var(--green); +} + +.inner-loop-completed code { + background: var(--bg-input); + padding: 0.1rem 0.3rem; + border-radius: 2px; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; +} + +/* Todo list in expanded view */ +.inner-todo-list { + padding: 0.5rem; + background: var(--bg-input); + border-radius: 4px; +} + +.inner-todo-header { + color: var(--text); + font-weight: 500; + margin-bottom: 0.35rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--border); +} + +.inner-todo-items { + list-style: none; + padding: 0; + margin: 0; + max-height: 200px; + overflow-y: auto; +} + +.inner-todo-items li { + padding: 0.25rem 0; + color: var(--text-dim); + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.inner-todo-items li.todo-completed { + color: var(--green); + text-decoration: line-through; + opacity: 0.7; +} + +.inner-todo-items li.todo-in-progress { + color: var(--accent-hover); +} + +.inner-todo-items li.todo-pending { + color: var(--text-dim); +} + +.inner-state-empty { + color: var(--text-muted); + font-style: italic; +} + /* Main - Terminal Area */ .main { flex: 1; diff --git a/src/web/server.ts b/src/web/server.ts index 9be74215..883d2329 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { EventEmitter } from 'node:events'; -import { Session, ClaudeMessage, type BackgroundTask } from '../session.js'; +import { Session, ClaudeMessage, type BackgroundTask, type InnerLoopState, type InnerTodoItem } from '../session.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; import { ScreenManager } from '../screen-manager.js'; import { getStore } from '../state-store.js'; @@ -223,6 +223,25 @@ export class WebServer extends EventEmitter { }; }); + // Get inner state (Ralph loop + todos) for a session + this.app.get('/api/sessions/:id/inner-state', async (req) => { + const { id } = req.params as { id: string }; + const session = this.sessions.get(id); + + if (!session) { + return { success: false, error: 'Session not found' }; + } + + return { + success: true, + data: { + loop: session.innerLoopState, + todos: session.innerTodos, + todoStats: session.innerTodoStats, + } + }; + }); + // Run prompt in session this.app.post('/api/sessions/:id/run', async (req): Promise<{ success?: boolean; message?: string; error?: string }> => { const { id } = req.params as { id: string }; @@ -850,6 +869,9 @@ export class WebServer extends EventEmitter { this.outputBatches.delete(sessionId); this.taskUpdateBatches.delete(sessionId); + // Clear inner state + this.store.removeInnerState(sessionId); + // Stop session and remove listeners if (session) { session.removeAllListeners(); @@ -940,6 +962,23 @@ export class WebServer extends EventEmitter { this.broadcast('session:autoCompact', { sessionId: session.id, ...data }); this.broadcast('session:updated', session.toDetailedState()); }); + + // Inner loop tracking events + session.on('innerLoopUpdate', (state: InnerLoopState) => { + this.broadcast('session:innerLoopUpdate', { sessionId: session.id, state }); + // Persist inner state + this.store.updateInnerState(session.id, { loop: state }); + }); + + session.on('innerTodoUpdate', (todos: InnerTodoItem[]) => { + this.broadcast('session:innerTodoUpdate', { sessionId: session.id, todos }); + // Persist inner state + this.store.updateInnerState(session.id, { todos }); + }); + + session.on('innerCompletionDetected', (phrase: string) => { + this.broadcast('session:innerCompletionDetected', { sessionId: session.id, phrase }); + }); } private setupRespawnListeners(sessionId: string, controller: RespawnController): void { diff --git a/test/inner-loop-tracker.test.ts b/test/inner-loop-tracker.test.ts new file mode 100644 index 00000000..5a60d0cf --- /dev/null +++ b/test/inner-loop-tracker.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { InnerLoopTracker } from '../src/inner-loop-tracker.js'; +import { InnerLoopState, InnerTodoItem } from '../src/types.js'; + +/** + * InnerLoopTracker Tests + * + * Tests the detection of Ralph Wiggum loops and todo lists from terminal output + * running inside Claude Code sessions. + */ + +describe('InnerLoopTracker', () => { + let tracker: InnerLoopTracker; + + beforeEach(() => { + tracker = new InnerLoopTracker(); + }); + + describe('Initialization', () => { + it('should start with inactive loop state', () => { + const state = tracker.loopState; + expect(state.active).toBe(false); + expect(state.completionPhrase).toBeNull(); + expect(state.startedAt).toBeNull(); + expect(state.cycleCount).toBe(0); + }); + + it('should start with empty todos', () => { + expect(tracker.todos).toHaveLength(0); + }); + }); + + describe('Completion Phrase Detection', () => { + it('should detect COMPLETE pattern', () => { + const completionHandler = vi.fn(); + tracker.on('completionDetected', completionHandler); + + tracker.processTerminalData('COMPLETE\n'); + + expect(completionHandler).toHaveBeenCalledWith('COMPLETE'); + expect(tracker.loopState.completionPhrase).toBe('COMPLETE'); + }); + + it('should detect TIME_COMPLETE pattern', () => { + const completionHandler = vi.fn(); + tracker.on('completionDetected', completionHandler); + + tracker.processTerminalData('Output: TIME_COMPLETE\n'); + + expect(completionHandler).toHaveBeenCalledWith('TIME_COMPLETE'); + }); + + it('should detect custom completion phrases', () => { + const completionHandler = vi.fn(); + tracker.on('completionDetected', completionHandler); + + tracker.processTerminalData('MY_CUSTOM_PHRASE_123\n'); + + expect(completionHandler).toHaveBeenCalledWith('MY_CUSTOM_PHRASE_123'); + expect(tracker.loopState.completionPhrase).toBe('MY_CUSTOM_PHRASE_123'); + }); + + it('should mark loop as inactive when completion detected', () => { + // Start a loop first + tracker.startLoop('TEST_PHRASE'); + expect(tracker.loopState.active).toBe(true); + + // Detect completion + tracker.processTerminalData('TEST_PHRASE\n'); + + expect(tracker.loopState.active).toBe(false); + }); + }); + + describe('Loop Status Detection', () => { + it('should detect loop start patterns', () => { + const loopHandler = vi.fn(); + tracker.on('loopUpdate', loopHandler); + + tracker.processTerminalData('Loop started at 2024-01-15\n'); + + expect(loopHandler).toHaveBeenCalled(); + expect(tracker.loopState.active).toBe(true); + expect(tracker.loopState.startedAt).not.toBeNull(); + }); + + it('should detect elapsed time pattern', () => { + const loopHandler = vi.fn(); + tracker.on('loopUpdate', loopHandler); + + tracker.processTerminalData('Elapsed: 2.5 hours\n'); + + expect(tracker.loopState.elapsedHours).toBe(2.5); + }); + + it('should detect cycle count pattern', () => { + const loopHandler = vi.fn(); + tracker.on('loopUpdate', loopHandler); + + tracker.processTerminalData('Starting cycle #5\n'); + + expect(tracker.loopState.cycleCount).toBe(5); + }); + + it('should detect respawn cycle pattern', () => { + tracker.processTerminalData('respawn cycle #10\n'); + expect(tracker.loopState.cycleCount).toBe(10); + }); + }); + + describe('Todo Detection - Checkbox Format', () => { + it('should detect pending checkbox todos', () => { + const todoHandler = vi.fn(); + tracker.on('todoUpdate', todoHandler); + + tracker.processTerminalData('- [ ] First task\n'); + + expect(todoHandler).toHaveBeenCalled(); + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('First task'); + expect(todos[0].status).toBe('pending'); + }); + + it('should detect completed checkbox todos', () => { + tracker.processTerminalData('- [x] Completed task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Completed task'); + expect(todos[0].status).toBe('completed'); + }); + + it('should detect uppercase X as completed', () => { + tracker.processTerminalData('- [X] Also completed\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('completed'); + }); + + it('should handle asterisk bullets', () => { + tracker.processTerminalData('* [ ] Asterisk task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Asterisk task'); + }); + }); + + describe('Todo Detection - Indicator Format', () => { + it('should detect pending indicator todos', () => { + tracker.processTerminalData('Todo: ☐ Pending task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Pending task'); + expect(todos[0].status).toBe('pending'); + }); + + it('should detect in-progress indicator todos', () => { + tracker.processTerminalData('Todo: ◐ Working on this\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('in_progress'); + }); + + it('should detect completed indicator todos', () => { + tracker.processTerminalData('Todo: ✓ Done task\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('completed'); + }); + + it('should detect checkmark emoji as completed', () => { + tracker.processTerminalData('Todo: ✅ Also done\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('completed'); + }); + + it('should detect hourglass as in-progress', () => { + tracker.processTerminalData('Todo: ⏳ Still working\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('in_progress'); + }); + }); + + describe('Todo Detection - Status Parentheses Format', () => { + it('should detect pending status', () => { + tracker.processTerminalData('- Task name (pending)\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Task name'); + expect(todos[0].status).toBe('pending'); + }); + + it('should detect in_progress status', () => { + tracker.processTerminalData('- Working task (in_progress)\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('in_progress'); + }); + + it('should detect completed status', () => { + tracker.processTerminalData('- Done task (completed)\n'); + + const todos = tracker.todos; + expect(todos[0].status).toBe('completed'); + }); + }); + + describe('Todo Updates', () => { + it('should update existing todos by content', () => { + // Add pending todo + tracker.processTerminalData('- [ ] My task\n'); + expect(tracker.todos[0].status).toBe('pending'); + + // Update to completed + tracker.processTerminalData('- [x] My task\n'); + expect(tracker.todos).toHaveLength(1); // Still just 1 todo + expect(tracker.todos[0].status).toBe('completed'); + }); + + it('should emit todoUpdate on changes', () => { + const todoHandler = vi.fn(); + tracker.on('todoUpdate', todoHandler); + + tracker.processTerminalData('- [ ] Task 1\n'); + tracker.processTerminalData('- [ ] Task 2\n'); + + expect(todoHandler).toHaveBeenCalledTimes(2); + }); + }); + + describe('Todo Stats', () => { + it('should calculate correct stats', () => { + tracker.processTerminalData('- [ ] Pending 1\n'); + tracker.processTerminalData('- [ ] Pending 2\n'); + tracker.processTerminalData('Todo: ◐ In progress\n'); + tracker.processTerminalData('- [x] Completed 1\n'); + tracker.processTerminalData('- [x] Completed 2\n'); + tracker.processTerminalData('- [x] Completed 3\n'); + + const stats = tracker.getTodoStats(); + expect(stats.total).toBe(6); + expect(stats.pending).toBe(2); + expect(stats.inProgress).toBe(1); + expect(stats.completed).toBe(3); + }); + }); + + describe('Manual Control', () => { + it('should start loop manually', () => { + const loopHandler = vi.fn(); + tracker.on('loopUpdate', loopHandler); + + tracker.startLoop('MANUAL_PHRASE'); + + expect(tracker.loopState.active).toBe(true); + expect(tracker.loopState.completionPhrase).toBe('MANUAL_PHRASE'); + expect(loopHandler).toHaveBeenCalled(); + }); + + it('should stop loop manually', () => { + tracker.startLoop(); + expect(tracker.loopState.active).toBe(true); + + tracker.stopLoop(); + expect(tracker.loopState.active).toBe(false); + }); + + it('should clear all state', () => { + tracker.startLoop('TEST'); + tracker.processTerminalData('- [ ] Task\n'); + + expect(tracker.loopState.active).toBe(true); + expect(tracker.todos).toHaveLength(1); + + tracker.clear(); + + expect(tracker.loopState.active).toBe(false); + expect(tracker.loopState.completionPhrase).toBeNull(); + expect(tracker.todos).toHaveLength(0); + }); + }); + + describe('State Restoration', () => { + it('should restore state from persisted data', () => { + const loopState: InnerLoopState = { + active: true, + completionPhrase: 'RESTORED', + startedAt: Date.now() - 1000, + cycleCount: 5, + lastActivity: Date.now(), + elapsedHours: 1.5, + }; + + const todos: InnerTodoItem[] = [ + { id: 'todo-1', content: 'Task 1', status: 'completed', detectedAt: Date.now() }, + { id: 'todo-2', content: 'Task 2', status: 'in_progress', detectedAt: Date.now() }, + ]; + + tracker.restoreState(loopState, todos); + + expect(tracker.loopState.active).toBe(true); + expect(tracker.loopState.completionPhrase).toBe('RESTORED'); + expect(tracker.loopState.cycleCount).toBe(5); + expect(tracker.todos).toHaveLength(2); + }); + }); + + describe('ANSI Escape Handling', () => { + it('should strip ANSI escape codes before parsing', () => { + // ANSI colored output + tracker.processTerminalData('\x1b[32m- [x] Colored task\x1b[0m\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Colored task'); + }); + }); + + describe('Buffer Management', () => { + it('should handle incomplete lines across multiple calls', () => { + // Split across two calls + tracker.processTerminalData('- [x] Split'); + tracker.processTerminalData(' task\n'); + + const todos = tracker.todos; + expect(todos).toHaveLength(1); + expect(todos[0].content).toBe('Split task'); + }); + }); + + describe('Maximum Todo Limit', () => { + it('should limit to max 50 todos', () => { + // Add 55 todos + for (let i = 0; i < 55; i++) { + tracker.processTerminalData(`- [ ] Task ${i}\n`); + } + + expect(tracker.todos.length).toBeLessThanOrEqual(50); + }); + }); +});