feat: add respawn controller verbosity with countdown timers and bug fixes

- Fix "enable twice" bug: add enabled:true to getModalRespawnConfig()
- Add timer tracking with countdown display in respawn banner
- New events: timerStarted, timerCancelled, timerCompleted, actionLog
- Show active timers with progress bars and recent action log
- Skip remaining timeouts after AI check returns IDLE/WORKING verdict
- Enhanced DetectionStatus with activeTimers, recentActions, currentPhase

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 03:57:44 +01:00
co-authored by Claude Opus 4.5
parent ae4c445601
commit d19d6ceddd
5 changed files with 721 additions and 167 deletions
+171 -1
View File
@@ -361,6 +361,9 @@ class ClaudemanApp {
this.activeSessionId = null;
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20)
this.timerCountdownInterval = null; // Interval for updating countdown display
this.terminalBuffers = new Map(); // Store terminal content per session
this.editingSessionId = null; // Session being edited in options modal
this.pendingCloseSessionId = null; // Session pending close confirmation
@@ -977,7 +980,7 @@ class ClaudemanApp {
}
});
// Respawn timer events
// Respawn run timer events (timed respawn runs)
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
const data = JSON.parse(e.data);
this.respawnTimers[data.sessionId] = {
@@ -990,6 +993,60 @@ class ClaudemanApp {
}
});
// Respawn controller countdown timer events (internal timers)
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
const data = JSON.parse(e.data);
// This may fire for both run timers and controller timers - check for timer object
if (data.timer) {
const { sessionId, timer } = data;
if (!this.respawnCountdownTimers[sessionId]) {
this.respawnCountdownTimers[sessionId] = {};
}
this.respawnCountdownTimers[sessionId][timer.name] = {
endsAt: timer.endsAt,
totalMs: timer.durationMs,
reason: timer.reason
};
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
this.startCountdownInterval();
}
}
});
this.eventSource.addEventListener('respawn:timerCancelled', (e) => {
const data = JSON.parse(e.data);
const { sessionId, timerName, reason } = data;
if (this.respawnCountdownTimers[sessionId]) {
delete this.respawnCountdownTimers[sessionId][timerName];
}
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
// Add to action log
this.addActionLogEntry(sessionId, { type: 'timer-cancel', detail: `${timerName}${reason ? ': ' + reason : ''}`, timestamp: Date.now() });
}
});
this.eventSource.addEventListener('respawn:timerCompleted', (e) => {
const data = JSON.parse(e.data);
const { sessionId, timerName } = data;
if (this.respawnCountdownTimers[sessionId]) {
delete this.respawnCountdownTimers[sessionId][timerName];
}
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
}
});
this.eventSource.addEventListener('respawn:actionLog', (e) => {
const data = JSON.parse(e.data);
const { sessionId, action } = data;
this.addActionLogEntry(sessionId, action);
if (sessionId === this.activeSessionId) {
this.updateActionLogDisplay();
}
});
// Auto-clear event
this.eventSource.addEventListener('session:autoClear', (e) => {
const data = JSON.parse(e.data);
@@ -1468,8 +1525,15 @@ class ClaudemanApp {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
// Update countdown timers and action log for this session
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
}
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
}
// Update task panel if open
@@ -1501,6 +1565,7 @@ class ClaudemanApp {
this.sessions.delete(sessionId);
this.terminalBuffers.delete(sessionId);
this.ralphStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
if (this.activeSessionId === sessionId) {
this.activeSessionId = null;
@@ -2011,11 +2076,112 @@ class ClaudemanApp {
}
}
// ========== Countdown Timer Display Methods ==========
addActionLogEntry(sessionId, action) {
if (!this.respawnActionLogs[sessionId]) {
this.respawnActionLogs[sessionId] = [];
}
this.respawnActionLogs[sessionId].unshift(action);
if (this.respawnActionLogs[sessionId].length > 20) {
this.respawnActionLogs[sessionId].pop();
}
}
startCountdownInterval() {
if (this.timerCountdownInterval) return;
this.timerCountdownInterval = setInterval(() => {
if (this.activeSessionId && this.respawnCountdownTimers[this.activeSessionId]) {
this.updateCountdownTimerDisplay();
}
}, 100);
}
stopCountdownInterval() {
if (this.timerCountdownInterval) {
clearInterval(this.timerCountdownInterval);
this.timerCountdownInterval = null;
}
}
updateCountdownTimerDisplay() {
const timersRow = this.$('respawnTimersRow');
const timersContainer = this.$('respawnCountdownTimers');
if (!timersRow || !timersContainer) return;
const timers = this.respawnCountdownTimers[this.activeSessionId];
if (!timers || Object.keys(timers).length === 0) {
timersRow.style.display = 'none';
return;
}
timersRow.style.display = '';
const now = Date.now();
let html = '';
for (const [name, timer] of Object.entries(timers)) {
const remainingMs = Math.max(0, timer.endsAt - now);
const remainingSec = (remainingMs / 1000).toFixed(1);
const percent = Math.max(0, Math.min(100, (remainingMs / timer.totalMs) * 100));
// Format timer name for display (replace hyphens with spaces, capitalize first letter)
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
html += `<div class="respawn-countdown-timer" title="${timer.reason || ''}">
<span class="timer-name">${displayName}</span>
<span class="timer-value">${remainingSec}s</span>
<div class="respawn-timer-bar">
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
</div>
</div>`;
}
timersContainer.innerHTML = html;
}
updateActionLogDisplay() {
const logContainer = this.$('respawnActionLog');
if (!logContainer) return;
const actions = this.respawnActionLogs[this.activeSessionId];
if (!actions || actions.length === 0) {
logContainer.innerHTML = '<span class="action-empty">No recent actions</span>';
return;
}
let html = '';
for (const action of actions.slice(0, 10)) {
const time = new Date(action.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
html += `<div class="respawn-action-entry">
<span class="action-time">${time}</span>
<span class="action-type">[${action.type}]</span>
<span class="action-detail">${action.detail}</span>
</div>`;
}
logContainer.innerHTML = html;
}
clearCountdownTimers(sessionId) {
delete this.respawnCountdownTimers[sessionId];
delete this.respawnActionLogs[sessionId];
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
}
}
async stopRespawn() {
if (!this.activeSessionId) return;
try {
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
delete this.respawnTimers[this.activeSessionId];
this.clearCountdownTimers(this.activeSessionId);
} catch (err) {
this.showToast('Failed to stop respawn', 'error');
}
@@ -2042,6 +2208,9 @@ class ClaudemanApp {
this.terminalBuffers.clear();
this.activeSessionId = null;
this.respawnStatus = {};
this.respawnCountdownTimers = {};
this.respawnActionLogs = {};
this.stopCountdownInterval();
this.hideRespawnBanner();
this.renderSessionTabs();
this.terminal.clear();
@@ -2397,6 +2566,7 @@ class ClaudemanApp {
return {
respawnConfig: {
enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden
updatePrompt,
sendClear,
sendInit,
+23 -13
View File
@@ -67,19 +67,29 @@
<!-- Respawn Banner -->
<div class="respawn-banner" id="respawnBanner" style="display: none;">
<span class="respawn-indicator">&#x21BB;</span>
<span class="respawn-state" id="respawnState">Watching</span>
<span class="respawn-detection" id="respawnDetection">
<span class="detection-status" id="detectionStatus"></span>
<span class="detection-waiting" id="detectionWaiting"></span>
<span class="detection-confidence" id="detectionConfidence"></span>
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
</span>
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
<span class="respawn-step" id="respawnStep"></span>
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
<div class="respawn-banner-row">
<span class="respawn-indicator">&#x21BB;</span>
<span class="respawn-state" id="respawnState">Watching</span>
<span class="respawn-detection" id="respawnDetection">
<span class="detection-status" id="detectionStatus"></span>
<span class="detection-waiting" id="detectionWaiting"></span>
<span class="detection-confidence" id="detectionConfidence"></span>
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
</span>
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
<span class="respawn-step" id="respawnStep"></span>
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
</div>
<div class="respawn-timers-row" id="respawnTimersRow" style="display: none;">
<div class="respawn-countdown-timers" id="respawnCountdownTimers">
<!-- Dynamically populated countdown timers -->
</div>
<div class="respawn-action-log" id="respawnActionLog">
<!-- Recent actions scrolling list -->
</div>
</div>
</div>
<!-- Ralph / Todo Tracker Panel -->
+99 -2
View File
@@ -454,8 +454,7 @@ body {
/* Respawn Banner - Compact */
.respawn-banner {
display: flex;
align-items: center;
gap: 0.75rem;
flex-direction: column;
padding: 0.35rem 1rem;
background: linear-gradient(90deg, #1e3a1e, #132513);
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
@@ -463,6 +462,104 @@ body {
flex-shrink: 0;
}
.respawn-banner-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.respawn-timers-row {
display: flex;
align-items: flex-start;
gap: 1rem;
padding-top: 0.35rem;
border-top: 1px solid rgba(34, 197, 94, 0.15);
margin-top: 0.35rem;
}
.respawn-countdown-timers {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.respawn-countdown-timer {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(0, 0, 0, 0.35);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.respawn-countdown-timer .timer-name {
color: var(--text-dim);
}
.respawn-countdown-timer .timer-value {
color: var(--green);
font-weight: 500;
min-width: 2.5em;
text-align: right;
}
.respawn-timer-bar {
width: 40px;
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.respawn-timer-progress {
height: 100%;
background: var(--green);
transition: width 0.1s linear;
}
.respawn-action-log {
flex: 1;
max-height: 60px;
overflow-y: auto;
font-size: 0.65rem;
opacity: 0.75;
font-family: 'SF Mono', Monaco, monospace;
color: var(--text-dim);
}
.respawn-action-log::-webkit-scrollbar {
width: 4px;
}
.respawn-action-log::-webkit-scrollbar-thumb {
background: rgba(34, 197, 94, 0.3);
border-radius: 2px;
}
.respawn-action-entry {
padding: 0.1rem 0;
border-bottom: 1px solid rgba(34, 197, 94, 0.1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.respawn-action-entry .action-time {
color: var(--text-muted);
margin-right: 0.4rem;
}
.respawn-action-entry .action-type {
color: var(--green);
margin-right: 0.4rem;
}
.respawn-action-entry .action-detail {
color: var(--text-dim);
}
.respawn-indicator {
color: var(--green);
animation: spin 2s linear infinite;
+17
View File
@@ -1866,6 +1866,23 @@ export class WebServer extends EventEmitter {
this.broadcast('respawn:planCheckFailed', { sessionId, error });
});
// Timer tracking events for UI countdown display
controller.on('timerStarted', (timer) => {
this.broadcast('respawn:timerStarted', { sessionId, timer });
});
controller.on('timerCancelled', (timerName, reason) => {
this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason });
});
controller.on('timerCompleted', (timerName) => {
this.broadcast('respawn:timerCompleted', { sessionId, timerName });
});
controller.on('actionLog', (action) => {
this.broadcast('respawn:actionLog', { sessionId, action });
});
controller.on('log', (message: string) => {
this.broadcast('respawn:log', { sessionId, message });
});