mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
@@ -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,
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = '⊞'; // 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 = '▼'; // 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;
|
||||
}
|
||||
|
||||
@@ -72,11 +72,11 @@
|
||||
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">■</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>
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user