ui: improve respawn controller layout and filter action log noise

- Stack timers row vertically (timers on top, action log below)
- Reduce action log height to 60px since it's now full width
- Filter action log to show only important entries:
  - Commands sent to console
  - Plan-check with action taken
  - Step completions
- Skip timer starts/cancels, detection updates, ai-check status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 19:59:42 +01:00
co-authored by Claude Opus 4.5
parent c6b16bf815
commit 40df0748c5
15 changed files with 2931 additions and 16 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i;
* Examples: "Iteration 5/50", "[5/50]", "iteration #5", "iter. 3 of 10"
* Capture groups: (1,2) for "Iteration X/Y" format, (3,4) for "[X/Y]" format
*/
const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*[\/of]\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
/**
* Matches Ralph loop start command or announcement
+34
View File
@@ -667,6 +667,10 @@ export class RespawnController extends EventEmitter {
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<RespawnConfig>;
this.config = { ...DEFAULT_CONFIG, ...filteredConfig };
// Validate configuration values
this.validateConfig();
this.aiChecker = new AiIdleChecker(session.id, {
enabled: this.config.aiIdleCheckEnabled,
model: this.config.aiIdleCheckModel,
@@ -685,6 +689,36 @@ export class RespawnController extends EventEmitter {
this.setupPlanCheckerListeners();
}
/**
* Validate configuration values and reset invalid ones to defaults.
* Ensures timeouts are positive and logically consistent.
*/
private validateConfig(): void {
const c = this.config;
// Ensure timeouts are positive
if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs;
if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs;
if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs;
if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs;
if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs;
// Ensure completion confirm doesn't exceed no-output timeout
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
c.completionConfirmMs = c.noOutputTimeoutMs;
}
// Ensure AI check timeouts are positive
if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
// Ensure plan check timeouts are positive
if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
}
/** Wire up AI checker events to controller events */
private setupAiCheckerListeners(): void {
this.aiChecker.on('log', (message: string) => {
+6 -1
View File
@@ -61,7 +61,12 @@ const LINE_BUFFER_FLUSH_INTERVAL = 100;
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
// Pre-compiled regex patterns for performance (avoid re-compilation on each call)
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
// Comprehensive ANSI escape pattern:
// - SGR (colors/styles): ESC [ params m
// - CSI sequences (cursor, scroll, etc.): ESC [ params letter
// - OSC sequences (title, etc.): ESC ] ... BEL or ESC ] ... ST
// - Single-char escapes: ESC = or ESC >
const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
// ============================================================================
+23
View File
@@ -227,6 +227,7 @@ export class SpawnOrchestrator extends EventEmitter {
/**
* Cancel an agent by ID.
* Cascades cancellation to all child agents before cleaning up the parent.
*/
async cancelAgent(agentId: string, reason: string = 'Cancelled by parent'): Promise<void> {
const agent = this._agents.get(agentId);
@@ -241,6 +242,28 @@ export class SpawnOrchestrator extends EventEmitter {
return;
}
// Cancel all child agents first (cascade)
// Child agents have their parentSessionId set to this agent's sessionId
if (agent.sessionId) {
for (const [childId, childAgent] of this._agents) {
if (childAgent.parentSessionId === agent.sessionId && childAgent.status !== 'cancelled') {
await this.cancelAgent(childId, `Parent ${agentId} cancelled`);
}
}
}
// Also remove any queued tasks that depend on this agent's session
if (agent.sessionId) {
const queuedChildren = this._queue.filter(t => t.parentSessionId === agent.sessionId);
for (const task of queuedChildren) {
const idx = this._queue.indexOf(task);
if (idx >= 0) {
this._queue.splice(idx, 1);
this.emit('cancelled', { agentId: task.spec.agentId, reason: `Parent ${agentId} cancelled` });
}
}
}
agent.status = 'cancelled';
this.emit('cancelled', { agentId, reason });
+52 -1
View File
@@ -54,9 +54,16 @@ export class TaskQueue extends EventEmitter {
}
}
/** Adds a new task to the queue. */
/**
* Adds a new task to the queue.
* @throws Error if the task's dependencies would create a circular dependency
*/
addTask(options: CreateTaskOptions): Task {
const task = new Task(options);
// Validate dependencies before adding to prevent circular dependency deadlocks
if (options.dependencies && options.dependencies.length > 0) {
this.validateDependencies(task.id, options.dependencies);
}
this.tasks.set(task.id, task);
this.store.setTask(task.id, task.toState());
this.emit('taskAdded', task);
@@ -151,6 +158,50 @@ export class TaskQueue extends EventEmitter {
return true;
}
/**
* Detect if adding a dependency would create a cycle.
* Uses DFS to check if there's a path from depId back to taskId.
*
* @param taskId - The task that would have the new dependency
* @param depId - The dependency being added
* @param visited - Set of already visited nodes (for DFS)
* @returns true if adding this dependency would create a cycle
*/
private wouldCreateCycle(taskId: string, depId: string, visited: Set<string> = new Set()): boolean {
// Direct self-reference
if (depId === taskId) return true;
// Already visited this node in current path
if (visited.has(depId)) return false;
visited.add(depId);
const depTask = this.tasks.get(depId);
if (!depTask) return false;
// Recursively check all dependencies of the dependency
for (const nextDep of depTask.dependencies) {
if (this.wouldCreateCycle(taskId, nextDep, visited)) {
return true;
}
}
return false;
}
/**
* Validates that a set of dependencies won't create cycles for a given task.
* Throws an error if a circular dependency is detected.
*
* @param taskId - The task ID that will have these dependencies
* @param dependencies - Array of dependency task IDs to validate
* @throws Error if a circular dependency would be created
*/
private validateDependencies(taskId: string, dependencies: string[]): void {
for (const depId of dependencies) {
if (this.wouldCreateCycle(taskId, depId)) {
throw new Error(`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`);
}
}
}
/** Gets all tasks assigned to a specific session. */
getTasksBySession(sessionId: string): Task[] {
return this.getAllTasks().filter((t) => t.assignedSessionId === sessionId);
+9
View File
@@ -2247,6 +2247,15 @@ class ClaudemanApp {
// ========== Countdown Timer Display Methods ==========
addActionLogEntry(sessionId, action) {
// Filter to important actions only
// Skip: timer starts/cancels, detection updates, ai-check status
// Keep: command (sent to console), plan-check (with action), step (completions)
if (action.type === 'timer' || action.type === 'timer-cancel') return;
if (action.type === 'detection') return;
if (action.type === 'ai-check') return;
if (action.type === 'plan-check' && !action.detail.includes('sending')) return;
if (action.type === 'step' && !action.detail.includes('completed')) return;
if (!this.respawnActionLogs[sessionId]) {
this.respawnActionLogs[sessionId] = [];
}
+5 -5
View File
@@ -470,8 +470,8 @@ body {
.respawn-timers-row {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-direction: column;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px solid rgba(34, 197, 94, 0.15);
margin-top: 0.35rem;
@@ -479,7 +479,7 @@ body {
.respawn-countdown-timers {
display: flex;
gap: 0.5rem;
gap: 0.4rem;
flex-wrap: wrap;
}
@@ -520,8 +520,8 @@ body {
}
.respawn-action-log {
flex: 1;
max-height: 100px;
width: 100%;
max-height: 60px;
overflow-y: auto;
font-size: 0.7rem;
opacity: 0.9;