From 2c38f7924ae16a7c9bba59a1e71d8ea29e41e7fa Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 19 Jan 2026 18:34:43 +0100 Subject: [PATCH] feat(tracker): disable Ralph Wiggum tracker by default, auto-enable on detection The InnerLoopTracker is now disabled by default and auto-enables when Ralph-related patterns are detected: - /ralph-loop command - PHRASE completion phrases - TodoWrite tool usage - Iteration patterns (Iteration 5/50, [5/50]) - Todo checkboxes (- [ ]/- [x]) or indicator icons This reduces noise for sessions that don't use Ralph loops while maintaining full functionality when loops are detected. Changes: - Add `enabled` property to InnerLoopState type - Add enable()/disable() methods to InnerLoopTracker - Implement shouldAutoEnable() for pattern detection - Update frontend to show "Tracking" status when enabled - Add CSS for tracking state indicator - Update CLAUDE.md documentation - Add comprehensive tests for auto-enable behavior Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 22 ++++++- src/inner-loop-tracker.ts | 104 ++++++++++++++++++++++++++++++- src/types.ts | 2 + src/web/public/app.js | 9 ++- src/web/public/styles.css | 4 ++ test/inner-loop-tracker.test.ts | 107 +++++++++++++++++++++++++++++++- 6 files changed, 241 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c7118b1c..5436ade5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ npm run test:coverage # With coverage report npx vitest run test/session.test.ts # Single file npx vitest run -t "should create session" # By pattern # Tests use ports 3101-3108 to avoid conflicts with dev server (3000) +# Test timeout: 30s (configured in vitest.config.ts for integration tests) # TypeScript checking (no linter configured) npx tsc --noEmit # Type check without building @@ -92,11 +93,21 @@ test/ # All tests use vitest, ports 3101-3108 | Component | File | Purpose | |-----------|------|---------| | Session | `session.ts` | PTY wrapper for Claude CLI. Modes: `runPrompt()`, `startInteractive()`, `startShell()` | -| RespawnController | `respawn-controller.ts` | State machine: watching → update → clear → init → monitor → kickstart → repeat | +| RespawnController | `respawn-controller.ts` | State machine for autonomous session cycling (see diagram below) | | ScreenManager | `screen-manager.ts` | GNU screen persistence, ghost discovery, 4-strategy kill | | WebServer | `web/server.ts` | Fastify REST + SSE at `/api/events` | | InnerLoopTracker | `inner-loop-tracker.ts` | Detects `PHRASE`, todos, loop status in output | +### Respawn State Machine + +``` +WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING + ↑ | + └──────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +Steps can be skipped via config (`sendClear: false`, `sendInit: false`). Idle detection triggers state transitions. + ### Session Modes Sessions have a `mode` property (`SessionMode` type): @@ -182,7 +193,14 @@ Both wait for idle. Configure via `session.setAutoCompact()` / `session.setAutoC ### Inner Loop Tracking -Detects Ralph loops and todos inside Claude sessions. Patterns: `PHRASE`, checkbox todos (`- [ ]`/`- [x]`), icons (`☐`/`◐`/`✓`), cycle counts. API: `GET /api/sessions/:id/inner-state`. UI: collapsible panel below tabs, auto-hides when empty. +Detects Ralph loops and todos inside Claude sessions. **Disabled by default** - auto-enables when Ralph-related patterns are detected: +- `/ralph-loop` command +- `PHRASE` completion phrases +- `TodoWrite` tool usage +- Iteration patterns (`Iteration 5/50`, `[5/50]`) +- Todo checkboxes (`- [ ]`/`- [x]`) or indicator icons (`☐`/`◐`/`✓`) + +API: `GET /api/sessions/:id/inner-state`. UI: collapsible panel below tabs, shows "Tracking" status when enabled. Use `tracker.enable()` / `tracker.disable()` for manual control. ### Terminal Display Fix diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index 83d24334..61fc40c1 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -50,12 +50,16 @@ export interface InnerLoopTrackerEvents { loopUpdate: (state: InnerLoopState) => void; todoUpdate: (todos: InnerTodoItem[]) => void; completionDetected: (phrase: string) => void; + enabled: () => void; // Emitted when tracker auto-enables } /** * 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 + * + * The tracker is DISABLED by default and auto-enables when Ralph-related + * patterns are detected (e.g., /ralph-loop, , todos). */ export class InnerLoopTracker extends EventEmitter { private _loopState: InnerLoopState; @@ -67,6 +71,36 @@ export class InnerLoopTracker extends EventEmitter { this._loopState = createInitialInnerLoopState(); } + /** + * Whether the tracker is enabled and actively monitoring + */ + get enabled(): boolean { + return this._loopState.enabled; + } + + /** + * Enable the tracker (called automatically when Ralph patterns detected) + */ + enable(): void { + if (!this._loopState.enabled) { + this._loopState.enabled = true; + this._loopState.lastActivity = Date.now(); + this.emit('enabled'); + this.emit('loopUpdate', this.loopState); + } + } + + /** + * Disable the tracker + */ + disable(): void { + if (this._loopState.enabled) { + this._loopState.enabled = false; + this._loopState.lastActivity = Date.now(); + this.emit('loopUpdate', this.loopState); + } + } + get loopState(): InnerLoopState { return { ...this._loopState }; } @@ -82,6 +116,16 @@ export class InnerLoopTracker extends EventEmitter { // Remove ANSI escape codes for cleaner parsing const cleanData = data.replace(ANSI_ESCAPE_PATTERN, ''); + // If tracker is disabled, only check for patterns that should auto-enable it + if (!this._loopState.enabled) { + if (this.shouldAutoEnable(cleanData)) { + this.enable(); + // Continue processing now that we're enabled + } else { + return; // Don't process further when disabled + } + } + // Buffer data for line-based processing this._lineBuffer += cleanData; @@ -100,6 +144,51 @@ export class InnerLoopTracker extends EventEmitter { this.cleanupExpiredTodos(); } + /** + * Check if the data contains patterns that should auto-enable the tracker + */ + private shouldAutoEnable(data: string): boolean { + // Ralph loop command: /ralph-loop + if (RALPH_START_PATTERN.test(data)) { + return true; + } + + // Completion phrase: ... + if (PROMISE_PATTERN.test(data)) { + return true; + } + + // TodoWrite tool usage + if (TODOWRITE_PATTERN.test(data)) { + return true; + } + + // Iteration patterns from Ralph loop: "Iteration 5/50", "[5/50]" + if (ITERATION_PATTERN.test(data)) { + return true; + } + + // Todo checkboxes: "- [ ] Task" or "- [x] Task" + if (TODO_CHECKBOX_PATTERN.test(data)) { + // Reset lastIndex since we're reusing the global regex + TODO_CHECKBOX_PATTERN.lastIndex = 0; + return true; + } + + // Todo indicator icons: "Todo: ☐", "Todo: ◐", etc. + if (TODO_INDICATOR_PATTERN.test(data)) { + TODO_INDICATOR_PATTERN.lastIndex = 0; + return true; + } + + // Loop start patterns + if (LOOP_START_PATTERN.test(data) && !PROMISE_PATTERN.test(data)) { + return true; + } + + return false; + } + /** * Process a single line of terminal output */ @@ -388,8 +477,14 @@ export class InnerLoopTracker extends EventEmitter { /** * Mark the loop as started (can be called externally) + * Also enables the tracker if not already enabled */ startLoop(completionPhrase?: string, maxIterations?: number): void { + // Enable tracker when loop is explicitly started + if (!this._loopState.enabled) { + this._loopState.enabled = true; + this.emit('enabled'); + } this._loopState.active = true; this._loopState.startedAt = Date.now(); this._loopState.cycleCount = 0; @@ -422,9 +517,10 @@ export class InnerLoopTracker extends EventEmitter { /** * Clear all state (e.g., when session is cleared) + * Resets to disabled state */ clear(): void { - this._loopState = createInitialInnerLoopState(); + this._loopState = createInitialInnerLoopState(); // This sets enabled: false this._todos.clear(); this._lineBuffer = ''; this.emit('loopUpdate', this.loopState); @@ -465,7 +561,11 @@ export class InnerLoopTracker extends EventEmitter { * Restore state from persisted data */ restoreState(loopState: InnerLoopState, todos: InnerTodoItem[]): void { - this._loopState = { ...loopState }; + // Ensure enabled flag exists (backwards compatibility) + this._loopState = { + ...loopState, + enabled: loopState.enabled ?? false, // Override after spread for backwards compat + }; this._todos.clear(); for (const todo of todos) { this._todos.set(todo.id, { ...todo }); diff --git a/src/types.ts b/src/types.ts index fd044442..9eeb519d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -251,6 +251,7 @@ export const DEFAULT_CONFIG: AppConfig = { export type InnerTodoStatus = 'pending' | 'in_progress' | 'completed'; export interface InnerLoopState { + enabled: boolean; // Whether the tracker is actively monitoring (disabled by default) active: boolean; completionPhrase: string | null; startedAt: number | null; @@ -280,6 +281,7 @@ export interface InnerStateRecord { export function createInitialInnerLoopState(): InnerLoopState { return { + enabled: false, // Disabled by default, auto-enables when Ralph patterns detected active: false, completionPhrase: null, startedAt: null, diff --git a/src/web/public/app.js b/src/web/public/app.js index 897de10c..d3cd8aaa 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1919,10 +1919,12 @@ class ClaudemanApp { const state = this.innerStates.get(this.activeSessionId); // Check if there's anything to show + // Only show panel if tracker is enabled OR there's active state to display + const isEnabled = state?.loop?.enabled === true; const hasLoop = state?.loop?.active || state?.loop?.completionPhrase; const hasTodos = state?.todos?.length > 0; - if (!hasLoop && !hasTodos) { + if (!isEnabled && !hasLoop && !hasTodos) { panel.style.display = 'none'; return; } @@ -1996,7 +1998,7 @@ class ClaudemanApp { const statusText = badge?.querySelector('.ralph-status-text'); if (!badge || !statusText) return; - badge.classList.remove('active', 'completed'); + badge.classList.remove('active', 'completed', 'tracking'); if (loop?.active) { badge.classList.add('active'); @@ -2004,6 +2006,9 @@ class ClaudemanApp { } else if (loop?.completionPhrase && !loop?.active) { badge.classList.add('completed'); statusText.textContent = 'Complete'; + } else if (loop?.enabled) { + badge.classList.add('tracking'); + statusText.textContent = 'Tracking'; } else { statusText.textContent = 'Idle'; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ad2ace59..12337411 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -556,6 +556,10 @@ body { background: var(--green); } +.ralph-status-badge.tracking .ralph-status-dot { + background: var(--yellow); +} + @keyframes ralph-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(1.2); } diff --git a/test/inner-loop-tracker.test.ts b/test/inner-loop-tracker.test.ts index d7b840da..b8b729d5 100644 --- a/test/inner-loop-tracker.test.ts +++ b/test/inner-loop-tracker.test.ts @@ -14,11 +14,14 @@ describe('InnerLoopTracker', () => { beforeEach(() => { tracker = new InnerLoopTracker(); + // Enable tracker by default for most tests (testing detection logic) + tracker.enable(); }); describe('Initialization', () => { it('should start with inactive loop state', () => { - const state = tracker.loopState; + const freshTracker = new InnerLoopTracker(); + const state = freshTracker.loopState; expect(state.active).toBe(false); expect(state.completionPhrase).toBeNull(); expect(state.startedAt).toBeNull(); @@ -28,6 +31,88 @@ describe('InnerLoopTracker', () => { it('should start with empty todos', () => { expect(tracker.todos).toHaveLength(0); }); + + it('should start disabled by default', () => { + const freshTracker = new InnerLoopTracker(); + expect(freshTracker.enabled).toBe(false); + expect(freshTracker.loopState.enabled).toBe(false); + }); + }); + + describe('Auto-Enable Behavior', () => { + it('should not process data when disabled', () => { + const freshTracker = new InnerLoopTracker(); + // This pattern doesn't trigger auto-enable + freshTracker.processTerminalData('Elapsed: 2.5 hours\n'); + + expect(freshTracker.loopState.elapsedHours).toBeNull(); + }); + + it('should auto-enable on /ralph-loop command', () => { + const freshTracker = new InnerLoopTracker(); + const enableHandler = vi.fn(); + freshTracker.on('enabled', enableHandler); + + freshTracker.processTerminalData('/ralph-loop\n'); + + expect(freshTracker.enabled).toBe(true); + expect(enableHandler).toHaveBeenCalled(); + }); + + it('should auto-enable on completion phrase', () => { + const freshTracker = new InnerLoopTracker(); + freshTracker.processTerminalData('COMPLETE\n'); + + expect(freshTracker.enabled).toBe(true); + }); + + it('should auto-enable on TodoWrite detection', () => { + const freshTracker = new InnerLoopTracker(); + freshTracker.processTerminalData('TodoWrite: Todos have been modified\n'); + + expect(freshTracker.enabled).toBe(true); + }); + + it('should auto-enable on todo checkboxes', () => { + const freshTracker = new InnerLoopTracker(); + freshTracker.processTerminalData('- [ ] New task\n'); + + expect(freshTracker.enabled).toBe(true); + expect(freshTracker.todos).toHaveLength(1); + }); + + it('should auto-enable on iteration patterns', () => { + const freshTracker = new InnerLoopTracker(); + freshTracker.processTerminalData('Iteration 5/50\n'); + + expect(freshTracker.enabled).toBe(true); + }); + + it('should auto-enable on loop start patterns', () => { + const freshTracker = new InnerLoopTracker(); + freshTracker.processTerminalData('Loop started at 2024-01-15\n'); + + expect(freshTracker.enabled).toBe(true); + }); + + it('should allow manual enable/disable', () => { + const freshTracker = new InnerLoopTracker(); + expect(freshTracker.enabled).toBe(false); + + freshTracker.enable(); + expect(freshTracker.enabled).toBe(true); + + freshTracker.disable(); + expect(freshTracker.enabled).toBe(false); + }); + + it('should reset to disabled on clear', () => { + tracker.processTerminalData('/ralph-loop\n'); + expect(tracker.enabled).toBe(true); + + tracker.clear(); + expect(tracker.enabled).toBe(false); + }); }); describe('Completion Phrase Detection', () => { @@ -289,6 +374,7 @@ describe('InnerLoopTracker', () => { describe('State Restoration', () => { it('should restore state from persisted data', () => { const loopState: InnerLoopState = { + enabled: true, active: true, completionPhrase: 'RESTORED', startedAt: Date.now() - 1000, @@ -305,12 +391,31 @@ describe('InnerLoopTracker', () => { tracker.restoreState(loopState, todos); + expect(tracker.loopState.enabled).toBe(true); expect(tracker.loopState.active).toBe(true); expect(tracker.loopState.completionPhrase).toBe('RESTORED'); expect(tracker.loopState.cycleCount).toBe(5); expect(tracker.loopState.maxIterations).toBe(50); expect(tracker.todos).toHaveLength(2); }); + + it('should handle missing enabled flag in legacy state', () => { + // Simulate old state without enabled flag + const loopState = { + active: true, + completionPhrase: 'TEST', + startedAt: Date.now(), + cycleCount: 0, + maxIterations: null, + lastActivity: Date.now(), + elapsedHours: null, + } as InnerLoopState; + + tracker.restoreState(loopState, []); + + // Should default to false for backwards compatibility + expect(tracker.loopState.enabled).toBe(false); + }); }); describe('Enhanced Ralph Detection Patterns', () => {