refactor: rename inner-loop-tracker to ralph-tracker with API improvements

- Rename inner-loop-tracker.ts → ralph-tracker.ts throughout codebase
- Add ralph-config.ts for parsing .claude/ralph-loop.local.md config
- Standardize API error responses using createErrorResponse()
- Add input validation for auto-compact/auto-clear thresholds
- Update UI labels to "Ralph / Todo Tracker" consistently
- Add 46 integration tests for Ralph tracking functionality
- Update test badge to 438 total tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-21 22:36:45 +01:00
co-authored by Claude Opus 4.5
parent 5be0babf01
commit 0d1ca37b6e
21 changed files with 2151 additions and 451 deletions
+63 -16
View File
@@ -65,8 +65,9 @@ npx vitest run -t "should create session" # By pattern
# 3110: edge-cases.test.ts
# 3115: integration-flows.test.ts
# 3120: session-cleanup.test.ts
# Unit tests (no port needed): respawn-controller, inner-loop-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates
# Next available: 3122+
# 3125: ralph-integration.test.ts
# Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config
# Next available: 3127+
# Tests mock PTY - no real Claude CLI spawned
# Test timeout: 30s (configured in vitest.config.ts)
@@ -82,7 +83,7 @@ screen -r <name> # Attach to screen session (Ctrl+A D t
curl localhost:3000/api/sessions # Check active sessions
curl localhost:3000/api/status | jq . # Full app state including respawn
cat ~/.claudeman/state.json | jq . # View main state
cat ~/.claudeman/state-inner.json | jq . # View inner loop state
cat ~/.claudeman/state-inner.json | jq . # View Ralph loop state
# Kill stuck screen sessions
screen -X -S <name> quit # Graceful quit
@@ -98,7 +99,8 @@ pkill -f "SCREEN.*claudeman" # Force kill all claudeman screens
| `src/session.ts` | Core PTY wrapper for Claude CLI. Modes: `runPrompt()`, `startInteractive()`, `startShell()` |
| `src/respawn-controller.ts` | State machine for autonomous session cycling |
| `src/screen-manager.ts` | GNU screen persistence, ghost discovery, 4-strategy kill |
| `src/inner-loop-tracker.ts` | Detects `<promise>PHRASE</promise>`, todos, loop status in output |
| `src/ralph-tracker.ts` | Detects `<promise>PHRASE</promise>`, todos, loop status in output |
| `src/ralph-config.ts` | Parses `.claude/ralph-loop.local.md` and CLAUDE.md for Ralph config |
| `src/task-tracker.ts` | Parses background task output (agent IDs, status) from Claude CLI |
| `src/session-manager.ts` | Manages session lifecycle, task assignment, and cleanup |
| `src/state-store.ts` | JSON persistence to `~/.claudeman/` with debounced writes |
@@ -259,18 +261,35 @@ curl -X POST localhost:3000/api/sessions/:id/input \
Both wait for idle. Configure via `session.setAutoCompact()` / `session.setAutoClear()`.
### Inner Loop Tracking
### Ralph / Todo Tracking
Detects Ralph loops and todos inside Claude sessions. **Disabled by default** but auto-enables when any of these patterns are detected in terminal output:
- `/ralph-loop:ralph-loop` command
- `<promise>PHRASE</promise>` completion phrases
- `<promise>PHRASE</promise>` completion phrases (supports hyphens: `TESTS-PASS`, underscores: `ALL_DONE`, numbers: `TASK_123`)
- `TodoWrite` tool usage
- Iteration patterns (`Iteration 5/50`, `[5/50]`)
- Todo checkboxes (`- [ ]`/`- [x]`) or indicator icons (`☐`/`◐`/`✓`)
- "All tasks complete" messages
- Individual task completion signals (`Task 8 is done`)
See `inner-loop-tracker.ts:shouldAutoEnable()` for detection logic.
See `ralph-tracker.ts:shouldAutoEnable()` for detection logic.
**Auto-Configuration from Ralph Plugin State**: When a session starts, Claudeman reads `.claude/ralph-loop.local.md` (the official Ralph Wiggum plugin state file) to auto-configure the tracker:
```yaml
---
enabled: true
iteration: 5
max-iterations: 50
completion-promise: "COMPLETE"
---
```
Priority order for configuration:
1. `.claude/ralph-loop.local.md` (official Ralph plugin state)
2. `CLAUDE.md` `<promise>` tags (fallback)
See `src/ralph-config.ts` for parsing logic.
**Completion Detection**: Uses multi-strategy detection:
- 1st occurrence of `<promise>PHRASE</promise>`: Stores as expected phrase (likely in prompt)
@@ -287,8 +306,8 @@ See `inner-loop-tracker.ts:shouldAutoEnable()` for detection logic.
- `tracker.fullReset()` → Complete reset to initial state
**API**:
- `GET /api/sessions/:id/inner-state` - Get loop state and todos
- `POST /api/sessions/:id/inner-config` - Configure tracker:
- `GET /api/sessions/:id/ralph-state` - Get loop state and todos
- `POST /api/sessions/:id/ralph-config` - Configure tracker:
- `{ enabled: boolean }` - Enable/disable
- `{ reset: true }` - Soft reset (keep enabled)
- `{ reset: "full" }` - Full reset
@@ -309,7 +328,7 @@ Key events for frontend handling (see `app.js:handleSSEEvent()`):
- `session:idle`, `session:working` - Status indicator updates
- `session:terminal`, `session:clearTerminal` - Terminal content
- `session:completion`, `session:autoClear`, `session:autoCompact` - Lifecycle events
- `session:innerLoopUpdate`, `session:innerTodoUpdate`, `session:innerCompletionDetected` - Ralph tracking
- `session:ralphLoopUpdate`, `session:ralphTodoUpdate`, `session:ralphCompletionDetected` - Ralph tracking
### Frontend (app.js)
@@ -337,9 +356,9 @@ Writes debounced to `~/.claudeman/state.json`. Batches rapid changes.
| Terminal batch interval | 16ms | `server.ts` (60fps) |
| Output batch interval | 50ms | `server.ts` |
| Task update batch interval | 100ms | `server.ts` |
| Inner loop event debounce | 50ms | `inner-loop-tracker.ts` |
| Ralph loop event debounce | 50ms | `ralph-tracker.ts` |
| Session tabs render debounce | 100ms | `app.js` |
| Inner panel render debounce | 50ms | `app.js` |
| Ralph panel render debounce | 50ms | `app.js` |
| Task panel render debounce | 100ms | `app.js` |
| Input batch interval | 16ms | `app.js` (60fps) |
| Idle activity timeout | 2s | `session.ts` |
@@ -354,8 +373,10 @@ Module resolution: NodeNext. Target: ES2022. Strict mode with additional checks:
| `noUnusedLocals` | Error on unused local variables |
| `noUnusedParameters` | Error on unused function parameters |
| `noImplicitReturns` | All code paths must return a value |
| `noImplicitOverride` | Require `override` keyword for overridden methods |
| `noFallthroughCasesInSwitch` | Require break/return in switch cases |
| `allowUnreachableCode: false` | Error on unreachable code |
| `allowUnusedLabels: false` | Error on unused labels |
TUI uses React JSX (`jsxImportSource: react`) for Ink components.
@@ -386,6 +407,32 @@ Use `createErrorResponse(code, details?)` from `types.ts`:
- **Ghost discovery**: `reconcileScreens()` finds orphaned screens on startup
- **Cleanup** (`cleanupSession()`): stops respawn, clears buffers/timers, kills screen
## TUI Architecture (Ink/React)
The TUI (`claudeman tui`) is built with [Ink](https://github.com/vadimdemedes/ink) (React for terminals).
**Component Hierarchy:**
```
App.tsx
├── StartScreen.tsx # Session/case list, navigation
│ ├── List navigation (↑/↓, Enter, a/d/D)
│ ├── Case creation flow
│ └── Tab switcher menu
├── TabBar.tsx # Session tabs (when attached)
├── TerminalView.tsx # Viewport into screen session
├── StatusBar.tsx # Bottom bar with status/tokens
├── RalphPanel.tsx # Ralph loop progress display
└── HelpOverlay.tsx # Keyboard shortcuts modal
```
**Key Hook:** `useSessionManager.ts` handles:
- API polling for sessions/screens
- Screen attach/detach flow
- Keyboard input routing
- State synchronization with web server
**TUI ↔ Web Server:** The TUI is a client to the web server. It doesn't manage sessions directly - it uses `/api/*` endpoints and attaches to screens via GNU screen commands.
## Buffer Limits
Long-running sessions are supported with automatic trimming:
@@ -413,8 +460,8 @@ Long-running sessions are supported with automatic trimming:
| POST | `/api/sessions/:id/respawn/stop` | Stop respawn controller |
| POST | `/api/sessions/:id/respawn/enable` | Enable respawn with config + optional timer |
| PUT | `/api/sessions/:id/respawn/config` | Update config on running respawn |
| POST | `/api/sessions/:id/inner-config` | Configure Ralph Wiggum loop settings |
| GET | `/api/sessions/:id/inner-state` | Get Ralph loop state + todos |
| POST | `/api/sessions/:id/ralph-config` | Configure Ralph / Todo Tracker settings |
| GET | `/api/sessions/:id/ralph-state` | Get Ralph loop state + todos |
| POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold |
| POST | `/api/sessions/:id/auto-clear` | Configure auto-clear threshold |
| POST | `/api/quick-start` | Create case + start session. Body: `{mode?: 'claude'\|'shell'}` |
@@ -478,7 +525,7 @@ Long-running sessions are supported with automatic trimming:
| File | Purpose |
|------|---------|
| `~/.claudeman/state.json` | Sessions, tasks, config |
| `~/.claudeman/state-inner.json` | Inner loop/todo state (separate to reduce writes) |
| `~/.claudeman/state-inner.json` | Ralph loop/todo state per session (separate to reduce writes) |
| `~/.claudeman/screens.json` | Screen session metadata |
Cases created in `~/claudeman-cases/` by default.
@@ -540,6 +587,6 @@ Extended documentation is available in the `docs/` directory:
/ralph-loop:help # Show help and usage
```
**Claudeman Implementation**: The `InnerLoopTracker` class (`src/inner-loop-tracker.ts`) detects Ralph patterns in Claude output and tracks loop state, todos, and completion phrases. It auto-enables when Ralph-related patterns are detected.
**Claudeman Implementation**: The `RalphTracker` class (`src/ralph-tracker.ts`) detects Ralph patterns in Claude output and tracks loop state, todos, and completion phrases. It auto-enables when Ralph-related patterns are detected.
See [`docs/ralph-wiggum-guide.md`](docs/ralph-wiggum-guide.md) for full documentation on best practices, prompt templates, and troubleshooting.
+1 -1
View File
@@ -14,7 +14,7 @@
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.5-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.5"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-355%20passing-22c55e?style=flat-square" alt="Tests">
<img src="https://img.shields.io/badge/Tests-438%20total-22c55e?style=flat-square" alt="Tests">
</p>
---
+153
View File
@@ -0,0 +1,153 @@
/**
* @fileoverview Ralph Wiggum configuration parser
*
* Parses the official Ralph Wiggum plugin state file (.claude/ralph-loop.local.md)
* and extracts configuration for session tracking.
*
* @module ralph-config
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
// Pattern to extract completion phrase from CLAUDE.md
// Matches <promise>PHRASE</promise> with optional whitespace
// Allows: letters, numbers, underscores, hyphens (e.g., TESTS-PASS, ALL_DONE)
const CLAUDE_MD_PROMISE_PATTERN = /<promise>\s*([A-Z0-9_-]+)\s*<\/promise>/gi;
// Pattern to parse YAML frontmatter from ralph-loop.local.md
// Extracts key: value pairs from between --- markers
const YAML_FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---/;
const YAML_LINE_PATTERN = /^([a-z-]+):\s*"?([^"\n]+)"?\s*$/gm;
/**
* Ralph Loop configuration from .claude/ralph-loop.local.md
*/
export interface RalphLoopConfig {
enabled: boolean;
iteration: number;
maxIterations: number | null;
completionPromise: string | null;
}
/**
* Parse Ralph Wiggum loop config from .claude/ralph-loop.local.md
*
* The official Ralph Wiggum plugin stores state in this file with YAML frontmatter:
* ```yaml
* ---
* enabled: true
* iteration: 5
* max-iterations: 50
* completion-promise: "COMPLETE"
* ---
* # Original Prompt
* ...
* ```
*
* @param workingDir - Working directory to search in
* @returns Parsed config or null if file doesn't exist or is invalid
*/
export function parseRalphLoopConfig(workingDir: string): RalphLoopConfig | null {
const configPath = join(workingDir, '.claude', 'ralph-loop.local.md');
try {
if (!existsSync(configPath)) return null;
const content = readFileSync(configPath, 'utf-8');
return parseRalphLoopConfigFromContent(content);
} catch (err) {
console.error(`[ralph-config] Error reading ralph-loop.local.md: ${err}`);
return null;
}
}
/**
* Parse Ralph Wiggum loop config from file content.
* Exported for testing.
*
* @param content - File content to parse
* @returns Parsed config or null if content is invalid
*/
export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfig | null {
// Extract YAML frontmatter
const frontmatterMatch = content.match(YAML_FRONTMATTER_PATTERN);
if (!frontmatterMatch) return null;
const yaml = frontmatterMatch[1];
const config: RalphLoopConfig = {
enabled: false,
iteration: 0,
maxIterations: null,
completionPromise: null,
};
// Parse each YAML line
let match;
YAML_LINE_PATTERN.lastIndex = 0;
while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) {
const key = match[1].toLowerCase();
const value = match[2].trim();
switch (key) {
case 'enabled':
config.enabled = value === 'true';
break;
case 'iteration':
config.iteration = parseInt(value, 10) || 0;
break;
case 'max-iterations':
config.maxIterations = parseInt(value, 10) || null;
break;
case 'completion-promise':
config.completionPromise = value.toUpperCase();
break;
}
}
return config;
}
/**
* Extract completion phrase from CLAUDE.md content.
* Looks for <promise>PHRASE</promise> pattern.
*
* Handles multiple variations:
* - Raw text: <promise>PHRASE</promise>
* - In backticks: `<promise>PHRASE</promise>`
* - With whitespace: <promise> PHRASE </promise>
* - Multiple occurrences: returns the first one
*
* @param claudeMdPath - Path to CLAUDE.md file
* @returns The completion phrase (uppercase), or null if not found
*/
export function extractCompletionPhrase(claudeMdPath: string): string | null {
try {
if (!existsSync(claudeMdPath)) return null;
const content = readFileSync(claudeMdPath, 'utf-8');
return extractCompletionPhraseFromContent(content);
} catch (err) {
console.error(`[ralph-config] Error reading CLAUDE.md: ${err}`);
return null;
}
}
/**
* Extract completion phrase from CLAUDE.md content string.
* Exported for testing.
*
* @param content - CLAUDE.md content to parse
* @returns The completion phrase (uppercase), or null if not found
*/
export function extractCompletionPhraseFromContent(content: string): string | null {
// Reset regex state (global flag)
CLAUDE_MD_PROMISE_PATTERN.lastIndex = 0;
// Find all matches and return the first one
const match = CLAUDE_MD_PROMISE_PATTERN.exec(content);
if (match && match[1]) {
return match[1].trim().toUpperCase();
}
return null;
}
@@ -1,5 +1,5 @@
/**
* @fileoverview Inner Loop Tracker for Ralph Wiggum detection
* @fileoverview Ralph Tracker - Detects Ralph Wiggum loops, todos, and completion phrases
*
* This module parses terminal output from Claude Code sessions to detect:
* - Ralph Wiggum loop state (active, completion phrase, iteration count)
@@ -10,15 +10,15 @@
* patterns are detected in the output stream, reducing overhead for
* sessions not using autonomous loops.
*
* @module inner-loop-tracker
* @module ralph-tracker
*/
import { EventEmitter } from 'node:events';
import {
InnerLoopState,
InnerTodoItem,
InnerTodoStatus,
createInitialInnerLoopState,
RalphTrackerState,
RalphTodoItem,
RalphTodoStatus,
createInitialRalphTrackerState,
} from './types.js';
// ========== Configuration Constants ==========
@@ -56,6 +56,12 @@ const EVENT_DEBOUNCE_MS = 50;
* Matches completion phrase tags: `<promise>PHRASE</promise>`
* Used to detect when Claude signals task completion.
* Capture group 1: The completion phrase text
*
* Supports any characters between tags including:
* - Uppercase letters: COMPLETE, DONE
* - Numbers: TASK_123
* - Underscores: ALL_TASKS_DONE
* - Hyphens: TESTS-PASS, TIME-COMPLETE
*/
const PROMISE_PATTERN = /<promise>([^<]+)<\/promise>/;
@@ -194,17 +200,17 @@ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
// ========== Event Types ==========
/**
* Events emitted by InnerLoopTracker
* Events emitted by RalphTracker
* @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 {
export interface RalphTrackerEvents {
/** Emitted when loop state changes */
loopUpdate: (state: InnerLoopState) => void;
loopUpdate: (state: RalphTrackerState) => void;
/** Emitted when todo list is modified */
todoUpdate: (todos: InnerTodoItem[]) => void;
todoUpdate: (todos: RalphTodoItem[]) => void;
/** Emitted when completion phrase detected (loop finished) */
completionDetected: (phrase: string) => void;
/** Emitted when tracker auto-enables from disabled state */
@@ -212,7 +218,7 @@ export interface InnerLoopTrackerEvents {
}
/**
* InnerLoopTracker - Parses terminal output to detect Ralph Wiggum loops and todos
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
*
* This class monitors Claude Code session output to detect:
* 1. **Ralph Wiggum loop state** - Active loops, completion phrases, iteration counts
@@ -242,19 +248,19 @@ export interface InnerLoopTrackerEvents {
* @extends EventEmitter
* @example
* ```typescript
* const tracker = new InnerLoopTracker();
* const tracker = new RalphTracker();
* tracker.on('completionDetected', (phrase) => {
* console.log('Loop completed with phrase:', phrase);
* });
* tracker.processTerminalData(ptyOutput);
* ```
*/
export class InnerLoopTracker extends EventEmitter {
export class RalphTracker extends EventEmitter {
/** Current state of the detected loop */
private _loopState: InnerLoopState;
private _loopState: RalphTrackerState;
/** Map of todo items by ID for O(1) lookup */
private _todos: Map<string, InnerTodoItem> = new Map();
private _todos: Map<string, RalphTodoItem> = new Map();
/** Buffer for incomplete lines from terminal data */
private _lineBuffer: string = '';
@@ -284,12 +290,12 @@ export class InnerLoopTracker extends EventEmitter {
private _autoEnableDisabled: boolean = false;
/**
* Creates a new InnerLoopTracker instance.
* Creates a new RalphTracker instance.
* Starts in disabled state until Ralph patterns are detected.
*/
constructor() {
super();
this._loopState = createInitialInnerLoopState();
this._loopState = createInitialRalphTrackerState();
}
/**
@@ -373,7 +379,7 @@ export class InnerLoopTracker extends EventEmitter {
this.clearDebounceTimers();
const wasEnabled = this._loopState.enabled;
this._loopState = createInitialInnerLoopState();
this._loopState = createInitialRalphTrackerState();
this._loopState.enabled = wasEnabled; // Keep enabled status
this._todos.clear();
this._completionPhraseCount.clear();
@@ -394,7 +400,7 @@ export class InnerLoopTracker extends EventEmitter {
// Clear debounce timers
this.clearDebounceTimers();
this._loopState = createInitialInnerLoopState();
this._loopState = createInitialRalphTrackerState();
this._todos.clear();
this._completionPhraseCount.clear();
this._lineBuffer = '';
@@ -489,7 +495,7 @@ export class InnerLoopTracker extends EventEmitter {
* Get a copy of the current loop state.
* @returns Shallow copy of loop state (safe to modify)
*/
get loopState(): InnerLoopState {
get loopState(): RalphTrackerState {
return { ...this._loopState };
}
@@ -497,7 +503,7 @@ export class InnerLoopTracker extends EventEmitter {
* Get all tracked todo items as an array.
* @returns Array of todo items (copy, safe to modify)
*/
get todos(): InnerTodoItem[] {
get todos(): RalphTodoItem[] {
return Array.from(this._todos.values());
}
@@ -1032,7 +1038,7 @@ export class InnerLoopTracker extends EventEmitter {
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';
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
this.upsertTodo(content, status);
updated = true;
}
@@ -1057,7 +1063,7 @@ export class InnerLoopTracker extends EventEmitter {
TODO_STATUS_PATTERN.lastIndex = 0;
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
const content = match[1].trim();
const status = match[2] as InnerTodoStatus;
const status = match[2] as RalphTodoStatus;
this.upsertTodo(content, status);
updated = true;
}
@@ -1099,9 +1105,9 @@ export class InnerLoopTracker extends EventEmitter {
* - Pending: `☐`, `○`, and anything else (default)
*
* @param icon - Single character icon
* @returns Corresponding InnerTodoStatus
* @returns Corresponding RalphTodoStatus
*/
private iconToStatus(icon: string): InnerTodoStatus {
private iconToStatus(icon: string): RalphTodoStatus {
switch (icon) {
case '✓':
case '✅':
@@ -1134,7 +1140,7 @@ export class InnerLoopTracker extends EventEmitter {
* @param content - Raw todo content text
* @param status - Status to set
*/
private upsertTodo(content: string, status: InnerTodoStatus): void {
private upsertTodo(content: string, status: RalphTodoStatus): void {
// Skip empty or whitespace-only content
if (!content || !content.trim()) return;
@@ -1225,8 +1231,8 @@ export class InnerLoopTracker extends EventEmitter {
* 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;
private findOldestTodo(): RalphTodoItem | undefined {
let oldest: RalphTodoItem | undefined;
for (const todo of this._todos.values()) {
if (!oldest || todo.detectedAt < oldest.detectedAt) {
oldest = todo;
@@ -1334,7 +1340,7 @@ export class InnerLoopTracker extends EventEmitter {
* @fires todoUpdate - With empty array
*/
clear(): void {
this._loopState = createInitialInnerLoopState(); // This sets enabled: false
this._loopState = createInitialRalphTrackerState(); // This sets enabled: false
this._todos.clear();
this._lineBuffer = '';
this._completionPhraseCount.clear();
@@ -1389,7 +1395,7 @@ export class InnerLoopTracker extends EventEmitter {
* @param loopState - Persisted loop state object
* @param todos - Persisted todo items array
*/
restoreState(loopState: InnerLoopState, todos: InnerTodoItem[]): void {
restoreState(loopState: RalphTrackerState, todos: RalphTodoItem[]): void {
// Ensure enabled flag exists (backwards compatibility)
this._loopState = {
...loopState,
+3 -3
View File
@@ -588,11 +588,11 @@ export class ScreenManager extends EventEmitter {
}
}
// Update inner loop (Ralph Wiggum) enabled state
updateInnerLoopEnabled(sessionId: string, enabled: boolean): void {
// Update Ralph enabled state
updateRalphEnabled(sessionId: string, enabled: boolean): void {
const screen = this.screens.get(sessionId);
if (screen) {
screen.innerLoopEnabled = enabled;
screen.ralphEnabled = enabled;
this.saveScreens();
}
}
+46 -46
View File
@@ -18,13 +18,13 @@
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, InnerLoopState, InnerTodoItem } from './types.js';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { InnerLoopTracker } from './inner-loop-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { ScreenManager } from './screen-manager.js';
export type { BackgroundTask } from './task-tracker.js';
export type { InnerLoopState, InnerTodoItem } from './types.js';
export type { RalphTrackerState, RalphTodoItem } from './types.js';
export { withTimeout };
// ============================================================================
@@ -219,12 +219,12 @@ export interface SessionEvents {
autoClear: (data: { tokens: number; threshold: number }) => void;
/** Auto-compact triggered due to token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Inner loop (Ralph Wiggum) state changed */
innerLoopUpdate: (state: InnerLoopState) => void;
/** Inner loop todo list updated */
innerTodoUpdate: (todos: InnerTodoItem[]) => void;
/** Inner loop completion phrase detected */
innerCompletionDetected: (phrase: string) => void;
/** Ralph loop state changed */
ralphLoopUpdate: (state: RalphTrackerState) => void;
/** Ralph todo list updated */
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
}
/**
@@ -320,8 +320,8 @@ 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;
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
private _ralphTracker: RalphTracker;
// Store handler references for cleanup (prevents memory leaks)
private _taskTrackerHandlers: {
@@ -331,9 +331,9 @@ export class Session extends EventEmitter {
taskFailed: (task: BackgroundTask, error: string) => void;
} | null = null;
private _innerLoopHandlers: {
loopUpdate: (state: InnerLoopState) => void;
todoUpdate: (todos: InnerTodoItem[]) => void;
private _ralphHandlers: {
loopUpdate: (state: RalphTrackerState) => void;
todoUpdate: (todos: RalphTodoItem[]) => void;
completionDetected: (phrase: string) => void;
} | null = null;
@@ -369,16 +369,16 @@ export class Session extends EventEmitter {
this._taskTracker.on('taskCompleted', this._taskTrackerHandlers.taskCompleted);
this._taskTracker.on('taskFailed', this._taskTrackerHandlers.taskFailed);
// Initialize inner loop tracker and forward events (store handlers for cleanup)
this._innerLoopTracker = new InnerLoopTracker();
this._innerLoopHandlers = {
loopUpdate: (state) => this.emit('innerLoopUpdate', state),
todoUpdate: (todos) => this.emit('innerTodoUpdate', todos),
completionDetected: (phrase) => this.emit('innerCompletionDetected', phrase),
// Initialize Ralph tracker and forward events (store handlers for cleanup)
this._ralphTracker = new RalphTracker();
this._ralphHandlers = {
loopUpdate: (state) => this.emit('ralphLoopUpdate', state),
todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos),
completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase),
};
this._innerLoopTracker.on('loopUpdate', this._innerLoopHandlers.loopUpdate);
this._innerLoopTracker.on('todoUpdate', this._innerLoopHandlers.todoUpdate);
this._innerLoopTracker.on('completionDetected', this._innerLoopHandlers.completionDetected);
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
}
get status(): SessionStatus {
@@ -449,21 +449,21 @@ export class Session extends EventEmitter {
return this._taskTracker.getStats();
}
// Inner loop tracking getters
get innerLoopTracker(): InnerLoopTracker {
return this._innerLoopTracker;
// Ralph tracking getters
get ralphTracker(): RalphTracker {
return this._ralphTracker;
}
get innerLoopState(): InnerLoopState {
return this._innerLoopTracker.loopState;
get ralphLoopState(): RalphTrackerState {
return this._ralphTracker.loopState;
}
get innerTodos(): InnerTodoItem[] {
return this._innerLoopTracker.todos;
get ralphTodos(): RalphTodoItem[] {
return this._ralphTracker.todos;
}
get innerTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
return this._innerLoopTracker.getTodoStats();
get ralphTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
return this._ralphTracker.getTodoStats();
}
// Token tracking getters and setters
@@ -582,10 +582,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(),
// Ralph tracking state
ralphLoop: this._ralphTracker.loopState,
ralphTodos: this._ralphTracker.todos,
ralphTodoStats: this._ralphTracker.getTodoStats(),
};
}
@@ -726,8 +726,8 @@ 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);
// Forward to Ralph tracker to detect Ralph loops and todos
this._ralphTracker.processTerminalData(data);
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(data);
@@ -1344,7 +1344,7 @@ export class Session extends EventEmitter {
}
/**
* Remove event listeners from TaskTracker and InnerLoopTracker.
* Remove event listeners from TaskTracker and RalphTracker.
* Prevents memory leaks by ensuring handlers don't persist after session stop.
*/
private cleanupTrackerListeners(): void {
@@ -1357,12 +1357,12 @@ export class Session extends EventEmitter {
this._taskTrackerHandlers = null;
}
// Remove InnerLoopTracker handlers
if (this._innerLoopHandlers) {
this._innerLoopTracker.off('loopUpdate', this._innerLoopHandlers.loopUpdate);
this._innerLoopTracker.off('todoUpdate', this._innerLoopHandlers.todoUpdate);
this._innerLoopTracker.off('completionDetected', this._innerLoopHandlers.completionDetected);
this._innerLoopHandlers = null;
// Remove RalphTracker handlers
if (this._ralphHandlers) {
this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected);
this._ralphHandlers = null;
}
}
@@ -1527,6 +1527,6 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._taskTracker.clear();
this._innerLoopTracker.clear();
this._ralphTracker.clear();
}
}
+46 -46
View File
@@ -8,7 +8,7 @@
* - `state.json`: Main app state (sessions, tasks, config)
* - `state-inner.json`: Inner loop state (todos, Ralph loop state per session)
*
* The separation reduces write frequency since inner loop state changes rapidly
* The separation reduces write frequency since Ralph state changes rapidly
* during Ralph Wiggum loops.
*
* @module state-store
@@ -17,7 +17,7 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { AppState, createInitialState, InnerSessionState, createInitialInnerSessionState } from './types.js';
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState } from './types.js';
/** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500;
@@ -50,17 +50,17 @@ export class StateStore {
private dirty: boolean = false;
// Inner state storage (separate from main state to reduce write frequency)
private innerStates: Map<string, InnerSessionState> = new Map();
private innerStatePath: string;
private innerStateSaveTimeout: NodeJS.Timeout | null = null;
private innerStateDirty: boolean = false;
private ralphStates: Map<string, RalphSessionState> = new Map();
private ralphStatePath: string;
private ralphStateSaveTimeout: NodeJS.Timeout | null = null;
private ralphStateDirty: boolean = false;
constructor(filePath?: string) {
this.filePath = filePath || join(homedir(), '.claudeman', 'state.json');
this.innerStatePath = this.filePath.replace('.json', '-inner.json');
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
this.state = this.load();
this.state.config.stateFilePath = this.filePath;
this.loadInnerStates();
this.loadRalphStates();
}
private ensureDir(): void {
@@ -203,20 +203,20 @@ export class StateStore {
reset(): void {
this.state = createInitialState();
this.state.config.stateFilePath = this.filePath;
this.innerStates.clear();
this.ralphStates.clear();
this.saveNow(); // Immediate save for reset operations
this.saveInnerStatesNow();
this.saveRalphStatesNow();
}
// ========== Inner State Methods (Ralph Loop tracking) ==========
private loadInnerStates(): void {
private loadRalphStates(): void {
try {
if (existsSync(this.innerStatePath)) {
const data = readFileSync(this.innerStatePath, 'utf-8');
const parsed = JSON.parse(data) as Record<string, InnerSessionState>;
if (existsSync(this.ralphStatePath)) {
const data = readFileSync(this.ralphStatePath, 'utf-8');
const parsed = JSON.parse(data) as Record<string, RalphSessionState>;
for (const [sessionId, state] of Object.entries(parsed)) {
this.innerStates.set(sessionId, state);
this.ralphStates.set(sessionId, state);
}
}
} catch (err) {
@@ -225,43 +225,43 @@ export class StateStore {
}
// Debounced save for inner states
private saveInnerStates(): void {
this.innerStateDirty = true;
if (this.innerStateSaveTimeout) {
private saveRalphStates(): void {
this.ralphStateDirty = true;
if (this.ralphStateSaveTimeout) {
return; // Already scheduled
}
this.innerStateSaveTimeout = setTimeout(() => {
this.saveInnerStatesNow();
this.ralphStateSaveTimeout = setTimeout(() => {
this.saveRalphStatesNow();
}, SAVE_DEBOUNCE_MS);
}
// Immediate save for inner states
private saveInnerStatesNow(): void {
if (this.innerStateSaveTimeout) {
clearTimeout(this.innerStateSaveTimeout);
this.innerStateSaveTimeout = null;
private saveRalphStatesNow(): void {
if (this.ralphStateSaveTimeout) {
clearTimeout(this.ralphStateSaveTimeout);
this.ralphStateSaveTimeout = null;
}
if (!this.innerStateDirty) {
if (!this.ralphStateDirty) {
return;
}
this.innerStateDirty = false;
this.ralphStateDirty = false;
this.ensureDir();
const data: Record<string, InnerSessionState> = {};
for (const [sessionId, state] of this.innerStates) {
const data: Record<string, RalphSessionState> = {};
for (const [sessionId, state] of this.ralphStates) {
data[sessionId] = state;
}
writeFileSync(this.innerStatePath, JSON.stringify(data, null, 2), 'utf-8');
writeFileSync(this.ralphStatePath, JSON.stringify(data, null, 2), 'utf-8');
}
/** Returns inner state for a session, or null if not found. */
getInnerState(sessionId: string): InnerSessionState | null {
return this.innerStates.get(sessionId) || null;
getRalphState(sessionId: string): RalphSessionState | null {
return this.ralphStates.get(sessionId) || null;
}
/** Sets inner state for a session and triggers a debounced save. */
setInnerState(sessionId: string, state: InnerSessionState): void {
this.innerStates.set(sessionId, state);
this.saveInnerStates();
setRalphState(sessionId: string, state: RalphSessionState): void {
this.ralphStates.set(sessionId, state);
this.saveRalphStates();
}
/**
@@ -269,34 +269,34 @@ export class StateStore {
* Creates initial state if none exists.
* @returns The updated inner state.
*/
updateInnerState(sessionId: string, updates: Partial<InnerSessionState>): InnerSessionState {
let state = this.innerStates.get(sessionId);
updateRalphState(sessionId: string, updates: Partial<RalphSessionState>): RalphSessionState {
let state = this.ralphStates.get(sessionId);
if (!state) {
state = createInitialInnerSessionState(sessionId);
state = createInitialRalphSessionState(sessionId);
}
state = { ...state, ...updates, lastUpdated: Date.now() };
this.innerStates.set(sessionId, state);
this.saveInnerStates();
this.ralphStates.set(sessionId, state);
this.saveRalphStates();
return state;
}
/** Removes inner state for a session and triggers a debounced save. */
removeInnerState(sessionId: string): void {
if (this.innerStates.has(sessionId)) {
this.innerStates.delete(sessionId);
this.saveInnerStates();
removeRalphState(sessionId: string): void {
if (this.ralphStates.has(sessionId)) {
this.ralphStates.delete(sessionId);
this.saveRalphStates();
}
}
/** Returns a copy of all inner states as a Map. */
getAllInnerStates(): Map<string, InnerSessionState> {
return new Map(this.innerStates);
getAllRalphStates(): Map<string, RalphSessionState> {
return new Map(this.ralphStates);
}
/** Flushes all pending saves (main and inner state). Call before shutdown. */
flushAll(): void {
this.saveNow();
this.saveInnerStatesNow();
this.saveRalphStatesNow();
}
}
+6 -6
View File
@@ -9,7 +9,7 @@
* - **Main view**: Active session management with:
* - TabBar: Session tabs with switching
* - TerminalView: Live terminal output display
* - RalphPanel: Inner loop tracking (conditional)
* - RalphPanel: Ralph loop tracking (conditional)
* - StatusBar: Session info and keyboard hints
*
* Keyboard shortcuts are handled globally via Ink's useInput hook.
@@ -116,8 +116,8 @@ export function App(): React.ReactElement {
prevSession,
sendInput,
terminalOutput,
innerLoopState,
innerTodos,
ralphLoopState,
ralphTodos,
respawnStatus,
cases,
lastUsedCase,
@@ -427,7 +427,7 @@ export function App(): React.ReactElement {
}
// Check if Ralph panel should be visible (enabled and has data)
const showRalphPanel = innerLoopState?.enabled && (innerLoopState.active || innerTodos.length > 0);
const showRalphPanel = ralphLoopState?.enabled && (ralphLoopState.active || ralphTodos.length > 0);
// Adjust terminal height if Ralph panel is shown
const adjustedTerminalHeight = showRalphPanel ? terminalHeight - 6 : terminalHeight;
@@ -449,8 +449,8 @@ export function App(): React.ReactElement {
{showRalphPanel && (
<RalphPanel
loopState={innerLoopState}
todos={innerTodos}
loopState={ralphLoopState}
todos={ralphTodos}
/>
)}
+5 -5
View File
@@ -11,17 +11,17 @@
* - Progress through todos with visual indicators
* - Cycle count and elapsed time tracking
*
* @see {@link file://./../../inner-loop-tracker.ts} for detection logic
* @see {@link file://./../../ralph-tracker.ts} for detection logic
* @see {@link file://./../../../docs/ralph-wiggum-guide.md} for full documentation
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { InnerLoopState, InnerTodoItem } from '../../types.js';
import type { RalphTrackerState, RalphTodoItem } from '../../types.js';
interface RalphPanelProps {
loopState: InnerLoopState | null;
todos: InnerTodoItem[];
loopState: RalphTrackerState | null;
todos: RalphTodoItem[];
visible?: boolean;
}
@@ -70,7 +70,7 @@ function formatElapsed(hours: number | null): string {
* Only renders when loop is enabled and has relevant data to show.
*
* @param props - Component props
* @param props.loopState - Current loop state from InnerLoopTracker
* @param props.loopState - Current loop state from RalphTracker
* @param props.todos - Array of todo items being tracked
* @param props.visible - Whether the panel should be visible (default: true)
* @returns The panel element or null if hidden/disabled
+24 -24
View File
@@ -7,7 +7,7 @@
* This hook provides a complete interface for managing Claude sessions:
* - Session discovery from ~/.claudeman/screens.json
* - Terminal output polling via GNU screen hardcopy
* - Inner loop state tracking for Ralph Wiggum loops
* - Ralph state tracking for Ralph Wiggum loops
* - Respawn status monitoring via the Claudeman API
*
* @example
@@ -29,12 +29,12 @@ import { homedir } from 'os';
import { join } from 'path';
import { execSync, exec } from 'child_process';
import { promisify } from 'util';
import type { ScreenSession, InnerLoopState, InnerTodoItem, InnerSessionState } from '../../types.js';
import type { ScreenSession, RalphTrackerState, RalphTodoItem, RalphSessionState } from '../../types.js';
const execAsync = promisify(exec);
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
const RALPH_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
const SETTINGS_FILE = join(homedir(), '.claudeman', 'settings.json');
const OUTPUT_POLL_INTERVAL = 300; // Poll terminal output every 300ms (faster refresh)
@@ -149,8 +149,8 @@ interface SessionManagerState {
activeSessionId: string | null;
activeSession: ScreenSession | null;
terminalOutput: string;
innerLoopState: InnerLoopState | null;
innerTodos: InnerTodoItem[];
ralphLoopState: RalphTrackerState | null;
ralphTodos: RalphTodoItem[];
respawnStatus: RespawnStatus | null;
cases: CaseInfo[];
lastUsedCase: string | null;
@@ -237,18 +237,18 @@ function loadSessions(): ScreenSession[] {
*
* @description
* Reads ~/.claudeman/state-inner.json which contains per-session
* tracking of inner loops, todos, and completion phrases.
* tracking of Ralph loops, todos, and completion phrases.
*
* @param sessionId - The UUID of the session to load state for
* @returns The inner session state or null if not found
* @returns The Ralph session state or null if not found
*/
function loadInnerState(sessionId: string): InnerSessionState | null {
function loadRalphState(sessionId: string): RalphSessionState | null {
try {
if (!existsSync(INNER_STATE_FILE)) {
if (!existsSync(RALPH_STATE_FILE)) {
return null;
}
const data = readFileSync(INNER_STATE_FILE, 'utf-8');
const allStates = JSON.parse(data) as Record<string, InnerSessionState>;
const data = readFileSync(RALPH_STATE_FILE, 'utf-8');
const allStates = JSON.parse(data) as Record<string, RalphSessionState>;
return allStates[sessionId] || null;
} catch {
return null;
@@ -284,7 +284,7 @@ function loadLastUsedCase(): string | null {
* Provides complete session lifecycle management:
* - Automatic session discovery and status monitoring
* - Terminal output polling (500ms interval)
* - Inner loop state tracking (500ms interval)
* - Ralph state tracking (500ms interval)
* - Respawn status polling via API (2000ms interval)
* - Session CRUD operations via Claudeman API
*
@@ -311,8 +311,8 @@ export function useSessionManager(): SessionManagerState {
const [sessions, setSessions] = useState<ScreenSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [terminalOutput, setTerminalOutput] = useState<string>('');
const [innerLoopState, setInnerLoopState] = useState<InnerLoopState | null>(null);
const [innerTodos, setInnerTodos] = useState<InnerTodoItem[]>([]);
const [ralphLoopState, setRalphTrackerState] = useState<RalphTrackerState | null>(null);
const [ralphTodos, setInnerTodos] = useState<RalphTodoItem[]>([]);
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
const [cases, setCases] = useState<CaseInfo[]>([]);
const [lastUsedCase, setLastUsedCase] = useState<string | null>(loadLastUsedCase);
@@ -346,30 +346,30 @@ export function useSessionManager(): SessionManagerState {
// Get active session
const activeSession = sessions.find((s) => s.sessionId === activeSessionId) || null;
// Poll inner state for active session
// Poll Ralph state for active session
useEffect(() => {
if (!activeSessionId) {
setInnerLoopState(null);
setRalphTrackerState(null);
setInnerTodos([]);
return;
}
const pollInnerState = () => {
const state = loadInnerState(activeSessionId);
const pollRalphState = () => {
const state = loadRalphState(activeSessionId);
if (state) {
setInnerLoopState(state.loop);
setRalphTrackerState(state.loop);
setInnerTodos(state.todos);
} else {
setInnerLoopState(null);
setRalphTrackerState(null);
setInnerTodos([]);
}
};
// Initial poll
pollInnerState();
pollRalphState();
// Set up polling interval (same as output polling)
const intervalId = setInterval(pollInnerState, OUTPUT_POLL_INTERVAL);
const intervalId = setInterval(pollRalphState, OUTPUT_POLL_INTERVAL);
return () => {
clearInterval(intervalId);
@@ -809,8 +809,8 @@ export function useSessionManager(): SessionManagerState {
activeSessionId,
activeSession,
terminalOutput,
innerLoopState,
innerTodos,
ralphLoopState,
ralphTodos,
respawnStatus,
cases,
lastUsedCase,
+20 -20
View File
@@ -475,8 +475,8 @@ export interface ScreenSession {
name?: string;
/** Persisted respawn controller configuration (restored on server restart) */
respawnConfig?: PersistedRespawnConfig;
/** Whether inner loop (Ralph Wiggum) tracking is enabled */
innerLoopEnabled?: boolean;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
}
/**
@@ -533,12 +533,12 @@ export const DEFAULT_CONFIG: AppConfig = {
*/
/** Status of a detected todo item */
export type InnerTodoStatus = 'pending' | 'in_progress' | 'completed';
export type RalphTodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* State of an inner loop (Ralph Wiggum loop inside Claude Code)
* State of per-session Ralph / Todo tracking (detected from Claude output)
*/
export interface InnerLoopState {
export interface RalphTrackerState {
/** Whether the tracker is actively monitoring (disabled by default) */
enabled: boolean;
/** Whether a loop is currently active */
@@ -560,27 +560,27 @@ export interface InnerLoopState {
/**
* A detected todo item from Claude Code output
*/
export interface InnerTodoItem {
export interface RalphTodoItem {
/** Unique identifier based on content hash */
id: string;
/** Todo item text content */
content: string;
/** Current status */
status: InnerTodoStatus;
status: RalphTodoStatus;
/** Timestamp when detected */
detectedAt: number;
}
/**
* Complete inner state for a session
* Complete Ralph/todo state for a session
*/
export interface InnerSessionState {
export interface RalphSessionState {
/** Session this state belongs to */
sessionId: string;
/** Loop tracking state */
loop: InnerLoopState;
loop: RalphTrackerState;
/** Detected todo items */
todos: InnerTodoItem[];
todos: RalphTodoItem[];
/** Timestamp of last update */
lastUpdated: number;
}
@@ -588,15 +588,15 @@ export interface InnerSessionState {
/**
* Map of session ID to inner state
*/
export interface InnerStateRecord {
[sessionId: string]: InnerSessionState;
export interface RalphStateRecord {
[sessionId: string]: RalphSessionState;
}
/**
* Creates initial inner loop state
* @returns Fresh inner loop state with defaults
* Creates initial Ralph tracker state
* @returns Fresh Ralph tracker state with defaults
*/
export function createInitialInnerLoopState(): InnerLoopState {
export function createInitialRalphTrackerState(): RalphTrackerState {
return {
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
active: false,
@@ -610,14 +610,14 @@ export function createInitialInnerLoopState(): InnerLoopState {
}
/**
* Creates initial inner session state
* Creates initial Ralph session state
* @param sessionId Session ID this state belongs to
* @returns Fresh inner session state
* @returns Fresh Ralph session state
*/
export function createInitialInnerSessionState(sessionId: string): InnerSessionState {
export function createInitialRalphSessionState(sessionId: string): RalphSessionState {
return {
sessionId,
loop: createInitialInnerLoopState(),
loop: createInitialRalphTrackerState(),
todos: [],
lastUpdated: Date.now(),
};
+62 -62
View File
@@ -19,9 +19,9 @@ 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<sessionId, { loop, todos }>
this.innerStatePanelCollapsed = true; // Default to collapsed
// Ralph loop/todo state per session
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
this.ralphStatePanelCollapsed = true; // Default to collapsed
// Terminal write batching
this.pendingWrites = '';
@@ -29,7 +29,7 @@ class ClaudemanApp {
// Render debouncing
this.renderSessionTabsTimeout = null;
this.renderInnerStatePanelTimeout = null;
this.renderRalphStatePanelTimeout = null;
this.renderTaskPanelTimeout = null;
this.renderScreenSessionsTimeout = null;
@@ -302,14 +302,14 @@ class ClaudemanApp {
const data = JSON.parse(e.data);
this.sessions.delete(data.id);
this.terminalBuffers.delete(data.id);
this.innerStates.delete(data.id); // Clean up inner state for this session
this.ralphStates.delete(data.id); // Clean up ralph state for this session
if (this.activeSessionId === data.id) {
this.activeSessionId = null;
this.terminal.clear();
this.showWelcome();
}
this.renderSessionTabs();
this.renderInnerStatePanel(); // Update inner panel after session deleted
this.renderRalphStatePanel(); // Update ralph panel after session deleted
});
this.eventSource.addEventListener('session:terminal', (e) => {
@@ -543,18 +543,18 @@ class ClaudemanApp {
}
});
// Inner loop/todo events
this.eventSource.addEventListener('session:innerLoopUpdate', (e) => {
// Ralph loop/todo events
this.eventSource.addEventListener('session:ralphLoopUpdate', (e) => {
const data = JSON.parse(e.data);
this.updateInnerState(data.sessionId, { loop: data.state });
this.updateRalphState(data.sessionId, { loop: data.state });
});
this.eventSource.addEventListener('session:innerTodoUpdate', (e) => {
this.eventSource.addEventListener('session:ralphTodoUpdate', (e) => {
const data = JSON.parse(e.data);
this.updateInnerState(data.sessionId, { todos: data.todos });
this.updateRalphState(data.sessionId, { todos: data.todos });
});
this.eventSource.addEventListener('session:innerCompletionDetected', (e) => {
this.eventSource.addEventListener('session:ralphCompletionDetected', (e) => {
const data = JSON.parse(e.data);
// Prevent duplicate notifications for the same completion
const completionKey = `${data.sessionId}:${data.phrase}`;
@@ -568,11 +568,11 @@ class ClaudemanApp {
// Clear after 30 seconds to allow re-notification if loop restarts
setTimeout(() => this._shownCompletions?.delete(completionKey), 30000);
// Update inner state to mark loop as inactive
const existing = this.innerStates.get(data.sessionId) || {};
// Update ralph state to mark loop as inactive
const existing = this.ralphStates.get(data.sessionId) || {};
if (existing.loop) {
existing.loop.active = false;
this.updateInnerState(data.sessionId, existing);
this.updateRalphState(data.sessionId, existing);
}
});
}
@@ -586,14 +586,14 @@ class ClaudemanApp {
handleInit(data) {
this.sessions.clear();
this.innerStates.clear();
this.ralphStates.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 || []
// Load ralph state from session data
if (s.ralphLoop || s.ralphTodos) {
this.ralphStates.set(s.id, {
loop: s.ralphLoop || null,
todos: s.ralphTodos || []
});
}
});
@@ -807,15 +807,15 @@ class ClaudemanApp {
this.renderTaskPanel();
}
// Update inner state panel for this session
// Update ralph 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
if (session && (session.ralphLoop || session.ralphTodos)) {
this.updateRalphState(sessionId, {
loop: session.ralphLoop,
todos: session.ralphTodos
});
}
this.renderInnerStatePanel();
this.renderRalphStatePanel();
this.terminal.focus();
} catch (err) {
@@ -828,7 +828,7 @@ class ClaudemanApp {
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
this.sessions.delete(sessionId);
this.terminalBuffers.delete(sessionId);
this.innerStates.delete(sessionId);
this.ralphStates.delete(sessionId);
if (this.activeSessionId === sessionId) {
this.activeSessionId = null;
@@ -839,7 +839,7 @@ class ClaudemanApp {
} else {
this.terminal.clear();
this.showWelcome();
this.renderInnerStatePanel(); // Clear inner panel when no sessions
this.renderRalphStatePanel(); // Clear ralph panel when no sessions
}
}
@@ -1088,7 +1088,7 @@ class ClaudemanApp {
// Apply global Ralph tracker setting
// If enabled: enable the tracker
// If disabled: disable auto-enable to prevent pattern-based activation
await fetch(`/api/sessions/${createData.session.id}/inner-config`, {
await fetch(`/api/sessions/${createData.session.id}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1518,13 +1518,13 @@ class ClaudemanApp {
this.selectDurationPreset('');
// Populate Ralph Wiggum form with current session values
const innerState = this.innerStates.get(sessionId);
const ralphState = this.ralphStates.get(sessionId);
this.populateRalphForm({
enabled: innerState?.loop?.enabled ?? session.innerConfig?.enabled ?? false,
completionPhrase: innerState?.loop?.completionPhrase || session.innerLoop?.completionPhrase || '',
maxIterations: innerState?.loop?.maxIterations || session.innerLoop?.maxIterations || 0,
maxTodos: session.innerConfig?.maxTodos || 50,
todoExpirationMinutes: session.innerConfig?.todoExpirationMinutes || 60
enabled: ralphState?.loop?.enabled ?? session.ralphConfig?.enabled ?? false,
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
maxTodos: session.ralphConfig?.maxTodos || 50,
todoExpirationMinutes: session.ralphConfig?.todoExpirationMinutes || 60
});
document.getElementById('sessionOptionsModal').classList.add('active');
@@ -1728,7 +1728,7 @@ class ClaudemanApp {
const config = this.getRalphConfig();
try {
const res = await fetch(`/api/sessions/${this.editingSessionId}/inner-config`, {
const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
@@ -2177,24 +2177,24 @@ class ClaudemanApp {
// ========== Enhanced Ralph Wiggum Loop Panel ==========
updateInnerState(sessionId, updates) {
const existing = this.innerStates.get(sessionId) || { loop: null, todos: [] };
updateRalphState(sessionId, updates) {
const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] };
const updated = { ...existing, ...updates };
this.innerStates.set(sessionId, updated);
this.ralphStates.set(sessionId, updated);
// Re-render if this is the active session
if (sessionId === this.activeSessionId) {
this.renderInnerStatePanel();
this.renderRalphStatePanel();
}
}
toggleInnerStatePanel() {
toggleRalphStatePanel() {
// Preserve xterm scroll position to prevent jump when panel height changes
const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport');
const scrollTop = xtermViewport?.scrollTop;
this.innerStatePanelCollapsed = !this.innerStatePanelCollapsed;
this.renderInnerStatePanel();
this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed;
this.renderRalphStatePanel();
// Restore scroll position and refit terminal after layout change
requestAnimationFrame(() => {
@@ -2213,19 +2213,19 @@ class ClaudemanApp {
if (!this.activeSessionId) return;
// Disable tracker via API
await fetch(`/api/sessions/${this.activeSessionId}/inner-config`, {
await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false })
});
// Clear local state and hide panel
this.innerStates.delete(this.activeSessionId);
this.renderInnerStatePanel();
this.ralphStates.delete(this.activeSessionId);
this.renderRalphStatePanel();
}
toggleRalphDetach() {
const panel = this.$('innerStatePanel');
const panel = this.$('ralphStatePanel');
const detachBtn = this.$('ralphDetachBtn');
if (!panel) return;
@@ -2245,7 +2245,7 @@ class ClaudemanApp {
// Detach as floating window
panel.classList.add('detached');
// Expand when detaching for better visibility
this.innerStatePanelCollapsed = false;
this.ralphStatePanelCollapsed = false;
panel.classList.remove('collapsed');
if (detachBtn) {
detachBtn.innerHTML = '&#x229E;'; // Attach icon (squared plus - dock back)
@@ -2254,11 +2254,11 @@ class ClaudemanApp {
// Setup drag functionality
this.setupRalphDrag();
}
this.renderInnerStatePanel();
this.renderRalphStatePanel();
}
setupRalphDrag() {
const panel = this.$('innerStatePanel');
const panel = this.$('ralphStatePanel');
const header = this.$('ralphSummary');
if (!panel || !header) return;
@@ -2313,23 +2313,23 @@ class ClaudemanApp {
header.addEventListener('mousedown', onMouseDown);
}
renderInnerStatePanel() {
renderRalphStatePanel() {
// Debounce renders at 50ms to prevent excessive DOM updates
if (this.renderInnerStatePanelTimeout) {
clearTimeout(this.renderInnerStatePanelTimeout);
if (this.renderRalphStatePanelTimeout) {
clearTimeout(this.renderRalphStatePanelTimeout);
}
this.renderInnerStatePanelTimeout = setTimeout(() => {
this._renderInnerStatePanelImmediate();
this.renderRalphStatePanelTimeout = setTimeout(() => {
this._renderRalphStatePanelImmediate();
}, 50);
}
_renderInnerStatePanelImmediate() {
const panel = this.$('innerStatePanel');
_renderRalphStatePanelImmediate() {
const panel = this.$('ralphStatePanel');
const toggle = this.$('ralphToggle');
if (!panel) return;
const state = this.innerStates.get(this.activeSessionId);
const state = this.ralphStates.get(this.activeSessionId);
// Check if there's anything to show
// Only show panel if tracker is enabled OR there's active state to display
@@ -2360,7 +2360,7 @@ class ClaudemanApp {
this.updateRalphStats(state?.loop, completed, total);
// Handle collapsed/expanded state
if (this.innerStatePanelCollapsed) {
if (this.ralphStatePanelCollapsed) {
panel.classList.add('collapsed');
if (toggle) toggle.innerHTML = '&#x25BC;'; // Down arrow when collapsed (click to expand)
} else {
@@ -2510,8 +2510,8 @@ class ClaudemanApp {
if (!grid) return;
if (todos.length === 0) {
if (grid.children.length !== 1 || !grid.querySelector('.inner-state-empty')) {
grid.innerHTML = '<div class="inner-state-empty">No tasks detected</div>';
if (grid.children.length !== 1 || !grid.querySelector('.ralph-state-empty')) {
grid.innerHTML = '<div class="ralph-state-empty">No tasks detected</div>';
}
return;
}
+7 -7
View File
@@ -72,11 +72,11 @@
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
</div>
<!-- Enhanced Ralph Wiggum Loop Panel -->
<div class="ralph-panel collapsed" id="innerStatePanel" style="display: none;">
<!-- Ralph / Todo Tracker Panel -->
<div class="ralph-panel collapsed" id="ralphStatePanel" style="display: none;">
<!-- Collapsed Summary Bar -->
<div class="ralph-summary" id="ralphSummary">
<div class="ralph-summary-content" onclick="app.toggleInnerStatePanel()">
<div class="ralph-summary-content" onclick="app.toggleRalphStatePanel()">
<div class="ralph-ring-mini" id="ralphRingMini">
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
<defs>
@@ -91,7 +91,7 @@
<span class="ralph-ring-text" id="ralphRingMiniText">0%</span>
</div>
<div class="ralph-summary-info">
<span class="ralph-title">Ralph Wiggum Tracker</span>
<span class="ralph-title">Ralph / Todo Tracker</span>
<span class="ralph-status-badge" id="ralphStatusBadge">
<span class="ralph-status-dot"></span>
<span class="ralph-status-text">Idle</span>
@@ -268,7 +268,7 @@
<!-- Modal Tabs -->
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="respawn" onclick="app.switchOptionsTab('respawn')">Respawn</button>
<button class="modal-tab-btn" data-tab="ralph" onclick="app.switchOptionsTab('ralph')">Ralph Wiggum</button>
<button class="modal-tab-btn" data-tab="ralph" onclick="app.switchOptionsTab('ralph')">Ralph / Todo Tracker</button>
</div>
<div class="modal-body">
<!-- Respawn Tab Content -->
@@ -374,7 +374,7 @@
<input type="checkbox" id="modalRalphEnabled">
<span class="slider"></span>
</label>
<span class="form-hint">Enable Ralph Wiggum tracker for this session (auto-detects loops and todos)</span>
<span class="form-hint">Enable Ralph / Todo tracker for this session (auto-detects loops and todos)</span>
</div>
<div class="form-row">
@@ -505,7 +505,7 @@
<span class="form-hint">Comma-separated list of tools to allow</span>
</div>
<div class="form-row form-row-switch">
<label>Enable Ralph Tracker</label>
<label>Enable Ralph / Todo Tracker</label>
<label class="switch">
<input type="checkbox" id="appSettingsRalphEnabled">
<span class="slider"></span>
+2 -2
View File
@@ -962,8 +962,8 @@ body {
color: var(--text-dim);
}
/* Empty state (legacy support) */
.inner-state-empty {
/* Empty state */
.ralph-state-empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem;
+147 -123
View File
@@ -17,11 +17,12 @@ 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, type InnerLoopState, type InnerTodoItem } from '../session.js';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem } from '../session.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import { ScreenManager } from '../screen-manager.js';
import { getStore } from '../state-store.js';
import { generateClaudeMd } from '../templates/claude-md.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { v4 as uuidv4 } from 'uuid';
import {
getErrorMessage,
@@ -73,59 +74,53 @@ const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
// Maximum concurrent sessions to prevent resource exhaustion
const MAX_CONCURRENT_SESSIONS = 50;
// Pattern to extract completion phrase from CLAUDE.md
// Matches <promise>PHRASE</promise> with optional whitespace
const CLAUDE_MD_PROMISE_PATTERN = /<promise>\s*([A-Z0-9_]+)\s*<\/promise>/gi;
/**
* Extract completion phrase from CLAUDE.md content.
* Looks for <promise>PHRASE</promise> pattern.
* Auto-configure Ralph tracker for a session.
*
* Handles multiple variations:
* - Raw text: <promise>PHRASE</promise>
* - In backticks: `<promise>PHRASE</promise>`
* - With whitespace: <promise> PHRASE </promise>
* - Multiple occurrences: returns the first one
* Priority order:
* 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state)
* 2. CLAUDE.md <promise> tags (fallback)
*
* @param claudeMdPath - Path to CLAUDE.md file
* @returns The completion phrase (uppercase), or null if not found
* The ralph-loop.local.md file has priority because it contains
* the exact configuration from an active Ralph loop session.
*/
function extractCompletionPhrase(claudeMdPath: string): string | null {
try {
if (!existsSync(claudeMdPath)) return null;
const content = readFileSync(claudeMdPath, 'utf-8');
function autoConfigureRalph(session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void): void {
// First, try to read the official Ralph Wiggum plugin state file
const ralphConfig = parseRalphLoopConfig(workingDir);
// Reset regex state (global flag)
CLAUDE_MD_PROMISE_PATTERN.lastIndex = 0;
if (ralphConfig && ralphConfig.completionPromise) {
session.ralphTracker.enable();
session.ralphTracker.startLoop(
ralphConfig.completionPromise,
ralphConfig.maxIterations ?? undefined
);
// Find all matches and return the first one
const match = CLAUDE_MD_PROMISE_PATTERN.exec(content);
if (match && match[1]) {
const phrase = match[1].trim().toUpperCase();
console.log(`[auto-detect] Found completion phrase in CLAUDE.md: ${phrase}`);
return phrase;
// Restore iteration count if available
if (ralphConfig.iteration > 0) {
// The tracker's cycleCount will be updated when we detect iteration patterns
// in the terminal output, but we can set maxIterations now
console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`);
}
return null;
} catch (err) {
console.error(`[auto-detect] Error reading CLAUDE.md: ${err}`);
return null;
}
}
/**
* Auto-configure inner loop tracker for a session based on CLAUDE.md
*/
function autoConfigureInnerLoop(session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void): void {
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`);
broadcast('session:ralphLoopUpdate', {
sessionId: session.id,
state: session.ralphTracker.loopState,
});
return;
}
// Fallback: try CLAUDE.md
const claudeMdPath = join(workingDir, 'CLAUDE.md');
const completionPhrase = extractCompletionPhrase(claudeMdPath);
if (completionPhrase) {
session.innerLoopTracker.enable();
session.innerLoopTracker.startLoop(completionPhrase);
console.log(`[auto-detect] Configured inner loop for session ${session.id} with phrase: ${completionPhrase}`);
broadcast('session:innerLoopUpdate', {
session.ralphTracker.enable();
session.ralphTracker.startLoop(completionPhrase);
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
broadcast('session:ralphLoopUpdate', {
sessionId: session.id,
state: session.innerLoopTracker.loopState,
state: session.ralphTracker.loopState,
});
}
}
@@ -280,7 +275,7 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return session.toDetailedState();
@@ -291,18 +286,21 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
textOutput: session.textOutput,
messages: session.messages,
errorBuffer: session.errorBuffer,
success: true,
data: {
textOutput: session.textOutput,
messages: session.messages,
errorBuffer: session.errorBuffer,
}
};
});
// Get inner state (Ralph loop + todos) for a session
this.app.get('/api/sessions/:id/inner-state', async (req) => {
// Get Ralph state (Ralph loop + todos) for a session
this.app.get('/api/sessions/:id/ralph-state', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
@@ -313,15 +311,15 @@ export class WebServer extends EventEmitter {
return {
success: true,
data: {
loop: session.innerLoopState,
todos: session.innerTodos,
todoStats: session.innerTodoStats,
loop: session.ralphLoopState,
todos: session.ralphTodos,
todoStats: session.ralphTodoStats,
}
};
});
// Configure inner loop (Ralph Wiggum) settings
this.app.post('/api/sessions/:id/inner-config', async (req) => {
// Configure Ralph (Ralph Wiggum) settings
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string };
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } = req.body as {
enabled?: boolean;
@@ -335,53 +333,53 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { success: false, error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Handle reset first (before other config)
if (reset) {
if (reset === 'full') {
session.innerLoopTracker.fullReset();
session.ralphTracker.fullReset();
} else {
session.innerLoopTracker.reset();
session.ralphTracker.reset();
}
}
// Configure auto-enable behavior
if (disableAutoEnable !== undefined) {
if (disableAutoEnable) {
session.innerLoopTracker.disableAutoEnable();
session.ralphTracker.disableAutoEnable();
} else {
session.innerLoopTracker.enableAutoEnable();
session.ralphTracker.enableAutoEnable();
}
}
// Enable/disable the tracker
if (enabled !== undefined) {
if (enabled) {
session.innerLoopTracker.enable();
session.ralphTracker.enable();
} else {
session.innerLoopTracker.disable();
session.ralphTracker.disable();
}
// Persist inner loop enabled state
this.screenManager.updateInnerLoopEnabled(id, enabled);
// Persist Ralph enabled state
this.screenManager.updateRalphEnabled(id, enabled);
}
// Configure the inner loop tracker
// Configure the Ralph tracker
if (completionPhrase !== undefined) {
// Start loop with completion phrase to set it up for watching
if (completionPhrase) {
session.innerLoopTracker.startLoop(completionPhrase, maxIterations || undefined);
session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined);
}
}
if (maxIterations !== undefined) {
session.innerLoopTracker.setMaxIterations(maxIterations || null);
session.ralphTracker.setMaxIterations(maxIterations || null);
}
// Store additional config on session for reference
(session as any).innerConfig = {
enabled: enabled ?? session.innerLoopTracker.enabled,
(session as any).ralphConfig = {
enabled: enabled ?? session.ralphTracker.enabled,
completionPhrase: completionPhrase || '',
maxIterations: maxIterations || 0,
maxTodos: maxTodos || 50,
@@ -389,9 +387,9 @@ export class WebServer extends EventEmitter {
};
// Broadcast the update
this.broadcast('session:innerLoopUpdate', {
this.broadcast('session:ralphLoopUpdate', {
sessionId: id,
state: session.innerLoopState
state: session.ralphLoopState
});
return { success: true };
@@ -435,7 +433,7 @@ export class WebServer extends EventEmitter {
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting
autoConfigureInnerLoop(session, session.workingDir, () => {});
autoConfigureRalph(session, session.workingDir, () => {});
await session.startInteractive();
this.broadcast('session:interactive', { id });
@@ -512,7 +510,7 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Clean the buffer: remove junk before actual Claude content
@@ -566,7 +564,7 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Create or get existing controller
@@ -595,7 +593,7 @@ export class WebServer extends EventEmitter {
const controller = this.respawnControllers.get(id);
if (!controller) {
return { error: 'Respawn controller not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
}
controller.stop();
@@ -615,7 +613,7 @@ export class WebServer extends EventEmitter {
const controller = this.respawnControllers.get(id);
if (!controller) {
return { error: 'Respawn controller not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
}
controller.updateConfig(config);
@@ -635,16 +633,16 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) {
return { error: 'Session is busy' };
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting
autoConfigureInnerLoop(session, session.workingDir, () => {});
autoConfigureRalph(session, session.workingDir, () => {});
// Start interactive session
await session.startInteractive();
@@ -666,11 +664,13 @@ export class WebServer extends EventEmitter {
return {
success: true,
message: 'Interactive session with respawn started',
respawnStatus: controller.getStatus(),
data: {
message: 'Interactive session with respawn started',
respawnStatus: controller.getStatus(),
},
};
} catch (err) {
return { error: getErrorMessage(err) };
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
@@ -681,12 +681,12 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Check if session is running (has a PID)
if (!session.pid) {
return { error: 'Session is not running. Start it first.' };
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.');
}
// Stop existing controller if any
@@ -725,7 +725,15 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (body.enabled === undefined) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required');
}
if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number');
}
session.setAutoClear(body.enabled, body.threshold);
@@ -733,9 +741,11 @@ export class WebServer extends EventEmitter {
return {
success: true,
autoClear: {
enabled: session.autoClearEnabled,
threshold: session.autoClearThreshold,
data: {
autoClear: {
enabled: session.autoClearEnabled,
threshold: session.autoClearThreshold,
},
},
};
});
@@ -747,7 +757,15 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (body.enabled === undefined) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required');
}
if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number');
}
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
@@ -755,10 +773,12 @@ export class WebServer extends EventEmitter {
return {
success: true,
autoCompact: {
enabled: session.autoCompactEnabled,
threshold: session.autoCompactThreshold,
prompt: session.autoCompactPrompt,
data: {
autoCompact: {
enabled: session.autoCompactEnabled,
threshold: session.autoCompactThreshold,
prompt: session.autoCompactPrompt,
},
},
};
});
@@ -767,10 +787,14 @@ export class WebServer extends EventEmitter {
this.app.post('/api/run', async (req) => {
// Prevent unbounded session creation
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` };
return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
}
const { prompt, workingDir } = req.body as QuickRunRequest;
if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
}
const dir = workingDir || process.cwd();
const session = new Session({ workingDir: dir });
@@ -1052,7 +1076,7 @@ export class WebServer extends EventEmitter {
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured
if (mode === 'claude') {
autoConfigureInnerLoop(session, casePath, () => {}); // no broadcast yet
autoConfigureRalph(session, casePath, () => {}); // no broadcast yet
}
this.sessions.set(session.id, session);
@@ -1240,20 +1264,20 @@ export class WebServer extends EventEmitter {
this.outputBatches.delete(sessionId);
this.taskUpdateBatches.delete(sessionId);
// Reset inner loop tracker on the session before cleanup
// Reset Ralph tracker on the session before cleanup
if (session) {
session.innerLoopTracker.fullReset();
session.ralphTracker.fullReset();
}
// Clear inner state from store
this.store.removeInnerState(sessionId);
// Clear Ralph state from store
this.store.removeRalphState(sessionId);
// Broadcast inner loop cleared to update UI
this.broadcast('session:innerLoopUpdate', {
// Broadcast Ralph cleared to update UI
this.broadcast('session:ralphLoopUpdate', {
sessionId,
state: { enabled: false, active: false, completionPhrase: null, startedAt: null, cycleCount: 0, maxIterations: null, lastActivity: Date.now(), elapsedHours: null }
});
this.broadcast('session:innerTodoUpdate', {
this.broadcast('session:ralphTodoUpdate', {
sessionId,
todos: [],
stats: { total: 0, pending: 0, inProgress: 0, completed: 0 }
@@ -1361,21 +1385,21 @@ export class WebServer extends EventEmitter {
this.broadcastSessionStateDebounced(session.id);
});
// 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 });
// Ralph tracking events
session.on('ralphLoopUpdate', (state: RalphTrackerState) => {
this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state });
// Persist Ralph state
this.store.updateRalphState(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('ralphTodoUpdate', (todos: RalphTodoItem[]) => {
this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos });
// Persist Ralph state
this.store.updateRalphState(session.id, { todos });
});
session.on('innerCompletionDetected', (phrase: string) => {
this.broadcast('session:innerCompletionDetected', { sessionId: session.id, phrase });
session.on('ralphCompletionDetected', (phrase: string) => {
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
});
}
@@ -1788,25 +1812,25 @@ export class WebServer extends EventEmitter {
this.sessions.set(session.id, session);
this.setupSessionListeners(session);
// Restore inner loop tracking state (Ralph Wiggum settings) if it was saved
const innerState = this.store.getInnerState(screen.sessionId);
if (innerState) {
session.innerLoopTracker.restoreState(innerState.loop, innerState.todos);
console.log(`[Server] Restored inner loop state for session ${session.id} (enabled: ${innerState.loop.enabled})`);
// Restore Ralph tracking state (Ralph Wiggum settings) if it was saved
const ralphState = this.store.getRalphState(screen.sessionId);
if (ralphState) {
session.ralphTracker.restoreState(ralphState.loop, ralphState.todos);
console.log(`[Server] Restored Ralph state for session ${session.id} (enabled: ${ralphState.loop.enabled})`);
}
// Also check screen.innerLoopEnabled as a fallback
if (screen.innerLoopEnabled && !session.innerLoopTracker.enabled) {
session.innerLoopTracker.enable();
console.log(`[Server] Enabled inner loop tracker for session ${session.id} from screen config`);
// Also check screen.ralphEnabled as a fallback
if (screen.ralphEnabled && !session.ralphTracker.enabled) {
session.ralphTracker.enable();
console.log(`[Server] Enabled Ralph tracker for session ${session.id} from screen config`);
}
// Auto-detect completion phrase from CLAUDE.md if not already set
if (!session.innerLoopTracker.loopState.completionPhrase) {
if (!session.ralphTracker.loopState.completionPhrase) {
const claudeMdPath = join(session.workingDir, 'CLAUDE.md');
const completionPhrase = extractCompletionPhrase(claudeMdPath);
if (completionPhrase) {
session.innerLoopTracker.enable();
session.innerLoopTracker.startLoop(completionPhrase);
session.ralphTracker.enable();
session.ralphTracker.startLoop(completionPhrase);
console.log(`[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}`);
}
}
+420
View File
@@ -0,0 +1,420 @@
/**
* @fileoverview Tests for Ralph Wiggum configuration parser
*
* Tests parsing of .claude/ralph-loop.local.md and CLAUDE.md
* for Ralph loop configuration.
*/
import { describe, it, expect } from 'vitest';
import {
parseRalphLoopConfigFromContent,
extractCompletionPhraseFromContent,
} from '../src/ralph-config.js';
describe('parseRalphLoopConfigFromContent', () => {
describe('valid YAML frontmatter', () => {
it('should parse complete config', () => {
const content = `---
enabled: true
iteration: 5
max-iterations: 50
completion-promise: "COMPLETE"
---
# Original Prompt
Build a REST API...
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(true);
expect(config!.iteration).toBe(5);
expect(config!.maxIterations).toBe(50);
expect(config!.completionPromise).toBe('COMPLETE');
});
it('should parse config without quotes around values', () => {
const content = `---
enabled: true
iteration: 10
max-iterations: 100
completion-promise: TESTS_PASS
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('TESTS_PASS');
});
it('should parse config with hyphenated completion promise', () => {
const content = `---
enabled: true
completion-promise: "TESTS-PASS"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('TESTS-PASS');
});
it('should handle partial config (only enabled)', () => {
const content = `---
enabled: true
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(true);
expect(config!.iteration).toBe(0);
expect(config!.maxIterations).toBeNull();
expect(config!.completionPromise).toBeNull();
});
it('should handle disabled state', () => {
const content = `---
enabled: false
iteration: 25
max-iterations: 50
completion-promise: "DONE"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(false);
expect(config!.iteration).toBe(25);
});
it('should convert completion promise to uppercase', () => {
const content = `---
completion-promise: "lower_case_phrase"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('LOWER_CASE_PHRASE');
});
it('should handle whitespace in values', () => {
const content = `---
enabled: true
completion-promise: "COMPLETE"
iteration: 5
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.completionPromise).toBe('COMPLETE');
expect(config!.iteration).toBe(5);
});
});
describe('invalid content', () => {
it('should return null for content without frontmatter', () => {
const content = `# Just a markdown file
No YAML frontmatter here.
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).toBeNull();
});
it('should return null for malformed frontmatter (missing closing)', () => {
const content = `---
enabled: true
# No closing ---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).toBeNull();
});
it('should return null for empty content', () => {
const config = parseRalphLoopConfigFromContent('');
expect(config).toBeNull();
});
it('should return null for empty frontmatter', () => {
const content = `---
---
Content here.
`;
const config = parseRalphLoopConfigFromContent(content);
// Empty frontmatter (nothing between ---) returns null
// because there's nothing useful to extract
expect(config).toBeNull();
});
});
describe('edge cases', () => {
it('should handle zero iteration', () => {
const content = `---
iteration: 0
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.iteration).toBe(0);
});
it('should handle invalid iteration as zero', () => {
const content = `---
iteration: not-a-number
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.iteration).toBe(0);
});
it('should handle invalid max-iterations as null', () => {
const content = `---
max-iterations: infinite
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.maxIterations).toBeNull();
});
it('should handle mixed valid and invalid values', () => {
const content = `---
enabled: true
iteration: abc
max-iterations: 50
completion-promise: "VALID-PHRASE"
---
`;
const config = parseRalphLoopConfigFromContent(content);
expect(config).not.toBeNull();
expect(config!.enabled).toBe(true);
expect(config!.iteration).toBe(0); // Invalid falls back to 0
expect(config!.maxIterations).toBe(50);
expect(config!.completionPromise).toBe('VALID-PHRASE');
});
});
});
describe('extractCompletionPhraseFromContent', () => {
describe('standard patterns', () => {
it('should extract simple completion phrase', () => {
const content = `Output <promise>COMPLETE</promise> when done.`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should extract phrase with underscores', () => {
const content = `<promise>ALL_TASKS_DONE</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('ALL_TASKS_DONE');
});
it('should extract phrase with hyphens', () => {
const content = `Output <promise>TESTS-PASS</promise> when all tests green.`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TESTS-PASS');
});
it('should extract phrase with numbers', () => {
const content = `<promise>TASK_123</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TASK_123');
});
it('should extract phrase with mixed characters', () => {
const content = `<promise>TASK-123_COMPLETE</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TASK-123_COMPLETE');
});
});
describe('whitespace handling', () => {
it('should handle whitespace inside tags', () => {
const content = `<promise> COMPLETE </promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should handle newlines around phrase', () => {
const content = `<promise>
DONE
</promise>`;
// The pattern does match across lines since \s* includes newlines
// This is acceptable - multi-line promises are unusual but still valid
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('DONE');
});
});
describe('case handling', () => {
it('should convert lowercase to uppercase', () => {
const content = `<promise>complete</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should convert mixed case to uppercase', () => {
const content = `<promise>TestsPass</promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TESTSPASS');
});
});
describe('multiple occurrences', () => {
it('should return first occurrence', () => {
const content = `
First: <promise>FIRST_PHRASE</promise>
Second: <promise>SECOND_PHRASE</promise>
Third: <promise>THIRD_PHRASE</promise>
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('FIRST_PHRASE');
});
});
describe('in backticks', () => {
it('should extract phrase from within backticks', () => {
const content = 'Output `<promise>COMPLETE</promise>` when done.';
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should extract phrase from code block', () => {
const content = `
\`\`\`
<promise>DONE</promise>
\`\`\`
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('DONE');
});
});
describe('no match cases', () => {
it('should return null when no pattern found', () => {
const content = `Just some regular text without any promise tags.`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
it('should return null for empty content', () => {
const phrase = extractCompletionPhraseFromContent('');
expect(phrase).toBeNull();
});
it('should return null for malformed tags', () => {
const content = `<promise>INCOMPLETE`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
it('should return null for empty promise', () => {
const content = `<promise></promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
it('should return null for promise with only whitespace', () => {
const content = `<promise> </promise>`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBeNull();
});
});
describe('real-world CLAUDE.md examples', () => {
it('should extract from typical CLAUDE.md content', () => {
const content = `
# CLAUDE.md
## Task
Build a REST API for user management.
## Completion Criteria
- All endpoints working
- Tests passing
- Documentation complete
Output <promise>COMPLETE</promise> when all criteria met.
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('COMPLETE');
});
it('should extract from Ralph Loop section', () => {
const content = `
## Ralph Wiggum Loop
Completion phrase: <promise>TIME_COMPLETE</promise>
Keep working until minimum duration reached.
`;
const phrase = extractCompletionPhraseFromContent(content);
expect(phrase).toBe('TIME_COMPLETE');
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,26 +1,26 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InnerLoopTracker } from '../src/inner-loop-tracker.js';
import { InnerLoopState, InnerTodoItem } from '../src/types.js';
import { RalphTracker } from '../src/ralph-tracker.js';
import { RalphTrackerState, RalphTodoItem } from '../src/types.js';
/**
* InnerLoopTracker Tests
* RalphTracker Tests
*
* Tests the detection of Ralph Wiggum loops and todo lists from terminal output
* running inside Claude Code sessions.
*/
describe('InnerLoopTracker', () => {
let tracker: InnerLoopTracker;
describe('RalphTracker', () => {
let tracker: RalphTracker;
beforeEach(() => {
tracker = new InnerLoopTracker();
tracker = new RalphTracker();
// Enable tracker by default for most tests (testing detection logic)
tracker.enable();
});
describe('Initialization', () => {
it('should start with inactive loop state', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
const state = freshTracker.loopState;
expect(state.active).toBe(false);
expect(state.completionPhrase).toBeNull();
@@ -33,7 +33,7 @@ describe('InnerLoopTracker', () => {
});
it('should start disabled by default', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
expect(freshTracker.enabled).toBe(false);
expect(freshTracker.loopState.enabled).toBe(false);
});
@@ -41,7 +41,7 @@ describe('InnerLoopTracker', () => {
describe('Auto-Enable Behavior', () => {
it('should not process data when disabled', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
// This pattern doesn't trigger auto-enable
freshTracker.processTerminalData('Elapsed: 2.5 hours\n');
@@ -49,7 +49,7 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on /ralph-loop:ralph-loop command', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
const enableHandler = vi.fn();
freshTracker.on('enabled', enableHandler);
@@ -60,21 +60,21 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on completion phrase', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('<promise>COMPLETE</promise>\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on TodoWrite detection', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('TodoWrite: Todos have been modified\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on todo checkboxes', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('- [ ] New task\n');
expect(freshTracker.enabled).toBe(true);
@@ -82,21 +82,21 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on iteration patterns', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('Iteration 5/50\n');
expect(freshTracker.enabled).toBe(true);
});
it('should auto-enable on loop start patterns', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('Loop started at 2024-01-15\n');
expect(freshTracker.enabled).toBe(true);
});
it('should allow manual enable/disable', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
expect(freshTracker.enabled).toBe(false);
freshTracker.enable();
@@ -151,6 +151,28 @@ describe('InnerLoopTracker', () => {
expect(tracker.loopState.completionPhrase).toBe('MY_CUSTOM_PHRASE_123');
});
it('should detect completion phrases with hyphens', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>TESTS-PASS</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TESTS-PASS');
expect(tracker.loopState.completionPhrase).toBe('TESTS-PASS');
});
it('should detect completion phrases with mixed characters', () => {
const completionHandler = vi.fn();
tracker.on('completionDetected', completionHandler);
tracker.startLoop();
tracker.processTerminalData('<promise>TASK-123_COMPLETE</promise>\n');
expect(completionHandler).toHaveBeenCalledWith('TASK-123_COMPLETE');
expect(tracker.loopState.completionPhrase).toBe('TASK-123_COMPLETE');
});
it('should mark loop as inactive when completion detected', () => {
// Start a loop first
tracker.startLoop('TEST_PHRASE');
@@ -357,7 +379,7 @@ describe('InnerLoopTracker', () => {
});
it('should auto-enable on native todo pattern', () => {
const freshTracker = new InnerLoopTracker();
const freshTracker = new RalphTracker();
freshTracker.processTerminalData('☐ New task\n');
expect(freshTracker.enabled).toBe(true);
@@ -427,8 +449,10 @@ describe('InnerLoopTracker', () => {
});
it('should clear all state', () => {
tracker.startLoop('TEST');
tracker.processTerminalData('- [ ] Test task to clear\n');
// Use unique phrase that won't appear in the todo content
// (bare phrase detection would trigger on common words like 'TEST')
tracker.startLoop('XYZZY_COMPLETE');
tracker.processTerminalData('- [ ] Sample task to clear\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.todos).toHaveLength(1);
@@ -443,7 +467,7 @@ describe('InnerLoopTracker', () => {
describe('State Restoration', () => {
it('should restore state from persisted data', () => {
const loopState: InnerLoopState = {
const loopState: RalphTrackerState = {
enabled: true,
active: true,
completionPhrase: 'RESTORED',
@@ -454,7 +478,7 @@ describe('InnerLoopTracker', () => {
elapsedHours: 1.5,
};
const todos: InnerTodoItem[] = [
const todos: RalphTodoItem[] = [
{ id: 'todo-1', content: 'Task 1', status: 'completed', detectedAt: Date.now() },
{ id: 'todo-2', content: 'Task 2', status: 'in_progress', detectedAt: Date.now() },
];
@@ -479,7 +503,7 @@ describe('InnerLoopTracker', () => {
maxIterations: null,
lastActivity: Date.now(),
elapsedHours: null,
} as InnerLoopState;
} as RalphTrackerState;
tracker.restoreState(loopState, []);
+4 -3
View File
@@ -125,9 +125,10 @@ describe('Interactive Session Lifecycle', () => {
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/output`);
const data = await response.json();
expect(data).toHaveProperty('textOutput');
expect(data).toHaveProperty('messages');
expect(data).toHaveProperty('errorBuffer');
expect(data.success).toBe(true);
expect(data.data).toHaveProperty('textOutput');
expect(data.data).toHaveProperty('messages');
expect(data.data).toHaveProperty('errorBuffer');
});
});
+27 -27
View File
@@ -2,7 +2,7 @@
* @fileoverview Tests for StateStore
*
* Tests the persistent JSON state storage including
* debounced saves, state CRUD operations, and inner state management.
* debounced saves, state CRUD operations, and Ralph state management.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -11,7 +11,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
// Import types before mocking
import type { AppState, SessionState, TaskState, InnerSessionState } from '../src/types.js';
import type { AppState, SessionState, TaskState, RalphSessionState } from '../src/types.js';
// We need to import without mocking to test the actual implementation
import { StateStore, getStore } from '../src/state-store.js';
@@ -231,7 +231,7 @@ describe('StateStore', () => {
expect(store.getSession('session-1')).toBeNull();
expect(store.getTask('task-1')).toBeNull();
expect(store.getAllInnerStates().size).toBe(0);
expect(store.getAllRalphStates().size).toBe(0);
});
it('should preserve state file path in config', () => {
@@ -243,27 +243,27 @@ describe('StateStore', () => {
});
});
describe('inner state operations', () => {
it('should get and set inner state', () => {
describe('ralph state operations', () => {
it('should get and set ralph state', () => {
const store = new StateStore(testFilePath);
const innerState = createMockInnerState('session-1');
const ralphState = createMockRalphState('session-1');
store.setInnerState('session-1', innerState);
store.setRalphState('session-1', ralphState);
expect(store.getInnerState('session-1')).toEqual(innerState);
expect(store.getRalphState('session-1')).toEqual(ralphState);
});
it('should return null for non-existent inner state', () => {
it('should return null for non-existent ralph state', () => {
const store = new StateStore(testFilePath);
expect(store.getInnerState('non-existent')).toBeNull();
expect(store.getRalphState('non-existent')).toBeNull();
});
it('should update inner state with partial merge', () => {
it('should update ralph state with partial merge', () => {
const store = new StateStore(testFilePath);
store.setInnerState('session-1', createMockInnerState('session-1'));
const updated = store.updateInnerState('session-1', { totalTodos: 10 });
store.setRalphState('session-1', createMockRalphState('session-1'));
const updated = store.updateRalphState('session-1', { totalTodos: 10 });
expect(updated.totalTodos).toBe(10);
expect(updated.sessionId).toBe('session-1');
@@ -272,27 +272,27 @@ describe('StateStore', () => {
it('should create initial state on update if none exists', () => {
const store = new StateStore(testFilePath);
const state = store.updateInnerState('new-session', { totalTodos: 5 });
const state = store.updateRalphState('new-session', { totalTodos: 5 });
expect(state.sessionId).toBe('new-session');
expect(state.totalTodos).toBe(5);
});
it('should remove inner state', () => {
it('should remove ralph state', () => {
const store = new StateStore(testFilePath);
store.setInnerState('session-1', createMockInnerState('session-1'));
store.setRalphState('session-1', createMockRalphState('session-1'));
store.removeInnerState('session-1');
store.removeRalphState('session-1');
expect(store.getInnerState('session-1')).toBeNull();
expect(store.getRalphState('session-1')).toBeNull();
});
it('should get all inner states', () => {
it('should get all ralph states', () => {
const store = new StateStore(testFilePath);
store.setInnerState('session-1', createMockInnerState('session-1'));
store.setInnerState('session-2', createMockInnerState('session-2'));
store.setRalphState('session-1', createMockRalphState('session-1'));
store.setRalphState('session-2', createMockRalphState('session-2'));
const allStates = store.getAllInnerStates();
const allStates = store.getAllRalphStates();
expect(allStates.size).toBe(2);
expect(allStates.has('session-1')).toBe(true);
@@ -301,11 +301,11 @@ describe('StateStore', () => {
});
describe('flushAll', () => {
it('should flush both main and inner state', () => {
it('should flush both main and ralph state', () => {
const store = new StateStore(testFilePath);
store.setSession('session-1', createMockSessionState('session-1'));
store.setInnerState('session-1', createMockInnerState('session-1'));
store.setRalphState('session-1', createMockRalphState('session-1'));
store.flushAll();
@@ -320,7 +320,7 @@ describe('StateStore', () => {
const store1 = new StateStore(testFilePath);
store1.setSession('session-1', createMockSessionState('session-1'));
store1.setTask('task-1', createMockTaskState('task-1'));
store1.setInnerState('session-1', createMockInnerState('session-1'));
store1.setRalphState('session-1', createMockRalphState('session-1'));
store1.flushAll();
// Create new instance and verify state is loaded
@@ -328,7 +328,7 @@ describe('StateStore', () => {
expect(store2.getSession('session-1')).toBeDefined();
expect(store2.getTask('task-1')).toBeDefined();
expect(store2.getInnerState('session-1')).toBeDefined();
expect(store2.getRalphState('session-1')).toBeDefined();
});
});
});
@@ -363,7 +363,7 @@ function createMockTaskState(id: string): TaskState {
};
}
function createMockInnerState(sessionId: string): InnerSessionState {
function createMockRalphState(sessionId: string): RalphSessionState {
return {
sessionId,
enabled: true,
+8 -8
View File
@@ -9,8 +9,8 @@ import { describe, it, expect } from 'vitest';
import {
createErrorResponse,
createSuccessResponse,
createInitialInnerLoopState,
createInitialInnerSessionState,
createInitialRalphTrackerState,
createInitialRalphSessionState,
createInitialState,
ErrorMessages,
} from '../src/types.js';
@@ -69,9 +69,9 @@ describe('types utility functions', () => {
});
});
describe('createInitialInnerLoopState', () => {
it('should create initial inner loop state', () => {
const state = createInitialInnerLoopState();
describe('createInitialRalphTrackerState', () => {
it('should create initial Ralph tracker state', () => {
const state = createInitialRalphTrackerState();
expect(state.enabled).toBe(false);
expect(state.active).toBe(false);
@@ -84,9 +84,9 @@ describe('types utility functions', () => {
});
});
describe('createInitialInnerSessionState', () => {
it('should create initial inner session state with session ID', () => {
const state = createInitialInnerSessionState('session-123');
describe('createInitialRalphSessionState', () => {
it('should create initial Ralph session state with session ID', () => {
const state = createInitialRalphSessionState('session-123');
expect(state.sessionId).toBe('session-123');
expect(state.loop).toBeDefined();