From b811c8dc80c542109d973de785f4d5b2006535b7 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 25 Jan 2026 22:05:28 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 16 +- docs/run-summary-plan.md | 172 +++++++++++++++ package.json | 2 +- src/run-summary.ts | 445 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 115 ++++++++++ src/web/public/app.js | 179 ++++++++++++++- src/web/public/index.html | 50 +++++ src/web/public/styles.css | 145 +++++++++++++ src/web/server.ts | 93 +++++++- 9 files changed, 1210 insertions(+), 7 deletions(-) create mode 100644 docs/run-summary-plan.md create mode 100644 src/run-summary.ts diff --git a/CLAUDE.md b/CLAUDE.md index 302e28d6..aaf23a4e 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.1366 (must match `package.json`) +**Version**: 0.1367 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty @@ -222,6 +222,7 @@ claudeman reset # Reset all state | `src/ai-plan-checker.ts` | Spawns Claude to detect plan mode prompts for auto-accept | | `src/ralph-tracker.ts` | Detects `PHRASE`, todos, loop status | | `src/ralph-config.ts` | Parses `.claude/ralph-loop.local.md` and CLAUDE.md for Ralph config | +| `src/run-summary.ts` | Tracks session events for "what happened while away" summaries | **Spawn Protocol (Autonomous Agents):** | File | Purpose | @@ -443,6 +444,18 @@ Key events (see `app.js:handleSSEEvent()`): - `subagent:discovered`, `subagent:tool_call`, `subagent:progress`, `subagent:message`, `subagent:completed` - Claude Code background agents - `hook:idle_prompt`, `hook:permission_prompt`, `hook:elicitation_dialog`, `hook:stop` - Claude Code hooks +### Run Summary + +Per-session event tracking for "what happened while you were away" view. Click the chart icon (📊) on any session tab to view. + +**Tracked Events**: session start/stop, respawn cycles, state changes, idle/working transitions, token milestones (every 50k), auto-compact/clear, Ralph completions, AI check results, hook events, errors/warnings, state stuck warnings (>10min same state). + +**Stats**: respawn cycles, peak tokens, active time, idle time, error/warning counts. + +**Storage**: In-memory only (not persisted). Fresh tracker created per session; cleared when session is deleted. + +**Implementation**: `RunSummaryTracker` class in `src/run-summary.ts`, integrated via `setupSessionListeners()` and `setupRespawnListeners()` in `server.ts`. + ### Frontend (app.js) Vanilla JS + xterm.js. 60fps rendering: server batches terminal data every 16ms, client uses `requestAnimationFrame` to batch xterm.js writes. @@ -536,6 +549,7 @@ All routes defined in `server.ts:buildServer()`. Key endpoint groups: - `/api/spawn/*` - Agent lifecycle (list, status, result, messages, cancel, trigger) - `/api/subagents` - List/get/kill Claude Code background agents, get transcripts - `/api/sessions/:id/subagents` - Get subagents for a specific session's working directory +- `/api/sessions/:id/run-summary` - Get run summary (events, stats) for "what happened while away" - `/api/hook-event` - Claude Code hook callbacks (`{event, sessionId, data?}`) diff --git a/docs/run-summary-plan.md b/docs/run-summary-plan.md new file mode 100644 index 00000000..41a5f35a --- /dev/null +++ b/docs/run-summary-plan.md @@ -0,0 +1,172 @@ +# Run Summary Feature - Implementation Plan + +## Overview + +The Run Summary feature provides users with a consolidated view of what happened in their session while they were away. It tracks significant events, issues, and statistics, presenting them in an easy-to-digest format. + +## Data Structures + +### RunSummaryEventType +```typescript +type RunSummaryEventType = + | 'session_started' + | 'session_stopped' + | 'respawn_cycle_started' + | 'respawn_cycle_completed' + | 'respawn_state_change' + | 'error' + | 'warning' + | 'token_milestone' + | 'auto_compact' + | 'auto_clear' + | 'idle_detected' + | 'working_detected' + | 'ralph_completion' + | 'ai_check_result' + | 'hook_event' + | 'state_stuck'; +``` + +### RunSummaryEvent +```typescript +interface RunSummaryEvent { + id: string; + timestamp: number; + type: RunSummaryEventType; + severity: 'info' | 'warning' | 'error' | 'success'; + title: string; + details?: string; + metadata?: Record; +} +``` + +### RunSummary +```typescript +interface RunSummary { + sessionId: string; + sessionName: string; + startedAt: number; + lastUpdatedAt: number; + events: RunSummaryEvent[]; + stats: { + totalRespawnCycles: number; + totalTokensUsed: number; + peakTokens: number; + totalTimeActiveMs: number; + totalTimeIdleMs: number; + errorCount: number; + warningCount: number; + aiCheckCount: number; + lastIdleAt: number | null; + lastWorkingAt: number | null; + stateTransitions: number; + }; +} +``` + +## Files to Create/Modify + +### 1. `src/run-summary.ts` (NEW) +- `RunSummaryTracker` class +- Event tracking and aggregation +- Statistics calculation +- Max 1000 events per session (FIFO trimming) + +### 2. `src/types.ts` (MODIFY) +- Add `RunSummaryEvent`, `RunSummaryEventType`, `RunSummary` interfaces +- Add `RunSummaryEventSeverity` type + +### 3. `src/web/server.ts` (MODIFY) +- Create `RunSummaryTracker` per session +- Subscribe to session events and forward to tracker +- Subscribe to respawn controller events +- Add API endpoint: `GET /api/sessions/:id/run-summary` +- Broadcast `session:runSummaryUpdate` SSE event + +### 4. `src/web/public/app.js` (MODIFY) +- Add "Run Summary" button to session header +- Create modal to display summary +- Handle `session:runSummaryUpdate` SSE event +- Timeline view for events +- Stats cards at top + +### 5. `src/web/public/index.html` (MODIFY) +- Add modal HTML structure for run summary + +### 6. `src/web/public/styles.css` (MODIFY) +- Styles for run summary modal and timeline + +## Event Sources + +| Event Type | Source | Trigger | +|------------|--------|---------| +| session_started | Session | `startInteractive()` / `startShell()` | +| session_stopped | Session | `stop()` | +| respawn_cycle_started | RespawnController | State → `sending_update` | +| respawn_cycle_completed | RespawnController | State → `watching` (after cycle) | +| respawn_state_change | RespawnController | Any state transition | +| error | Various | Errors caught in try/catch | +| warning | RunSummaryTracker | State stuck > 5min, high tokens | +| token_milestone | Session | Every 50k tokens | +| auto_compact | Session | `autoCompact` event | +| auto_clear | Session | `autoClear` event | +| idle_detected | Session | `idle` event | +| working_detected | Session | `working` event | +| ralph_completion | RalphTracker | `completionDetected` event | +| ai_check_result | RespawnController | AI check completes | +| hook_event | Server | `/api/hook-event` endpoint | +| state_stuck | RunSummaryTracker | Same state > 10min | + +## API Endpoint + +### GET /api/sessions/:id/run-summary +Returns the full run summary for a session. + +Response: +```json +{ + "success": true, + "summary": { + "sessionId": "...", + "sessionName": "...", + "startedAt": 1234567890, + "lastUpdatedAt": 1234567890, + "events": [...], + "stats": {...} + } +} +``` + +## UI Design + +### Summary Modal +- Header: Session name, duration, status indicator +- Stats Cards Row: + - Respawn Cycles: count + - Tokens Used: peak / current + - Active Time: formatted duration + - Issues: errors + warnings count +- Timeline: + - Vertical timeline of events + - Color-coded by severity (green=success, blue=info, yellow=warning, red=error) + - Expandable details + - Filter by event type +- Footer: "Close" button + +## Implementation Steps + +1. Add types to `types.ts` +2. Create `run-summary.ts` with RunSummaryTracker class +3. Integrate tracker with server.ts (create per session, wire events) +4. Add API endpoint +5. Add frontend modal and button +6. Test with live session + +## Storage + +Run summaries are kept in memory only (not persisted to disk) since: +- They're session-specific and regenerated on session start +- Persisting thousands of events would bloat state.json +- Server restart = fresh session anyway + +If persistence is needed later, could add to `state-inner.json` with per-session limits. diff --git a/package.json b/package.json index c72c4223..6ab2212d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1366", + "version": "0.1367", "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/run-summary.ts b/src/run-summary.ts new file mode 100644 index 00000000..6df851a8 --- /dev/null +++ b/src/run-summary.ts @@ -0,0 +1,445 @@ +/** + * @fileoverview Run Summary Tracker for Claudeman sessions + * + * Tracks significant events during a session's lifetime to provide users + * with a consolidated "what happened while I was away" view. + * + * Features: + * - Event timeline with severity levels + * - Aggregated statistics (respawn cycles, tokens, active time, etc.) + * - State stuck detection (warning when state doesn't change for extended periods) + * - Token milestone tracking (every 50k tokens) + * + * @module run-summary + */ + +import { v4 as uuidv4 } from 'uuid'; +import { + RunSummary, + RunSummaryEvent, + RunSummaryEventType, + RunSummaryEventSeverity, + RunSummaryStats, + createInitialRunSummaryStats, +} from './types.js'; + +/** Maximum events to keep per session (FIFO trimming) */ +const MAX_EVENTS = 1000; + +/** Trim to this many events when MAX_EVENTS exceeded */ +const TRIM_TO_EVENTS = 800; + +/** Token milestone interval (track every N tokens) */ +const TOKEN_MILESTONE_INTERVAL = 50000; + +/** State stuck warning threshold (ms) */ +const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes + +/** State stuck check interval (ms) */ +const STATE_STUCK_CHECK_INTERVAL = 60 * 1000; // 1 minute + +/** + * Tracks events and statistics for a session's run summary. + * + * @example + * ```typescript + * const tracker = new RunSummaryTracker('session-123', 'My Session'); + * + * // Record events + * tracker.addEvent('session_started', 'info', 'Session started'); + * tracker.addEvent('idle_detected', 'success', 'Claude is idle'); + * + * // Get the summary + * const summary = tracker.getSummary(); + * ``` + */ +export class RunSummaryTracker { + private sessionId: string; + private sessionName: string; + private startedAt: number; + private lastUpdatedAt: number; + private events: RunSummaryEvent[] = []; + private stats: RunSummaryStats; + + // State tracking for stuck detection + private currentState: string | null = null; + private stateEnteredAt: number | null = null; + private stateStuckCheckTimer: NodeJS.Timeout | null = null; + private stateStuckWarned: boolean = false; + + // Token tracking for milestones + private lastTokenMilestone: number = 0; + + // Active/idle time tracking + private isCurrentlyActive: boolean = false; + private lastStatusChangeAt: number; + + // Respawn cycle tracking + private inRespawnCycle: boolean = false; + + constructor(sessionId: string, sessionName: string = '') { + this.sessionId = sessionId; + this.sessionName = sessionName; + this.startedAt = Date.now(); + this.lastUpdatedAt = this.startedAt; + this.lastStatusChangeAt = this.startedAt; + this.stats = createInitialRunSummaryStats(); + + // Start state stuck detection + this.startStateStuckDetection(); + } + + /** + * Add an event to the timeline. + */ + addEvent( + type: RunSummaryEventType, + severity: RunSummaryEventSeverity, + title: string, + details?: string, + metadata?: Record + ): RunSummaryEvent { + const event: RunSummaryEvent = { + id: uuidv4(), + timestamp: Date.now(), + type, + severity, + title, + details, + metadata, + }; + + this.events.push(event); + this.lastUpdatedAt = event.timestamp; + + // Update stats based on event type + this.updateStatsFromEvent(event); + + // Trim if needed + if (this.events.length > MAX_EVENTS) { + this.events = this.events.slice(-TRIM_TO_EVENTS); + } + + return event; + } + + /** + * Record a state change (for respawn controller states). + */ + recordStateChange(newState: string, details?: string): void { + const oldState = this.currentState; + + // Check if entering or leaving a respawn cycle + if (newState === 'sending_update' && !this.inRespawnCycle) { + this.inRespawnCycle = true; + this.addEvent('respawn_cycle_started', 'info', 'Respawn cycle started', details); + } else if (newState === 'watching' && this.inRespawnCycle && oldState !== 'watching') { + this.inRespawnCycle = false; + this.stats.totalRespawnCycles++; + this.addEvent('respawn_cycle_completed', 'success', 'Respawn cycle completed', details); + } + + // Record state transition + if (oldState && oldState !== newState) { + this.stats.stateTransitions++; + this.addEvent( + 'respawn_state_change', + 'info', + `State: ${oldState} → ${newState}`, + details, + { from: oldState, to: newState } + ); + } + + // Update state tracking + this.currentState = newState; + this.stateEnteredAt = Date.now(); + this.stateStuckWarned = false; + } + + /** + * Record when Claude becomes idle. + */ + recordIdle(): void { + const now = Date.now(); + + // Update active time if was previously active + if (this.isCurrentlyActive) { + this.stats.totalTimeActiveMs += now - this.lastStatusChangeAt; + } + + this.isCurrentlyActive = false; + this.lastStatusChangeAt = now; + this.stats.lastIdleAt = now; + + this.addEvent('idle_detected', 'success', 'Claude is idle', undefined, { + activeTimeMs: this.stats.totalTimeActiveMs, + }); + } + + /** + * Record when Claude starts working. + */ + recordWorking(): void { + const now = Date.now(); + + // Update idle time if was previously idle + if (!this.isCurrentlyActive && this.stats.lastIdleAt) { + this.stats.totalTimeIdleMs += now - this.lastStatusChangeAt; + } + + this.isCurrentlyActive = true; + this.lastStatusChangeAt = now; + this.stats.lastWorkingAt = now; + + this.addEvent('working_detected', 'info', 'Claude is working'); + } + + /** + * Record token usage update. + */ + recordTokens(inputTokens: number, outputTokens: number): void { + const total = inputTokens + outputTokens; + this.stats.totalTokensUsed = total; + + if (total > this.stats.peakTokens) { + this.stats.peakTokens = total; + } + + // Check for milestone + const currentMilestone = Math.floor(total / TOKEN_MILESTONE_INTERVAL) * TOKEN_MILESTONE_INTERVAL; + if (currentMilestone > this.lastTokenMilestone && currentMilestone > 0) { + this.lastTokenMilestone = currentMilestone; + this.addEvent( + 'token_milestone', + 'info', + `Token milestone: ${this.formatTokens(currentMilestone)}`, + `Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`, + { total, input: inputTokens, output: outputTokens } + ); + } + } + + /** + * Record an AI idle check result. + */ + recordAiCheckResult(verdict: string, confidence?: number): void { + this.stats.aiCheckCount++; + const isIdle = verdict === 'IDLE'; + this.addEvent( + 'ai_check_result', + isIdle ? 'success' : 'info', + `AI check: ${verdict}`, + confidence !== undefined ? `Confidence: ${confidence}%` : undefined, + { verdict, confidence } + ); + } + + /** + * Record an auto-compact event. + */ + recordAutoCompact(tokens: number, threshold: number): void { + this.addEvent( + 'auto_compact', + 'warning', + 'Auto-compact triggered', + `Tokens: ${this.formatTokens(tokens)} / Threshold: ${this.formatTokens(threshold)}`, + { tokens, threshold } + ); + } + + /** + * Record an auto-clear event. + */ + recordAutoClear(tokens: number, threshold: number): void { + this.addEvent( + 'auto_clear', + 'warning', + 'Auto-clear triggered', + `Tokens: ${this.formatTokens(tokens)} / Threshold: ${this.formatTokens(threshold)}`, + { tokens, threshold } + ); + } + + /** + * Record a Ralph completion detection. + */ + recordRalphCompletion(phrase: string): void { + this.addEvent( + 'ralph_completion', + 'success', + 'Ralph completion detected', + `Phrase: ${phrase}`, + { phrase } + ); + } + + /** + * Record a hook event from Claude Code. + */ + recordHookEvent(eventType: string, data?: Record): void { + const severity: RunSummaryEventSeverity = + eventType === 'stop' ? 'warning' : + eventType === 'permission_prompt' ? 'info' : + 'info'; + + this.addEvent( + 'hook_event', + severity, + `Hook: ${eventType}`, + data?.tool_name ? `Tool: ${data.tool_name}` : undefined, + { eventType, ...data } + ); + } + + /** + * Record an error. + */ + recordError(title: string, details?: string, metadata?: Record): void { + this.stats.errorCount++; + this.addEvent('error', 'error', title, details, metadata); + } + + /** + * Record a warning. + */ + recordWarning(title: string, details?: string, metadata?: Record): void { + this.stats.warningCount++; + this.addEvent('warning', 'warning', title, details, metadata); + } + + /** + * Record session started. + */ + recordSessionStarted(mode: string, workingDir: string): void { + this.addEvent( + 'session_started', + 'success', + 'Session started', + `Mode: ${mode}, Dir: ${workingDir}`, + { mode, workingDir } + ); + } + + /** + * Record session stopped. + */ + recordSessionStopped(): void { + // Finalize active/idle time + const now = Date.now(); + if (this.isCurrentlyActive) { + this.stats.totalTimeActiveMs += now - this.lastStatusChangeAt; + } else { + this.stats.totalTimeIdleMs += now - this.lastStatusChangeAt; + } + + this.addEvent( + 'session_stopped', + 'info', + 'Session stopped', + `Duration: ${this.formatDuration(now - this.startedAt)}` + ); + } + + /** + * Get the complete run summary. + */ + getSummary(): RunSummary { + // Update times before returning + const now = Date.now(); + const stats = { ...this.stats }; + + // Add current active/idle period + if (this.isCurrentlyActive) { + stats.totalTimeActiveMs += now - this.lastStatusChangeAt; + } else { + stats.totalTimeIdleMs += now - this.lastStatusChangeAt; + } + + return { + sessionId: this.sessionId, + sessionName: this.sessionName, + startedAt: this.startedAt, + lastUpdatedAt: this.lastUpdatedAt, + events: [...this.events], + stats, + }; + } + + /** + * Get recent events (for SSE updates). + */ + getRecentEvents(count: number = 10): RunSummaryEvent[] { + return this.events.slice(-count); + } + + /** + * Update session name. + */ + setSessionName(name: string): void { + this.sessionName = name; + } + + /** + * Stop tracking and clean up. + */ + stop(): void { + if (this.stateStuckCheckTimer) { + clearInterval(this.stateStuckCheckTimer); + this.stateStuckCheckTimer = null; + } + } + + // ========== Private Methods ========== + + private updateStatsFromEvent(_event: RunSummaryEvent): void { + // Most stats are updated in the specific record* methods + // This is for any additional cross-cutting concerns + } + + private startStateStuckDetection(): void { + this.stateStuckCheckTimer = setInterval(() => { + if (!this.currentState || !this.stateEnteredAt || this.stateStuckWarned) { + return; + } + + const timeInState = Date.now() - this.stateEnteredAt; + if (timeInState >= STATE_STUCK_WARNING_MS) { + this.stateStuckWarned = true; + const minutes = Math.floor(timeInState / 60000); + this.stats.warningCount++; + this.addEvent( + 'state_stuck', + 'warning', + `State stuck: ${this.currentState}`, + `In state for ${minutes}+ minutes`, + { state: this.currentState, durationMs: timeInState } + ); + } + }, STATE_STUCK_CHECK_INTERVAL); + } + + private formatTokens(tokens: number): string { + if (tokens >= 1000000) { + return `${(tokens / 1000000).toFixed(1)}M`; + } + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return String(tokens); + } + + private formatDuration(ms: number): string { + 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`; + } +} diff --git a/src/types.ts b/src/types.ts index e9c6e202..3c80a5a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -859,6 +859,121 @@ export function getErrorMessage(error: unknown): string { return 'An unknown error occurred'; } +// ========== Run Summary Types ========== + +/** + * Types of events tracked in the run summary. + * These provide a historical view of what happened during a session. + */ +export type RunSummaryEventType = + | 'session_started' + | 'session_stopped' + | 'respawn_cycle_started' + | 'respawn_cycle_completed' + | 'respawn_state_change' + | 'error' + | 'warning' + | 'token_milestone' + | 'auto_compact' + | 'auto_clear' + | 'idle_detected' + | 'working_detected' + | 'ralph_completion' + | 'ai_check_result' + | 'hook_event' + | 'state_stuck'; + +/** + * Severity levels for run summary events. + */ +export type RunSummaryEventSeverity = 'info' | 'warning' | 'error' | 'success'; + +/** + * A single event in the run summary timeline. + */ +export interface RunSummaryEvent { + /** Unique event identifier */ + id: string; + /** Timestamp when event occurred */ + timestamp: number; + /** Type of event */ + type: RunSummaryEventType; + /** Severity level for display */ + severity: RunSummaryEventSeverity; + /** Short title for the event */ + title: string; + /** Optional detailed description */ + details?: string; + /** Optional additional metadata */ + metadata?: Record; +} + +/** + * Statistics aggregated from run summary events. + */ +export interface RunSummaryStats { + /** Number of respawn cycles completed */ + totalRespawnCycles: number; + /** Total tokens used during this run */ + totalTokensUsed: number; + /** Peak token count observed */ + peakTokens: number; + /** Total time Claude was actively working (ms) */ + totalTimeActiveMs: number; + /** Total time Claude was idle (ms) */ + totalTimeIdleMs: number; + /** Number of errors encountered */ + errorCount: number; + /** Number of warnings encountered */ + warningCount: number; + /** Number of AI idle checks performed */ + aiCheckCount: number; + /** Timestamp when last became idle */ + lastIdleAt: number | null; + /** Timestamp when last started working */ + lastWorkingAt: number | null; + /** Total number of state transitions */ + stateTransitions: number; +} + +/** + * Complete run summary for a session. + * Provides a historical view of session activity for users returning after absence. + */ +export interface RunSummary { + /** Session ID this summary belongs to */ + sessionId: string; + /** Session display name */ + sessionName: string; + /** Timestamp when tracking started */ + startedAt: number; + /** Timestamp of last update */ + lastUpdatedAt: number; + /** Timeline of events (most recent last) */ + events: RunSummaryEvent[]; + /** Aggregated statistics */ + stats: RunSummaryStats; +} + +/** + * Creates initial run summary stats. + */ +export function createInitialRunSummaryStats(): RunSummaryStats { + return { + totalRespawnCycles: 0, + totalTokensUsed: 0, + peakTokens: 0, + totalTimeActiveMs: 0, + totalTimeIdleMs: 0, + errorCount: 0, + warningCount: 0, + aiCheckCount: 0, + lastIdleAt: null, + lastWorkingAt: null, + stateTransitions: 0, + }; +} + // ========== Spawn1337 Protocol Re-exports ========== export type { diff --git a/src/web/public/app.js b/src/web/public/app.js index fa027e08..81183152 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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' ? 'sh' : ''} ${this.escapeHtml(name)} ${hasRunningTasks ? `${taskStats.running}` : ''} + 📊 ⚙ × `); @@ -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 = '

Loading summary...

'; + + try { + const response = await fetch(`/api/sessions/${sessionId}/run-summary`); + const data = await response.json(); + + if (!data.success) { + timeline.innerHTML = `

Failed to load summary: ${data.error}

`; + return; + } + + this.runSummaryData = data.summary; + this.renderRunSummary(); + } catch (err) { + console.error('Failed to load run summary:', err); + timeline.innerHTML = '

Failed to load summary

'; + } + } + + 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 = '

No events recorded yet

'; + 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 ` +
+
${icon}
+
+
+ ${this.escapeHtml(event.title)} + ${time} +
+ ${event.details ? `
${this.escapeHtml(event.details)}
` : ''} +
+
+ `; + }).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 diff --git a/src/web/public/index.html b/src/web/public/index.html index f67cb717..6067bd1f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -469,6 +469,56 @@ + + +