aicodeman 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -4
- package/dist/config/server-timing.d.ts +8 -2
- package/dist/config/server-timing.d.ts.map +1 -1
- package/dist/config/server-timing.js +8 -2
- package/dist/config/server-timing.js.map +1 -1
- package/dist/hooks-config.d.ts +21 -6
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +21 -6
- package/dist/hooks-config.js.map +1 -1
- package/dist/prompts/planner.d.ts +7 -8
- package/dist/prompts/planner.d.ts.map +1 -1
- package/dist/prompts/planner.js +7 -8
- package/dist/prompts/planner.js.map +1 -1
- package/dist/prompts/research-agent.d.ts +6 -4
- package/dist/prompts/research-agent.d.ts.map +1 -1
- package/dist/prompts/research-agent.js +6 -4
- package/dist/prompts/research-agent.js.map +1 -1
- package/dist/ralph-loop.d.ts +14 -4
- package/dist/ralph-loop.d.ts.map +1 -1
- package/dist/ralph-loop.js +14 -4
- package/dist/ralph-loop.js.map +1 -1
- package/dist/ralph-tracker.d.ts +28 -11
- package/dist/ralph-tracker.d.ts.map +1 -1
- package/dist/ralph-tracker.js +43 -13
- package/dist/ralph-tracker.js.map +1 -1
- package/dist/respawn-controller.d.ts +23 -14
- package/dist/respawn-controller.d.ts.map +1 -1
- package/dist/respawn-controller.js +23 -14
- package/dist/respawn-controller.js.map +1 -1
- package/dist/session-manager.d.ts +17 -5
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +17 -5
- package/dist/session-manager.js.map +1 -1
- package/dist/session.d.ts +21 -8
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +21 -8
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +17 -7
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +17 -7
- package/dist/state-store.js.map +1 -1
- package/dist/subagent-watcher.d.ts +23 -3
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +23 -3
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +1 -2
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/types/api.d.ts +16 -1
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js +16 -1
- package/dist/types/api.js.map +1 -1
- package/dist/types/app-state.d.ts +18 -1
- package/dist/types/app-state.d.ts.map +1 -1
- package/dist/types/app-state.js +18 -1
- package/dist/types/app-state.js.map +1 -1
- package/dist/types/common.d.ts +10 -1
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/common.js +10 -1
- package/dist/types/common.js.map +1 -1
- package/dist/types/index.d.ts +50 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +50 -2
- package/dist/types/index.js.map +1 -1
- package/dist/types/lifecycle.d.ts +12 -1
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/lifecycle.js +12 -1
- package/dist/types/lifecycle.js.map +1 -1
- package/dist/types/plan.d.ts +14 -1
- package/dist/types/plan.d.ts.map +1 -1
- package/dist/types/plan.js +14 -1
- package/dist/types/plan.js.map +1 -1
- package/dist/types/push.d.ts +14 -1
- package/dist/types/push.d.ts.map +1 -1
- package/dist/types/push.js +14 -1
- package/dist/types/push.js.map +1 -1
- package/dist/types/ralph.d.ts +22 -1
- package/dist/types/ralph.d.ts.map +1 -1
- package/dist/types/ralph.js +22 -1
- package/dist/types/ralph.js.map +1 -1
- package/dist/types/respawn.d.ts +22 -1
- package/dist/types/respawn.d.ts.map +1 -1
- package/dist/types/respawn.js +22 -1
- package/dist/types/respawn.js.map +1 -1
- package/dist/types/run-summary.d.ts +16 -1
- package/dist/types/run-summary.d.ts.map +1 -1
- package/dist/types/run-summary.js +16 -1
- package/dist/types/run-summary.js.map +1 -1
- package/dist/types/session.d.ts +23 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js +23 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/task.d.ts +15 -1
- package/dist/types/task.d.ts.map +1 -1
- package/dist/types/task.js +15 -1
- package/dist/types/task.js.map +1 -1
- package/dist/types/teams.d.ts +19 -1
- package/dist/types/teams.d.ts.map +1 -1
- package/dist/types/teams.js +19 -1
- package/dist/types/teams.js.map +1 -1
- package/dist/types/tools.d.ts +16 -1
- package/dist/types/tools.d.ts.map +1 -1
- package/dist/types/tools.js +16 -1
- package/dist/types/tools.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -1
- package/dist/web/public/api-client.js +12 -0
- package/dist/web/public/api-client.js.br +0 -0
- package/dist/web/public/api-client.js.gz +0 -0
- package/dist/web/public/app.js +103 -103
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/constants.js +133 -6
- package/dist/web/public/constants.js.br +0 -0
- package/dist/web/public/constants.js.gz +0 -0
- package/dist/web/public/index.html +14 -10
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/keyboard-accessory.js +27 -4
- package/dist/web/public/keyboard-accessory.js.br +0 -0
- package/dist/web/public/keyboard-accessory.js.gz +0 -0
- package/dist/web/public/mobile-handlers.js +30 -6
- package/dist/web/public/mobile-handlers.js.br +0 -0
- package/dist/web/public/mobile-handlers.js.gz +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/notification-manager.js +27 -0
- package/dist/web/public/notification-manager.js.br +0 -0
- package/dist/web/public/notification-manager.js.gz +0 -0
- package/dist/web/public/ralph-wizard.js +30 -6
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/subagent-windows.js +46 -12
- package/dist/web/public/subagent-windows.js.br +0 -0
- package/dist/web/public/subagent-windows.js.gz +0 -0
- package/dist/web/public/sw.js +15 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.js +4 -0
- package/dist/web/public/vendor/xterm-zerolag-input.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.js +26 -2
- package/dist/web/public/voice-input.js.br +0 -0
- package/dist/web/public/voice-input.js.gz +0 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +3 -2
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +11 -4
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/plan-routes.d.ts.map +1 -1
- package/dist/web/routes/plan-routes.js +26 -22
- package/dist/web/routes/plan-routes.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +16 -6
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +25 -15
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +40 -18
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +26 -5
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/server.d.ts +25 -6
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +237 -156
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +361 -0
- package/dist/web/sse-events.d.ts.map +1 -0
- package/dist/web/sse-events.js +396 -0
- package/dist/web/sse-events.js.map +1 -0
- package/package.json +2 -1
- package/scripts/postinstall.js +58 -0
package/dist/web/server.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Codeman web server
|
|
2
|
+
* @fileoverview Codeman web server — central hub coordinating all subsystems.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - REST API
|
|
6
|
-
* -
|
|
7
|
-
* - Static file serving for the web UI
|
|
8
|
-
* - 60fps terminal streaming
|
|
4
|
+
* Fastify-based web server providing:
|
|
5
|
+
* - ~111 REST API routes (delegated to `src/web/routes/` domain modules)
|
|
6
|
+
* - SSE streaming at `/api/events` with backpressure handling
|
|
7
|
+
* - Static file serving for the web UI (1-year cache in production)
|
|
8
|
+
* - 60fps terminal streaming via batched PTY output (16-50ms adaptive)
|
|
9
|
+
*
|
|
10
|
+
* Coordinates: SessionManager, RespawnController, SubagentWatcher, TeamWatcher,
|
|
11
|
+
* TranscriptWatcher, ImageWatcher, TunnelManager, PushSubscriptionStore,
|
|
12
|
+
* PlanOrchestrator, RunSummaryTracker, FileStreamManager.
|
|
13
|
+
*
|
|
14
|
+
* Key exports:
|
|
15
|
+
* - `WebServer` class — implements all port interfaces, extends EventEmitter
|
|
16
|
+
* - `startWebServer(options)` — factory function to create and start the server
|
|
17
|
+
*
|
|
18
|
+
* Implements port interfaces: `SessionPort`, `EventPort`, `ConfigPort`,
|
|
19
|
+
* `RespawnPort`, `MuxPort`, `FilePort`, `ScheduledPort`, `PushPort`, `TeamPort`
|
|
20
|
+
* (see `src/web/ports/` for definitions)
|
|
21
|
+
*
|
|
22
|
+
* @dependencies All major subsystems (session, respawn-controller, subagent-watcher,
|
|
23
|
+
* team-watcher, tunnel-manager, state-store, etc.)
|
|
24
|
+
* @consumedby src/index.ts (entry point), src/cli.ts
|
|
25
|
+
* @emits SSE events via broadcast() — see sse-events.ts for full registry
|
|
9
26
|
*
|
|
10
27
|
* @module web/server
|
|
11
28
|
*/
|
|
@@ -43,16 +60,22 @@ const { version: APP_VERSION } = require('../../package.json');
|
|
|
43
60
|
import { getErrorMessage, ApiErrorCode, createErrorResponse, DEFAULT_NICE_CONFIG, } from '../types.js';
|
|
44
61
|
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
|
|
45
62
|
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
|
63
|
+
import { SseEvent } from './sse-events.js';
|
|
46
64
|
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
|
47
65
|
import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, } from './routes/index.js';
|
|
48
66
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
49
|
-
import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, STATE_UPDATE_DEBOUNCE_INTERVAL, SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE,
|
|
67
|
+
import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, STATE_UPDATE_DEBOUNCE_INTERVAL, SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEARTBEAT_INTERVAL, SSE_PADDING_SIZE, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, STATS_COLLECTION_INTERVAL_MS, } from '../config/server-timing.js';
|
|
50
68
|
// DEC mode 2026 - Synchronized Output
|
|
51
69
|
// When terminal supports this, it buffers all output between start/end markers
|
|
52
70
|
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
|
|
53
71
|
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
|
54
72
|
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
|
|
55
73
|
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
|
|
74
|
+
// SSE padding for Cloudflare tunnel buffer flushing.
|
|
75
|
+
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
|
|
76
|
+
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
|
|
77
|
+
// Pre-computed once at startup to avoid repeated string allocation.
|
|
78
|
+
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
|
|
56
79
|
/**
|
|
57
80
|
* Get or generate a self-signed TLS certificate for HTTPS.
|
|
58
81
|
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
|
@@ -138,6 +161,8 @@ export class WebServer extends EventEmitter {
|
|
|
138
161
|
subagentWatcherHandlers = null;
|
|
139
162
|
imageWatcherHandlers = null;
|
|
140
163
|
tunnelManager = new TunnelManager();
|
|
164
|
+
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
|
|
165
|
+
_isTunnelActive = false;
|
|
141
166
|
authSessions = null;
|
|
142
167
|
authFailures = null;
|
|
143
168
|
qrAuthFailures = null;
|
|
@@ -160,10 +185,10 @@ export class WebServer extends EventEmitter {
|
|
|
160
185
|
this.mux = createMultiplexer();
|
|
161
186
|
// Set up mux event listeners
|
|
162
187
|
this.mux.on('sessionCreated', (session) => {
|
|
163
|
-
this.broadcast(
|
|
188
|
+
this.broadcast(SseEvent.MuxCreated, session);
|
|
164
189
|
});
|
|
165
190
|
this.mux.on('sessionKilled', (data) => {
|
|
166
|
-
this.broadcast(
|
|
191
|
+
this.broadcast(SseEvent.MuxKilled, data);
|
|
167
192
|
});
|
|
168
193
|
this.mux.on('sessionDied', (data) => {
|
|
169
194
|
getLifecycleLog().log({
|
|
@@ -171,10 +196,10 @@ export class WebServer extends EventEmitter {
|
|
|
171
196
|
sessionId: data.sessionId || 'unknown',
|
|
172
197
|
extra: data,
|
|
173
198
|
});
|
|
174
|
-
this.broadcast(
|
|
199
|
+
this.broadcast(SseEvent.MuxDied, data);
|
|
175
200
|
});
|
|
176
201
|
this.mux.on('statsUpdated', (sessions) => {
|
|
177
|
-
this.broadcast(
|
|
202
|
+
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
|
178
203
|
});
|
|
179
204
|
// Set up subagent watcher listeners
|
|
180
205
|
this.setupSubagentWatcherListeners();
|
|
@@ -184,16 +209,18 @@ export class WebServer extends EventEmitter {
|
|
|
184
209
|
this.setupTeamWatcherListeners();
|
|
185
210
|
// Set up tunnel manager listeners
|
|
186
211
|
this.tunnelManager.on('started', (data) => {
|
|
187
|
-
this.
|
|
212
|
+
this._isTunnelActive = true;
|
|
213
|
+
this.broadcast(SseEvent.TunnelStarted, data);
|
|
188
214
|
});
|
|
189
215
|
this.tunnelManager.on('stopped', () => {
|
|
190
|
-
this.
|
|
216
|
+
this._isTunnelActive = false;
|
|
217
|
+
this.broadcast(SseEvent.TunnelStopped, {});
|
|
191
218
|
});
|
|
192
219
|
this.tunnelManager.on('error', (message) => {
|
|
193
|
-
this.broadcast(
|
|
220
|
+
this.broadcast(SseEvent.TunnelError, { message });
|
|
194
221
|
});
|
|
195
222
|
this.tunnelManager.on('progress', (data) => {
|
|
196
|
-
this.broadcast(
|
|
223
|
+
this.broadcast(SseEvent.TunnelProgress, data);
|
|
197
224
|
});
|
|
198
225
|
// QR token rotation — broadcast inline SVG for instant desktop refresh
|
|
199
226
|
this.tunnelManager.on('qrTokenRotated', async () => {
|
|
@@ -201,7 +228,7 @@ export class WebServer extends EventEmitter {
|
|
|
201
228
|
if (url && process.env.CODEMAN_PASSWORD) {
|
|
202
229
|
try {
|
|
203
230
|
const svg = await this.tunnelManager.getQrSvg(url);
|
|
204
|
-
this.broadcast(
|
|
231
|
+
this.broadcast(SseEvent.TunnelQrRotated, { svg });
|
|
205
232
|
}
|
|
206
233
|
catch {
|
|
207
234
|
// QR generation failed — skip this rotation
|
|
@@ -213,7 +240,7 @@ export class WebServer extends EventEmitter {
|
|
|
213
240
|
if (url && process.env.CODEMAN_PASSWORD) {
|
|
214
241
|
try {
|
|
215
242
|
const svg = await this.tunnelManager.getQrSvg(url);
|
|
216
|
-
this.broadcast(
|
|
243
|
+
this.broadcast(SseEvent.TunnelQrRegenerated, { svg });
|
|
217
244
|
}
|
|
218
245
|
catch {
|
|
219
246
|
// QR generation failed — skip
|
|
@@ -232,13 +259,13 @@ export class WebServer extends EventEmitter {
|
|
|
232
259
|
setupSubagentWatcherListeners() {
|
|
233
260
|
// Store handlers for cleanup on shutdown
|
|
234
261
|
this.subagentWatcherHandlers = {
|
|
235
|
-
discovered: (info) => this.broadcast(
|
|
236
|
-
updated: (info) => this.broadcast(
|
|
237
|
-
toolCall: (data) => this.broadcast(
|
|
238
|
-
toolResult: (data) => this.broadcast(
|
|
239
|
-
progress: (data) => this.broadcast(
|
|
240
|
-
message: (data) => this.broadcast(
|
|
241
|
-
completed: (info) => this.broadcast(
|
|
262
|
+
discovered: (info) => this.broadcast(SseEvent.SubagentDiscovered, info),
|
|
263
|
+
updated: (info) => this.broadcast(SseEvent.SubagentUpdated, info),
|
|
264
|
+
toolCall: (data) => this.broadcast(SseEvent.SubagentToolCall, data),
|
|
265
|
+
toolResult: (data) => this.broadcast(SseEvent.SubagentToolResult, data),
|
|
266
|
+
progress: (data) => this.broadcast(SseEvent.SubagentProgress, data),
|
|
267
|
+
message: (data) => this.broadcast(SseEvent.SubagentMessage, data),
|
|
268
|
+
completed: (info) => this.broadcast(SseEvent.SubagentCompleted, info),
|
|
242
269
|
error: (error, agentId) => {
|
|
243
270
|
console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
|
|
244
271
|
},
|
|
@@ -275,7 +302,7 @@ export class WebServer extends EventEmitter {
|
|
|
275
302
|
setupImageWatcherListeners() {
|
|
276
303
|
// Store handlers for cleanup on shutdown
|
|
277
304
|
this.imageWatcherHandlers = {
|
|
278
|
-
detected: (event) => this.broadcast(
|
|
305
|
+
detected: (event) => this.broadcast(SseEvent.ImageDetected, event),
|
|
279
306
|
error: (error, sessionId) => {
|
|
280
307
|
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
|
281
308
|
},
|
|
@@ -299,10 +326,10 @@ export class WebServer extends EventEmitter {
|
|
|
299
326
|
*/
|
|
300
327
|
setupTeamWatcherListeners() {
|
|
301
328
|
this.teamWatcherHandlers = {
|
|
302
|
-
teamCreated: (config) => this.broadcast(
|
|
303
|
-
teamUpdated: (config) => this.broadcast(
|
|
304
|
-
teamRemoved: (config) => this.broadcast(
|
|
305
|
-
taskUpdated: (data) => this.broadcast(
|
|
329
|
+
teamCreated: (config) => this.broadcast(SseEvent.TeamCreated, config),
|
|
330
|
+
teamUpdated: (config) => this.broadcast(SseEvent.TeamUpdated, config),
|
|
331
|
+
teamRemoved: (config) => this.broadcast(SseEvent.TeamRemoved, config),
|
|
332
|
+
taskUpdated: (data) => this.broadcast(SseEvent.TeamTaskUpdated, data),
|
|
306
333
|
};
|
|
307
334
|
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
|
308
335
|
this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
|
@@ -436,7 +463,17 @@ export class WebServer extends EventEmitter {
|
|
|
436
463
|
// Send initial state
|
|
437
464
|
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
|
438
465
|
// Buffers are fetched on-demand when switching tabs
|
|
439
|
-
this.sendSSE(reply,
|
|
466
|
+
this.sendSSE(reply, SseEvent.Init, this.getLightState());
|
|
467
|
+
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
|
468
|
+
// (and any immediately following events) are delivered without proxy delay.
|
|
469
|
+
if (this._isTunnelActive) {
|
|
470
|
+
try {
|
|
471
|
+
reply.raw.write(SSE_PADDING);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
/* client gone */
|
|
475
|
+
}
|
|
476
|
+
}
|
|
440
477
|
req.raw.on('close', () => {
|
|
441
478
|
this.sseClients.delete(reply);
|
|
442
479
|
this.backpressuredClients.delete(reply);
|
|
@@ -482,20 +519,20 @@ export class WebServer extends EventEmitter {
|
|
|
482
519
|
if (controller) {
|
|
483
520
|
controller.signalTranscriptComplete();
|
|
484
521
|
}
|
|
485
|
-
this.broadcast(
|
|
522
|
+
this.broadcast(SseEvent.TranscriptComplete, { sessionId, timestamp: Date.now() });
|
|
486
523
|
});
|
|
487
524
|
watcher.on('transcript:plan_mode', () => {
|
|
488
525
|
const controller = this.respawnControllers.get(sessionId);
|
|
489
526
|
if (controller) {
|
|
490
527
|
controller.signalTranscriptPlanMode();
|
|
491
528
|
}
|
|
492
|
-
this.broadcast(
|
|
529
|
+
this.broadcast(SseEvent.TranscriptPlanMode, { sessionId, timestamp: Date.now() });
|
|
493
530
|
});
|
|
494
531
|
watcher.on('transcript:tool_start', (toolName) => {
|
|
495
|
-
this.broadcast(
|
|
532
|
+
this.broadcast(SseEvent.TranscriptToolStart, { sessionId, toolName, timestamp: Date.now() });
|
|
496
533
|
});
|
|
497
534
|
watcher.on('transcript:tool_end', (toolName, isError) => {
|
|
498
|
-
this.broadcast(
|
|
535
|
+
this.broadcast(SseEvent.TranscriptToolEnd, {
|
|
499
536
|
sessionId,
|
|
500
537
|
toolName,
|
|
501
538
|
isError,
|
|
@@ -635,7 +672,7 @@ export class WebServer extends EventEmitter {
|
|
|
635
672
|
controller.removeAllListeners();
|
|
636
673
|
this.respawnControllers.delete(sessionId);
|
|
637
674
|
// Notify UI that respawn is stopped for this session
|
|
638
|
-
this.broadcast(
|
|
675
|
+
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'session_cleanup' });
|
|
639
676
|
}
|
|
640
677
|
// Clear respawn timer
|
|
641
678
|
const timerInfo = this.respawnTimers.get(sessionId);
|
|
@@ -678,7 +715,7 @@ export class WebServer extends EventEmitter {
|
|
|
678
715
|
// Clear Ralph state from store
|
|
679
716
|
this.store.removeRalphState(sessionId);
|
|
680
717
|
// Broadcast Ralph cleared to update UI
|
|
681
|
-
this.broadcast(
|
|
718
|
+
this.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
|
682
719
|
sessionId,
|
|
683
720
|
state: {
|
|
684
721
|
enabled: false,
|
|
@@ -691,7 +728,7 @@ export class WebServer extends EventEmitter {
|
|
|
691
728
|
elapsedHours: null,
|
|
692
729
|
},
|
|
693
730
|
});
|
|
694
|
-
this.broadcast(
|
|
731
|
+
this.broadcast(SseEvent.SessionRalphTodoUpdate, {
|
|
695
732
|
sessionId,
|
|
696
733
|
todos: [],
|
|
697
734
|
stats: { total: 0, pending: 0, inProgress: 0, completed: 0 },
|
|
@@ -755,7 +792,7 @@ export class WebServer extends EventEmitter {
|
|
|
755
792
|
this.store.removeSession(sessionId);
|
|
756
793
|
}
|
|
757
794
|
}
|
|
758
|
-
this.broadcast(
|
|
795
|
+
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
|
759
796
|
}
|
|
760
797
|
async setupSessionListeners(session) {
|
|
761
798
|
// Create run summary tracker for this session
|
|
@@ -770,45 +807,51 @@ export class WebServer extends EventEmitter {
|
|
|
770
807
|
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
|
|
771
808
|
imageWatcher.watchSession(session.id, session.workingDir);
|
|
772
809
|
}
|
|
773
|
-
// Store all listener references for explicit cleanup on session delete
|
|
774
|
-
// This prevents memory leaks from closure references keeping objects alive
|
|
810
|
+
// Store all listener references for explicit cleanup on session delete.
|
|
811
|
+
// This prevents memory leaks from closure references keeping objects alive.
|
|
775
812
|
const listeners = {
|
|
813
|
+
// ─── Terminal Output ─────────────────────────────────────
|
|
814
|
+
// These listeners handle raw PTY output streaming to SSE clients.
|
|
815
|
+
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
|
|
776
816
|
terminal: (data) => {
|
|
777
|
-
// Use batching for better performance at high throughput
|
|
778
817
|
this.batchTerminalData(session.id, data);
|
|
779
818
|
},
|
|
819
|
+
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
|
|
780
820
|
clearTerminal: () => {
|
|
781
|
-
|
|
782
|
-
this.broadcast('session:clearTerminal', { id: session.id });
|
|
821
|
+
this.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
|
|
783
822
|
},
|
|
823
|
+
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer (e.g., after OpenCode TUI stabilizes) */
|
|
784
824
|
needsRefresh: () => {
|
|
785
|
-
|
|
786
|
-
this.broadcast('session:needsRefresh', { id: session.id });
|
|
825
|
+
this.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
|
|
787
826
|
},
|
|
827
|
+
// ─── Session Messages & Errors ──────────────────────────
|
|
828
|
+
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
|
|
788
829
|
message: (msg) => {
|
|
789
|
-
this.broadcast(
|
|
830
|
+
this.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
|
|
790
831
|
},
|
|
832
|
+
/** Broadcasts `session:error` + sends push notification */
|
|
791
833
|
error: (error) => {
|
|
792
|
-
this.broadcast(
|
|
793
|
-
this.sendPushNotifications(
|
|
834
|
+
this.broadcast(SseEvent.SessionError, { id: session.id, error });
|
|
835
|
+
this.sendPushNotifications(SseEvent.SessionError, {
|
|
794
836
|
sessionId: session.id,
|
|
795
837
|
sessionName: session.name,
|
|
796
838
|
error: String(error),
|
|
797
839
|
});
|
|
798
|
-
// Track in run summary
|
|
799
840
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
800
841
|
if (tracker)
|
|
801
842
|
tracker.recordError('Session error', String(error));
|
|
802
843
|
},
|
|
844
|
+
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
|
|
803
845
|
completion: (result, cost) => {
|
|
804
|
-
this.broadcast(
|
|
805
|
-
this.broadcast(
|
|
846
|
+
this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
|
|
847
|
+
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
|
806
848
|
this.persistSessionState(session);
|
|
807
|
-
// Track tokens in run summary (completion event has updated token values)
|
|
808
849
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
809
850
|
if (tracker)
|
|
810
851
|
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
|
811
852
|
},
|
|
853
|
+
// ─── Session Lifecycle ──────────────────────────────────
|
|
854
|
+
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
|
812
855
|
exit: (code) => {
|
|
813
856
|
getLifecycleLog().log({
|
|
814
857
|
event: 'exit',
|
|
@@ -818,8 +861,8 @@ export class WebServer extends EventEmitter {
|
|
|
818
861
|
});
|
|
819
862
|
// Wrap in try/catch to ensure cleanup always happens
|
|
820
863
|
try {
|
|
821
|
-
this.broadcast(
|
|
822
|
-
this.broadcast(
|
|
864
|
+
this.broadcast(SseEvent.SessionExit, { id: session.id, code });
|
|
865
|
+
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
|
823
866
|
this.persistSessionState(session);
|
|
824
867
|
}
|
|
825
868
|
catch (err) {
|
|
@@ -908,121 +951,130 @@ export class WebServer extends EventEmitter {
|
|
|
908
951
|
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
|
|
909
952
|
}
|
|
910
953
|
},
|
|
954
|
+
// ─── Activity State ─────────────────────────────────────
|
|
955
|
+
/** Broadcasts `session:working` — Claude started processing */
|
|
911
956
|
working: () => {
|
|
912
|
-
this.broadcast(
|
|
913
|
-
// Track in run summary
|
|
957
|
+
this.broadcast(SseEvent.SessionWorking, { id: session.id });
|
|
914
958
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
915
959
|
if (tracker) {
|
|
916
960
|
tracker.recordWorking();
|
|
917
961
|
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
|
918
962
|
}
|
|
919
963
|
},
|
|
964
|
+
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
|
920
965
|
idle: () => {
|
|
921
|
-
this.broadcast(
|
|
922
|
-
// Use debounced state update (idle can fire frequently)
|
|
966
|
+
this.broadcast(SseEvent.SessionIdle, { id: session.id });
|
|
923
967
|
this.broadcastSessionStateDebounced(session.id);
|
|
924
|
-
// Track in run summary
|
|
925
968
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
926
969
|
if (tracker) {
|
|
927
970
|
tracker.recordIdle();
|
|
928
971
|
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
|
929
972
|
}
|
|
930
973
|
},
|
|
931
|
-
// Background
|
|
974
|
+
// ─── Background Task Events ──────────────────────────────
|
|
975
|
+
// Debounced state updates to reduce serialization overhead.
|
|
976
|
+
/** Broadcasts `task:created` — new background task discovered */
|
|
932
977
|
taskCreated: (task) => {
|
|
933
|
-
this.broadcast(
|
|
978
|
+
this.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
|
|
934
979
|
this.broadcastSessionStateDebounced(session.id);
|
|
935
980
|
},
|
|
981
|
+
/** Batched broadcast of `task:updated` — high-frequency progress updates */
|
|
936
982
|
taskUpdated: (task) => {
|
|
937
|
-
// Use batching for better performance at high update rates
|
|
938
983
|
this.batchTaskUpdate(session.id, task);
|
|
939
984
|
},
|
|
985
|
+
/** Broadcasts `task:completed` — background task finished successfully */
|
|
940
986
|
taskCompleted: (task) => {
|
|
941
|
-
this.broadcast(
|
|
987
|
+
this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
|
|
942
988
|
this.broadcastSessionStateDebounced(session.id);
|
|
943
989
|
},
|
|
990
|
+
/** Broadcasts `task:failed` — background task errored */
|
|
944
991
|
taskFailed: (task, error) => {
|
|
945
|
-
this.broadcast(
|
|
992
|
+
this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
|
|
946
993
|
this.broadcastSessionStateDebounced(session.id);
|
|
947
994
|
},
|
|
995
|
+
// ─── Auto-Operations ────────────────────────────────────
|
|
996
|
+
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
|
|
948
997
|
autoClear: (data) => {
|
|
949
|
-
this.broadcast(
|
|
998
|
+
this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
|
|
950
999
|
this.broadcastSessionStateDebounced(session.id);
|
|
951
|
-
// Track in run summary
|
|
952
1000
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
953
1001
|
if (tracker)
|
|
954
1002
|
tracker.recordAutoClear(data.tokens, data.threshold);
|
|
955
1003
|
},
|
|
1004
|
+
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
|
|
956
1005
|
autoCompact: (data) => {
|
|
957
|
-
this.broadcast(
|
|
1006
|
+
this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
|
|
958
1007
|
this.broadcastSessionStateDebounced(session.id);
|
|
959
|
-
// Track in run summary
|
|
960
1008
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
961
1009
|
if (tracker)
|
|
962
1010
|
tracker.recordAutoCompact(data.tokens, data.threshold);
|
|
963
1011
|
},
|
|
964
|
-
//
|
|
1012
|
+
// ─── CLI Info ────────────────────────────────────────────
|
|
1013
|
+
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
|
965
1014
|
cliInfoUpdated: (data) => {
|
|
966
|
-
this.broadcast(
|
|
1015
|
+
this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
|
|
967
1016
|
this.broadcastSessionStateDebounced(session.id);
|
|
968
1017
|
},
|
|
969
|
-
// Ralph
|
|
1018
|
+
// ─── Ralph Tracking Events ──────────────────────────────
|
|
1019
|
+
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
|
970
1020
|
ralphLoopUpdate: (state) => {
|
|
971
|
-
this.broadcast(
|
|
972
|
-
// Persist Ralph state
|
|
1021
|
+
this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
|
|
973
1022
|
this.store.updateRalphState(session.id, { loop: state });
|
|
974
1023
|
},
|
|
1024
|
+
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
|
|
975
1025
|
ralphTodoUpdate: (todos) => {
|
|
976
|
-
this.broadcast(
|
|
977
|
-
// Persist Ralph state
|
|
1026
|
+
this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
|
|
978
1027
|
this.store.updateRalphState(session.id, { todos });
|
|
979
1028
|
},
|
|
1029
|
+
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
|
|
980
1030
|
ralphCompletionDetected: (phrase) => {
|
|
981
|
-
this.broadcast(
|
|
982
|
-
this.sendPushNotifications(
|
|
1031
|
+
this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
|
|
1032
|
+
this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
|
|
983
1033
|
sessionId: session.id,
|
|
984
1034
|
sessionName: session.name,
|
|
985
1035
|
phrase,
|
|
986
1036
|
});
|
|
987
|
-
// Track in run summary
|
|
988
1037
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
989
1038
|
if (tracker)
|
|
990
1039
|
tracker.recordRalphCompletion(phrase);
|
|
991
1040
|
},
|
|
992
|
-
|
|
1041
|
+
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
|
|
993
1042
|
ralphStatusBlockDetected: (block) => {
|
|
994
|
-
this.broadcast(
|
|
995
|
-
// Track in run summary
|
|
1043
|
+
this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
|
|
996
1044
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
997
1045
|
if (tracker) {
|
|
998
1046
|
tracker.addEvent(block.status === 'BLOCKED' ? 'warning' : 'idle_detected', block.status === 'BLOCKED' ? 'warning' : 'info', `Ralph Status: ${block.status}`, `Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`);
|
|
999
1047
|
}
|
|
1000
1048
|
},
|
|
1049
|
+
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
|
|
1001
1050
|
ralphCircuitBreakerUpdate: (status) => {
|
|
1002
|
-
this.broadcast(
|
|
1003
|
-
// Track state changes in run summary
|
|
1051
|
+
this.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
|
|
1004
1052
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1005
1053
|
if (tracker && status.state === 'OPEN') {
|
|
1006
1054
|
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
|
|
1007
1055
|
}
|
|
1008
1056
|
},
|
|
1057
|
+
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
|
|
1009
1058
|
ralphExitGateMet: (data) => {
|
|
1010
|
-
this.broadcast(
|
|
1011
|
-
// Track in run summary
|
|
1059
|
+
this.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
|
|
1012
1060
|
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1013
1061
|
if (tracker) {
|
|
1014
1062
|
tracker.addEvent('ralph_completion', 'success', 'Exit Gate Met', `Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`);
|
|
1015
1063
|
}
|
|
1016
1064
|
},
|
|
1017
|
-
// Bash
|
|
1065
|
+
// ─── Bash Tool Tracking ────────────────────────────────
|
|
1066
|
+
// Used for clickable file paths in the UI.
|
|
1067
|
+
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
|
|
1018
1068
|
bashToolStart: (tool) => {
|
|
1019
|
-
this.broadcast(
|
|
1069
|
+
this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
|
|
1020
1070
|
},
|
|
1071
|
+
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
|
|
1021
1072
|
bashToolEnd: (tool) => {
|
|
1022
|
-
this.broadcast(
|
|
1073
|
+
this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
|
|
1023
1074
|
},
|
|
1075
|
+
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
|
|
1024
1076
|
bashToolsUpdate: (tools) => {
|
|
1025
|
-
this.broadcast(
|
|
1077
|
+
this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
|
1026
1078
|
},
|
|
1027
1079
|
};
|
|
1028
1080
|
// Store listener refs for cleanup
|
|
@@ -1059,102 +1111,124 @@ export class WebServer extends EventEmitter {
|
|
|
1059
1111
|
controller.setTeamWatcher(this.teamWatcher);
|
|
1060
1112
|
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
|
1061
1113
|
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
|
1114
|
+
// ─── Respawn State Machine ──────────────────────────────
|
|
1115
|
+
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
|
|
1062
1116
|
controller.on('stateChanged', (state, prevState) => {
|
|
1063
|
-
this.broadcast(
|
|
1064
|
-
// Track in run summary (lazy lookup since tracker may be created after controller)
|
|
1117
|
+
this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
|
|
1065
1118
|
const tracker = getTracker();
|
|
1066
1119
|
if (tracker)
|
|
1067
1120
|
tracker.recordStateChange(state, `${prevState} → ${state}`);
|
|
1068
1121
|
});
|
|
1122
|
+
// ─── Respawn Cycle Lifecycle ────────────────────────────
|
|
1123
|
+
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
|
|
1069
1124
|
controller.on('respawnCycleStarted', (cycleNumber) => {
|
|
1070
|
-
this.broadcast(
|
|
1125
|
+
this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
|
|
1071
1126
|
});
|
|
1127
|
+
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
|
|
1072
1128
|
controller.on('respawnCycleCompleted', (cycleNumber) => {
|
|
1073
|
-
this.broadcast(
|
|
1129
|
+
this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
|
|
1074
1130
|
});
|
|
1131
|
+
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
|
|
1075
1132
|
controller.on('respawnBlocked', (data) => {
|
|
1076
|
-
this.broadcast(
|
|
1133
|
+
this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
|
|
1077
1134
|
const sessionForPush = this.sessions.get(sessionId);
|
|
1078
|
-
this.sendPushNotifications(
|
|
1135
|
+
this.sendPushNotifications(SseEvent.RespawnBlocked, {
|
|
1079
1136
|
sessionId,
|
|
1080
1137
|
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
|
1081
1138
|
reason: data.reason,
|
|
1082
1139
|
});
|
|
1083
|
-
// Track in run summary (lazy lookup)
|
|
1084
1140
|
const tracker = getTracker();
|
|
1085
1141
|
if (tracker)
|
|
1086
1142
|
tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
|
1087
1143
|
});
|
|
1144
|
+
// ─── Respawn Step Progress ──────────────────────────────
|
|
1145
|
+
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
|
|
1088
1146
|
controller.on('stepSent', (step, input) => {
|
|
1089
|
-
this.broadcast(
|
|
1147
|
+
this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
|
|
1090
1148
|
});
|
|
1149
|
+
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
|
|
1091
1150
|
controller.on('stepCompleted', (step) => {
|
|
1092
|
-
this.broadcast(
|
|
1151
|
+
this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
|
|
1093
1152
|
});
|
|
1153
|
+
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
|
|
1094
1154
|
controller.on('detectionUpdate', (detection) => {
|
|
1095
|
-
this.broadcast(
|
|
1155
|
+
this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
|
|
1096
1156
|
});
|
|
1157
|
+
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
|
|
1097
1158
|
controller.on('autoAcceptSent', () => {
|
|
1098
|
-
this.broadcast(
|
|
1159
|
+
this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
|
|
1099
1160
|
});
|
|
1161
|
+
// ─── AI Checker Events ──────────────────────────────────
|
|
1162
|
+
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
|
|
1100
1163
|
controller.on('aiCheckStarted', () => {
|
|
1101
|
-
this.broadcast(
|
|
1164
|
+
this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
|
|
1102
1165
|
});
|
|
1166
|
+
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
|
|
1103
1167
|
controller.on('aiCheckCompleted', (result) => {
|
|
1104
|
-
this.broadcast(
|
|
1168
|
+
this.broadcast(SseEvent.RespawnAiCheckCompleted, {
|
|
1105
1169
|
sessionId,
|
|
1106
1170
|
verdict: result.verdict,
|
|
1107
1171
|
reasoning: result.reasoning,
|
|
1108
1172
|
durationMs: result.durationMs,
|
|
1109
1173
|
});
|
|
1110
|
-
// Track in run summary (lazy lookup)
|
|
1111
1174
|
const tracker = getTracker();
|
|
1112
1175
|
if (tracker)
|
|
1113
1176
|
tracker.recordAiCheckResult(result.verdict);
|
|
1114
1177
|
});
|
|
1178
|
+
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
|
|
1115
1179
|
controller.on('aiCheckFailed', (error) => {
|
|
1116
|
-
this.broadcast(
|
|
1117
|
-
// Track in run summary (lazy lookup)
|
|
1180
|
+
this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
|
|
1118
1181
|
const tracker = getTracker();
|
|
1119
1182
|
if (tracker)
|
|
1120
1183
|
tracker.recordError('AI check failed', error);
|
|
1121
1184
|
});
|
|
1185
|
+
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
|
|
1122
1186
|
controller.on('aiCheckCooldown', (active, endsAt) => {
|
|
1123
|
-
this.broadcast(
|
|
1187
|
+
this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
|
|
1124
1188
|
});
|
|
1189
|
+
// ─── Plan Checker Events ────────────────────────────────
|
|
1190
|
+
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
|
|
1125
1191
|
controller.on('planCheckStarted', () => {
|
|
1126
|
-
this.broadcast(
|
|
1192
|
+
this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
|
|
1127
1193
|
});
|
|
1194
|
+
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
|
|
1128
1195
|
controller.on('planCheckCompleted', (result) => {
|
|
1129
|
-
this.broadcast(
|
|
1196
|
+
this.broadcast(SseEvent.RespawnPlanCheckCompleted, {
|
|
1130
1197
|
sessionId,
|
|
1131
1198
|
verdict: result.verdict,
|
|
1132
1199
|
reasoning: result.reasoning,
|
|
1133
1200
|
durationMs: result.durationMs,
|
|
1134
1201
|
});
|
|
1135
1202
|
});
|
|
1203
|
+
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
|
|
1136
1204
|
controller.on('planCheckFailed', (error) => {
|
|
1137
|
-
this.broadcast(
|
|
1205
|
+
this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
|
|
1138
1206
|
});
|
|
1139
|
-
// Timer
|
|
1207
|
+
// ─── Timer Events (UI countdown display) ────────────────
|
|
1208
|
+
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
|
|
1140
1209
|
controller.on('timerStarted', (timer) => {
|
|
1141
|
-
this.broadcast(
|
|
1210
|
+
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
|
|
1142
1211
|
});
|
|
1212
|
+
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
|
|
1143
1213
|
controller.on('timerCancelled', (timerName, reason) => {
|
|
1144
|
-
this.broadcast(
|
|
1214
|
+
this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
|
|
1145
1215
|
});
|
|
1216
|
+
/** Broadcasts `respawn:timerCompleted` — timer expired */
|
|
1146
1217
|
controller.on('timerCompleted', (timerName) => {
|
|
1147
|
-
this.broadcast(
|
|
1218
|
+
this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
|
|
1148
1219
|
});
|
|
1220
|
+
// ─── Logging & Errors ───────────────────────────────────
|
|
1221
|
+
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
|
|
1149
1222
|
controller.on('actionLog', (action) => {
|
|
1150
|
-
this.broadcast(
|
|
1223
|
+
this.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
|
|
1151
1224
|
});
|
|
1225
|
+
/** Broadcasts `respawn:log` — general respawn log message */
|
|
1152
1226
|
controller.on('log', (message) => {
|
|
1153
|
-
this.broadcast(
|
|
1227
|
+
this.broadcast(SseEvent.RespawnLog, { sessionId, message });
|
|
1154
1228
|
});
|
|
1229
|
+
/** Broadcasts `respawn:error` — respawn controller error */
|
|
1155
1230
|
controller.on('error', (error) => {
|
|
1156
|
-
this.broadcast(
|
|
1157
|
-
// Track in run summary (lazy lookup)
|
|
1231
|
+
this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
|
|
1158
1232
|
const tracker = getTracker();
|
|
1159
1233
|
if (tracker)
|
|
1160
1234
|
tracker.recordError('Respawn error', error.message);
|
|
@@ -1175,7 +1249,7 @@ export class WebServer extends EventEmitter {
|
|
|
1175
1249
|
controller.stop();
|
|
1176
1250
|
controller.removeAllListeners();
|
|
1177
1251
|
this.respawnControllers.delete(sessionId);
|
|
1178
|
-
this.broadcast(
|
|
1252
|
+
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
|
|
1179
1253
|
}
|
|
1180
1254
|
this.respawnTimers.delete(sessionId);
|
|
1181
1255
|
// Update persisted state (respawn no longer active)
|
|
@@ -1185,7 +1259,7 @@ export class WebServer extends EventEmitter {
|
|
|
1185
1259
|
}
|
|
1186
1260
|
}, durationMinutes * 60 * 1000);
|
|
1187
1261
|
this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
|
|
1188
|
-
this.broadcast(
|
|
1262
|
+
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
|
|
1189
1263
|
}
|
|
1190
1264
|
/**
|
|
1191
1265
|
* Restore a RespawnController from persisted configuration.
|
|
@@ -1238,7 +1312,7 @@ export class WebServer extends EventEmitter {
|
|
|
1238
1312
|
const ctrl = this.respawnControllers.get(session.id);
|
|
1239
1313
|
if (ctrl && ctrl.state === 'stopped') {
|
|
1240
1314
|
ctrl.start();
|
|
1241
|
-
this.broadcast(
|
|
1315
|
+
this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
|
|
1242
1316
|
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
|
1243
1317
|
}
|
|
1244
1318
|
}, delayMs);
|
|
@@ -1334,7 +1408,7 @@ export class WebServer extends EventEmitter {
|
|
|
1334
1408
|
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
|
1335
1409
|
};
|
|
1336
1410
|
this.scheduledRuns.set(id, run);
|
|
1337
|
-
this.broadcast(
|
|
1411
|
+
this.broadcast(SseEvent.ScheduledCreated, run);
|
|
1338
1412
|
// Start the run loop (fire-and-forget with error handling)
|
|
1339
1413
|
this.runScheduledLoop(id).catch((err) => {
|
|
1340
1414
|
console.error(`[WebServer] Scheduled run ${id} failed:`, err);
|
|
@@ -1342,7 +1416,7 @@ export class WebServer extends EventEmitter {
|
|
|
1342
1416
|
if (failedRun && failedRun.status === 'running') {
|
|
1343
1417
|
failedRun.status = 'stopped';
|
|
1344
1418
|
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1345
|
-
this.broadcast(
|
|
1419
|
+
this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' });
|
|
1346
1420
|
}
|
|
1347
1421
|
});
|
|
1348
1422
|
return run;
|
|
@@ -1353,7 +1427,7 @@ export class WebServer extends EventEmitter {
|
|
|
1353
1427
|
return;
|
|
1354
1428
|
const addLog = (msg) => {
|
|
1355
1429
|
run.logs.push(`[${new Date().toISOString()}] ${msg}`);
|
|
1356
|
-
this.broadcast(
|
|
1430
|
+
this.broadcast(SseEvent.ScheduledLog, { id: runId, log: run.logs[run.logs.length - 1] });
|
|
1357
1431
|
};
|
|
1358
1432
|
while (Date.now() < run.endAt && run.status === 'running') {
|
|
1359
1433
|
// Check session limit before creating new session
|
|
@@ -1372,7 +1446,7 @@ export class WebServer extends EventEmitter {
|
|
|
1372
1446
|
await this.setupSessionListeners(session);
|
|
1373
1447
|
run.sessionId = session.id;
|
|
1374
1448
|
addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`);
|
|
1375
|
-
this.broadcast(
|
|
1449
|
+
this.broadcast(SseEvent.ScheduledUpdated, run);
|
|
1376
1450
|
// Run the prompt
|
|
1377
1451
|
const timeRemaining = Math.round((run.endAt - Date.now()) / 60000);
|
|
1378
1452
|
const enhancedPrompt = `${run.prompt}\n\nNote: You have approximately ${timeRemaining} minutes remaining in this scheduled run. Work efficiently.`;
|
|
@@ -1380,7 +1454,7 @@ export class WebServer extends EventEmitter {
|
|
|
1380
1454
|
run.completedTasks++;
|
|
1381
1455
|
run.totalCost += result.cost;
|
|
1382
1456
|
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
|
|
1383
|
-
this.broadcast(
|
|
1457
|
+
this.broadcast(SseEvent.ScheduledUpdated, run);
|
|
1384
1458
|
// Clean up the session after iteration to prevent memory leaks
|
|
1385
1459
|
await this.cleanupSession(session.id, true, 'scheduled_run');
|
|
1386
1460
|
run.sessionId = null;
|
|
@@ -1389,7 +1463,7 @@ export class WebServer extends EventEmitter {
|
|
|
1389
1463
|
}
|
|
1390
1464
|
catch (err) {
|
|
1391
1465
|
addLog(`Error: ${getErrorMessage(err)}`);
|
|
1392
|
-
this.broadcast(
|
|
1466
|
+
this.broadcast(SseEvent.ScheduledUpdated, run);
|
|
1393
1467
|
// Clean up the session on error too
|
|
1394
1468
|
if (session) {
|
|
1395
1469
|
try {
|
|
@@ -1408,7 +1482,7 @@ export class WebServer extends EventEmitter {
|
|
|
1408
1482
|
run.status = 'completed';
|
|
1409
1483
|
addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`);
|
|
1410
1484
|
}
|
|
1411
|
-
this.broadcast(
|
|
1485
|
+
this.broadcast(SseEvent.ScheduledCompleted, run);
|
|
1412
1486
|
}
|
|
1413
1487
|
async stopScheduledRun(id) {
|
|
1414
1488
|
const run = this.scheduledRuns.get(id);
|
|
@@ -1421,7 +1495,7 @@ export class WebServer extends EventEmitter {
|
|
|
1421
1495
|
await this.cleanupSession(run.sessionId, true, 'scheduled_run_stopped');
|
|
1422
1496
|
run.sessionId = null;
|
|
1423
1497
|
}
|
|
1424
|
-
this.broadcast(
|
|
1498
|
+
this.broadcast(SseEvent.ScheduledStopped, run);
|
|
1425
1499
|
}
|
|
1426
1500
|
/**
|
|
1427
1501
|
* Get session state with respawn controller info included.
|
|
@@ -1467,7 +1541,7 @@ export class WebServer extends EventEmitter {
|
|
|
1467
1541
|
}
|
|
1468
1542
|
for (const id of toDelete) {
|
|
1469
1543
|
this.scheduledRuns.delete(id);
|
|
1470
|
-
this.broadcast(
|
|
1544
|
+
this.broadcast(SseEvent.ScheduledDeleted, { id });
|
|
1471
1545
|
}
|
|
1472
1546
|
if (toDelete.length > 0) {
|
|
1473
1547
|
console.log(`[Server] Cleaned up ${toDelete.length} old scheduled run(s)`);
|
|
@@ -1545,7 +1619,7 @@ export class WebServer extends EventEmitter {
|
|
|
1545
1619
|
// Client may have missed terminal data during backpressure.
|
|
1546
1620
|
// Tell it to reload the active session's buffer to recover.
|
|
1547
1621
|
try {
|
|
1548
|
-
reply.raw.write(`event:
|
|
1622
|
+
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n`);
|
|
1549
1623
|
}
|
|
1550
1624
|
catch {
|
|
1551
1625
|
/* client gone */
|
|
@@ -1559,21 +1633,25 @@ export class WebServer extends EventEmitter {
|
|
|
1559
1633
|
}
|
|
1560
1634
|
}
|
|
1561
1635
|
broadcast(event, data) {
|
|
1562
|
-
//
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
|
|
1636
|
+
// Skip serialization entirely when no clients are listening
|
|
1637
|
+
if (this.sseClients.size === 0)
|
|
1638
|
+
return;
|
|
1639
|
+
// Invalidate caches only on structural changes (creation/deletion).
|
|
1640
|
+
// SessionUpdated fires too frequently (working/idle transitions, completion)
|
|
1641
|
+
// and makes the 1s TTL cache useless — the debounced session:updated follows
|
|
1642
|
+
// within 500ms anyway, and these caches serve /api/sessions and SSE init
|
|
1643
|
+
// which aren't polled rapidly.
|
|
1644
|
+
if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted) {
|
|
1570
1645
|
this.cachedLightState = null;
|
|
1571
1646
|
this.cachedSessionsList = null;
|
|
1572
1647
|
}
|
|
1573
|
-
// Performance optimization: serialize JSON once for all clients
|
|
1648
|
+
// Performance optimization: serialize JSON once for all clients.
|
|
1649
|
+
// Only append Cloudflare tunnel padding when tunnel is actually active —
|
|
1650
|
+
// direct/Tailscale clients don't need 8KB padding on every event.
|
|
1651
|
+
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
|
1574
1652
|
let message;
|
|
1575
1653
|
try {
|
|
1576
|
-
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n
|
|
1654
|
+
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
|
|
1577
1655
|
}
|
|
1578
1656
|
catch (err) {
|
|
1579
1657
|
// Handle circular references or non-serializable values
|
|
@@ -1687,7 +1765,7 @@ export class WebServer extends EventEmitter {
|
|
|
1687
1765
|
return;
|
|
1688
1766
|
}
|
|
1689
1767
|
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
|
1690
|
-
this.broadcast(
|
|
1768
|
+
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
|
1691
1769
|
}
|
|
1692
1770
|
this.taskUpdateBatches.clear();
|
|
1693
1771
|
}
|
|
@@ -1718,7 +1796,7 @@ export class WebServer extends EventEmitter {
|
|
|
1718
1796
|
const session = this.sessions.get(sessionId);
|
|
1719
1797
|
if (session) {
|
|
1720
1798
|
// Single expensive serialization per batch interval
|
|
1721
|
-
this.broadcast(
|
|
1799
|
+
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
|
1722
1800
|
}
|
|
1723
1801
|
}
|
|
1724
1802
|
this.stateUpdatePending.clear();
|
|
@@ -1726,7 +1804,7 @@ export class WebServer extends EventEmitter {
|
|
|
1726
1804
|
// ========== Web Push ==========
|
|
1727
1805
|
/** Map SSE event names to push notification payloads */
|
|
1728
1806
|
static PUSH_EVENT_MAP = {
|
|
1729
|
-
|
|
1807
|
+
[SseEvent.HookPermissionPrompt]: {
|
|
1730
1808
|
title: 'Permission Required',
|
|
1731
1809
|
urgency: 'critical',
|
|
1732
1810
|
actions: [
|
|
@@ -1734,12 +1812,12 @@ export class WebServer extends EventEmitter {
|
|
|
1734
1812
|
{ action: 'deny', title: 'Deny' },
|
|
1735
1813
|
],
|
|
1736
1814
|
},
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1815
|
+
[SseEvent.HookElicitationDialog]: { title: 'Question Asked', urgency: 'critical' },
|
|
1816
|
+
[SseEvent.HookIdlePrompt]: { title: 'Waiting for Input', urgency: 'warning' },
|
|
1817
|
+
[SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
|
|
1818
|
+
[SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
|
|
1819
|
+
[SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
|
|
1820
|
+
[SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
|
|
1743
1821
|
};
|
|
1744
1822
|
/**
|
|
1745
1823
|
* Send push notifications for a given event to all subscribed devices.
|
|
@@ -1759,19 +1837,19 @@ export class WebServer extends EventEmitter {
|
|
|
1759
1837
|
const sessionId = data.sessionId || '';
|
|
1760
1838
|
// Build body text from event data
|
|
1761
1839
|
let body = sessionName ? `[${sessionName}]` : '';
|
|
1762
|
-
if (event ===
|
|
1840
|
+
if (event === SseEvent.SessionError && data.error) {
|
|
1763
1841
|
body += body ? ' ' : '';
|
|
1764
1842
|
body += String(data.error).slice(0, 200);
|
|
1765
1843
|
}
|
|
1766
|
-
else if (event ===
|
|
1844
|
+
else if (event === SseEvent.RespawnBlocked && data.reason) {
|
|
1767
1845
|
body += body ? ' ' : '';
|
|
1768
1846
|
body += String(data.reason);
|
|
1769
1847
|
}
|
|
1770
|
-
else if (event ===
|
|
1848
|
+
else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
|
1771
1849
|
body += body ? ' ' : '';
|
|
1772
1850
|
body += String(data.phrase);
|
|
1773
1851
|
}
|
|
1774
|
-
else if (event ===
|
|
1852
|
+
else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
|
1775
1853
|
body += body ? ' ' : '';
|
|
1776
1854
|
body += `Tool: ${String(data.tool_name)}`;
|
|
1777
1855
|
}
|
|
@@ -1814,8 +1892,11 @@ export class WebServer extends EventEmitter {
|
|
|
1814
1892
|
deadClients.push(client);
|
|
1815
1893
|
}
|
|
1816
1894
|
else {
|
|
1817
|
-
// Send SSE comment as keep-alive
|
|
1818
|
-
|
|
1895
|
+
// Send SSE comment as keep-alive. Only add padding when tunnel is
|
|
1896
|
+
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
|
|
1897
|
+
// for direct/Tailscale connections.
|
|
1898
|
+
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
|
|
1899
|
+
client.raw.write(ka);
|
|
1819
1900
|
}
|
|
1820
1901
|
}
|
|
1821
1902
|
catch {
|
|
@@ -1881,7 +1962,7 @@ export class WebServer extends EventEmitter {
|
|
|
1881
1962
|
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
|
1882
1963
|
this.cleanup.setInterval(() => {
|
|
1883
1964
|
this.cleanupDeadSSEClients();
|
|
1884
|
-
},
|
|
1965
|
+
}, SSE_HEARTBEAT_INTERVAL, { description: 'SSE heartbeat + dead client cleanup' });
|
|
1885
1966
|
// Start token recording timer (every 5 minutes for long-running sessions)
|
|
1886
1967
|
this.cleanup.setInterval(() => {
|
|
1887
1968
|
this.recordPeriodicTokenUsage();
|