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', () => {