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