mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14: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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user