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:
arkon
2026-01-19 13:21:01 +01:00
co-authored by Claude Opus 4.5
parent d711ec8b3a
commit ee8f6bd0cb
9 changed files with 1328 additions and 4 deletions
+188 -1
View File
@@ -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 = '&#x25B6;'; // Right arrow
content.innerHTML = this.renderInnerStateSummary(state);
} else {
panel.classList.remove('collapsed');
toggle.innerHTML = '&#x25BC;'; // 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() {
+9
View File
@@ -72,6 +72,15 @@
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</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">&#x25B6;</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>
+162
View File
@@ -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
View File
@@ -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 {