From 925ea09ccae82f6c663c2ce49ab6757dc61c13a4 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 27 Jan 2026 00:54:02 +0100 Subject: [PATCH] feat: add smart respawn integration and priority todos (Phase 2) Smart Respawn Integration (2.5): - Check circuit breaker OPEN state before respawn (blocks cycle) - Check RALPH_STATUS EXIT_SIGNAL=true to stop respawn - Check STATUS=BLOCKED to pause respawn for human intervention - Use RECOMMENDATION from RALPH_STATUS in update prompt - Emit respawn:blocked SSE event with reason and details - Show notification when respawn is blocked - Visual blocked state with pulsing red indicator Priority-based Todos (part of 2.3): - Add RalphTodoPriority type (P0/P1/P2/null) - Parse priority from todo content (P0:, Critical:, High Priority, etc.) - Add priority field to RalphTodoItem interface - Update frontend to display priority badges with colors - Sort todos by priority (P0 > P1 > P2) then by status - Add CSS for priority badges and highlighted cards Bump version to 0.1391 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- package.json | 2 +- src/ralph-tracker.ts | 39 ++++++++++++++++++- src/respawn-controller.ts | 50 +++++++++++++++++++++++-- src/types.ts | 16 ++++---- src/web/public/app.js | 79 ++++++++++++++++++++++++++++++++++----- src/web/public/styles.css | 45 ++++++++++++++++++++++ src/web/server.ts | 7 ++++ 8 files changed, 217 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef40703f..f6f7388a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R When adding new features, always ask: "Will this maintain responsiveness with 20 sessions and 50 agent windows?" -**Version**: 0.1390 (must match `package.json`) +**Version**: 0.1391 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty diff --git a/package.json b/package.json index 342b84ec..a921ce88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1390", + "version": "0.1391", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index d71689b6..e8d90266 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -18,6 +18,7 @@ import { RalphTrackerState, RalphTodoItem, RalphTodoStatus, + RalphTodoPriority, RalphStatusBlock, RalphStatusValue, RalphTestsStatus, @@ -1375,6 +1376,31 @@ export class RalphTracker extends EventEmitter { } } + /** + * Parse priority from todo content. + * Looks for patterns like: "P0:", "P1:", "P2:", "(P0)", "(P1)", "(P2)", + * "Critical:", "Blocker:", "High Priority:", etc. + * + * @param content - Todo content text + * @returns Parsed priority level or null + */ + private parsePriority(content: string): RalphTodoPriority { + const upper = content.toUpperCase(); + + // Direct P0/P1/P2 patterns + if (/\bP0\b|^\(P0\)|:?\s*P0\s*:|\bCRITICAL\b|\bBLOCKER\b/.test(upper)) { + return 'P0'; + } + if (/\bP1\b|^\(P1\)|:?\s*P1\s*:|\bHIGH\s*PRIORITY\b/.test(upper)) { + return 'P1'; + } + if (/\bP2\b|^\(P2\)|:?\s*P2\s*:|\bNICE\s*TO\s*HAVE\b|\bLOW\s*PRIORITY\b/.test(upper)) { + return 'P2'; + } + + return null; + } + /** * Add a new todo item or update an existing one. * @@ -1382,6 +1408,7 @@ export class RalphTracker extends EventEmitter { * - Content is cleaned (ANSI removed, whitespace collapsed) * - Content under 5 chars is skipped * - ID is generated from normalized content (stable hash) + * - Priority is parsed from content (P0/P1/P2, Critical, High Priority, etc.) * - Existing item: Updates status and timestamp * - New item: Adds to map, evicts oldest if at MAX_TODO_ITEMS * @@ -1399,6 +1426,9 @@ export class RalphTracker extends EventEmitter { .trim(); if (cleanContent.length < 5) return; // Skip very short content + // Parse priority from content + const priority = this.parsePriority(cleanContent); + // Generate a stable ID from normalized content const id = this.generateTodoId(cleanContent); @@ -1407,6 +1437,8 @@ export class RalphTracker extends EventEmitter { // Update existing todo existing.status = status; existing.detectedAt = Date.now(); + // Update priority if parsed (don't overwrite with null) + if (priority) existing.priority = priority; } else { // Add new todo if (this._todos.size >= MAX_TODO_ITEMS) { @@ -1422,6 +1454,7 @@ export class RalphTracker extends EventEmitter { content: cleanContent, status, detectedAt: Date.now(), + priority, }); } } @@ -1699,7 +1732,11 @@ export class RalphTracker extends EventEmitter { }; this._todos.clear(); for (const todo of todos) { - this._todos.set(todo.id, { ...todo }); + // Backwards compatibility: ensure priority field exists + this._todos.set(todo.id, { + ...todo, + priority: todo.priority ?? null, + }); } } diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 27a19b55..a885be7b 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -2315,6 +2315,38 @@ export class RespawnController extends EventEmitter { `tokensStable=${status.tokensStable}, ` + `noWorking=${status.workingPatternsAbsent}`); + // ========== RALPH_STATUS Integration ========== + // Check circuit breaker status - if OPEN, pause respawn + const circuitBreaker = this.session.ralphTracker.circuitBreakerStatus; + if (circuitBreaker.state === 'OPEN') { + this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`); + this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`); + this.emit('respawnBlocked', { reason: 'circuit_breaker_open', details: circuitBreaker.reason }); + this.setState('watching'); + // Don't restart timers - wait for manual reset or circuit breaker resolution + return; + } + + // Check RALPH_STATUS EXIT_SIGNAL - if true, loop is complete + const statusBlock = this.session.ralphTracker.lastStatusBlock; + if (statusBlock?.exitSignal) { + this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`); + this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`); + this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete' }); + this.setState('watching'); + // Don't restart timers - loop is complete + return; + } + + // Check if STATUS=BLOCKED - trigger circuit breaker + if (statusBlock?.status === 'BLOCKED') { + this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`); + this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`); + this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention' }); + this.setState('watching'); + return; + } + // Reset detection state this.completionMessageTime = null; this.cancelCompletionConfirm(); @@ -2344,6 +2376,7 @@ export class RespawnController extends EventEmitter { /** * Send the update docs prompt (first step of cycle). + * Uses RALPH_STATUS RECOMMENDATION if available, otherwise falls back to configured prompt. * @fires stepSent - With step 'update' */ private sendUpdateDocs(): void { @@ -2356,10 +2389,21 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, () => { this.stepTimer = null; - const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI - this.logAction('command', `Sending: "${this.config.updatePrompt.substring(0, 50)}..."`); + + // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config + const statusBlock = this.session.ralphTracker.lastStatusBlock; + let updatePrompt = this.config.updatePrompt; + + if (statusBlock?.recommendation) { + // Append RECOMMENDATION to the update prompt for context + updatePrompt = `${this.config.updatePrompt}\n\nClaude's last recommendation: ${statusBlock.recommendation}`; + this.logAction('ralph', `Using RECOMMENDATION: ${statusBlock.recommendation.substring(0, 50)}...`); + } + + const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI + this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`); this.session.writeViaScreen(input); - this.emit('stepSent', 'update', this.config.updatePrompt); + this.emit('stepSent', 'update', updatePrompt); this.setState('waiting_update'); this.promptDetected = false; this.workingDetected = false; diff --git a/src/types.ts b/src/types.ts index 2ccc8ab3..851ce05f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -718,6 +718,12 @@ export interface RalphTrackerState { elapsedHours: number | null; } +/** + * Priority levels for todo items. + * Matches @fix_plan.md format (P0=critical, P1=high, P2=normal). + */ +export type RalphTodoPriority = 'P0' | 'P1' | 'P2' | null; + /** * A detected todo item from Claude Code output */ @@ -730,6 +736,8 @@ export interface RalphTodoItem { status: RalphTodoStatus; /** Timestamp when detected */ detectedAt: number; + /** Priority level (P0=critical, P1=high, P2=normal) */ + priority: RalphTodoPriority; } /** @@ -878,14 +886,6 @@ export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus { }; } -// ========== Priority Todo Types ========== - -/** - * Priority levels for todo items. - * Matches @fix_plan.md format. - */ -export type RalphTodoPriority = 'P0' | 'P1' | 'P2' | null; - /** * Creates initial Ralph tracker state * @returns Fresh Ralph tracker state with defaults diff --git a/src/web/public/app.js b/src/web/public/app.js index e773bf74..4b9e19d9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1294,6 +1294,33 @@ class ClaudemanApp { } }); + this.eventSource.addEventListener('respawn:blocked', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + const reasonMap = { + circuit_breaker_open: 'Circuit Breaker Open', + exit_signal: 'Exit Signal Detected', + status_blocked: 'Claude Reported BLOCKED', + }; + const title = reasonMap[data.reason] || 'Respawn Blocked'; + this.notificationManager?.notify({ + urgency: 'critical', + category: 'respawn-blocked', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId?.slice(0, 8), + title, + message: data.details, + }); + // Update respawn panel to show blocked state + if (data.sessionId === this.activeSessionId) { + const stateEl = document.getElementById('respawnStateLabel'); + if (stateEl) { + stateEl.textContent = title; + stateEl.classList.add('respawn-blocked'); + } + } + }); + this.eventSource.addEventListener('respawn:stepSent', (_e) => { // Step info is shown via state label (e.g., "Sending prompt", "Clearing context") }); @@ -3000,7 +3027,10 @@ class ClaudemanApp { } updateRespawnBanner(state) { - this.$('respawnState').textContent = this.getStateLabel(state); + const stateEl = this.$('respawnState'); + stateEl.textContent = this.getStateLabel(state); + // Clear blocked state when state changes (resumed from blocked) + stateEl.classList.remove('respawn-blocked'); } updateDetectionDisplay(detection) { @@ -5531,10 +5561,14 @@ class ClaudemanApp { return; } - // Sort: in_progress first, then pending, then completed + // Sort: by priority (P0 > P1 > P2 > null), then by status (in_progress > pending > completed) + const priorityOrder = { 'P0': 0, 'P1': 1, 'P2': 2, null: 3 }; + const statusOrder = { in_progress: 0, pending: 1, completed: 2 }; const sorted = [...todos].sort((a, b) => { - const order = { in_progress: 0, pending: 1, completed: 2 }; - return (order[a.status] || 1) - (order[b.status] || 1); + const priA = priorityOrder[a.priority] ?? 3; + const priB = priorityOrder[b.priority] ?? 3; + if (priA !== priB) return priA - priB; + return (statusOrder[a.status] || 1) - (statusOrder[b.status] || 1); }); // Incremental DOM update - reuse existing elements where possible @@ -5548,11 +5582,13 @@ class ClaudemanApp { sorted.forEach((todo, i) => { const card = existingCards[i]; const statusClass = `task-${todo.status.replace('_', '-')}`; + const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; const icon = this.getRalphTaskIcon(todo.status); // Update class if changed - if (!card.classList.contains(statusClass)) { - card.className = `ralph-task-card ${statusClass}`; + const newClass = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); + if (card.className !== newClass) { + card.className = newClass; } // Update icon if changed @@ -5561,6 +5597,22 @@ class ClaudemanApp { iconEl.textContent = icon; } + // Update priority badge + let badgeEl = card.querySelector('.ralph-task-priority'); + if (todo.priority) { + if (!badgeEl) { + badgeEl = document.createElement('span'); + badgeEl.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; + card.insertBefore(badgeEl, card.querySelector('.ralph-task-content')); + } + if (badgeEl.textContent !== todo.priority) { + badgeEl.textContent = todo.priority; + badgeEl.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; + } + } else if (badgeEl) { + badgeEl.remove(); + } + // Update content if changed const contentEl = card.querySelector('.ralph-task-content'); if (contentEl && contentEl.textContent !== todo.content) { @@ -5572,18 +5624,27 @@ class ClaudemanApp { sorted.forEach(todo => { const card = document.createElement('div'); const statusClass = `task-${todo.status.replace('_', '-')}`; - card.className = `ralph-task-card ${statusClass}`; + const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : ''; + card.className = `ralph-task-card ${statusClass} ${priorityClass}`.trim(); const iconSpan = document.createElement('span'); iconSpan.className = 'ralph-task-icon'; iconSpan.textContent = this.getRalphTaskIcon(todo.status); + card.appendChild(iconSpan); + + // Add priority badge if present + if (todo.priority) { + const prioritySpan = document.createElement('span'); + prioritySpan.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`; + prioritySpan.textContent = todo.priority; + card.appendChild(prioritySpan); + } const contentSpan = document.createElement('span'); contentSpan.className = 'ralph-task-content'; contentSpan.textContent = todo.content; - - card.appendChild(iconSpan); card.appendChild(contentSpan); + fragment.appendChild(card); }); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 2a794337..d90ec3df 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -620,6 +620,17 @@ body { font-size: 0.7rem; } +.respawn-state.respawn-blocked { + background: rgba(244, 67, 54, 0.3); + color: #f44336; + animation: respawn-blocked-pulse 1.5s ease-in-out infinite; +} + +@keyframes respawn-blocked-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + /* Respawn spacer removed - using flex layout */ .respawn-cycles { @@ -1305,6 +1316,40 @@ body { color: var(--text-dim); } +/* Priority badges */ +.ralph-task-priority { + font-size: 0.55rem; + font-weight: 700; + padding: 0.1rem 0.25rem; + border-radius: 2px; + flex-shrink: 0; + text-transform: uppercase; +} + +.ralph-task-priority.priority-p0 { + background: rgba(244, 67, 54, 0.3); + color: #f44336; +} + +.ralph-task-priority.priority-p1 { + background: rgba(255, 193, 7, 0.3); + color: #ffc107; +} + +.ralph-task-priority.priority-p2 { + background: rgba(33, 150, 243, 0.2); + color: #2196f3; +} + +/* Priority-based card highlighting */ +.ralph-task-card.task-priority-p0 { + border-left-color: #f44336; +} + +.ralph-task-card.task-priority-p1:not(.task-completed) { + border-left-color: #ffc107; +} + /* Empty state */ .ralph-state-empty { color: var(--text-muted); diff --git a/src/web/server.ts b/src/web/server.ts index 73273d87..2459bf90 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2532,6 +2532,13 @@ export class WebServer extends EventEmitter { this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber }); }); + controller.on('respawnBlocked', (data: { reason: string; details: string }) => { + this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details }); + // Track in run summary (lazy lookup) + const tracker = getTracker(); + if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details); + }); + controller.on('stepSent', (step: string, input: string) => { this.broadcast('respawn:stepSent', { sessionId, step, input }); });