aicodeman 0.5.2 → 0.5.4
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/dist/ai-checker-base.d.ts.map +1 -1
- package/dist/ai-checker-base.js +3 -2
- package/dist/ai-checker-base.js.map +1 -1
- package/dist/bash-tool-parser.d.ts +6 -0
- package/dist/bash-tool-parser.d.ts.map +1 -1
- package/dist/bash-tool-parser.js +87 -101
- package/dist/bash-tool-parser.js.map +1 -1
- package/dist/file-stream-manager.d.ts.map +1 -1
- package/dist/file-stream-manager.js +2 -1
- package/dist/file-stream-manager.js.map +1 -1
- package/dist/orchestrator-loop.d.ts +2 -0
- package/dist/orchestrator-loop.d.ts.map +1 -1
- package/dist/orchestrator-loop.js +27 -22
- package/dist/orchestrator-loop.js.map +1 -1
- package/dist/orchestrator-verifier.d.ts +1 -1
- package/dist/orchestrator-verifier.d.ts.map +1 -1
- package/dist/orchestrator-verifier.js +3 -2
- package/dist/orchestrator-verifier.js.map +1 -1
- package/dist/plan-orchestrator.d.ts +4 -1
- package/dist/plan-orchestrator.d.ts.map +1 -1
- package/dist/plan-orchestrator.js +66 -88
- package/dist/plan-orchestrator.js.map +1 -1
- package/dist/ralph-status-parser.d.ts +2 -0
- package/dist/ralph-status-parser.d.ts.map +1 -1
- package/dist/ralph-status-parser.js +98 -102
- package/dist/ralph-status-parser.js.map +1 -1
- package/dist/ralph-tracker.d.ts +9 -0
- package/dist/ralph-tracker.d.ts.map +1 -1
- package/dist/ralph-tracker.js +52 -60
- package/dist/ralph-tracker.js.map +1 -1
- package/dist/respawn-controller.d.ts +18 -1
- package/dist/respawn-controller.d.ts.map +1 -1
- package/dist/respawn-controller.js +215 -181
- package/dist/respawn-controller.js.map +1 -1
- package/dist/session-auto-ops.d.ts.map +1 -1
- package/dist/session-auto-ops.js +57 -55
- package/dist/session-auto-ops.js.map +1 -1
- package/dist/session.d.ts +5 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +182 -218
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +6 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +67 -79
- package/dist/state-store.js.map +1 -1
- package/dist/subagent-watcher.d.ts +24 -0
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +215 -220
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/tmux-manager.d.ts +17 -0
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +57 -66
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +2 -1
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/web/public/api-client.3adebdc2.js.gz +0 -0
- package/dist/web/public/app.16290ae3.js +26 -0
- package/dist/web/public/app.16290ae3.js.br +0 -0
- package/dist/web/public/app.16290ae3.js.gz +0 -0
- package/dist/web/public/constants.64161167.js.gz +0 -0
- package/dist/web/public/index.html +7 -7
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.88082175.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.9fb81db6.js.gz +0 -0
- package/dist/web/public/mobile-handlers.1e2a8ef8.js.gz +0 -0
- package/dist/web/public/mobile.0b213796.css.gz +0 -0
- package/dist/web/public/notification-manager.2d5ea8ec.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.8204db1e.js → panels-ui.2d5b9703.js} +1 -1
- package/dist/web/public/panels-ui.2d5b9703.js.br +0 -0
- package/dist/web/public/panels-ui.2d5b9703.js.gz +0 -0
- package/dist/web/public/{ralph-panel.a2733fd5.js → ralph-panel.61076370.js} +1 -1
- package/dist/web/public/ralph-panel.61076370.js.br +0 -0
- package/dist/web/public/ralph-panel.61076370.js.gz +0 -0
- package/dist/web/public/ralph-wizard.f31ab90e.js.gz +0 -0
- package/dist/web/public/{respawn-ui.372c6ea7.js → respawn-ui.60be6ef5.js} +1 -1
- package/dist/web/public/respawn-ui.60be6ef5.js.br +0 -0
- package/dist/web/public/respawn-ui.60be6ef5.js.gz +0 -0
- package/dist/web/public/{session-ui.72f2f538.js → session-ui.554092ae.js} +1 -1
- package/dist/web/public/session-ui.554092ae.js.br +0 -0
- package/dist/web/public/session-ui.554092ae.js.gz +0 -0
- package/dist/web/public/{settings-ui.bd3eaadb.js → settings-ui.c58b0b9b.js} +7 -7
- package/dist/web/public/settings-ui.c58b0b9b.js.br +0 -0
- package/dist/web/public/settings-ui.c58b0b9b.js.gz +0 -0
- package/dist/web/public/styles.111ff326.css.gz +0 -0
- package/dist/web/public/subagent-windows.a366a4ad.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.474f79df.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.137ad9f0.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.085e9e73.js.gz +0 -0
- package/dist/web/respawn-event-wiring.d.ts +51 -0
- package/dist/web/respawn-event-wiring.d.ts.map +1 -0
- package/dist/web/respawn-event-wiring.js +280 -0
- package/dist/web/respawn-event-wiring.js.map +1 -0
- package/dist/web/route-helpers.d.ts +23 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +53 -0
- 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 +2 -11
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +8 -24
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
- package/dist/web/routes/orchestrator-routes.js +23 -30
- package/dist/web/routes/orchestrator-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +17 -71
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/server.d.ts +4 -51
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +98 -941
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +89 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -0
- package/dist/web/session-listener-wiring.js +290 -0
- package/dist/web/session-listener-wiring.js.map +1 -0
- package/dist/web/sse-stream-manager.d.ts +91 -0
- package/dist/web/sse-stream-manager.d.ts.map +1 -0
- package/dist/web/sse-stream-manager.js +426 -0
- package/dist/web/sse-stream-manager.js.map +1 -0
- package/package.json +1 -1
- package/dist/web/public/app.e09fd4a6.js +0 -26
- package/dist/web/public/app.e09fd4a6.js.br +0 -0
- package/dist/web/public/app.e09fd4a6.js.gz +0 -0
- package/dist/web/public/panels-ui.8204db1e.js.br +0 -0
- package/dist/web/public/panels-ui.8204db1e.js.gz +0 -0
- package/dist/web/public/ralph-panel.a2733fd5.js.br +0 -0
- package/dist/web/public/ralph-panel.a2733fd5.js.gz +0 -0
- package/dist/web/public/respawn-ui.372c6ea7.js.br +0 -0
- package/dist/web/public/respawn-ui.372c6ea7.js.gz +0 -0
- package/dist/web/public/session-ui.72f2f538.js.br +0 -0
- package/dist/web/public/session-ui.72f2f538.js.gz +0 -0
- package/dist/web/public/settings-ui.bd3eaadb.js.br +0 -0
- package/dist/web/public/settings-ui.bd3eaadb.js.gz +0 -0
package/dist/web/server.js
CHANGED
|
@@ -38,8 +38,7 @@ import fs from 'node:fs/promises';
|
|
|
38
38
|
import { execSync } from 'node:child_process';
|
|
39
39
|
import { homedir } from 'node:os';
|
|
40
40
|
import { EventEmitter } from 'node:events';
|
|
41
|
-
import { Session
|
|
42
|
-
import { RespawnController } from '../respawn-controller.js';
|
|
41
|
+
import { Session } from '../session.js';
|
|
43
42
|
import { createMultiplexer } from '../mux-factory.js';
|
|
44
43
|
import { getStore } from '../state-store.js';
|
|
45
44
|
import { extractCompletionPhrase } from '../ralph-config.js';
|
|
@@ -56,22 +55,20 @@ import { OrchestratorLoop } from '../orchestrator-loop.js';
|
|
|
56
55
|
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
|
57
56
|
import { PushSubscriptionStore } from '../push-store.js';
|
|
58
57
|
import webpush from 'web-push';
|
|
58
|
+
import { SseStreamManager } from './sse-stream-manager.js';
|
|
59
|
+
import { createSessionListeners, attachSessionListeners, detachSessionListeners, } from './session-listener-wiring.js';
|
|
60
|
+
import { wireRespawnListeners, setupTimedRespawn, restoreRespawnController, saveRespawnConfig, } from './respawn-event-wiring.js';
|
|
59
61
|
// Load version from package.json
|
|
60
62
|
const require = createRequire(import.meta.url);
|
|
61
63
|
const { version: APP_VERSION } = require('../../package.json');
|
|
62
64
|
import { getErrorMessage, ApiErrorCode, createErrorResponse, DEFAULT_NICE_CONFIG, } from '../types.js';
|
|
63
|
-
import { CleanupManager, KeyedDebouncer
|
|
65
|
+
import { CleanupManager, KeyedDebouncer } from '../utils/index.js';
|
|
64
66
|
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
|
65
67
|
import { SseEvent } from './sse-events.js';
|
|
66
68
|
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
|
67
69
|
import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerOrchestratorRoutes, registerWsRoutes, } from './routes/index.js';
|
|
68
70
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
69
|
-
import {
|
|
70
|
-
// SSE padding for Cloudflare tunnel buffer flushing.
|
|
71
|
-
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
|
|
72
|
-
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
|
|
73
|
-
// Pre-computed once at startup to avoid repeated string allocation.
|
|
74
|
-
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
|
|
71
|
+
import { SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEARTBEAT_INTERVAL, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, } from '../config/server-timing.js';
|
|
75
72
|
/**
|
|
76
73
|
* Get or generate a self-signed TLS certificate for HTTPS.
|
|
77
74
|
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
|
@@ -109,41 +106,14 @@ export class WebServer extends EventEmitter {
|
|
|
109
106
|
// Store session listener references for explicit cleanup (prevents memory leaks)
|
|
110
107
|
sessionListenerRefs = new Map();
|
|
111
108
|
scheduledRuns = new Map();
|
|
112
|
-
|
|
113
|
-
* SSE clients mapped to their session subscription filter.
|
|
114
|
-
* Value is a Set of session IDs the client wants events for,
|
|
115
|
-
* or `null` meaning "receive all events" (backwards-compatible default).
|
|
116
|
-
*/
|
|
117
|
-
sseClients = new Map();
|
|
118
|
-
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
|
119
|
-
remoteSseClients = new Set();
|
|
120
|
-
/** Clients with backpressure — skip writes until 'drain' fires */
|
|
121
|
-
backpressuredClients = new Set();
|
|
109
|
+
sse;
|
|
122
110
|
store = getStore();
|
|
123
111
|
port;
|
|
124
112
|
https;
|
|
125
113
|
testMode;
|
|
126
114
|
mux;
|
|
127
|
-
// Terminal batching for performance
|
|
128
|
-
terminalBatches = new Map();
|
|
129
|
-
terminalBatchSizes = new Map(); // Running total avoids O(n) reduce per push
|
|
130
|
-
terminalBatchTimers = new Map(); // Per-session timers (staggered flushes)
|
|
131
|
-
// Adaptive batching: track rapid events to extend batch window (per-session)
|
|
132
|
-
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
|
|
133
|
-
lastTerminalEventTime = new StaleExpirationMap({
|
|
134
|
-
ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
|
|
135
|
-
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
|
136
|
-
});
|
|
137
115
|
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
|
138
116
|
cleanup = new CleanupManager();
|
|
139
|
-
// SSE event batching
|
|
140
|
-
taskUpdateBatches = new Map();
|
|
141
|
-
taskUpdateBatchTimerId = null;
|
|
142
|
-
// State update batching (reduce expensive toDetailedState() serialization)
|
|
143
|
-
stateUpdatePending = new Set();
|
|
144
|
-
stateUpdateTimerId = null;
|
|
145
|
-
// Flag to prevent new timers during shutdown
|
|
146
|
-
_isStopping = false;
|
|
147
117
|
// Cached light state for SSE init (avoids rebuilding on every reconnect)
|
|
148
118
|
cachedLightState = null;
|
|
149
119
|
static LIGHT_STATE_CACHE_TTL_MS = 1000;
|
|
@@ -158,14 +128,10 @@ export class WebServer extends EventEmitter {
|
|
|
158
128
|
// Active plan orchestrators (for cancellation via API)
|
|
159
129
|
activePlanOrchestrators = new Map();
|
|
160
130
|
persistDeb = new KeyedDebouncer(100);
|
|
161
|
-
// Grace period before starting restored respawn controllers (2 minutes)
|
|
162
|
-
static RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
|
|
163
131
|
// Stored listener handlers for cleanup
|
|
164
132
|
subagentWatcherHandlers = null;
|
|
165
133
|
imageWatcherHandlers = null;
|
|
166
134
|
tunnelManager = new TunnelManager();
|
|
167
|
-
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
|
|
168
|
-
_isTunnelActive = false;
|
|
169
135
|
authSessions = null;
|
|
170
136
|
authFailures = null;
|
|
171
137
|
qrAuthFailures = null;
|
|
@@ -187,6 +153,12 @@ export class WebServer extends EventEmitter {
|
|
|
187
153
|
this.app = Fastify({ logger: false });
|
|
188
154
|
}
|
|
189
155
|
this.mux = createMultiplexer();
|
|
156
|
+
this.sse = new SseStreamManager({
|
|
157
|
+
getSessionStateWithRespawn: (sessionId) => {
|
|
158
|
+
const session = this.sessions.get(sessionId);
|
|
159
|
+
return session ? this.getSessionStateWithRespawn(session) : null;
|
|
160
|
+
},
|
|
161
|
+
}, this.cleanup);
|
|
190
162
|
// Set up mux event listeners
|
|
191
163
|
this.mux.on('sessionCreated', (session) => {
|
|
192
164
|
this.broadcast(SseEvent.MuxCreated, session);
|
|
@@ -213,11 +185,11 @@ export class WebServer extends EventEmitter {
|
|
|
213
185
|
this.setupTeamWatcherListeners();
|
|
214
186
|
// Set up tunnel manager listeners
|
|
215
187
|
this.tunnelManager.on('started', (data) => {
|
|
216
|
-
this.
|
|
188
|
+
this.sse.setTunnelActive(true);
|
|
217
189
|
this.broadcast(SseEvent.TunnelStarted, data);
|
|
218
190
|
});
|
|
219
191
|
this.tunnelManager.on('stopped', () => {
|
|
220
|
-
this.
|
|
192
|
+
this.sse.setTunnelActive(false);
|
|
221
193
|
this.broadcast(SseEvent.TunnelStopped, {});
|
|
222
194
|
});
|
|
223
195
|
this.tunnelManager.on('error', (message) => {
|
|
@@ -376,7 +348,7 @@ export class WebServer extends EventEmitter {
|
|
|
376
348
|
batchTerminalData: this.batchTerminalData.bind(this),
|
|
377
349
|
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
|
378
350
|
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
|
379
|
-
getSseClientCount: () => this.
|
|
351
|
+
getSseClientCount: () => this.sse.remoteClientCount,
|
|
380
352
|
// RespawnPort
|
|
381
353
|
respawnControllers: this.respawnControllers,
|
|
382
354
|
respawnTimers: this.respawnTimers,
|
|
@@ -472,7 +444,7 @@ export class WebServer extends EventEmitter {
|
|
|
472
444
|
// SSE endpoint for real-time updates
|
|
473
445
|
this.app.get('/api/events', (req, reply) => {
|
|
474
446
|
// Enforce SSE client limit to prevent memory exhaustion from too many connections
|
|
475
|
-
if (this.
|
|
447
|
+
if (this.sse.clientCount >= MAX_SSE_CLIENTS) {
|
|
476
448
|
reply.code(503).send('Too many SSE connections');
|
|
477
449
|
return;
|
|
478
450
|
}
|
|
@@ -496,30 +468,19 @@ export class WebServer extends EventEmitter {
|
|
|
496
468
|
Connection: 'keep-alive',
|
|
497
469
|
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
498
470
|
});
|
|
499
|
-
this.sseClients.set(reply, sessionFilter);
|
|
500
471
|
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
|
501
472
|
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
473
|
+
const isRemote = !!req.headers['cf-connecting-ip'];
|
|
474
|
+
this.sse.addClient(reply, sessionFilter, isRemote);
|
|
505
475
|
// Send initial state
|
|
506
476
|
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
|
507
477
|
// Buffers are fetched on-demand when switching tabs
|
|
508
|
-
this.sendSSE(reply, SseEvent.Init, this.getLightState());
|
|
478
|
+
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState());
|
|
509
479
|
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
|
510
480
|
// (and any immediately following events) are delivered without proxy delay.
|
|
511
|
-
|
|
512
|
-
try {
|
|
513
|
-
reply.raw.write(SSE_PADDING);
|
|
514
|
-
}
|
|
515
|
-
catch {
|
|
516
|
-
/* client gone */
|
|
517
|
-
}
|
|
518
|
-
}
|
|
481
|
+
this.sse.sendPadding(reply);
|
|
519
482
|
req.raw.on('close', () => {
|
|
520
|
-
this.
|
|
521
|
-
this.remoteSseClients.delete(reply);
|
|
522
|
-
this.backpressuredClients.delete(reply);
|
|
483
|
+
this.sse.removeClient(reply);
|
|
523
484
|
});
|
|
524
485
|
});
|
|
525
486
|
// Global error handler for structured errors thrown by findSessionOrFail
|
|
@@ -664,33 +625,8 @@ export class WebServer extends EventEmitter {
|
|
|
664
625
|
}
|
|
665
626
|
this.store.setSession(session.id, state);
|
|
666
627
|
}
|
|
667
|
-
// Helper to save respawn config to mux session for persistence
|
|
668
628
|
saveRespawnConfig(sessionId, config, durationMinutes) {
|
|
669
|
-
|
|
670
|
-
enabled: config.enabled,
|
|
671
|
-
idleTimeoutMs: config.idleTimeoutMs,
|
|
672
|
-
updatePrompt: config.updatePrompt,
|
|
673
|
-
interStepDelayMs: config.interStepDelayMs,
|
|
674
|
-
sendClear: config.sendClear,
|
|
675
|
-
sendInit: config.sendInit,
|
|
676
|
-
kickstartPrompt: config.kickstartPrompt,
|
|
677
|
-
autoAcceptPrompts: config.autoAcceptPrompts,
|
|
678
|
-
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
|
679
|
-
completionConfirmMs: config.completionConfirmMs,
|
|
680
|
-
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
|
681
|
-
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
|
682
|
-
aiIdleCheckModel: config.aiIdleCheckModel,
|
|
683
|
-
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
|
684
|
-
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
|
685
|
-
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
|
686
|
-
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
|
687
|
-
aiPlanCheckModel: config.aiPlanCheckModel,
|
|
688
|
-
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
|
689
|
-
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
|
690
|
-
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
|
691
|
-
durationMinutes,
|
|
692
|
-
};
|
|
693
|
-
this.mux.updateRespawnConfig(sessionId, persistedConfig);
|
|
629
|
+
saveRespawnConfig(sessionId, config, this.mux, durationMinutes);
|
|
694
630
|
}
|
|
695
631
|
// Clean up all resources associated with a session
|
|
696
632
|
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
|
@@ -768,16 +704,7 @@ export class WebServer extends EventEmitter {
|
|
|
768
704
|
// Clear pending persist-debounce timer (prevents stale closure holding session ref)
|
|
769
705
|
this.persistDeb.cancelKey(sessionId);
|
|
770
706
|
// Clear batches, per-session timers, and pending state updates
|
|
771
|
-
this.
|
|
772
|
-
this.terminalBatchSizes.delete(sessionId);
|
|
773
|
-
const batchTimer = this.terminalBatchTimers.get(sessionId);
|
|
774
|
-
if (batchTimer) {
|
|
775
|
-
clearTimeout(batchTimer);
|
|
776
|
-
this.terminalBatchTimers.delete(sessionId);
|
|
777
|
-
}
|
|
778
|
-
this.taskUpdateBatches.delete(sessionId);
|
|
779
|
-
this.stateUpdatePending.delete(sessionId);
|
|
780
|
-
this.lastTerminalEventTime.delete(sessionId);
|
|
707
|
+
this.sse.cleanupSessionBatches(sessionId);
|
|
781
708
|
// Reset Ralph tracker on the session before cleanup
|
|
782
709
|
if (session) {
|
|
783
710
|
session.ralphTracker.fullReset();
|
|
@@ -822,31 +749,7 @@ export class WebServer extends EventEmitter {
|
|
|
822
749
|
// Explicitly remove stored listeners to break closure references (prevents memory leak)
|
|
823
750
|
const listeners = this.sessionListenerRefs.get(sessionId);
|
|
824
751
|
if (listeners) {
|
|
825
|
-
session
|
|
826
|
-
session.off('clearTerminal', listeners.clearTerminal);
|
|
827
|
-
session.off('needsRefresh', listeners.needsRefresh);
|
|
828
|
-
session.off('message', listeners.message);
|
|
829
|
-
session.off('error', listeners.error);
|
|
830
|
-
session.off('completion', listeners.completion);
|
|
831
|
-
session.off('exit', listeners.exit);
|
|
832
|
-
session.off('working', listeners.working);
|
|
833
|
-
session.off('idle', listeners.idle);
|
|
834
|
-
session.off('taskCreated', listeners.taskCreated);
|
|
835
|
-
session.off('taskUpdated', listeners.taskUpdated);
|
|
836
|
-
session.off('taskCompleted', listeners.taskCompleted);
|
|
837
|
-
session.off('taskFailed', listeners.taskFailed);
|
|
838
|
-
session.off('autoClear', listeners.autoClear);
|
|
839
|
-
session.off('autoCompact', listeners.autoCompact);
|
|
840
|
-
session.off('cliInfoUpdated', listeners.cliInfoUpdated);
|
|
841
|
-
session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
|
842
|
-
session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
|
843
|
-
session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
|
844
|
-
session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
|
845
|
-
session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
|
846
|
-
session.off('ralphExitGateMet', listeners.ralphExitGateMet);
|
|
847
|
-
session.off('bashToolStart', listeners.bashToolStart);
|
|
848
|
-
session.off('bashToolEnd', listeners.bashToolEnd);
|
|
849
|
-
session.off('bashToolsUpdate', listeners.bashToolsUpdate);
|
|
752
|
+
detachSessionListeners(session, listeners);
|
|
850
753
|
this.sessionListenerRefs.delete(sessionId);
|
|
851
754
|
}
|
|
852
755
|
session.removeAllListeners();
|
|
@@ -877,525 +780,82 @@ export class WebServer extends EventEmitter {
|
|
|
877
780
|
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
|
|
878
781
|
imageWatcher.watchSession(session.id, session.workingDir);
|
|
879
782
|
}
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
},
|
|
902
|
-
/** Broadcasts `session:error` + sends push notification */
|
|
903
|
-
error: (error) => {
|
|
904
|
-
this.broadcast(SseEvent.SessionError, { id: session.id, error });
|
|
905
|
-
this.sendPushNotifications(SseEvent.SessionError, {
|
|
906
|
-
sessionId: session.id,
|
|
907
|
-
sessionName: session.name,
|
|
908
|
-
error: String(error),
|
|
909
|
-
});
|
|
910
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
911
|
-
if (tracker)
|
|
912
|
-
tracker.recordError('Session error', String(error));
|
|
913
|
-
},
|
|
914
|
-
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
|
|
915
|
-
completion: (result, cost) => {
|
|
916
|
-
this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
|
|
917
|
-
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
|
918
|
-
this.persistSessionState(session);
|
|
919
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
920
|
-
if (tracker)
|
|
921
|
-
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
|
922
|
-
},
|
|
923
|
-
// ─── Session Lifecycle ──────────────────────────────────
|
|
924
|
-
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
|
925
|
-
exit: (code) => {
|
|
926
|
-
getLifecycleLog().log({
|
|
927
|
-
event: 'exit',
|
|
928
|
-
sessionId: session.id,
|
|
929
|
-
name: session.name,
|
|
930
|
-
exitCode: code,
|
|
931
|
-
});
|
|
932
|
-
// Wrap in try/catch to ensure cleanup always happens
|
|
933
|
-
try {
|
|
934
|
-
this.broadcast(SseEvent.SessionExit, { id: session.id, code });
|
|
935
|
-
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
|
936
|
-
this.persistSessionState(session);
|
|
937
|
-
}
|
|
938
|
-
catch (err) {
|
|
939
|
-
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
|
|
940
|
-
}
|
|
941
|
-
// Always clean up respawn controller, even if broadcast failed
|
|
942
|
-
try {
|
|
943
|
-
const controller = this.respawnControllers.get(session.id);
|
|
944
|
-
if (controller) {
|
|
945
|
-
controller.stop();
|
|
946
|
-
controller.removeAllListeners();
|
|
947
|
-
this.respawnControllers.delete(session.id);
|
|
948
|
-
}
|
|
949
|
-
// Also clean up the respawn timer to prevent orphaned timers
|
|
950
|
-
const timerInfo = this.respawnTimers.get(session.id);
|
|
951
|
-
if (timerInfo) {
|
|
952
|
-
clearTimeout(timerInfo.timer);
|
|
953
|
-
this.respawnTimers.delete(session.id);
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
catch (err) {
|
|
957
|
-
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
|
|
958
|
-
}
|
|
959
|
-
// Clean up per-session resources that are stale after PTY exit.
|
|
960
|
-
// These are only cleaned by cleanupSession() on explicit delete,
|
|
961
|
-
// so without this they leak when a session exits without deletion.
|
|
962
|
-
try {
|
|
963
|
-
// Transcript watcher is tied to the specific PTY run
|
|
964
|
-
this.stopTranscriptWatcher(session.id);
|
|
965
|
-
// Finalize run summary tracker
|
|
966
|
-
const summaryTracker = this.runSummaryTrackers.get(session.id);
|
|
967
|
-
if (summaryTracker) {
|
|
968
|
-
summaryTracker.recordSessionStopped();
|
|
969
|
-
summaryTracker.stop();
|
|
970
|
-
this.runSummaryTrackers.delete(session.id);
|
|
971
|
-
}
|
|
972
|
-
// Flush/clear terminal batching state (no more output coming)
|
|
973
|
-
this.terminalBatches.delete(session.id);
|
|
974
|
-
this.terminalBatchSizes.delete(session.id);
|
|
975
|
-
const batchTimer = this.terminalBatchTimers.get(session.id);
|
|
976
|
-
if (batchTimer) {
|
|
977
|
-
clearTimeout(batchTimer);
|
|
978
|
-
this.terminalBatchTimers.delete(session.id);
|
|
979
|
-
}
|
|
980
|
-
this.taskUpdateBatches.delete(session.id);
|
|
981
|
-
this.stateUpdatePending.delete(session.id);
|
|
982
|
-
this.lastTerminalEventTime.delete(session.id);
|
|
983
|
-
// Clear pending persist-debounce timer
|
|
984
|
-
this.persistDeb.cancelKey(session.id);
|
|
985
|
-
// Close any active file streams
|
|
986
|
-
fileStreamManager.closeSessionStreams(session.id);
|
|
987
|
-
// Remove stored listener refs to break closure references (prevents memory leak).
|
|
988
|
-
// Without this, the closures capture the Session object (including up to 2MB terminal buffer)
|
|
989
|
-
// and keep it alive even after the PTY exits.
|
|
990
|
-
const listenerRefs = this.sessionListenerRefs.get(session.id);
|
|
991
|
-
if (listenerRefs) {
|
|
992
|
-
session.off('terminal', listenerRefs.terminal);
|
|
993
|
-
session.off('clearTerminal', listenerRefs.clearTerminal);
|
|
994
|
-
session.off('needsRefresh', listenerRefs.needsRefresh);
|
|
995
|
-
session.off('message', listenerRefs.message);
|
|
996
|
-
session.off('error', listenerRefs.error);
|
|
997
|
-
session.off('completion', listenerRefs.completion);
|
|
998
|
-
session.off('exit', listenerRefs.exit);
|
|
999
|
-
session.off('working', listenerRefs.working);
|
|
1000
|
-
session.off('idle', listenerRefs.idle);
|
|
1001
|
-
session.off('taskCreated', listenerRefs.taskCreated);
|
|
1002
|
-
session.off('taskUpdated', listenerRefs.taskUpdated);
|
|
1003
|
-
session.off('taskCompleted', listenerRefs.taskCompleted);
|
|
1004
|
-
session.off('taskFailed', listenerRefs.taskFailed);
|
|
1005
|
-
session.off('autoClear', listenerRefs.autoClear);
|
|
1006
|
-
session.off('autoCompact', listenerRefs.autoCompact);
|
|
1007
|
-
session.off('cliInfoUpdated', listenerRefs.cliInfoUpdated);
|
|
1008
|
-
session.off('ralphLoopUpdate', listenerRefs.ralphLoopUpdate);
|
|
1009
|
-
session.off('ralphTodoUpdate', listenerRefs.ralphTodoUpdate);
|
|
1010
|
-
session.off('ralphCompletionDetected', listenerRefs.ralphCompletionDetected);
|
|
1011
|
-
session.off('ralphStatusBlockDetected', listenerRefs.ralphStatusBlockDetected);
|
|
1012
|
-
session.off('ralphCircuitBreakerUpdate', listenerRefs.ralphCircuitBreakerUpdate);
|
|
1013
|
-
session.off('ralphExitGateMet', listenerRefs.ralphExitGateMet);
|
|
1014
|
-
session.off('bashToolStart', listenerRefs.bashToolStart);
|
|
1015
|
-
session.off('bashToolEnd', listenerRefs.bashToolEnd);
|
|
1016
|
-
session.off('bashToolsUpdate', listenerRefs.bashToolsUpdate);
|
|
1017
|
-
this.sessionListenerRefs.delete(session.id);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
catch (err) {
|
|
1021
|
-
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
|
|
1022
|
-
}
|
|
1023
|
-
},
|
|
1024
|
-
// ─── Activity State ─────────────────────────────────────
|
|
1025
|
-
/** Broadcasts `session:working` — Claude started processing */
|
|
1026
|
-
working: () => {
|
|
1027
|
-
this.broadcast(SseEvent.SessionWorking, { id: session.id });
|
|
1028
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1029
|
-
if (tracker) {
|
|
1030
|
-
tracker.recordWorking();
|
|
1031
|
-
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
|
1032
|
-
}
|
|
1033
|
-
},
|
|
1034
|
-
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
|
1035
|
-
idle: () => {
|
|
1036
|
-
this.broadcast(SseEvent.SessionIdle, { id: session.id });
|
|
1037
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1038
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
783
|
+
// Create and attach all listener handlers via dependency injection
|
|
784
|
+
const listeners = createSessionListeners(session, this.buildSessionListenerDeps());
|
|
785
|
+
this.sessionListenerRefs.set(session.id, listeners);
|
|
786
|
+
attachSessionListeners(session, listeners);
|
|
787
|
+
}
|
|
788
|
+
/** Build the deps object for session listener wiring. */
|
|
789
|
+
buildSessionListenerDeps() {
|
|
790
|
+
return {
|
|
791
|
+
broadcast: this.broadcast.bind(this),
|
|
792
|
+
batchTerminalData: this.batchTerminalData.bind(this),
|
|
793
|
+
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
|
794
|
+
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
|
795
|
+
sendPushNotifications: this.sendPushNotifications.bind(this),
|
|
796
|
+
persistSessionState: this.persistSessionState.bind(this),
|
|
797
|
+
getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
|
|
798
|
+
getRunSummaryTracker: (id) => this.runSummaryTrackers.get(id),
|
|
799
|
+
stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this),
|
|
800
|
+
cleanupSessionBatches: (id) => this.sse.cleanupSessionBatches(id),
|
|
801
|
+
cancelPersistDebounce: (id) => this.persistDeb.cancelKey(id),
|
|
802
|
+
removeRunSummaryTracker: (id) => {
|
|
803
|
+
const tracker = this.runSummaryTrackers.get(id);
|
|
1039
804
|
if (tracker) {
|
|
1040
|
-
tracker.
|
|
1041
|
-
tracker.
|
|
805
|
+
tracker.recordSessionStopped();
|
|
806
|
+
tracker.stop();
|
|
807
|
+
this.runSummaryTrackers.delete(id);
|
|
1042
808
|
}
|
|
1043
809
|
},
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1050
|
-
},
|
|
1051
|
-
/** Batched broadcast of `task:updated` — high-frequency progress updates */
|
|
1052
|
-
taskUpdated: (task) => {
|
|
1053
|
-
this.batchTaskUpdate(session.id, task);
|
|
1054
|
-
},
|
|
1055
|
-
/** Broadcasts `task:completed` — background task finished successfully */
|
|
1056
|
-
taskCompleted: (task) => {
|
|
1057
|
-
this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
|
|
1058
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1059
|
-
},
|
|
1060
|
-
/** Broadcasts `task:failed` — background task errored */
|
|
1061
|
-
taskFailed: (task, error) => {
|
|
1062
|
-
this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
|
|
1063
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1064
|
-
},
|
|
1065
|
-
// ─── Auto-Operations ────────────────────────────────────
|
|
1066
|
-
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
|
|
1067
|
-
autoClear: (data) => {
|
|
1068
|
-
this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
|
|
1069
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1070
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1071
|
-
if (tracker)
|
|
1072
|
-
tracker.recordAutoClear(data.tokens, data.threshold);
|
|
1073
|
-
},
|
|
1074
|
-
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
|
|
1075
|
-
autoCompact: (data) => {
|
|
1076
|
-
this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
|
|
1077
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1078
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1079
|
-
if (tracker)
|
|
1080
|
-
tracker.recordAutoCompact(data.tokens, data.threshold);
|
|
1081
|
-
},
|
|
1082
|
-
// ─── CLI Info ────────────────────────────────────────────
|
|
1083
|
-
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
|
1084
|
-
cliInfoUpdated: (data) => {
|
|
1085
|
-
this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
|
|
1086
|
-
this.broadcastSessionStateDebounced(session.id);
|
|
1087
|
-
},
|
|
1088
|
-
// ─── Ralph Tracking Events ──────────────────────────────
|
|
1089
|
-
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
|
1090
|
-
ralphLoopUpdate: (state) => {
|
|
1091
|
-
this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
|
|
1092
|
-
this.store.updateRalphState(session.id, { loop: state });
|
|
1093
|
-
},
|
|
1094
|
-
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
|
|
1095
|
-
ralphTodoUpdate: (todos) => {
|
|
1096
|
-
this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
|
|
1097
|
-
this.store.updateRalphState(session.id, { todos });
|
|
1098
|
-
},
|
|
1099
|
-
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
|
|
1100
|
-
ralphCompletionDetected: (phrase) => {
|
|
1101
|
-
this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
|
|
1102
|
-
this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
|
|
1103
|
-
sessionId: session.id,
|
|
1104
|
-
sessionName: session.name,
|
|
1105
|
-
phrase,
|
|
1106
|
-
});
|
|
1107
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1108
|
-
if (tracker)
|
|
1109
|
-
tracker.recordRalphCompletion(phrase);
|
|
1110
|
-
},
|
|
1111
|
-
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
|
|
1112
|
-
ralphStatusBlockDetected: (block) => {
|
|
1113
|
-
this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
|
|
1114
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1115
|
-
if (tracker) {
|
|
1116
|
-
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}`);
|
|
810
|
+
removeSessionListenerRefs: (id) => {
|
|
811
|
+
const refs = this.sessionListenerRefs.get(id);
|
|
812
|
+
const sess = this.sessions.get(id);
|
|
813
|
+
if (refs && sess) {
|
|
814
|
+
detachSessionListeners(sess, refs);
|
|
1117
815
|
}
|
|
816
|
+
this.sessionListenerRefs.delete(id);
|
|
1118
817
|
},
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
818
|
+
cleanupRespawnOnExit: (id) => {
|
|
819
|
+
const controller = this.respawnControllers.get(id);
|
|
820
|
+
if (controller) {
|
|
821
|
+
controller.stop();
|
|
822
|
+
controller.removeAllListeners();
|
|
823
|
+
this.respawnControllers.delete(id);
|
|
1125
824
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
const tracker = this.runSummaryTrackers.get(session.id);
|
|
1131
|
-
if (tracker) {
|
|
1132
|
-
tracker.addEvent('ralph_completion', 'success', 'Exit Gate Met', `Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`);
|
|
825
|
+
const timerInfo = this.respawnTimers.get(id);
|
|
826
|
+
if (timerInfo) {
|
|
827
|
+
clearTimeout(timerInfo.timer);
|
|
828
|
+
this.respawnTimers.delete(id);
|
|
1133
829
|
}
|
|
1134
830
|
},
|
|
1135
|
-
|
|
1136
|
-
// Used for clickable file paths in the UI.
|
|
1137
|
-
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
|
|
1138
|
-
bashToolStart: (tool) => {
|
|
1139
|
-
this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
|
|
1140
|
-
},
|
|
1141
|
-
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
|
|
1142
|
-
bashToolEnd: (tool) => {
|
|
1143
|
-
this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
|
|
1144
|
-
},
|
|
1145
|
-
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
|
|
1146
|
-
bashToolsUpdate: (tools) => {
|
|
1147
|
-
this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
|
1148
|
-
},
|
|
831
|
+
getStore: () => this.store,
|
|
1149
832
|
};
|
|
1150
|
-
// Store listener refs for cleanup
|
|
1151
|
-
this.sessionListenerRefs.set(session.id, listeners);
|
|
1152
|
-
// Attach all listeners to the session
|
|
1153
|
-
session.on('terminal', listeners.terminal);
|
|
1154
|
-
session.on('clearTerminal', listeners.clearTerminal);
|
|
1155
|
-
session.on('needsRefresh', listeners.needsRefresh);
|
|
1156
|
-
session.on('message', listeners.message);
|
|
1157
|
-
session.on('error', listeners.error);
|
|
1158
|
-
session.on('completion', listeners.completion);
|
|
1159
|
-
session.on('exit', listeners.exit);
|
|
1160
|
-
session.on('working', listeners.working);
|
|
1161
|
-
session.on('idle', listeners.idle);
|
|
1162
|
-
session.on('taskCreated', listeners.taskCreated);
|
|
1163
|
-
session.on('taskUpdated', listeners.taskUpdated);
|
|
1164
|
-
session.on('taskCompleted', listeners.taskCompleted);
|
|
1165
|
-
session.on('taskFailed', listeners.taskFailed);
|
|
1166
|
-
session.on('autoClear', listeners.autoClear);
|
|
1167
|
-
session.on('autoCompact', listeners.autoCompact);
|
|
1168
|
-
session.on('cliInfoUpdated', listeners.cliInfoUpdated);
|
|
1169
|
-
session.on('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
|
1170
|
-
session.on('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
|
1171
|
-
session.on('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
|
1172
|
-
session.on('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
|
1173
|
-
session.on('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
|
1174
|
-
session.on('ralphExitGateMet', listeners.ralphExitGateMet);
|
|
1175
|
-
session.on('bashToolStart', listeners.bashToolStart);
|
|
1176
|
-
session.on('bashToolEnd', listeners.bashToolEnd);
|
|
1177
|
-
session.on('bashToolsUpdate', listeners.bashToolsUpdate);
|
|
1178
833
|
}
|
|
1179
834
|
setupRespawnListeners(sessionId, controller) {
|
|
1180
|
-
|
|
1181
|
-
controller.setTeamWatcher(this.teamWatcher);
|
|
1182
|
-
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
|
1183
|
-
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
|
1184
|
-
// ─── Respawn State Machine ──────────────────────────────
|
|
1185
|
-
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
|
|
1186
|
-
controller.on('stateChanged', (state, prevState) => {
|
|
1187
|
-
this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
|
|
1188
|
-
const tracker = getTracker();
|
|
1189
|
-
if (tracker)
|
|
1190
|
-
tracker.recordStateChange(state, `${prevState} → ${state}`);
|
|
1191
|
-
});
|
|
1192
|
-
// ─── Respawn Cycle Lifecycle ────────────────────────────
|
|
1193
|
-
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
|
|
1194
|
-
controller.on('respawnCycleStarted', (cycleNumber) => {
|
|
1195
|
-
this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
|
|
1196
|
-
});
|
|
1197
|
-
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
|
|
1198
|
-
controller.on('respawnCycleCompleted', (cycleNumber) => {
|
|
1199
|
-
this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
|
|
1200
|
-
});
|
|
1201
|
-
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
|
|
1202
|
-
controller.on('respawnBlocked', (data) => {
|
|
1203
|
-
this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
|
|
1204
|
-
const sessionForPush = this.sessions.get(sessionId);
|
|
1205
|
-
this.sendPushNotifications(SseEvent.RespawnBlocked, {
|
|
1206
|
-
sessionId,
|
|
1207
|
-
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
|
1208
|
-
reason: data.reason,
|
|
1209
|
-
});
|
|
1210
|
-
const tracker = getTracker();
|
|
1211
|
-
if (tracker)
|
|
1212
|
-
tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
|
1213
|
-
});
|
|
1214
|
-
// ─── Respawn Step Progress ──────────────────────────────
|
|
1215
|
-
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
|
|
1216
|
-
controller.on('stepSent', (step, input) => {
|
|
1217
|
-
this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
|
|
1218
|
-
});
|
|
1219
|
-
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
|
|
1220
|
-
controller.on('stepCompleted', (step) => {
|
|
1221
|
-
this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
|
|
1222
|
-
});
|
|
1223
|
-
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
|
|
1224
|
-
controller.on('detectionUpdate', (detection) => {
|
|
1225
|
-
this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
|
|
1226
|
-
});
|
|
1227
|
-
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
|
|
1228
|
-
controller.on('autoAcceptSent', () => {
|
|
1229
|
-
this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
|
|
1230
|
-
});
|
|
1231
|
-
// ─── AI Checker Events ──────────────────────────────────
|
|
1232
|
-
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
|
|
1233
|
-
controller.on('aiCheckStarted', () => {
|
|
1234
|
-
this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
|
|
1235
|
-
});
|
|
1236
|
-
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
|
|
1237
|
-
controller.on('aiCheckCompleted', (result) => {
|
|
1238
|
-
this.broadcast(SseEvent.RespawnAiCheckCompleted, {
|
|
1239
|
-
sessionId,
|
|
1240
|
-
verdict: result.verdict,
|
|
1241
|
-
reasoning: result.reasoning,
|
|
1242
|
-
durationMs: result.durationMs,
|
|
1243
|
-
});
|
|
1244
|
-
const tracker = getTracker();
|
|
1245
|
-
if (tracker)
|
|
1246
|
-
tracker.recordAiCheckResult(result.verdict);
|
|
1247
|
-
});
|
|
1248
|
-
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
|
|
1249
|
-
controller.on('aiCheckFailed', (error) => {
|
|
1250
|
-
this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
|
|
1251
|
-
const tracker = getTracker();
|
|
1252
|
-
if (tracker)
|
|
1253
|
-
tracker.recordError('AI check failed', error);
|
|
1254
|
-
});
|
|
1255
|
-
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
|
|
1256
|
-
controller.on('aiCheckCooldown', (active, endsAt) => {
|
|
1257
|
-
this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
|
|
1258
|
-
});
|
|
1259
|
-
// ─── Plan Checker Events ────────────────────────────────
|
|
1260
|
-
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
|
|
1261
|
-
controller.on('planCheckStarted', () => {
|
|
1262
|
-
this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
|
|
1263
|
-
});
|
|
1264
|
-
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
|
|
1265
|
-
controller.on('planCheckCompleted', (result) => {
|
|
1266
|
-
this.broadcast(SseEvent.RespawnPlanCheckCompleted, {
|
|
1267
|
-
sessionId,
|
|
1268
|
-
verdict: result.verdict,
|
|
1269
|
-
reasoning: result.reasoning,
|
|
1270
|
-
durationMs: result.durationMs,
|
|
1271
|
-
});
|
|
1272
|
-
});
|
|
1273
|
-
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
|
|
1274
|
-
controller.on('planCheckFailed', (error) => {
|
|
1275
|
-
this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
|
|
1276
|
-
});
|
|
1277
|
-
// ─── Timer Events (UI countdown display) ────────────────
|
|
1278
|
-
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
|
|
1279
|
-
controller.on('timerStarted', (timer) => {
|
|
1280
|
-
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
|
|
1281
|
-
});
|
|
1282
|
-
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
|
|
1283
|
-
controller.on('timerCancelled', (timerName, reason) => {
|
|
1284
|
-
this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
|
|
1285
|
-
});
|
|
1286
|
-
/** Broadcasts `respawn:timerCompleted` — timer expired */
|
|
1287
|
-
controller.on('timerCompleted', (timerName) => {
|
|
1288
|
-
this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
|
|
1289
|
-
});
|
|
1290
|
-
// ─── Logging & Errors ───────────────────────────────────
|
|
1291
|
-
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
|
|
1292
|
-
controller.on('actionLog', (action) => {
|
|
1293
|
-
this.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
|
|
1294
|
-
});
|
|
1295
|
-
/** Broadcasts `respawn:log` — general respawn log message */
|
|
1296
|
-
controller.on('log', (message) => {
|
|
1297
|
-
this.broadcast(SseEvent.RespawnLog, { sessionId, message });
|
|
1298
|
-
});
|
|
1299
|
-
/** Broadcasts `respawn:error` — respawn controller error */
|
|
1300
|
-
controller.on('error', (error) => {
|
|
1301
|
-
this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
|
|
1302
|
-
const tracker = getTracker();
|
|
1303
|
-
if (tracker)
|
|
1304
|
-
tracker.recordError('Respawn error', error.message);
|
|
1305
|
-
});
|
|
835
|
+
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
|
|
1306
836
|
}
|
|
1307
837
|
setupTimedRespawn(sessionId, durationMinutes) {
|
|
1308
|
-
|
|
1309
|
-
const existing = this.respawnTimers.get(sessionId);
|
|
1310
|
-
if (existing) {
|
|
1311
|
-
clearTimeout(existing.timer);
|
|
1312
|
-
}
|
|
1313
|
-
const now = Date.now();
|
|
1314
|
-
const endAt = now + durationMinutes * 60 * 1000;
|
|
1315
|
-
const timer = setTimeout(() => {
|
|
1316
|
-
// Stop respawn when time is up
|
|
1317
|
-
const controller = this.respawnControllers.get(sessionId);
|
|
1318
|
-
if (controller) {
|
|
1319
|
-
controller.stop();
|
|
1320
|
-
controller.removeAllListeners();
|
|
1321
|
-
this.respawnControllers.delete(sessionId);
|
|
1322
|
-
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
|
|
1323
|
-
}
|
|
1324
|
-
this.respawnTimers.delete(sessionId);
|
|
1325
|
-
// Update persisted state (respawn no longer active)
|
|
1326
|
-
const session = this.sessions.get(sessionId);
|
|
1327
|
-
if (session) {
|
|
1328
|
-
this.persistSessionState(session);
|
|
1329
|
-
}
|
|
1330
|
-
}, durationMinutes * 60 * 1000);
|
|
1331
|
-
this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
|
|
1332
|
-
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
|
|
838
|
+
setupTimedRespawn(sessionId, durationMinutes, this.buildRespawnWiringDeps());
|
|
1333
839
|
}
|
|
1334
|
-
/**
|
|
1335
|
-
* Restore a RespawnController from persisted configuration.
|
|
1336
|
-
* Creates the controller, sets up listeners, but does NOT start it.
|
|
1337
|
-
*
|
|
1338
|
-
* @param session - The session to attach the controller to
|
|
1339
|
-
* @param config - The persisted respawn configuration
|
|
1340
|
-
* @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json')
|
|
1341
|
-
*/
|
|
1342
840
|
restoreRespawnController(session, config, source) {
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
aiPlanCheckModel: config.aiPlanCheckModel,
|
|
1362
|
-
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
|
1363
|
-
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
|
1364
|
-
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
|
1365
|
-
});
|
|
1366
|
-
this.respawnControllers.set(session.id, controller);
|
|
1367
|
-
this.setupRespawnListeners(session.id, controller);
|
|
1368
|
-
// Calculate delay: wait until 2 minutes after server start before starting respawn
|
|
1369
|
-
// This prevents false idle detection immediately after a server restart/rebuild
|
|
1370
|
-
const timeSinceStart = Date.now() - this.serverStartTime;
|
|
1371
|
-
const delayMs = Math.max(0, WebServer.RESPAWN_RESTORE_GRACE_PERIOD_MS - timeSinceStart);
|
|
1372
|
-
if (delayMs > 0) {
|
|
1373
|
-
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`);
|
|
1374
|
-
const timer = setTimeout(() => {
|
|
1375
|
-
this.pendingRespawnStarts.delete(session.id);
|
|
1376
|
-
// Verify session still exists (may have been deleted during grace period)
|
|
1377
|
-
if (!this.sessions.has(session.id)) {
|
|
1378
|
-
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1381
|
-
// Double-check controller still exists and is stopped
|
|
1382
|
-
const ctrl = this.respawnControllers.get(session.id);
|
|
1383
|
-
if (ctrl && ctrl.state === 'stopped') {
|
|
1384
|
-
ctrl.start();
|
|
1385
|
-
this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
|
|
1386
|
-
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
|
1387
|
-
}
|
|
1388
|
-
}, delayMs);
|
|
1389
|
-
this.pendingRespawnStarts.set(session.id, timer);
|
|
1390
|
-
}
|
|
1391
|
-
else {
|
|
1392
|
-
// Grace period has passed, start immediately
|
|
1393
|
-
controller.start();
|
|
1394
|
-
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`);
|
|
1395
|
-
}
|
|
1396
|
-
if (config.durationMinutes && config.durationMinutes > 0) {
|
|
1397
|
-
this.setupTimedRespawn(session.id, config.durationMinutes);
|
|
1398
|
-
}
|
|
841
|
+
restoreRespawnController(session, config, source, this.buildRespawnWiringDeps());
|
|
842
|
+
}
|
|
843
|
+
buildRespawnWiringDeps() {
|
|
844
|
+
return {
|
|
845
|
+
broadcast: this.broadcast.bind(this),
|
|
846
|
+
sendPushNotifications: this.sendPushNotifications.bind(this),
|
|
847
|
+
persistSessionState: this.persistSessionState.bind(this),
|
|
848
|
+
getSession: (id) => this.sessions.get(id),
|
|
849
|
+
sessionExists: (id) => this.sessions.has(id),
|
|
850
|
+
getRunSummaryTracker: (id) => this.runSummaryTrackers.get(id),
|
|
851
|
+
getRespawnControllers: () => this.respawnControllers,
|
|
852
|
+
getRespawnTimers: () => this.respawnTimers,
|
|
853
|
+
getPendingRespawnStarts: () => this.pendingRespawnStarts,
|
|
854
|
+
teamWatcher: this.teamWatcher,
|
|
855
|
+
serverStartTime: this.serverStartTime,
|
|
856
|
+
respawnRestoreGracePeriodMs: 2 * 60 * 1000,
|
|
857
|
+
mux: this.mux,
|
|
858
|
+
};
|
|
1399
859
|
}
|
|
1400
860
|
// Helper to get custom CLAUDE.md template path from settings
|
|
1401
861
|
async getDefaultClaudeMdPath() {
|
|
@@ -1485,7 +945,7 @@ export class WebServer extends EventEmitter {
|
|
|
1485
945
|
const failedRun = this.scheduledRuns.get(id);
|
|
1486
946
|
if (failedRun && failedRun.status === 'running') {
|
|
1487
947
|
failedRun.status = 'stopped';
|
|
1488
|
-
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${
|
|
948
|
+
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${getErrorMessage(err)}`);
|
|
1489
949
|
this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' });
|
|
1490
950
|
}
|
|
1491
951
|
});
|
|
@@ -1665,246 +1125,23 @@ export class WebServer extends EventEmitter {
|
|
|
1665
1125
|
this.cachedLightState = { data: result, timestamp: now };
|
|
1666
1126
|
return result;
|
|
1667
1127
|
}
|
|
1668
|
-
|
|
1669
|
-
try {
|
|
1670
|
-
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1671
|
-
}
|
|
1672
|
-
catch {
|
|
1673
|
-
this.sseClients.delete(reply);
|
|
1674
|
-
this.remoteSseClients.delete(reply);
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
// Optimized: send pre-formatted SSE message to a client
|
|
1678
|
-
// Returns false if client is backpressured or dead
|
|
1679
|
-
sendSSEPreformatted(reply, message) {
|
|
1680
|
-
// Skip backpressured clients to prevent unbounded memory growth.
|
|
1681
|
-
// Terminal data dropped here is recovered via session:needsRefresh on drain.
|
|
1682
|
-
if (this.backpressuredClients.has(reply))
|
|
1683
|
-
return;
|
|
1684
|
-
try {
|
|
1685
|
-
const ok = reply.raw.write(message);
|
|
1686
|
-
if (!ok) {
|
|
1687
|
-
// Buffer is full — mark as backpressured, resume on drain
|
|
1688
|
-
this.backpressuredClients.add(reply);
|
|
1689
|
-
reply.raw.once('drain', () => {
|
|
1690
|
-
this.backpressuredClients.delete(reply);
|
|
1691
|
-
// Client may have missed terminal data during backpressure.
|
|
1692
|
-
// Tell it to reload the active session's buffer to recover.
|
|
1693
|
-
try {
|
|
1694
|
-
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
|
|
1695
|
-
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
|
|
1696
|
-
}
|
|
1697
|
-
catch {
|
|
1698
|
-
/* client gone */
|
|
1699
|
-
}
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
catch {
|
|
1704
|
-
this.sseClients.delete(reply);
|
|
1705
|
-
this.remoteSseClients.delete(reply);
|
|
1706
|
-
this.backpressuredClients.delete(reply);
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1128
|
+
// ========== SSE Delegates (SseStreamManager) ==========
|
|
1709
1129
|
broadcast(event, data) {
|
|
1710
|
-
//
|
|
1711
|
-
if (this.sseClients.size === 0)
|
|
1712
|
-
return;
|
|
1713
|
-
// Invalidate caches only on structural changes (creation/deletion).
|
|
1714
|
-
// SessionUpdated fires too frequently (working/idle transitions, completion)
|
|
1715
|
-
// and makes the 1s TTL cache useless — the debounced session:updated follows
|
|
1716
|
-
// within 500ms anyway, and these caches serve /api/sessions and SSE init
|
|
1717
|
-
// which aren't polled rapidly.
|
|
1130
|
+
// Invalidate caches on structural changes (creation/deletion)
|
|
1718
1131
|
if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted) {
|
|
1719
1132
|
this.cachedLightState = null;
|
|
1720
1133
|
this.cachedSessionsList = null;
|
|
1721
1134
|
}
|
|
1722
|
-
|
|
1723
|
-
// Only append Cloudflare tunnel padding for latency-sensitive events —
|
|
1724
|
-
// Recovery events need immediate proxy flush; low-frequency metadata events
|
|
1725
|
-
// (session:created, ralph:*, respawn:*, etc.) don't need padding.
|
|
1726
|
-
// Note: session:terminal has its own padding in flushSessionTerminalBatch().
|
|
1727
|
-
const needsPadding = this._isTunnelActive && event === SseEvent.SessionNeedsRefresh;
|
|
1728
|
-
const padding = needsPadding ? SSE_PADDING : '';
|
|
1729
|
-
let message;
|
|
1730
|
-
try {
|
|
1731
|
-
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
|
|
1732
|
-
}
|
|
1733
|
-
catch (err) {
|
|
1734
|
-
// Handle circular references or non-serializable values
|
|
1735
|
-
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
|
1736
|
-
return;
|
|
1737
|
-
}
|
|
1738
|
-
// Extract sessionId from event data for subscription filtering.
|
|
1739
|
-
const eventSessionId = this.extractSessionId(event, data);
|
|
1740
|
-
for (const [client, filter] of this.sseClients) {
|
|
1741
|
-
// No filter (null) = receive everything. Otherwise, skip if event is
|
|
1742
|
-
// session-scoped and the session isn't in the client's subscription set.
|
|
1743
|
-
if (filter && eventSessionId && !filter.has(eventSessionId))
|
|
1744
|
-
continue;
|
|
1745
|
-
this.sendSSEPreformatted(client, message);
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
/**
|
|
1749
|
-
* Extract the session ID from an event's data payload for subscription filtering.
|
|
1750
|
-
* Returns the sessionId string if the event is session-scoped, or null for global events.
|
|
1751
|
-
*/
|
|
1752
|
-
extractSessionId(event, data) {
|
|
1753
|
-
if (data == null || typeof data !== 'object')
|
|
1754
|
-
return null;
|
|
1755
|
-
const record = data;
|
|
1756
|
-
// Most session-scoped events use `sessionId`
|
|
1757
|
-
if (typeof record.sessionId === 'string')
|
|
1758
|
-
return record.sessionId;
|
|
1759
|
-
// Session lifecycle events (session:*) use `id` from the session state object
|
|
1760
|
-
if (typeof record.id === 'string' && event.startsWith('session:'))
|
|
1761
|
-
return record.id;
|
|
1762
|
-
// No session ID found — treat as global event (sent to all clients)
|
|
1763
|
-
return null;
|
|
1135
|
+
this.sse.broadcast(event, data);
|
|
1764
1136
|
}
|
|
1765
|
-
// Batch terminal data for better performance (60fps)
|
|
1766
|
-
// Uses per-session timers with adaptive intervals to prevent thundering herd:
|
|
1767
|
-
// each session flushes independently rather than all sessions flushing in one burst.
|
|
1768
1137
|
batchTerminalData(sessionId, data) {
|
|
1769
|
-
|
|
1770
|
-
if (this._isStopping)
|
|
1771
|
-
return;
|
|
1772
|
-
let chunks = this.terminalBatches.get(sessionId);
|
|
1773
|
-
if (!chunks) {
|
|
1774
|
-
chunks = [];
|
|
1775
|
-
this.terminalBatches.set(sessionId, chunks);
|
|
1776
|
-
}
|
|
1777
|
-
chunks.push(data);
|
|
1778
|
-
const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
|
|
1779
|
-
const totalLength = prevSize + data.length;
|
|
1780
|
-
this.terminalBatchSizes.set(sessionId, totalLength);
|
|
1781
|
-
// Adaptive batching: detect rapid events and extend batch window (per-session)
|
|
1782
|
-
const now = Date.now();
|
|
1783
|
-
const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
|
|
1784
|
-
const eventGap = now - lastEvent;
|
|
1785
|
-
this.lastTerminalEventTime.set(sessionId, now);
|
|
1786
|
-
// Adjust batch interval based on event frequency (per-session)
|
|
1787
|
-
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
|
|
1788
|
-
let sessionInterval;
|
|
1789
|
-
if (eventGap > 0 && eventGap < 10) {
|
|
1790
|
-
sessionInterval = 50;
|
|
1791
|
-
}
|
|
1792
|
-
else if (eventGap > 0 && eventGap < 20) {
|
|
1793
|
-
sessionInterval = 32;
|
|
1794
|
-
}
|
|
1795
|
-
else {
|
|
1796
|
-
sessionInterval = TERMINAL_BATCH_INTERVAL;
|
|
1797
|
-
}
|
|
1798
|
-
// Flush immediately if batch is large for responsiveness
|
|
1799
|
-
if (totalLength > BATCH_FLUSH_THRESHOLD) {
|
|
1800
|
-
const existingTimer = this.terminalBatchTimers.get(sessionId);
|
|
1801
|
-
if (existingTimer) {
|
|
1802
|
-
clearTimeout(existingTimer);
|
|
1803
|
-
this.terminalBatchTimers.delete(sessionId);
|
|
1804
|
-
}
|
|
1805
|
-
this.flushSessionTerminalBatch(sessionId);
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
// Start per-session batch timer if not already running
|
|
1809
|
-
// Each session flushes independently — prevents one busy session from
|
|
1810
|
-
// forcing all sessions to flush at its rate (thundering herd)
|
|
1811
|
-
if (!this.terminalBatchTimers.has(sessionId)) {
|
|
1812
|
-
this.terminalBatchTimers.set(sessionId, setTimeout(() => {
|
|
1813
|
-
this.terminalBatchTimers.delete(sessionId);
|
|
1814
|
-
this.flushSessionTerminalBatch(sessionId);
|
|
1815
|
-
}, sessionInterval));
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
|
-
/** Flush a single session's batched terminal data */
|
|
1819
|
-
flushSessionTerminalBatch(sessionId) {
|
|
1820
|
-
if (this._isStopping) {
|
|
1821
|
-
this.terminalBatches.delete(sessionId);
|
|
1822
|
-
this.terminalBatchSizes.delete(sessionId);
|
|
1823
|
-
return;
|
|
1824
|
-
}
|
|
1825
|
-
const chunks = this.terminalBatches.get(sessionId);
|
|
1826
|
-
if (chunks && chunks.length > 0) {
|
|
1827
|
-
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
|
|
1828
|
-
const data = chunks.join('');
|
|
1829
|
-
// xterm.js 6.0+ handles DEC 2026 synchronized output natively.
|
|
1830
|
-
// Claude CLI (Ink) already emits its own DEC 2026 markers around redraws.
|
|
1831
|
-
// Do NOT add an outer wrapper — DEC 2026 is not reference-counted, so
|
|
1832
|
-
// the inner 2026l would prematurely exit sync mode, defeating the purpose.
|
|
1833
|
-
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
|
|
1834
|
-
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
|
|
1835
|
-
const escapedData = JSON.stringify(data);
|
|
1836
|
-
// Append tunnel padding for immediate Cloudflare proxy flush —
|
|
1837
|
-
// terminal data is high-frequency and latency-sensitive.
|
|
1838
|
-
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
|
1839
|
-
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
|
|
1840
|
-
for (const [client, filter] of this.sseClients) {
|
|
1841
|
-
// Skip clients that have a session filter and aren't subscribed to this session
|
|
1842
|
-
if (filter && !filter.has(sessionId))
|
|
1843
|
-
continue;
|
|
1844
|
-
this.sendSSEPreformatted(client, message);
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
this.terminalBatches.delete(sessionId);
|
|
1848
|
-
this.terminalBatchSizes.delete(sessionId);
|
|
1138
|
+
this.sse.batchTerminalData(sessionId, data);
|
|
1849
1139
|
}
|
|
1850
|
-
// Batch task:updated events at 100ms - only send latest update per task
|
|
1851
|
-
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
|
|
1852
1140
|
batchTaskUpdate(sessionId, task) {
|
|
1853
|
-
|
|
1854
|
-
if (this._isStopping)
|
|
1855
|
-
return;
|
|
1856
|
-
// Use composite key to avoid losing updates when multiple tasks update in same batch window
|
|
1857
|
-
const key = `${sessionId}:${task.id}`;
|
|
1858
|
-
this.taskUpdateBatches.set(key, { sessionId, task });
|
|
1859
|
-
if (!this.taskUpdateBatchTimerId) {
|
|
1860
|
-
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(() => {
|
|
1861
|
-
this.taskUpdateBatchTimerId = null;
|
|
1862
|
-
this.flushTaskUpdateBatches();
|
|
1863
|
-
}, TASK_UPDATE_BATCH_INTERVAL, { description: 'task update batch flush' });
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
flushTaskUpdateBatches() {
|
|
1867
|
-
// Skip if server is stopping (timer may have been queued before stop() was called)
|
|
1868
|
-
if (this._isStopping) {
|
|
1869
|
-
this.taskUpdateBatches.clear();
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
|
1873
|
-
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
|
1874
|
-
}
|
|
1875
|
-
this.taskUpdateBatches.clear();
|
|
1141
|
+
this.sse.batchTaskUpdate(sessionId, task);
|
|
1876
1142
|
}
|
|
1877
|
-
/**
|
|
1878
|
-
* Debounce expensive session:updated broadcasts.
|
|
1879
|
-
* Instead of calling toDetailedState() on every event, batch requests
|
|
1880
|
-
* and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL.
|
|
1881
|
-
*/
|
|
1882
1143
|
broadcastSessionStateDebounced(sessionId) {
|
|
1883
|
-
|
|
1884
|
-
if (this._isStopping)
|
|
1885
|
-
return;
|
|
1886
|
-
this.stateUpdatePending.add(sessionId);
|
|
1887
|
-
if (!this.stateUpdateTimerId) {
|
|
1888
|
-
this.stateUpdateTimerId = this.cleanup.setTimeout(() => {
|
|
1889
|
-
this.stateUpdateTimerId = null;
|
|
1890
|
-
this.flushStateUpdates();
|
|
1891
|
-
}, STATE_UPDATE_DEBOUNCE_INTERVAL, { description: 'state update debounce flush' });
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
flushStateUpdates() {
|
|
1895
|
-
// Skip if server is stopping (timer may have been queued before stop() was called)
|
|
1896
|
-
if (this._isStopping) {
|
|
1897
|
-
this.stateUpdatePending.clear();
|
|
1898
|
-
return;
|
|
1899
|
-
}
|
|
1900
|
-
for (const sessionId of this.stateUpdatePending) {
|
|
1901
|
-
const session = this.sessions.get(sessionId);
|
|
1902
|
-
if (session) {
|
|
1903
|
-
// Single expensive serialization per batch interval
|
|
1904
|
-
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
this.stateUpdatePending.clear();
|
|
1144
|
+
this.sse.broadcastSessionStateDebounced(sessionId);
|
|
1908
1145
|
}
|
|
1909
1146
|
// ========== Web Push ==========
|
|
1910
1147
|
/** Map SSE event names to push notification payloads */
|
|
@@ -1982,42 +1219,8 @@ export class WebServer extends EventEmitter {
|
|
|
1982
1219
|
});
|
|
1983
1220
|
}
|
|
1984
1221
|
}
|
|
1985
|
-
/**
|
|
1986
|
-
* Clean up dead SSE clients and send keep-alive comments.
|
|
1987
|
-
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
|
1988
|
-
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
|
1989
|
-
*/
|
|
1990
1222
|
cleanupDeadSSEClients() {
|
|
1991
|
-
|
|
1992
|
-
for (const [client] of this.sseClients) {
|
|
1993
|
-
try {
|
|
1994
|
-
// Check if the underlying socket is still writable
|
|
1995
|
-
const socket = client.raw.socket;
|
|
1996
|
-
if (!socket || socket.destroyed || !socket.writable) {
|
|
1997
|
-
deadClients.push(client);
|
|
1998
|
-
}
|
|
1999
|
-
else {
|
|
2000
|
-
// Send SSE comment as keep-alive. Only add padding when tunnel is
|
|
2001
|
-
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
|
|
2002
|
-
// for direct/Tailscale connections.
|
|
2003
|
-
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
|
|
2004
|
-
client.raw.write(ka);
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
catch {
|
|
2008
|
-
// Error accessing socket means client is dead
|
|
2009
|
-
deadClients.push(client);
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
// Remove dead clients
|
|
2013
|
-
for (const client of deadClients) {
|
|
2014
|
-
this.sseClients.delete(client);
|
|
2015
|
-
this.remoteSseClients.delete(client);
|
|
2016
|
-
this.backpressuredClients.delete(client);
|
|
2017
|
-
}
|
|
2018
|
-
if (deadClients.length > 0) {
|
|
2019
|
-
console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`);
|
|
2020
|
-
}
|
|
1223
|
+
this.sse.cleanupDeadClients();
|
|
2021
1224
|
}
|
|
2022
1225
|
/**
|
|
2023
1226
|
* Records token usage for long-running sessions periodically.
|
|
@@ -2330,32 +1533,11 @@ export class WebServer extends EventEmitter {
|
|
|
2330
1533
|
async stop() {
|
|
2331
1534
|
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
|
|
2332
1535
|
// Set stopping flag to prevent new timer creation during shutdown
|
|
2333
|
-
this.
|
|
1536
|
+
this.sse.setStopping();
|
|
2334
1537
|
// Dispose all managed timers (intervals + resettable timeouts)
|
|
2335
1538
|
this.cleanup.dispose();
|
|
2336
|
-
// Gracefully close all SSE connections
|
|
2337
|
-
|
|
2338
|
-
try {
|
|
2339
|
-
// Send a final event to notify clients of shutdown
|
|
2340
|
-
this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
|
|
2341
|
-
client.raw.end();
|
|
2342
|
-
}
|
|
2343
|
-
catch {
|
|
2344
|
-
// Client may already be disconnected
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
this.sseClients.clear();
|
|
2348
|
-
this.remoteSseClients.clear();
|
|
2349
|
-
this.backpressuredClients.clear();
|
|
2350
|
-
// Clear per-session batch timers
|
|
2351
|
-
for (const timer of this.terminalBatchTimers.values()) {
|
|
2352
|
-
clearTimeout(timer);
|
|
2353
|
-
}
|
|
2354
|
-
this.terminalBatchTimers.clear();
|
|
2355
|
-
this.terminalBatches.clear();
|
|
2356
|
-
this.terminalBatchSizes.clear();
|
|
2357
|
-
this.taskUpdateBatches.clear();
|
|
2358
|
-
this.stateUpdatePending.clear();
|
|
1539
|
+
// Gracefully close all SSE connections and clear batching state
|
|
1540
|
+
this.sse.stop();
|
|
2359
1541
|
this.lastRecordedTokens.clear();
|
|
2360
1542
|
// Stop multiplexer and flush pending saves
|
|
2361
1543
|
this.mux.destroy();
|
|
@@ -2398,31 +1580,7 @@ export class WebServer extends EventEmitter {
|
|
|
2398
1580
|
// Remove listeners to avoid spurious events during teardown
|
|
2399
1581
|
const listeners = this.sessionListenerRefs.get(sessionId);
|
|
2400
1582
|
if (listeners) {
|
|
2401
|
-
session
|
|
2402
|
-
session.off('clearTerminal', listeners.clearTerminal);
|
|
2403
|
-
session.off('needsRefresh', listeners.needsRefresh);
|
|
2404
|
-
session.off('message', listeners.message);
|
|
2405
|
-
session.off('error', listeners.error);
|
|
2406
|
-
session.off('completion', listeners.completion);
|
|
2407
|
-
session.off('exit', listeners.exit);
|
|
2408
|
-
session.off('working', listeners.working);
|
|
2409
|
-
session.off('idle', listeners.idle);
|
|
2410
|
-
session.off('taskCreated', listeners.taskCreated);
|
|
2411
|
-
session.off('taskUpdated', listeners.taskUpdated);
|
|
2412
|
-
session.off('taskCompleted', listeners.taskCompleted);
|
|
2413
|
-
session.off('taskFailed', listeners.taskFailed);
|
|
2414
|
-
session.off('autoClear', listeners.autoClear);
|
|
2415
|
-
session.off('autoCompact', listeners.autoCompact);
|
|
2416
|
-
session.off('cliInfoUpdated', listeners.cliInfoUpdated);
|
|
2417
|
-
session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
|
2418
|
-
session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
|
2419
|
-
session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
|
2420
|
-
session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
|
2421
|
-
session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
|
2422
|
-
session.off('ralphExitGateMet', listeners.ralphExitGateMet);
|
|
2423
|
-
session.off('bashToolStart', listeners.bashToolStart);
|
|
2424
|
-
session.off('bashToolEnd', listeners.bashToolEnd);
|
|
2425
|
-
session.off('bashToolsUpdate', listeners.bashToolsUpdate);
|
|
1583
|
+
detachSessionListeners(session, listeners);
|
|
2426
1584
|
this.sessionListenerRefs.delete(sessionId);
|
|
2427
1585
|
}
|
|
2428
1586
|
session.removeAllListeners();
|
|
@@ -2469,7 +1627,6 @@ export class WebServer extends EventEmitter {
|
|
|
2469
1627
|
this.sessionListenerRefs.clear();
|
|
2470
1628
|
this.scheduledRuns.clear();
|
|
2471
1629
|
// Dispose StaleExpirationMaps (stops internal cleanup timers)
|
|
2472
|
-
this.lastTerminalEventTime.dispose();
|
|
2473
1630
|
if (this.authSessions) {
|
|
2474
1631
|
this.authSessions.dispose();
|
|
2475
1632
|
this.authSessions = null;
|