mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat: add Run Summary feature for session activity tracking
Implements a "what happened while you were away" view for sessions: - Track significant events: session start/stop, respawn cycles, state changes, idle/working transitions, token milestones, auto-compact/clear, Ralph completions, AI check results, hook events, errors/warnings - Stats dashboard: respawn cycles, peak tokens, active/idle time, issue counts - Timeline view with color-coded severity and emoji icons - Filter events by type (all/errors/warnings/respawn/idle) - State stuck detection (warns when state unchanged for 10+ minutes) Click the chart icon on any session tab to open the Run Summary modal. Files: - src/run-summary.ts: RunSummaryTracker class with event tracking - src/types.ts: RunSummary, RunSummaryEvent types - src/web/server.ts: Integration with session/respawn events, API endpoint - src/web/public/: Modal UI, timeline rendering, filter controls - docs/run-summary-plan.md: Implementation plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+175
-4
@@ -2,6 +2,14 @@
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 5000;
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// 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.
|
||||
const DEC_SYNC_START = '\x1b[?2026h';
|
||||
const DEC_SYNC_END = '\x1b[?2026l';
|
||||
|
||||
// Notification Manager - Multi-layer browser notification system
|
||||
class NotificationManager {
|
||||
constructor(app) {
|
||||
@@ -643,9 +651,9 @@ class ClaudemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// For small buffers, write directly
|
||||
// For small buffers, write directly with DEC 2026 sync for atomic render
|
||||
if (buffer.length <= chunkSize) {
|
||||
this.terminal.write(buffer);
|
||||
this.terminal.write(DEC_SYNC_START + buffer + DEC_SYNC_END);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
@@ -659,7 +667,8 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
const chunk = buffer.slice(offset, offset + chunkSize);
|
||||
this.terminal.write(chunk);
|
||||
// Wrap each chunk with DEC 2026 sync markers for atomic render per frame
|
||||
this.terminal.write(DEC_SYNC_START + chunk + DEC_SYNC_END);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
@@ -807,7 +816,8 @@ class ClaudemanApp {
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
if (termData.terminalBuffer) {
|
||||
this.terminal.write(termData.terminalBuffer);
|
||||
// Wrap with DEC 2026 sync markers for atomic render (prevents flicker)
|
||||
this.terminal.write(DEC_SYNC_START + termData.terminalBuffer + DEC_SYNC_END);
|
||||
}
|
||||
|
||||
// Send resize to ensure proper dimensions
|
||||
@@ -1600,6 +1610,7 @@ class ClaudemanApp {
|
||||
${mode === 'shell' ? '<span class="tab-mode shell">sh</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</span>
|
||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()">${taskStats.running}</span>` : ''}
|
||||
<span class="tab-summary" onclick="event.stopPropagation(); app.openRunSummary('${id}')" title="Run Summary">📊</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${id}')" title="Session options">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')">×</span>
|
||||
</div>`);
|
||||
@@ -2926,6 +2937,166 @@ class ClaudemanApp {
|
||||
document.getElementById('sessionOptionsModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// ========== Run Summary Modal ==========
|
||||
|
||||
async openRunSummary(sessionId) {
|
||||
this.runSummarySessionId = sessionId;
|
||||
this.runSummaryFilter = 'all';
|
||||
|
||||
// Show modal
|
||||
document.getElementById('runSummaryModal').classList.add('active');
|
||||
|
||||
// Reset filter buttons
|
||||
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||||
});
|
||||
|
||||
// Load summary data
|
||||
await this.loadRunSummary(sessionId);
|
||||
}
|
||||
|
||||
closeRunSummary() {
|
||||
this.runSummarySessionId = null;
|
||||
document.getElementById('runSummaryModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async loadRunSummary(sessionId) {
|
||||
const timeline = document.getElementById('runSummaryTimeline');
|
||||
timeline.innerHTML = '<p class="empty-message">Loading summary...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}/run-summary`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
timeline.innerHTML = `<p class="empty-message">Failed to load summary: ${data.error}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.runSummaryData = data.summary;
|
||||
this.renderRunSummary();
|
||||
} catch (err) {
|
||||
console.error('Failed to load run summary:', err);
|
||||
timeline.innerHTML = '<p class="empty-message">Failed to load summary</p>';
|
||||
}
|
||||
}
|
||||
|
||||
renderRunSummary() {
|
||||
if (!this.runSummaryData) return;
|
||||
|
||||
const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData;
|
||||
|
||||
// Update stats
|
||||
document.getElementById('rsStat-cycles').textContent = stats.totalRespawnCycles;
|
||||
document.getElementById('rsStat-tokens').textContent = this.formatTokens(stats.peakTokens || stats.totalTokensUsed);
|
||||
document.getElementById('rsStat-active').textContent = this.formatDuration(stats.totalTimeActiveMs);
|
||||
document.getElementById('rsStat-issues').textContent = stats.errorCount + stats.warningCount;
|
||||
|
||||
// Update session info
|
||||
const duration = lastUpdatedAt - startedAt;
|
||||
document.getElementById('runSummarySessionInfo').textContent =
|
||||
`${sessionName || 'Session'} - ${this.formatDuration(duration)} total`;
|
||||
|
||||
// Filter and render events
|
||||
const filteredEvents = this.filterRunSummaryEvents(events);
|
||||
this.renderRunSummaryTimeline(filteredEvents);
|
||||
}
|
||||
|
||||
filterRunSummaryEvents(events) {
|
||||
if (this.runSummaryFilter === 'all') return events;
|
||||
|
||||
return events.filter(event => {
|
||||
switch (this.runSummaryFilter) {
|
||||
case 'errors': return event.severity === 'error';
|
||||
case 'warnings': return event.severity === 'warning' || event.severity === 'error';
|
||||
case 'respawn': return event.type.startsWith('respawn_') || event.type === 'state_stuck';
|
||||
case 'idle': return event.type === 'idle_detected' || event.type === 'working_detected';
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
filterRunSummary(filter) {
|
||||
this.runSummaryFilter = filter;
|
||||
|
||||
// Update active state on buttons
|
||||
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||||
});
|
||||
|
||||
this.renderRunSummary();
|
||||
}
|
||||
|
||||
renderRunSummaryTimeline(events) {
|
||||
const timeline = document.getElementById('runSummaryTimeline');
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
timeline.innerHTML = '<p class="empty-message">No events recorded yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Reverse to show most recent first
|
||||
const reversedEvents = [...events].reverse();
|
||||
|
||||
const html = reversedEvents.map(event => {
|
||||
const time = new Date(event.timestamp).toLocaleTimeString();
|
||||
const severityClass = `event-${event.severity}`;
|
||||
const icon = this.getEventIcon(event.type, event.severity);
|
||||
|
||||
return `
|
||||
<div class="timeline-event ${severityClass}">
|
||||
<div class="event-icon">${icon}</div>
|
||||
<div class="event-content">
|
||||
<div class="event-header">
|
||||
<span class="event-title">${this.escapeHtml(event.title)}</span>
|
||||
<span class="event-time">${time}</span>
|
||||
</div>
|
||||
${event.details ? `<div class="event-details">${this.escapeHtml(event.details)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
timeline.innerHTML = html;
|
||||
}
|
||||
|
||||
getEventIcon(type, severity) {
|
||||
if (severity === 'error') return '❌'; // Red X
|
||||
if (severity === 'warning') return '⚠'; // Warning triangle
|
||||
if (severity === 'success') return '✔'; // Checkmark
|
||||
|
||||
switch (type) {
|
||||
case 'session_started': return '🚀'; // Rocket
|
||||
case 'session_stopped': return '🛑'; // Stop sign
|
||||
case 'respawn_cycle_started': return '🔄'; // Cycle
|
||||
case 'respawn_cycle_completed': return '✅'; // Green check
|
||||
case 'respawn_state_change': return '➡'; // Arrow
|
||||
case 'token_milestone': return '💰'; // Money bag
|
||||
case 'idle_detected': return '💤'; // Zzz
|
||||
case 'working_detected': return '💻'; // Laptop
|
||||
case 'ai_check_result': return '🤖'; // Robot
|
||||
case 'hook_event': return '🔔'; // Bell
|
||||
default: return '•'; // Bullet
|
||||
}
|
||||
}
|
||||
|
||||
formatTokens(tokens) {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
|
||||
return String(tokens || 0);
|
||||
}
|
||||
|
||||
formatDuration(ms) {
|
||||
if (!ms || ms < 0) return '0s';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
saveSessionOptions() {
|
||||
// Session options are applied immediately via individual controls
|
||||
// This just closes the modal
|
||||
|
||||
@@ -469,6 +469,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Summary Modal -->
|
||||
<div class="modal" id="runSummaryModal">
|
||||
<div class="modal-backdrop" onclick="app.closeRunSummary()"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>Run Summary</h3>
|
||||
<button class="modal-close" onclick="app.closeRunSummary()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Stats Cards -->
|
||||
<div class="run-summary-stats" id="runSummaryStats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="rsStat-cycles">0</div>
|
||||
<div class="stat-label">Respawn Cycles</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="rsStat-tokens">0</div>
|
||||
<div class="stat-label">Tokens Used</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="rsStat-active">0m</div>
|
||||
<div class="stat-label">Active Time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="rsStat-issues">0</div>
|
||||
<div class="stat-label">Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="run-summary-filters">
|
||||
<button class="filter-btn active" data-filter="all" onclick="app.filterRunSummary('all')">All</button>
|
||||
<button class="filter-btn" data-filter="errors" onclick="app.filterRunSummary('errors')">Errors</button>
|
||||
<button class="filter-btn" data-filter="warnings" onclick="app.filterRunSummary('warnings')">Warnings</button>
|
||||
<button class="filter-btn" data-filter="respawn" onclick="app.filterRunSummary('respawn')">Respawn</button>
|
||||
<button class="filter-btn" data-filter="idle" onclick="app.filterRunSummary('idle')">Idle/Working</button>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="run-summary-timeline" id="runSummaryTimeline">
|
||||
<p class="empty-message">Loading summary...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="run-summary-session-info" id="runSummarySessionInfo"></span>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.closeRunSummary()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Session Confirmation Modal -->
|
||||
<div class="modal" id="closeConfirmModal">
|
||||
<div class="modal-backdrop" onclick="app.cancelCloseSession()"></div>
|
||||
|
||||
@@ -1960,6 +1960,151 @@ body {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* Run Summary Modal */
|
||||
.run-summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.run-summary-stats .stat-card {
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.run-summary-stats .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.run-summary-stats .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.run-summary-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.run-summary-timeline {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-event {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--border);
|
||||
}
|
||||
|
||||
.timeline-event.event-success {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
|
||||
.timeline-event.event-warning {
|
||||
border-left-color: var(--yellow);
|
||||
}
|
||||
|
||||
.timeline-event.event-error {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
|
||||
.timeline-event.event-info {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-details {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.run-summary-session-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Tab summary icon */
|
||||
.tab-summary {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: opacity 0.15s;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.session-tab:hover .tab-summary {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.session-tab:hover .tab-summary:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-warning-text {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
|
||||
+92
-1
@@ -31,6 +31,7 @@ import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type Subagen
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createRequire } from 'node:module';
|
||||
import { RunSummaryTracker } from '../run-summary.js';
|
||||
|
||||
// Load version from package.json
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -77,6 +78,13 @@ const TERMINAL_BATCH_INTERVAL = 16;
|
||||
const OUTPUT_BATCH_INTERVAL = 50;
|
||||
// Batch task:updated events for 100ms
|
||||
const TASK_UPDATE_BATCH_INTERVAL = 100;
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// When terminal supports this, it buffers all output between start/end markers
|
||||
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
|
||||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
|
||||
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
|
||||
// State update debounce interval (batch expensive toDetailedState() calls)
|
||||
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
|
||||
// Scheduled runs cleanup interval (check every 5 minutes)
|
||||
@@ -234,6 +242,7 @@ export class WebServer extends EventEmitter {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private respawnControllers: Map<string, RespawnController> = new Map();
|
||||
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
|
||||
private runSummaryTrackers: Map<string, RunSummaryTracker> = new Map();
|
||||
private transcriptWatchers: Map<string, TranscriptWatcher> = new Map();
|
||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||
private sseClients: Set<FastifyReply> = new Set();
|
||||
@@ -533,6 +542,29 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
});
|
||||
|
||||
// Get run summary for a session (what happened while you were away)
|
||||
this.app.get('/api/sessions/:id/run-summary', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
const tracker = this.runSummaryTrackers.get(id);
|
||||
if (!tracker) {
|
||||
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
||||
const newTracker = new RunSummaryTracker(id, session.name);
|
||||
this.runSummaryTrackers.set(id, newTracker);
|
||||
return { success: true, summary: newTracker.getSummary() };
|
||||
}
|
||||
|
||||
// Update session name in case it changed
|
||||
tracker.setSessionName(session.name);
|
||||
|
||||
return { success: true, summary: tracker.getSummary() };
|
||||
});
|
||||
|
||||
// Configure Ralph (Ralph Wiggum) settings
|
||||
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -1726,6 +1758,13 @@ export class WebServer extends EventEmitter {
|
||||
// Sanitize forwarded data: only include known safe fields, limit size
|
||||
const safeData = sanitizeHookData(data);
|
||||
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
||||
|
||||
// Track in run summary
|
||||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker) {
|
||||
summaryTracker.recordHookEvent(event, safeData);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
@@ -1929,6 +1968,14 @@ export class WebServer extends EventEmitter {
|
||||
// Stop transcript watcher
|
||||
this.stopTranscriptWatcher(sessionId);
|
||||
|
||||
// Stop and remove run summary tracker
|
||||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker) {
|
||||
summaryTracker.recordSessionStopped();
|
||||
summaryTracker.stop();
|
||||
this.runSummaryTrackers.delete(sessionId);
|
||||
}
|
||||
|
||||
// Clear batches and pending state updates
|
||||
this.terminalBatches.delete(sessionId);
|
||||
this.outputBatches.delete(sessionId);
|
||||
@@ -1985,6 +2032,11 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private setupSessionListeners(session: Session): void {
|
||||
// Create run summary tracker for this session
|
||||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||||
this.runSummaryTrackers.set(session.id, summaryTracker);
|
||||
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
|
||||
|
||||
session.on('output', (data) => {
|
||||
// Use batching for better performance at high throughput
|
||||
this.batchOutputData(session.id, data);
|
||||
@@ -2006,6 +2058,9 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
session.on('error', (error) => {
|
||||
this.broadcast('session:error', { id: session.id, error });
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordError('Session error', String(error));
|
||||
});
|
||||
|
||||
session.on('completion', (result, cost) => {
|
||||
@@ -2039,12 +2094,24 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
session.on('working', () => {
|
||||
this.broadcast('session:working', { id: session.id });
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
}
|
||||
});
|
||||
|
||||
session.on('idle', () => {
|
||||
this.broadcast('session:idle', { id: session.id });
|
||||
// Use debounced state update (idle can fire frequently)
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
}
|
||||
});
|
||||
|
||||
// Background task events - use debounced state updates to reduce serialization overhead
|
||||
@@ -2071,11 +2138,17 @@ export class WebServer extends EventEmitter {
|
||||
session.on('autoClear', (data: { tokens: number; threshold: number }) => {
|
||||
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
|
||||
});
|
||||
|
||||
session.on('autoCompact', (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||||
this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
});
|
||||
|
||||
// Ralph tracking events
|
||||
@@ -2093,13 +2166,20 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
session.on('ralphCompletionDetected', (phrase: string) => {
|
||||
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordRalphCompletion(phrase);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||||
|
||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
||||
// Track in run summary
|
||||
if (summaryTracker) summaryTracker.recordStateChange(state, `${prevState} → ${state}`);
|
||||
});
|
||||
|
||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||
@@ -2132,10 +2212,14 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
this.broadcast('respawn:aiCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs });
|
||||
// Track in run summary
|
||||
if (summaryTracker) summaryTracker.recordAiCheckResult(result.verdict);
|
||||
});
|
||||
|
||||
controller.on('aiCheckFailed', (error: string) => {
|
||||
this.broadcast('respawn:aiCheckFailed', { sessionId, error });
|
||||
// Track in run summary
|
||||
if (summaryTracker) summaryTracker.recordError('AI check failed', error);
|
||||
});
|
||||
|
||||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||
@@ -2177,6 +2261,8 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
controller.on('error', (error: Error) => {
|
||||
this.broadcast('respawn:error', { sessionId, error: error.message });
|
||||
// Track in run summary
|
||||
if (summaryTracker) summaryTracker.recordError('Respawn error', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2609,7 +2695,12 @@ export class WebServer extends EventEmitter {
|
||||
private flushTerminalBatches(): void {
|
||||
for (const [sessionId, data] of this.terminalBatches) {
|
||||
if (data.length > 0) {
|
||||
this.broadcast('session:terminal', { id: sessionId, data });
|
||||
// Wrap with DEC mode 2026 synchronized output markers
|
||||
// Terminal buffers all output between markers and renders atomically,
|
||||
// eliminating partial-frame flicker from Ink's full-screen redraws.
|
||||
// Unsupported terminals ignore these sequences harmlessly.
|
||||
const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
|
||||
this.broadcast('session:terminal', { id: sessionId, data: syncData });
|
||||
}
|
||||
}
|
||||
this.terminalBatches.clear();
|
||||
|
||||
Reference in New Issue
Block a user