mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +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:
@@ -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.
|
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
|
**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/ai-plan-checker.ts` | Spawns Claude to detect plan mode prompts for auto-accept |
|
||||||
| `src/ralph-tracker.ts` | Detects `<promise>PHRASE</promise>`, todos, loop status |
|
| `src/ralph-tracker.ts` | Detects `<promise>PHRASE</promise>`, todos, loop status |
|
||||||
| `src/ralph-config.ts` | Parses `.claude/ralph-loop.local.md` and CLAUDE.md for Ralph config |
|
| `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):**
|
**Spawn Protocol (Autonomous Agents):**
|
||||||
| File | Purpose |
|
| 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
|
- `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
|
- `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)
|
### Frontend (app.js)
|
||||||
|
|
||||||
Vanilla JS + xterm.js. 60fps rendering: server batches terminal data every 16ms, client uses `requestAnimationFrame` to batch xterm.js writes.
|
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/spawn/*` - Agent lifecycle (list, status, result, messages, cancel, trigger)
|
||||||
- `/api/subagents` - List/get/kill Claude Code background agents, get transcripts
|
- `/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/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?}`)
|
- `/api/hook-event` - Claude Code hook callbacks (`{event, sessionId, data?}`)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
): 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<string, unknown>): 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<string, unknown>): void {
|
||||||
|
this.stats.errorCount++;
|
||||||
|
this.addEvent('error', 'error', title, details, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a warning.
|
||||||
|
*/
|
||||||
|
recordWarning(title: string, details?: string, metadata?: Record<string, unknown>): 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -859,6 +859,121 @@ export function getErrorMessage(error: unknown): string {
|
|||||||
return 'An unknown error occurred';
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ==========
|
// ========== Spawn1337 Protocol Re-exports ==========
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
+175
-4
@@ -2,6 +2,14 @@
|
|||||||
// Default terminal scrollback (can be changed via settings)
|
// Default terminal scrollback (can be changed via settings)
|
||||||
const DEFAULT_SCROLLBACK = 5000;
|
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
|
// Notification Manager - Multi-layer browser notification system
|
||||||
class NotificationManager {
|
class NotificationManager {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
@@ -643,9 +651,9 @@ class ClaudemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For small buffers, write directly
|
// For small buffers, write directly with DEC 2026 sync for atomic render
|
||||||
if (buffer.length <= chunkSize) {
|
if (buffer.length <= chunkSize) {
|
||||||
this.terminal.write(buffer);
|
this.terminal.write(DEC_SYNC_START + buffer + DEC_SYNC_END);
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -659,7 +667,8 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chunk = buffer.slice(offset, offset + chunkSize);
|
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;
|
offset += chunkSize;
|
||||||
|
|
||||||
// Schedule next chunk on next frame
|
// Schedule next chunk on next frame
|
||||||
@@ -807,7 +816,8 @@ class ClaudemanApp {
|
|||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.reset();
|
this.terminal.reset();
|
||||||
if (termData.terminalBuffer) {
|
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
|
// Send resize to ensure proper dimensions
|
||||||
@@ -1600,6 +1610,7 @@ class ClaudemanApp {
|
|||||||
${mode === 'shell' ? '<span class="tab-mode shell">sh</span>' : ''}
|
${mode === 'shell' ? '<span class="tab-mode shell">sh</span>' : ''}
|
||||||
<span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</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>` : ''}
|
${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-gear" onclick="event.stopPropagation(); app.openSessionOptions('${id}')" title="Session options">⚙</span>
|
||||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')">×</span>
|
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')">×</span>
|
||||||
</div>`);
|
</div>`);
|
||||||
@@ -2926,6 +2937,166 @@ class ClaudemanApp {
|
|||||||
document.getElementById('sessionOptionsModal').classList.remove('active');
|
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() {
|
saveSessionOptions() {
|
||||||
// Session options are applied immediately via individual controls
|
// Session options are applied immediately via individual controls
|
||||||
// This just closes the modal
|
// This just closes the modal
|
||||||
|
|||||||
@@ -469,6 +469,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Close Session Confirmation Modal -->
|
||||||
<div class="modal" id="closeConfirmModal">
|
<div class="modal" id="closeConfirmModal">
|
||||||
<div class="modal-backdrop" onclick="app.cancelCloseSession()"></div>
|
<div class="modal-backdrop" onclick="app.cancelCloseSession()"></div>
|
||||||
|
|||||||
@@ -1960,6 +1960,151 @@ body {
|
|||||||
max-width: 520px;
|
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 {
|
.modal-warning-text {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 0.85rem;
|
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 { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
import { RunSummaryTracker } from '../run-summary.js';
|
||||||
|
|
||||||
// Load version from package.json
|
// Load version from package.json
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -77,6 +78,13 @@ const TERMINAL_BATCH_INTERVAL = 16;
|
|||||||
const OUTPUT_BATCH_INTERVAL = 50;
|
const OUTPUT_BATCH_INTERVAL = 50;
|
||||||
// Batch task:updated events for 100ms
|
// Batch task:updated events for 100ms
|
||||||
const TASK_UPDATE_BATCH_INTERVAL = 100;
|
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)
|
// State update debounce interval (batch expensive toDetailedState() calls)
|
||||||
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
|
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
|
||||||
// Scheduled runs cleanup interval (check every 5 minutes)
|
// 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 sessions: Map<string, Session> = new Map();
|
||||||
private respawnControllers: Map<string, RespawnController> = new Map();
|
private respawnControllers: Map<string, RespawnController> = new Map();
|
||||||
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = 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 transcriptWatchers: Map<string, TranscriptWatcher> = new Map();
|
||||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||||
private sseClients: Set<FastifyReply> = new Set();
|
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
|
// Configure Ralph (Ralph Wiggum) settings
|
||||||
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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
|
// Sanitize forwarded data: only include known safe fields, limit size
|
||||||
const safeData = sanitizeHookData(data);
|
const safeData = sanitizeHookData(data);
|
||||||
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
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 };
|
return { success: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1929,6 +1968,14 @@ export class WebServer extends EventEmitter {
|
|||||||
// Stop transcript watcher
|
// Stop transcript watcher
|
||||||
this.stopTranscriptWatcher(sessionId);
|
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
|
// Clear batches and pending state updates
|
||||||
this.terminalBatches.delete(sessionId);
|
this.terminalBatches.delete(sessionId);
|
||||||
this.outputBatches.delete(sessionId);
|
this.outputBatches.delete(sessionId);
|
||||||
@@ -1985,6 +2032,11 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupSessionListeners(session: Session): void {
|
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) => {
|
session.on('output', (data) => {
|
||||||
// Use batching for better performance at high throughput
|
// Use batching for better performance at high throughput
|
||||||
this.batchOutputData(session.id, data);
|
this.batchOutputData(session.id, data);
|
||||||
@@ -2006,6 +2058,9 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
session.on('error', (error) => {
|
session.on('error', (error) => {
|
||||||
this.broadcast('session:error', { id: session.id, 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) => {
|
session.on('completion', (result, cost) => {
|
||||||
@@ -2039,12 +2094,24 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
session.on('working', () => {
|
session.on('working', () => {
|
||||||
this.broadcast('session:working', { id: session.id });
|
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', () => {
|
session.on('idle', () => {
|
||||||
this.broadcast('session:idle', { id: session.id });
|
this.broadcast('session:idle', { id: session.id });
|
||||||
// Use debounced state update (idle can fire frequently)
|
// Use debounced state update (idle can fire frequently)
|
||||||
this.broadcastSessionStateDebounced(session.id);
|
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
|
// 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 }) => {
|
session.on('autoClear', (data: { tokens: number; threshold: number }) => {
|
||||||
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
|
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
|
||||||
this.broadcastSessionStateDebounced(session.id);
|
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 }) => {
|
session.on('autoCompact', (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||||||
this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
|
this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
|
||||||
this.broadcastSessionStateDebounced(session.id);
|
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
|
// Ralph tracking events
|
||||||
@@ -2093,13 +2166,20 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
session.on('ralphCompletionDetected', (phrase: string) => {
|
session.on('ralphCompletionDetected', (phrase: string) => {
|
||||||
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
|
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 {
|
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||||
|
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||||||
|
|
||||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||||
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
||||||
|
// Track in run summary
|
||||||
|
if (summaryTracker) summaryTracker.recordStateChange(state, `${prevState} → ${state}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||||
@@ -2132,10 +2212,14 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||||
this.broadcast('respawn:aiCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs });
|
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) => {
|
controller.on('aiCheckFailed', (error: string) => {
|
||||||
this.broadcast('respawn:aiCheckFailed', { sessionId, error });
|
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) => {
|
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||||
@@ -2177,6 +2261,8 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
controller.on('error', (error: Error) => {
|
controller.on('error', (error: Error) => {
|
||||||
this.broadcast('respawn:error', { sessionId, error: error.message });
|
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 {
|
private flushTerminalBatches(): void {
|
||||||
for (const [sessionId, data] of this.terminalBatches) {
|
for (const [sessionId, data] of this.terminalBatches) {
|
||||||
if (data.length > 0) {
|
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();
|
this.terminalBatches.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user