From de5ee8643c5d0484cb5f21b72eaa37b9c513b32e Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 25 Jan 2026 22:09:45 +0100 Subject: [PATCH] feat: implement DEC mode 2026 synchronized output to reduce terminal flicker - Server wraps SSE terminal batches with DEC 2026 sync markers - Client extracts and strips markers, writes atomically via RAF batching - Native terminals (WezTerm, Kitty, etc.) get full sync support - xterm.js web gets application-layer sync via marker extraction Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- package.json | 2 +- respawn-fixes.md | 17 +++ respawn-issues.md | 13 ++ respawn-monitor.sh | 313 ++++++++++++++++++++++++++++++++++++++ src/web/public/app.js | 175 +++++++++++++++++++-- src/web/public/index.html | 9 +- src/web/public/styles.css | 19 +++ 8 files changed, 531 insertions(+), 19 deletions(-) create mode 100644 respawn-fixes.md create mode 100644 respawn-issues.md create mode 100755 respawn-monitor.sh diff --git a/CLAUDE.md b/CLAUDE.md index aaf23a4e..dca8c62b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1367 (must match `package.json`) +**Version**: 0.1368 (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 6ab2212d..56002fc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1367", + "version": "0.1368", "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/respawn-fixes.md b/respawn-fixes.md new file mode 100644 index 00000000..afdad37c --- /dev/null +++ b/respawn-fixes.md @@ -0,0 +1,17 @@ +# Suggested Fixes for Respawn Controller Issues + +**DO NOT EXECUTE** - Waiting for user review + +## Fixes + + +### Fix for Issue #1 +**Enable respawn controller** + +Via API: +```bash +curl -sk -X POST 'https://localhost:3000/api/sessions/5ecdf859-3605-42d8-b0a2-f8f0a1b5ff98/respawn/enable' +``` + +Or via web UI: Open session tab -> Respawn panel -> Enable + diff --git a/respawn-issues.md b/respawn-issues.md new file mode 100644 index 00000000..98491c03 --- /dev/null +++ b/respawn-issues.md @@ -0,0 +1,13 @@ +# Respawn Controller Issues - w3-reddit-analyse + +Monitoring started: Sun Jan 25 09:40:47 PM CET 2026 +Session ID: 5ecdf859-3605-42d8-b0a2-f8f0a1b5ff98 + +## Issues Detected + + +### Issue #1 [INFO] - 2026-01-25 21:40:48 +**Respawn controller not enabled** + +Respawn is currently disabled for this session. State: null + diff --git a/respawn-monitor.sh b/respawn-monitor.sh new file mode 100755 index 00000000..02b63f99 --- /dev/null +++ b/respawn-monitor.sh @@ -0,0 +1,313 @@ +#!/bin/bash + +# Respawn Controller Monitor for w3-reddit-analyse +# Monitors for 12 hours, logs issues and suggested fixes + +SESSION_ID="5ecdf859-3605-42d8-b0a2-f8f0a1b5ff98" +SESSION_NAME="w3-reddit-analyse" +ISSUES_FILE="/home/arkon/default/claudeman/respawn-issues.md" +FIXES_FILE="/home/arkon/default/claudeman/respawn-fixes.md" +LOG_FILE="/home/arkon/default/claudeman/respawn-monitor.log" +API_BASE="https://localhost:3000" +DURATION_HOURS=12 +POLL_INTERVAL=30 # seconds + +# Initialize files +cat > "$ISSUES_FILE" << 'EOF' +# Respawn Controller Issues - w3-reddit-analyse + +Monitoring started: $(date) +Session ID: 5ecdf859-3605-42d8-b0a2-f8f0a1b5ff98 + +## Issues Detected + +EOF +sed -i "s/\$(date)/$(date)/" "$ISSUES_FILE" + +cat > "$FIXES_FILE" << 'EOF' +# Suggested Fixes for Respawn Controller Issues + +**DO NOT EXECUTE** - Waiting for user review + +## Fixes + +EOF + +echo "=== Respawn Monitor Started: $(date) ===" > "$LOG_FILE" +echo "Session: $SESSION_NAME ($SESSION_ID)" >> "$LOG_FILE" +echo "Duration: $DURATION_HOURS hours" >> "$LOG_FILE" +echo "Poll interval: ${POLL_INTERVAL}s" >> "$LOG_FILE" +echo "" >> "$LOG_FILE" + +# Track state for anomaly detection +PREV_STATE="" +PREV_TOKENS=0 +STATE_STUCK_COUNT=0 +LAST_ACTIVITY_TIME=$(date +%s) +ISSUE_COUNT=0 + +log_issue() { + local severity="$1" + local title="$2" + local details="$3" + ISSUE_COUNT=$((ISSUE_COUNT + 1)) + + echo "" >> "$ISSUES_FILE" + echo "### Issue #$ISSUE_COUNT [$severity] - $(date '+%Y-%m-%d %H:%M:%S')" >> "$ISSUES_FILE" + echo "**$title**" >> "$ISSUES_FILE" + echo "" >> "$ISSUES_FILE" + echo "$details" >> "$ISSUES_FILE" + echo "" >> "$ISSUES_FILE" + + echo "[$(date '+%H:%M:%S')] ISSUE #$ISSUE_COUNT [$severity]: $title" >> "$LOG_FILE" +} + +log_fix() { + local issue_num="$1" + local fix_title="$2" + local fix_details="$3" + + echo "" >> "$FIXES_FILE" + echo "### Fix for Issue #$issue_num" >> "$FIXES_FILE" + echo "**$fix_title**" >> "$FIXES_FILE" + echo "" >> "$FIXES_FILE" + echo "$fix_details" >> "$FIXES_FILE" + echo "" >> "$FIXES_FILE" +} + +END_TIME=$(($(date +%s) + DURATION_HOURS * 3600)) + +while [ $(date +%s) -lt $END_TIME ]; do + TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + + # Fetch session status + RESPONSE=$(curl -sk "$API_BASE/api/sessions/$SESSION_ID" 2>/dev/null) + + if [ -z "$RESPONSE" ] || [ "$RESPONSE" = "null" ]; then + log_issue "CRITICAL" "API request failed or session not found" "Could not fetch session data. Server may be down or session deleted." + log_fix "$ISSUE_COUNT" "Check server status" "1. Check if claudeman-web is running: \`systemctl --user status claudeman-web\` +2. If down, restart: \`systemctl --user restart claudeman-web\` +3. Check server logs: \`journalctl --user -u claudeman-web -n 50\`" + sleep $POLL_INTERVAL + continue + fi + + # Parse response + STATUS=$(echo "$RESPONSE" | jq -r '.status // "unknown"') + RESPAWN_ENABLED=$(echo "$RESPONSE" | jq -r '.respawnEnabled // "null"') + RESPAWN_STATE=$(echo "$RESPONSE" | jq -r '.respawnState // "null"') + INPUT_TOKENS=$(echo "$RESPONSE" | jq -r '.inputTokens // 0') + OUTPUT_TOKENS=$(echo "$RESPONSE" | jq -r '.outputTokens // 0') + TOTAL_TOKENS=$((INPUT_TOKENS + OUTPUT_TOKENS)) + + # Fetch respawn controller details if enabled + if [ "$RESPAWN_ENABLED" = "true" ]; then + RESPAWN_DETAILS=$(curl -sk "$API_BASE/api/sessions/$SESSION_ID/respawn/status" 2>/dev/null) + RESPAWN_STATE=$(echo "$RESPAWN_DETAILS" | jq -r '.state // "unknown"') + DETECTION_STATUS=$(echo "$RESPAWN_DETAILS" | jq -r '.detectionStatus // {}') + IDLE_CONFIDENCE=$(echo "$DETECTION_STATUS" | jq -r '.idleConfidence // 0') + TIME_IN_STATE=$(echo "$RESPAWN_DETAILS" | jq -r '.timeInState // 0') + LAST_ERROR=$(echo "$RESPAWN_DETAILS" | jq -r '.lastError // "none"') + fi + + echo "[$TIMESTAMP] status=$STATUS respawn=$RESPAWN_ENABLED state=$RESPAWN_STATE tokens=$TOTAL_TOKENS" >> "$LOG_FILE" + + # Check 1: Respawn not enabled when it should be + if [ "$RESPAWN_ENABLED" = "null" ] || [ "$RESPAWN_ENABLED" = "false" ]; then + if [ -z "$RESPAWN_NOT_ENABLED_LOGGED" ]; then + log_issue "INFO" "Respawn controller not enabled" "Respawn is currently disabled for this session. State: $RESPAWN_ENABLED" + log_fix "$ISSUE_COUNT" "Enable respawn controller" "Via API: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/enable' +\`\`\` + +Or via web UI: Open session tab -> Respawn panel -> Enable" + RESPAWN_NOT_ENABLED_LOGGED=1 + fi + else + unset RESPAWN_NOT_ENABLED_LOGGED + + # Check 2: State stuck for too long + if [ "$RESPAWN_STATE" = "$PREV_STATE" ] && [ -n "$PREV_STATE" ]; then + STATE_STUCK_COUNT=$((STATE_STUCK_COUNT + 1)) + + # Stuck in same state for 10+ minutes (20 polls at 30s) + if [ $STATE_STUCK_COUNT -ge 20 ]; then + STUCK_MINS=$((STATE_STUCK_COUNT * POLL_INTERVAL / 60)) + log_issue "WARNING" "State stuck: $RESPAWN_STATE for ${STUCK_MINS}+ minutes" "The respawn controller has been in '$RESPAWN_STATE' state for over $STUCK_MINS minutes. +Time in state: ${TIME_IN_STATE}ms +Detection status: $DETECTION_STATUS" + + case "$RESPAWN_STATE" in + "watching") + log_fix "$ISSUE_COUNT" "Watching state stuck" "The controller is watching but not detecting idle. Possible causes: +1. Session is actively working (expected behavior) +2. Idle detection thresholds too high + +Check session activity: +\`\`\`bash +curl -sk '$API_BASE/api/sessions/$SESSION_ID' | jq '{status, lastActivityAt}' +\`\`\` + +If truly idle, try adjusting config: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/config' -H 'Content-Type: application/json' -d '{\"idleThresholdMs\": 30000}' +\`\`\`" + ;; + "confirming_idle") + log_fix "$ISSUE_COUNT" "Confirming idle stuck" "Stuck confirming idle state. The session may be oscillating between idle/working. + +Check idle confidence: +\`\`\`bash +curl -sk '$API_BASE/api/sessions/$SESSION_ID/respawn/status' | jq '.detectionStatus' +\`\`\` + +Consider increasing confirmation time or check for output patterns that reset detection." + ;; + "ai_checking") + log_fix "$ISSUE_COUNT" "AI check stuck" "AI idle check is taking too long or failing. + +Check AI checker status and logs: +\`\`\`bash +journalctl --user -u claudeman-web -n 100 | grep -i 'ai.*check\\|idle.*check' +\`\`\` + +The AI checker spawns a separate Claude process. It may be hanging. Consider: +1. Check for stuck Claude processes: \`ps aux | grep claude\` +2. Restart the respawn controller: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/stop' +sleep 2 +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/start' +\`\`\`" + ;; + "sending_"*|"waiting_"*) + log_fix "$ISSUE_COUNT" "Send/wait state stuck" "Stuck in $RESPAWN_STATE - the controller is trying to send a command but may not be receiving expected response. + +Check terminal output: +\`\`\`bash +curl -sk '$API_BASE/api/sessions/$SESSION_ID/terminal?tail=10000' | tail -100 +\`\`\` + +Try restarting respawn: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/stop' +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/start' +\`\`\`" + ;; + *) + log_fix "$ISSUE_COUNT" "Unknown stuck state" "State '$RESPAWN_STATE' is stuck. Review respawn-controller.ts for this state's expected behavior and transitions." + ;; + esac + STATE_STUCK_COUNT=0 + fi + else + STATE_STUCK_COUNT=0 + PREV_STATE="$RESPAWN_STATE" + fi + + # Check 3: Error in respawn controller + if [ "$LAST_ERROR" != "none" ] && [ "$LAST_ERROR" != "null" ] && [ -n "$LAST_ERROR" ]; then + log_issue "ERROR" "Respawn controller error" "Error reported: $LAST_ERROR" + log_fix "$ISSUE_COUNT" "Resolve respawn error" "Error: $LAST_ERROR + +General recovery steps: +1. Stop respawn: \`curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/stop'\` +2. Check session health: \`curl -sk '$API_BASE/api/sessions/$SESSION_ID' | jq .\` +3. Restart respawn: \`curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/start'\` + +If error persists, check server logs: +\`journalctl --user -u claudeman-web -n 200 | grep -i error\`" + fi + + # Check 4: Token count not progressing during active work + if [ "$STATUS" = "busy" ] && [ "$RESPAWN_STATE" = "watching" ]; then + if [ $TOTAL_TOKENS -eq $PREV_TOKENS ] && [ $PREV_TOKENS -gt 0 ]; then + TOKENS_STUCK_COUNT=$((TOKENS_STUCK_COUNT + 1)) + if [ $TOKENS_STUCK_COUNT -ge 10 ]; then # 5 minutes + log_issue "WARNING" "Tokens not increasing while busy" "Session status is 'busy' but token count hasn't changed in 5+ minutes. +Current: $TOTAL_TOKENS, Previous: $PREV_TOKENS +This could indicate a hung Claude process." + log_fix "$ISSUE_COUNT" "Check for hung session" "The session may be hung. Check: + +1. Session process status: +\`\`\`bash +screen -ls | grep reddit +\`\`\` + +2. Attach to screen and check: +\`\`\`bash +screen -r claudeman-w3-reddit-analyse +# Press Ctrl+A D to detach +\`\`\` + +3. If hung, restart the session via web UI or: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/stop' +# Then start a new session +\`\`\`" + TOKENS_STUCK_COUNT=0 + fi + else + TOKENS_STUCK_COUNT=0 + fi + fi + PREV_TOKENS=$TOTAL_TOKENS + + # Check 5: Rapid state cycling (flapping) + if [ "$RESPAWN_STATE" != "$PREV_STATE" ]; then + STATE_CHANGES=$((STATE_CHANGES + 1)) + fi + + # Check every 5 minutes for flapping + if [ $((STATE_CHANGES_CHECK_COUNT % 10)) -eq 0 ] && [ $STATE_CHANGES_CHECK_COUNT -gt 0 ]; then + if [ $STATE_CHANGES -gt 20 ]; then # More than 20 state changes in 5 mins + log_issue "WARNING" "State flapping detected" "Respawn controller changed states $STATE_CHANGES times in the last 5 minutes. This indicates instability." + log_fix "$ISSUE_COUNT" "Reduce state flapping" "The controller is cycling states too rapidly. This usually means: +1. Idle detection is too sensitive +2. Session output is oscillating + +Adjust detection thresholds: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/respawn/config' -H 'Content-Type: application/json' -d '{ + \"idleThresholdMs\": 60000, + \"completionConfirmMs\": 15000 +}' +\`\`\`" + fi + STATE_CHANGES=0 + fi + STATE_CHANGES_CHECK_COUNT=$((STATE_CHANGES_CHECK_COUNT + 1)) + + # Check 6: High token usage without respawn cycling + if [ $TOTAL_TOKENS -gt 180000 ]; then + log_issue "WARNING" "High token usage: $TOTAL_TOKENS" "Token count is very high. Auto-compact or respawn should have triggered by now." + log_fix "$ISSUE_COUNT" "Handle high token usage" "Tokens at $TOTAL_TOKENS. Consider: + +1. Manual compact: +\`\`\`bash +curl -sk -X POST '$API_BASE/api/sessions/$SESSION_ID/input' -H 'Content-Type: application/json' -d '{\"input\": \"/compact\\r\"}' +\`\`\` + +2. Check auto-compact settings: +\`\`\`bash +curl -sk '$API_BASE/api/sessions/$SESSION_ID' | jq '{autoCompactEnabled, autoCompactThreshold}' +\`\`\`" + fi + fi + + sleep $POLL_INTERVAL +done + +echo "" >> "$ISSUES_FILE" +echo "---" >> "$ISSUES_FILE" +echo "Monitoring completed: $(date)" >> "$ISSUES_FILE" +echo "Total issues detected: $ISSUE_COUNT" >> "$ISSUES_FILE" + +echo "" >> "$FIXES_FILE" +echo "---" >> "$FIXES_FILE" +echo "Monitoring completed: $(date)" >> "$FIXES_FILE" + +echo "" >> "$LOG_FILE" +echo "=== Monitoring Complete: $(date) ===" >> "$LOG_FILE" +echo "Total issues: $ISSUE_COUNT" >> "$LOG_FILE" diff --git a/src/web/public/app.js b/src/web/public/app.js index 81183152..c77995e1 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -6,10 +6,63 @@ const DEFAULT_SCROLLBACK = 5000; // Wrap terminal writes with these markers to prevent partial-frame flicker. // Terminal buffers all output between markers and renders atomically. // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal -// Unsupported terminals ignore these sequences harmlessly. +// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves. const DEC_SYNC_START = '\x1b[?2026h'; const DEC_SYNC_END = '\x1b[?2026l'; +/** + * Process data containing DEC 2026 sync markers. + * Strips markers and returns segments that should be written atomically. + * Each returned segment represents content between SYNC_START and SYNC_END. + * Content outside sync blocks is returned as-is. + * + * @param {string} data - Raw terminal data with potential sync markers + * @returns {string[]} - Array of content segments to write (markers stripped) + */ +function extractSyncSegments(data) { + const segments = []; + let remaining = data; + + while (remaining.length > 0) { + const startIdx = remaining.indexOf(DEC_SYNC_START); + + if (startIdx === -1) { + // No more sync blocks, return rest as-is + if (remaining.length > 0) { + segments.push(remaining); + } + break; + } + + // Content before sync block (if any) + if (startIdx > 0) { + segments.push(remaining.slice(0, startIdx)); + } + + // Find matching end marker + const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length); + const endIdx = afterStart.indexOf(DEC_SYNC_END); + + if (endIdx === -1) { + // No end marker found - sync block continues in next chunk + // Include the start marker so it can be handled when more data arrives + segments.push(remaining.slice(startIdx)); + break; + } + + // Extract synchronized content (without markers) + const syncContent = afterStart.slice(0, endIdx); + if (syncContent.length > 0) { + segments.push(syncContent); + } + + // Continue with content after end marker + remaining = afterStart.slice(endIdx + DEC_SYNC_END.length); + } + + return segments; +} + // Notification Manager - Multi-layer browser notification system class NotificationManager { constructor(app) { @@ -393,10 +446,11 @@ class ClaudemanApp { // Tab alert states: Map this.tabAlerts = new Map(); - // Terminal write batching + // Terminal write batching with DEC 2026 sync support this.pendingWrites = ''; this.writeFrameScheduled = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll + this.syncWaitTimeout = null; // Timeout for incomplete sync blocks // Render debouncing this.renderSessionTabsTimeout = null; @@ -620,23 +674,70 @@ class ClaudemanApp { if (!this.writeFrameScheduled) { this._wasAtBottomBeforeWrite = this.isTerminalAtBottom(); } + + // Accumulate raw data (may contain DEC 2026 markers) this.pendingWrites += data; + if (!this.writeFrameScheduled) { this.writeFrameScheduled = true; requestAnimationFrame(() => { if (this.pendingWrites && this.terminal) { - this.terminal.write(this.pendingWrites); - this.pendingWrites = ''; - // Sticky scroll: if user was at bottom, keep them there after new output - if (this._wasAtBottomBeforeWrite) { - this.terminal.scrollToBottom(); + // Check if we have an incomplete sync block (SYNC_START without SYNC_END) + const hasStart = this.pendingWrites.includes(DEC_SYNC_START); + const hasEnd = this.pendingWrites.includes(DEC_SYNC_END); + + if (hasStart && !hasEnd) { + // Incomplete sync block - wait for more data (up to 50ms max) + if (!this.syncWaitTimeout) { + this.syncWaitTimeout = setTimeout(() => { + this.syncWaitTimeout = null; + // Force flush after timeout to prevent stuck state + this.flushPendingWrites(); + }, 50); + } + this.writeFrameScheduled = false; + return; } + + // Clear any pending sync wait timeout + if (this.syncWaitTimeout) { + clearTimeout(this.syncWaitTimeout); + this.syncWaitTimeout = null; + } + + this.flushPendingWrites(); } this.writeFrameScheduled = false; }); } } + /** + * Flush pending writes to terminal, processing DEC 2026 sync markers. + * Strips markers and writes content atomically within a single frame. + */ + flushPendingWrites() { + if (!this.pendingWrites || !this.terminal) return; + + // Extract segments, stripping DEC 2026 markers + // This implements synchronized output for xterm.js which doesn't support DEC 2026 natively + const segments = extractSyncSegments(this.pendingWrites); + this.pendingWrites = ''; + + // Write all segments in a single batch (atomic within this frame) + // xterm.js internally batches multiple write() calls within same frame + for (const segment of segments) { + if (segment && !segment.startsWith(DEC_SYNC_START)) { + this.terminal.write(segment); + } + } + + // Sticky scroll: if user was at bottom, keep them there after new output + if (this._wasAtBottomBeforeWrite) { + this.terminal.scrollToBottom(); + } + } + /** * Write large buffer to terminal in chunks to avoid UI jank. * Uses requestAnimationFrame to spread work across frames. @@ -651,24 +752,29 @@ class ClaudemanApp { return; } - // For small buffers, write directly with DEC 2026 sync for atomic render - if (buffer.length <= chunkSize) { - this.terminal.write(DEC_SYNC_START + buffer + DEC_SYNC_END); + // Strip any DEC 2026 markers that might be in the buffer + // (from historical SSE data that was stored with markers) + const cleanBuffer = buffer + .replaceAll(DEC_SYNC_START, '') + .replaceAll(DEC_SYNC_END, ''); + + // For small buffers, write directly + if (cleanBuffer.length <= chunkSize) { + this.terminal.write(cleanBuffer); resolve(); return; } let offset = 0; const writeChunk = () => { - if (offset >= buffer.length) { + if (offset >= cleanBuffer.length) { // Wait one more frame for xterm to finish rendering before resolving requestAnimationFrame(() => resolve()); return; } - const chunk = buffer.slice(offset, offset + chunkSize); - // Wrap each chunk with DEC 2026 sync markers for atomic render per frame - this.terminal.write(DEC_SYNC_START + chunk + DEC_SYNC_END); + const chunk = cleanBuffer.slice(offset, offset + chunkSize); + this.terminal.write(chunk); offset += chunkSize; // Schedule next chunk on next frame @@ -816,8 +922,12 @@ class ClaudemanApp { this.terminal.clear(); this.terminal.reset(); if (termData.terminalBuffer) { - // Wrap with DEC 2026 sync markers for atomic render (prevents flicker) - this.terminal.write(DEC_SYNC_START + termData.terminalBuffer + DEC_SYNC_END); + // Strip any DEC 2026 markers and write raw content + // (markers don't help here - this is a static buffer reload, not live Ink redraws) + const cleanBuffer = termData.terminalBuffer + .replaceAll(DEC_SYNC_START, '') + .replaceAll(DEC_SYNC_END, ''); + this.terminal.write(cleanBuffer); } // Send resize to ensure proper dimensions @@ -2957,9 +3067,42 @@ class ClaudemanApp { closeRunSummary() { this.runSummarySessionId = null; + this.stopRunSummaryAutoRefresh(); document.getElementById('runSummaryModal').classList.remove('active'); } + async refreshRunSummary() { + if (!this.runSummarySessionId) return; + await this.loadRunSummary(this.runSummarySessionId); + } + + toggleRunSummaryAutoRefresh() { + const checkbox = document.getElementById('runSummaryAutoRefresh'); + if (checkbox.checked) { + this.startRunSummaryAutoRefresh(); + } else { + this.stopRunSummaryAutoRefresh(); + } + } + + startRunSummaryAutoRefresh() { + if (this.runSummaryAutoRefreshTimer) return; + this.runSummaryAutoRefreshTimer = setInterval(() => { + if (this.runSummarySessionId) { + this.loadRunSummary(this.runSummarySessionId); + } + }, 5000); // Refresh every 5 seconds + } + + stopRunSummaryAutoRefresh() { + if (this.runSummaryAutoRefreshTimer) { + clearInterval(this.runSummaryAutoRefreshTimer); + this.runSummaryAutoRefreshTimer = null; + } + const checkbox = document.getElementById('runSummaryAutoRefresh'); + if (checkbox) checkbox.checked = false; + } + async loadRunSummary(sessionId) { const timeline = document.getElementById('runSummaryTimeline'); timeline.innerHTML = '

Loading summary...

'; diff --git a/src/web/public/index.html b/src/web/public/index.html index 6067bd1f..124d6fd0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -514,7 +514,14 @@ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ac136488..342bc987 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2088,6 +2088,25 @@ body { color: var(--text-dim); } +.modal-footer-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.auto-refresh-label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8rem; + color: var(--text-dim); + cursor: pointer; +} + +.auto-refresh-label input[type="checkbox"] { + cursor: pointer; +} + /* Tab summary icon */ .tab-summary { opacity: 0;