aegis-bridge 2.2.2

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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
  4. package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/auth.d.ts +76 -0
  7. package/dist/auth.js +219 -0
  8. package/dist/channels/index.d.ts +8 -0
  9. package/dist/channels/index.js +9 -0
  10. package/dist/channels/manager.d.ts +39 -0
  11. package/dist/channels/manager.js +101 -0
  12. package/dist/channels/telegram-style.d.ts +118 -0
  13. package/dist/channels/telegram-style.js +203 -0
  14. package/dist/channels/telegram.d.ts +76 -0
  15. package/dist/channels/telegram.js +1396 -0
  16. package/dist/channels/types.d.ts +77 -0
  17. package/dist/channels/types.js +9 -0
  18. package/dist/channels/webhook.d.ts +58 -0
  19. package/dist/channels/webhook.js +162 -0
  20. package/dist/cli.d.ts +8 -0
  21. package/dist/cli.js +223 -0
  22. package/dist/config.d.ts +60 -0
  23. package/dist/config.js +188 -0
  24. package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
  25. package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
  26. package/dist/dashboard/index.html +14 -0
  27. package/dist/events.d.ts +86 -0
  28. package/dist/events.js +258 -0
  29. package/dist/hook-settings.d.ts +67 -0
  30. package/dist/hook-settings.js +138 -0
  31. package/dist/hook.d.ts +18 -0
  32. package/dist/hook.js +199 -0
  33. package/dist/hooks.d.ts +32 -0
  34. package/dist/hooks.js +279 -0
  35. package/dist/jsonl-watcher.d.ts +57 -0
  36. package/dist/jsonl-watcher.js +159 -0
  37. package/dist/mcp-server.d.ts +60 -0
  38. package/dist/mcp-server.js +788 -0
  39. package/dist/metrics.d.ts +104 -0
  40. package/dist/metrics.js +226 -0
  41. package/dist/monitor.d.ts +84 -0
  42. package/dist/monitor.js +553 -0
  43. package/dist/permission-guard.d.ts +51 -0
  44. package/dist/permission-guard.js +197 -0
  45. package/dist/pipeline.d.ts +84 -0
  46. package/dist/pipeline.js +218 -0
  47. package/dist/screenshot.d.ts +26 -0
  48. package/dist/screenshot.js +57 -0
  49. package/dist/server.d.ts +10 -0
  50. package/dist/server.js +1577 -0
  51. package/dist/session.d.ts +297 -0
  52. package/dist/session.js +1275 -0
  53. package/dist/sse-limiter.d.ts +47 -0
  54. package/dist/sse-limiter.js +62 -0
  55. package/dist/sse-writer.d.ts +31 -0
  56. package/dist/sse-writer.js +95 -0
  57. package/dist/ssrf.d.ts +57 -0
  58. package/dist/ssrf.js +169 -0
  59. package/dist/swarm-monitor.d.ts +114 -0
  60. package/dist/swarm-monitor.js +267 -0
  61. package/dist/terminal-parser.d.ts +16 -0
  62. package/dist/terminal-parser.js +343 -0
  63. package/dist/tmux.d.ts +161 -0
  64. package/dist/tmux.js +725 -0
  65. package/dist/transcript.d.ts +47 -0
  66. package/dist/transcript.js +244 -0
  67. package/dist/validation.d.ts +222 -0
  68. package/dist/validation.js +268 -0
  69. package/dist/ws-terminal.d.ts +32 -0
  70. package/dist/ws-terminal.js +297 -0
  71. package/package.json +71 -0
package/dist/server.js ADDED
@@ -0,0 +1,1577 @@
1
+ /**
2
+ * server.ts — HTTP API server for Aegis.
3
+ *
4
+ * Exposes RESTful endpoints for creating, managing, and interacting
5
+ * with Claude Code sessions running in tmux.
6
+ *
7
+ * Notification channels (Telegram, webhooks, etc.) are pluggable —
8
+ * the server doesn't know which channels are active.
9
+ */
10
+ import Fastify from 'fastify';
11
+ import fs from 'node:fs/promises';
12
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
13
+ import fastifyStatic from '@fastify/static';
14
+ import fastifyWebsocket from '@fastify/websocket';
15
+ import fastifyCors from '@fastify/cors';
16
+ import { z } from 'zod';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { TmuxManager } from './tmux.js';
20
+ import { SessionManager } from './session.js';
21
+ import { SessionMonitor, DEFAULT_MONITOR_CONFIG } from './monitor.js';
22
+ import { JsonlWatcher } from './jsonl-watcher.js';
23
+ import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/index.js';
24
+ import { loadConfig } from './config.js';
25
+ import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
26
+ import { validateScreenshotUrl, resolveAndCheckIp } from './ssrf.js';
27
+ import { validateWorkDir } from './validation.js';
28
+ import { SessionEventBus } from './events.js';
29
+ import { SSEWriter } from './sse-writer.js';
30
+ import { SSEConnectionLimiter } from './sse-limiter.js';
31
+ import { PipelineManager } from './pipeline.js';
32
+ import { AuthManager } from './auth.js';
33
+ import { MetricsCollector } from './metrics.js';
34
+ import { registerHookRoutes } from './hooks.js';
35
+ import { registerWsTerminalRoute } from './ws-terminal.js';
36
+ import { SwarmMonitor } from './swarm-monitor.js';
37
+ import { execSync } from 'node:child_process';
38
+ import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, parseIntSafe, } from './validation.js';
39
+ const __filename = fileURLToPath(import.meta.url);
40
+ const __dirname = path.dirname(__filename);
41
+ // ── Configuration ────────────────────────────────────────────────────
42
+ // Issue #349: CSP policy for dashboard responses (shared between static and SPA fallback)
43
+ const DASHBOARD_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:";
44
+ // Config loaded at startup; env vars override file values
45
+ let config;
46
+ // These will be initialized after config is loaded
47
+ let tmux;
48
+ let sessions;
49
+ let monitor;
50
+ let jsonlWatcher;
51
+ const channels = new ChannelManager();
52
+ const eventBus = new SessionEventBus();
53
+ let sseLimiter;
54
+ let pipelines;
55
+ let auth;
56
+ let metrics;
57
+ let swarmMonitor;
58
+ // ── Inbound command handler ─────────────────────────────────────────
59
+ async function handleInbound(cmd) {
60
+ try {
61
+ switch (cmd.action) {
62
+ case 'approve':
63
+ await sessions.approve(cmd.sessionId);
64
+ break;
65
+ case 'reject':
66
+ await sessions.reject(cmd.sessionId);
67
+ break;
68
+ case 'escape':
69
+ await sessions.escape(cmd.sessionId);
70
+ break;
71
+ case 'kill':
72
+ await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
73
+ await sessions.killSession(cmd.sessionId);
74
+ monitor.removeSession(cmd.sessionId);
75
+ metrics.cleanupSession(cmd.sessionId);
76
+ break;
77
+ case 'message':
78
+ case 'command':
79
+ if (cmd.text)
80
+ await sessions.sendMessage(cmd.sessionId, cmd.text);
81
+ break;
82
+ }
83
+ }
84
+ catch (e) {
85
+ console.error(`Inbound command error [${cmd.action}]:`, e);
86
+ }
87
+ }
88
+ // ── HTTP Server ─────────────────────────────────────────────────────
89
+ const app = Fastify({
90
+ bodyLimit: 1048576, // 1MB — Issue #349: explicit body size limit
91
+ logger: {
92
+ // #230: Redact auth tokens from request logs
93
+ serializers: {
94
+ req(req) {
95
+ const url = req.url?.includes('token=')
96
+ ? req.url.replace(/token=[^&]*/g, 'token=[REDACTED]')
97
+ : req.url;
98
+ return {
99
+ method: req.method,
100
+ url,
101
+ // ...rest intentionally omitted — prevents token leakage via headers
102
+ };
103
+ },
104
+ },
105
+ },
106
+ });
107
+ // #227: Security headers on all API responses (skip SSE)
108
+ app.addHook('onSend', (req, reply, payload, done) => {
109
+ const contentType = reply.getHeader('content-type');
110
+ if (typeof contentType === 'string' && contentType.includes('text/event-stream')) {
111
+ return done();
112
+ }
113
+ reply.header('X-Content-Type-Options', 'nosniff');
114
+ reply.header('X-Frame-Options', 'DENY');
115
+ reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
116
+ reply.header('Permissions-Policy', 'camera=(), microphone=()');
117
+ done();
118
+ });
119
+ // Auth middleware setup (Issue #39: multi-key auth with rate limiting)
120
+ // #228: Per-IP rate limiting (applies even with master token, with higher limits)
121
+ const ipRateLimits = new Map();
122
+ const IP_WINDOW_MS = 60_000;
123
+ const IP_LIMIT_NORMAL = 120; // per minute for regular keys
124
+ const IP_LIMIT_MASTER = 300; // per minute for master token
125
+ function checkIpRateLimit(ip, isMaster) {
126
+ const now = Date.now();
127
+ const timestamps = ipRateLimits.get(ip) || [];
128
+ // Prune old entries
129
+ while (timestamps.length > 0 && timestamps[0] < now - IP_WINDOW_MS) {
130
+ timestamps.shift();
131
+ }
132
+ timestamps.push(now);
133
+ ipRateLimits.set(ip, timestamps);
134
+ const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL;
135
+ return timestamps.length > limit;
136
+ }
137
+ /** #357: Prune IPs whose timestamp arrays are entirely outside the rate-limit window. */
138
+ function pruneIpRateLimits() {
139
+ const cutoff = Date.now() - IP_WINDOW_MS;
140
+ for (const [ip, timestamps] of ipRateLimits) {
141
+ // All timestamps are old — remove the entry entirely
142
+ if (timestamps.length === 0 || timestamps[timestamps.length - 1] < cutoff) {
143
+ ipRateLimits.delete(ip);
144
+ }
145
+ }
146
+ }
147
+ function setupAuth(authManager) {
148
+ app.addHook('onRequest', async (req, reply) => {
149
+ // Skip auth for health endpoint and dashboard (Issue #349: exact path matching)
150
+ // #126: Dashboard is served as public static files; API endpoints are protected
151
+ const urlPath = req.url?.split('?')[0] ?? '';
152
+ if (urlPath === '/health' || urlPath === '/v1/health')
153
+ return;
154
+ if (urlPath === '/dashboard' || urlPath.startsWith('/dashboard/'))
155
+ return;
156
+ // Hook routes — exact match: /v1/hooks/{eventName} (alpha only, no path traversal)
157
+ // Issue #394: Require valid X-Session-Id for known sessions instead of blanket bypass.
158
+ // CC hooks run from localhost and always include the session ID they were started with.
159
+ const hookMatch = /^\/v1\/hooks\/[A-Za-z]+$/.exec(urlPath);
160
+ if (hookMatch) {
161
+ const hookSessionId = req.headers['x-session-id']
162
+ || req.query?.sessionId;
163
+ if (hookSessionId && sessions.getSession(hookSessionId)) {
164
+ return; // valid session — allow
165
+ }
166
+ // No valid session context — reject even when auth is disabled
167
+ return reply.status(401).send({ error: 'Unauthorized — hook endpoint requires valid session ID' });
168
+ }
169
+ // #303: WS terminal routes have their own preHandler for auth (supports ?token=)
170
+ // Exact match: /v1/sessions/{id}/terminal
171
+ if (/^\/v1\/sessions\/[^/]+\/terminal$/.test(urlPath))
172
+ return;
173
+ // If no auth configured (no master token, no keys), allow all
174
+ if (!authManager.authEnabled)
175
+ return;
176
+ // #124/#125: Accept token from Authorization header; ?token= query param
177
+ // only on SSE routes where EventSource cannot set headers.
178
+ // #297: SSE routes also accept short-lived SSE tokens via ?token=.
179
+ const isSSERoute = req.url?.includes('/events');
180
+ let token;
181
+ const header = req.headers.authorization;
182
+ if (header?.startsWith('Bearer ')) {
183
+ token = header.slice(7);
184
+ }
185
+ else if (isSSERoute) {
186
+ token = req.query.token;
187
+ }
188
+ if (!token) {
189
+ return reply.status(401).send({ error: 'Unauthorized — Bearer token required' });
190
+ }
191
+ // #297: Check if this is a short-lived SSE token first
192
+ if (isSSERoute && token.startsWith('sse_')) {
193
+ if (authManager.validateSSEToken(token)) {
194
+ return; // authenticated via short-lived SSE token
195
+ }
196
+ return reply.status(401).send({ error: 'Unauthorized — SSE token invalid or expired' });
197
+ }
198
+ const result = authManager.validate(token);
199
+ if (!result.valid) {
200
+ return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
201
+ }
202
+ if (result.rateLimited) {
203
+ return reply.status(429).send({ error: 'Rate limit exceeded — 100 req/min per key' });
204
+ }
205
+ // #228: Per-IP rate limiting (applies to all authenticated requests)
206
+ const clientIp = req.ip ?? req.headers['x-forwarded-for'] ?? 'unknown';
207
+ const isMaster = result.keyId === 'master';
208
+ if (checkIpRateLimit(clientIp, isMaster)) {
209
+ return reply.status(429).send({ error: 'Rate limit exceeded — IP throttled' });
210
+ }
211
+ });
212
+ }
213
+ // ── v1 API Routes ───────────────────────────────────────────────────
214
+ // #226: Zod schema for session creation
215
+ const createSessionSchema = z.object({
216
+ workDir: z.string().min(1),
217
+ name: z.string().max(200).optional(),
218
+ prompt: z.string().max(100_000).optional(),
219
+ resumeSessionId: z.string().uuid().optional(),
220
+ claudeCommand: z.string().max(10_000).optional(),
221
+ env: z.record(z.string(), z.string()).optional(),
222
+ stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
223
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
224
+ autoApprove: z.boolean().optional(),
225
+ }).strict();
226
+ // Health
227
+ app.get('/v1/health', async () => {
228
+ const pkg = await import('../package.json', { with: { type: 'json' } });
229
+ const activeCount = sessions.listSessions().length;
230
+ const totalCount = metrics.getTotalSessionsCreated();
231
+ return {
232
+ status: 'ok',
233
+ version: pkg.default.version,
234
+ uptime: process.uptime(),
235
+ sessions: { active: activeCount, total: totalCount },
236
+ timestamp: new Date().toISOString(),
237
+ };
238
+ });
239
+ // Backwards compat: unversioned health
240
+ app.get('/health', async () => {
241
+ const pkg = await import('../package.json', { with: { type: 'json' } });
242
+ const activeCount = sessions.listSessions().length;
243
+ const totalCount = metrics.getTotalSessionsCreated();
244
+ return {
245
+ status: 'ok',
246
+ version: pkg.default.version,
247
+ uptime: process.uptime(),
248
+ sessions: { active: activeCount, total: totalCount },
249
+ timestamp: new Date().toISOString(),
250
+ };
251
+ });
252
+ // Issue #81: Swarm awareness — list all detected CC swarms and their teammates
253
+ app.get('/v1/swarm', async () => {
254
+ const result = await swarmMonitor.scan();
255
+ return result;
256
+ });
257
+ // API key management (Issue #39)
258
+ // Security: reject all auth key operations when auth is not enabled
259
+ app.post('/v1/auth/keys', async (req, reply) => {
260
+ if (!auth.authEnabled)
261
+ return reply.status(403).send({ error: 'Auth is not enabled' });
262
+ const parsed = authKeySchema.safeParse(req.body);
263
+ if (!parsed.success)
264
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
265
+ const { name, rateLimit } = parsed.data;
266
+ const result = await auth.createKey(name, rateLimit);
267
+ return reply.status(201).send(result);
268
+ });
269
+ app.get('/v1/auth/keys', async (req, reply) => {
270
+ if (!auth.authEnabled)
271
+ return reply.status(403).send({ error: 'Auth is not enabled' });
272
+ return auth.listKeys();
273
+ });
274
+ app.delete('/v1/auth/keys/:id', async (req, reply) => {
275
+ if (!auth.authEnabled)
276
+ return reply.status(403).send({ error: 'Auth is not enabled' });
277
+ const revoked = await auth.revokeKey(req.params.id);
278
+ if (!revoked)
279
+ return reply.status(404).send({ error: 'Key not found' });
280
+ return { ok: true };
281
+ });
282
+ // #297: SSE token endpoint — generates short-lived, single-use token
283
+ // to avoid exposing long-lived bearer tokens in SSE URL query params.
284
+ // Must be registered BEFORE setupAuth skips auth key routes.
285
+ app.post('/v1/auth/sse-token', async (req, reply) => {
286
+ // This route goes through the onRequest auth hook, so the caller is
287
+ // already authenticated. We just need to extract their keyId.
288
+ const header = req.headers.authorization;
289
+ let keyId = 'anonymous';
290
+ if (header?.startsWith('Bearer ')) {
291
+ const token = header.slice(7);
292
+ const result = auth.validate(token);
293
+ if (result.keyId)
294
+ keyId = result.keyId;
295
+ }
296
+ try {
297
+ const sseToken = await auth.generateSSEToken(keyId);
298
+ return reply.status(201).send(sseToken);
299
+ }
300
+ catch (e) {
301
+ return reply.status(429).send({ error: e instanceof Error ? e.message : 'SSE token limit reached' });
302
+ }
303
+ });
304
+ // Global metrics (Issue #40)
305
+ app.get('/v1/metrics', async () => metrics.getGlobalMetrics(sessions.listSessions().length));
306
+ // Per-session metrics (Issue #40)
307
+ app.get('/v1/sessions/:id/metrics', async (req, reply) => {
308
+ const m = metrics.getSessionMetrics(req.params.id);
309
+ if (!m)
310
+ return reply.status(404).send({ error: 'No metrics for this session' });
311
+ return m;
312
+ });
313
+ // Issue #89 L14: Webhook dead letter queue
314
+ app.get('/v1/webhooks/dead-letter', async () => {
315
+ for (const ch of channels.getChannels()) {
316
+ if (ch.name === 'webhook' && 'getDeadLetterQueue' in ch) {
317
+ return ch.getDeadLetterQueue();
318
+ }
319
+ }
320
+ return [];
321
+ });
322
+ // Issue #89 L15: Per-channel health reporting
323
+ app.get('/v1/channels/health', async () => {
324
+ return channels.getChannels().map(ch => {
325
+ const health = ch.getHealth?.();
326
+ if (health)
327
+ return health;
328
+ return { channel: ch.name, healthy: true, lastSuccess: null, lastError: null, pendingCount: 0 };
329
+ });
330
+ });
331
+ // Issue #87: Per-session latency metrics
332
+ app.get('/v1/sessions/:id/latency', async (req, reply) => {
333
+ const session = sessions.getSession(req.params.id);
334
+ if (!session)
335
+ return reply.status(404).send({ error: 'Session not found' });
336
+ const realtimeLatency = sessions.getLatencyMetrics(req.params.id);
337
+ const aggregatedLatency = metrics.getSessionLatency(req.params.id);
338
+ return {
339
+ sessionId: req.params.id,
340
+ realtime: realtimeLatency,
341
+ aggregated: aggregatedLatency,
342
+ };
343
+ });
344
+ // Global SSE event stream — aggregates events from ALL active sessions
345
+ app.get('/v1/events', async (req, reply) => {
346
+ const clientIp = req.ip;
347
+ const acquireResult = sseLimiter.acquire(clientIp);
348
+ if (!acquireResult.allowed) {
349
+ const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
350
+ return reply.status(status).send({
351
+ error: acquireResult.reason === 'per_ip_limit'
352
+ ? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
353
+ : `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
354
+ reason: acquireResult.reason,
355
+ });
356
+ }
357
+ // Issue #505: Subscribe BEFORE writing response headers so that if
358
+ // subscription fails, we can still return a proper HTTP error.
359
+ let unsubscribe;
360
+ const connectionId = acquireResult.connectionId;
361
+ let writer;
362
+ // Queue events that arrive between subscription and writer creation
363
+ const pendingEvents = [];
364
+ let subscriptionReady = false;
365
+ const handler = (event) => {
366
+ if (!subscriptionReady) {
367
+ pendingEvents.push(event);
368
+ return;
369
+ }
370
+ const id = event.id != null ? `id: ${event.id}\n` : '';
371
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
372
+ };
373
+ try {
374
+ unsubscribe = eventBus.subscribeGlobal(handler);
375
+ }
376
+ catch (err) {
377
+ req.log.error({ err }, 'Global SSE subscription failed');
378
+ sseLimiter.release(connectionId);
379
+ return reply.status(500).send({ error: 'Failed to create SSE subscription' });
380
+ }
381
+ reply.raw.writeHead(200, {
382
+ 'Content-Type': 'text/event-stream',
383
+ 'Cache-Control': 'no-cache',
384
+ 'Connection': 'keep-alive',
385
+ 'X-Accel-Buffering': 'no',
386
+ });
387
+ writer = new SSEWriter(reply.raw, req.raw, () => {
388
+ unsubscribe?.();
389
+ sseLimiter.release(connectionId);
390
+ });
391
+ // Now safe to deliver events — flush any queued during setup
392
+ subscriptionReady = true;
393
+ for (const event of pendingEvents) {
394
+ const id = event.id != null ? `id: ${event.id}\n` : '';
395
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
396
+ }
397
+ writer.write(`data: ${JSON.stringify({
398
+ event: 'connected',
399
+ timestamp: new Date().toISOString(),
400
+ data: { activeSessions: sessions.listSessions().length },
401
+ })}\n\n`);
402
+ // Issue #301: Replay missed global events if client sends Last-Event-ID
403
+ const lastEventId = req.headers['last-event-id'];
404
+ if (lastEventId) {
405
+ const missed = eventBus.getGlobalEventsSince(parseInt(lastEventId, 10) || 0);
406
+ for (const { id, event: globalEvent } of missed) {
407
+ writer.write(`id: ${id}\ndata: ${JSON.stringify(globalEvent)}\n\n`);
408
+ }
409
+ }
410
+ writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
411
+ await reply;
412
+ });
413
+ // List sessions (with pagination and status filter)
414
+ app.get('/v1/sessions', async (req) => {
415
+ const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
416
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
417
+ const statusFilter = req.query.status;
418
+ let all = sessions.listSessions();
419
+ if (statusFilter) {
420
+ all = all.filter(s => s.status === statusFilter);
421
+ }
422
+ // Sort by createdAt descending (newest first)
423
+ all.sort((a, b) => b.createdAt - a.createdAt);
424
+ const total = all.length;
425
+ const start = (page - 1) * limit;
426
+ const items = all.slice(start, start + limit);
427
+ return {
428
+ sessions: items,
429
+ total,
430
+ page,
431
+ limit,
432
+ };
433
+ });
434
+ // Backwards compat: /sessions (no prefix) returns raw array
435
+ app.get('/sessions', async () => sessions.listSessions());
436
+ /** Validate workDir — delegates to validation.ts (Issue #435). */
437
+ const validateWorkDirWithConfig = (workDir) => validateWorkDir(workDir, config.allowedWorkDirs);
438
+ // Create session
439
+ app.post('/v1/sessions', async (req, reply) => {
440
+ const parsed = createSessionSchema.safeParse(req.body);
441
+ if (!parsed.success) {
442
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
443
+ }
444
+ const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
445
+ console.time("POST_CREATE_SESSION");
446
+ if (!workDir)
447
+ return reply.status(400).send({ error: 'workDir is required' });
448
+ const safeWorkDir = await validateWorkDirWithConfig(workDir);
449
+ if (typeof safeWorkDir === 'object')
450
+ return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
451
+ const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
452
+ console.timeEnd("POST_CREATE_SESSION");
453
+ console.time("POST_CHANNEL_CREATED");
454
+ // Issue #46: Create Telegram topic BEFORE sending prompt.
455
+ // The monitor starts polling immediately after createSession().
456
+ // If we wait for sendInitialPrompt (up to 15s), the monitor may find
457
+ // new messages but can't forward them because no topic exists yet.
458
+ // Those messages are lost forever (monitorOffset advances past them).
459
+ await channels.sessionCreated({
460
+ event: 'session.created',
461
+ timestamp: new Date().toISOString(),
462
+ session: { id: session.id, name: session.windowName, workDir },
463
+ detail: `Session created: ${session.windowName}`,
464
+ meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
465
+ });
466
+ console.timeEnd("POST_CHANNEL_CREATED");
467
+ console.time("POST_SEND_INITIAL_PROMPT");
468
+ // Now send the prompt (topic exists, monitor can forward messages)
469
+ let promptDelivery;
470
+ if (prompt) {
471
+ promptDelivery = await sessions.sendInitialPrompt(session.id, prompt);
472
+ console.timeEnd("POST_SEND_INITIAL_PROMPT");
473
+ metrics.promptSent(promptDelivery.delivered);
474
+ }
475
+ else {
476
+ console.timeEnd("POST_SEND_INITIAL_PROMPT");
477
+ }
478
+ return reply.status(201).send({ ...session, promptDelivery });
479
+ });
480
+ // Backwards compat
481
+ app.post('/sessions', async (req, reply) => {
482
+ const parsed = createSessionSchema.safeParse(req.body);
483
+ if (!parsed.success) {
484
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
485
+ }
486
+ const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
487
+ if (!workDir)
488
+ return reply.status(400).send({ error: 'workDir is required' });
489
+ const safeWorkDir = await validateWorkDirWithConfig(workDir);
490
+ if (typeof safeWorkDir === 'object')
491
+ return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
492
+ const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
493
+ // Issue #46: Topic first, then prompt (same fix as v1 route)
494
+ await channels.sessionCreated({
495
+ event: 'session.created',
496
+ timestamp: new Date().toISOString(),
497
+ session: { id: session.id, name: session.windowName, workDir },
498
+ detail: `Session created: ${session.windowName}`,
499
+ meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
500
+ });
501
+ let promptDelivery;
502
+ if (prompt) {
503
+ promptDelivery = await sessions.sendInitialPrompt(session.id, prompt);
504
+ metrics.promptSent(promptDelivery.delivered);
505
+ }
506
+ return reply.status(201).send({ ...session, promptDelivery });
507
+ });
508
+ // Get session (Issue #20: includes actionHints for interactive states)
509
+ app.get('/v1/sessions/:id', async (req, reply) => {
510
+ const session = sessions.getSession(req.params.id);
511
+ if (!session)
512
+ return reply.status(404).send({ error: 'Session not found' });
513
+ return addActionHints(session);
514
+ });
515
+ app.get('/sessions/:id', async (req, reply) => {
516
+ const session = sessions.getSession(req.params.id);
517
+ if (!session)
518
+ return reply.status(404).send({ error: 'Session not found' });
519
+ return addActionHints(session);
520
+ });
521
+ // #128: Bulk health check — returns health for all sessions in one request
522
+ app.get('/v1/sessions/health', async () => {
523
+ const allSessions = sessions.listSessions();
524
+ const results = {};
525
+ await Promise.all(allSessions.map(async (s) => {
526
+ try {
527
+ results[s.id] = await sessions.getHealth(s.id);
528
+ }
529
+ catch { /* health check failed — report error state */
530
+ results[s.id] = {
531
+ alive: false, windowExists: false, claudeRunning: false,
532
+ paneCommand: null, status: 'unknown', hasTranscript: false,
533
+ lastActivity: 0, lastActivityAgo: 0, sessionAge: 0,
534
+ details: 'Error fetching health',
535
+ };
536
+ }
537
+ }));
538
+ return results;
539
+ });
540
+ // Session health check (Issue #2)
541
+ app.get('/v1/sessions/:id/health', async (req, reply) => {
542
+ try {
543
+ return await sessions.getHealth(req.params.id);
544
+ }
545
+ catch (e) {
546
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
547
+ }
548
+ });
549
+ app.get('/sessions/:id/health', async (req, reply) => {
550
+ try {
551
+ return await sessions.getHealth(req.params.id);
552
+ }
553
+ catch (e) {
554
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
555
+ }
556
+ });
557
+ // Send message (with delivery verification — Issue #1)
558
+ app.post('/v1/sessions/:id/send', async (req, reply) => {
559
+ const parsed = sendMessageSchema.safeParse(req.body);
560
+ if (!parsed.success)
561
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
562
+ const { text } = parsed.data;
563
+ try {
564
+ const result = await sessions.sendMessage(req.params.id, text);
565
+ await channels.message({
566
+ event: 'message.user',
567
+ timestamp: new Date().toISOString(),
568
+ session: { id: req.params.id, name: '', workDir: '' },
569
+ detail: text,
570
+ });
571
+ return { ok: true, delivered: result.delivered, attempts: result.attempts };
572
+ }
573
+ catch (e) {
574
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
575
+ }
576
+ });
577
+ app.post('/sessions/:id/send', async (req, reply) => {
578
+ const parsed = sendMessageSchema.safeParse(req.body);
579
+ if (!parsed.success)
580
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
581
+ const { text } = parsed.data;
582
+ try {
583
+ const result = await sessions.sendMessage(req.params.id, text);
584
+ await channels.message({
585
+ event: 'message.user',
586
+ timestamp: new Date().toISOString(),
587
+ session: { id: req.params.id, name: '', workDir: '' },
588
+ detail: text,
589
+ });
590
+ return { ok: true, delivered: result.delivered, attempts: result.attempts };
591
+ }
592
+ catch (e) {
593
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
594
+ }
595
+ });
596
+ // Read messages
597
+ app.get('/v1/sessions/:id/read', async (req, reply) => {
598
+ try {
599
+ return await sessions.readMessages(req.params.id);
600
+ }
601
+ catch (e) {
602
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
603
+ }
604
+ });
605
+ app.get('/sessions/:id/read', async (req, reply) => {
606
+ try {
607
+ return await sessions.readMessages(req.params.id);
608
+ }
609
+ catch (e) {
610
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
611
+ }
612
+ });
613
+ // Approve
614
+ app.post('/v1/sessions/:id/approve', async (req, reply) => {
615
+ try {
616
+ await sessions.approve(req.params.id);
617
+ // Issue #87: Record permission response latency
618
+ const lat = sessions.getLatencyMetrics(req.params.id);
619
+ if (lat !== null && lat.permission_response_ms !== null) {
620
+ metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
621
+ }
622
+ return { ok: true };
623
+ }
624
+ catch (e) {
625
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
626
+ }
627
+ });
628
+ app.post('/sessions/:id/approve', async (req, reply) => {
629
+ try {
630
+ await sessions.approve(req.params.id);
631
+ const lat = sessions.getLatencyMetrics(req.params.id);
632
+ if (lat !== null && lat.permission_response_ms !== null) {
633
+ metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
634
+ }
635
+ return { ok: true };
636
+ }
637
+ catch (e) {
638
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
639
+ }
640
+ });
641
+ // Reject
642
+ app.post('/v1/sessions/:id/reject', async (req, reply) => {
643
+ try {
644
+ await sessions.reject(req.params.id);
645
+ const lat = sessions.getLatencyMetrics(req.params.id);
646
+ if (lat !== null && lat.permission_response_ms !== null) {
647
+ metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
648
+ }
649
+ return { ok: true };
650
+ }
651
+ catch (e) {
652
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
653
+ }
654
+ });
655
+ app.post('/sessions/:id/reject', async (req, reply) => {
656
+ try {
657
+ await sessions.reject(req.params.id);
658
+ const lat = sessions.getLatencyMetrics(req.params.id);
659
+ if (lat !== null && lat.permission_response_ms !== null) {
660
+ metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
661
+ }
662
+ return { ok: true };
663
+ }
664
+ catch (e) {
665
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
666
+ }
667
+ });
668
+ // Issue #336: Answer pending AskUserQuestion
669
+ app.post('/v1/sessions/:id/answer', async (req, reply) => {
670
+ const { questionId, answer } = req.body || {};
671
+ if (!questionId || answer === undefined || answer === null) {
672
+ return reply.status(400).send({ error: 'questionId and answer are required' });
673
+ }
674
+ const session = sessions.getSession(req.params.id);
675
+ if (!session)
676
+ return reply.status(404).send({ error: 'Session not found' });
677
+ const resolved = sessions.submitAnswer(req.params.id, questionId, answer);
678
+ if (!resolved) {
679
+ return reply.status(409).send({ error: 'No pending question matching this questionId' });
680
+ }
681
+ return { ok: true };
682
+ });
683
+ // Escape
684
+ app.post('/v1/sessions/:id/escape', async (req, reply) => {
685
+ try {
686
+ await sessions.escape(req.params.id);
687
+ return { ok: true };
688
+ }
689
+ catch (e) {
690
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
691
+ }
692
+ });
693
+ app.post('/sessions/:id/escape', async (req, reply) => {
694
+ try {
695
+ await sessions.escape(req.params.id);
696
+ return { ok: true };
697
+ }
698
+ catch (e) {
699
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
700
+ }
701
+ });
702
+ // Interrupt (Ctrl+C)
703
+ app.post('/v1/sessions/:id/interrupt', async (req, reply) => {
704
+ try {
705
+ await sessions.interrupt(req.params.id);
706
+ return { ok: true };
707
+ }
708
+ catch (e) {
709
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
710
+ }
711
+ });
712
+ app.post('/sessions/:id/interrupt', async (req, reply) => {
713
+ try {
714
+ await sessions.interrupt(req.params.id);
715
+ return { ok: true };
716
+ }
717
+ catch (e) {
718
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
719
+ }
720
+ });
721
+ // Kill session
722
+ app.delete('/v1/sessions/:id', async (req, reply) => {
723
+ if (!sessions.getSession(req.params.id)) {
724
+ return reply.status(404).send({ error: 'Session not found' });
725
+ }
726
+ try {
727
+ eventBus.emitEnded(req.params.id, 'killed');
728
+ await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
729
+ await sessions.killSession(req.params.id);
730
+ monitor.removeSession(req.params.id);
731
+ metrics.cleanupSession(req.params.id);
732
+ return { ok: true };
733
+ }
734
+ catch (e) {
735
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
736
+ }
737
+ });
738
+ app.delete('/sessions/:id', async (req, reply) => {
739
+ if (!sessions.getSession(req.params.id)) {
740
+ return reply.status(404).send({ error: 'Session not found' });
741
+ }
742
+ try {
743
+ eventBus.emitEnded(req.params.id, 'killed');
744
+ await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
745
+ await sessions.killSession(req.params.id);
746
+ monitor.removeSession(req.params.id);
747
+ metrics.cleanupSession(req.params.id);
748
+ return { ok: true };
749
+ }
750
+ catch (e) {
751
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
752
+ }
753
+ });
754
+ // Capture raw pane
755
+ app.get('/v1/sessions/:id/pane', async (req, reply) => {
756
+ const session = sessions.getSession(req.params.id);
757
+ if (!session)
758
+ return reply.status(404).send({ error: 'Session not found' });
759
+ const pane = await tmux.capturePane(session.windowId);
760
+ return { pane };
761
+ });
762
+ app.get('/sessions/:id/pane', async (req, reply) => {
763
+ const session = sessions.getSession(req.params.id);
764
+ if (!session)
765
+ return reply.status(404).send({ error: 'Session not found' });
766
+ const pane = await tmux.capturePane(session.windowId);
767
+ return { pane };
768
+ });
769
+ // Slash command
770
+ app.post('/v1/sessions/:id/command', async (req, reply) => {
771
+ const parsed = commandSchema.safeParse(req.body);
772
+ if (!parsed.success)
773
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
774
+ const { command } = parsed.data;
775
+ try {
776
+ const cmd = command.startsWith('/') ? command : `/${command}`;
777
+ await sessions.sendMessage(req.params.id, cmd);
778
+ return { ok: true };
779
+ }
780
+ catch (e) {
781
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
782
+ }
783
+ });
784
+ app.post('/sessions/:id/command', async (req, reply) => {
785
+ const parsed = commandSchema.safeParse(req.body);
786
+ if (!parsed.success)
787
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
788
+ const { command } = parsed.data;
789
+ try {
790
+ const cmd = command.startsWith('/') ? command : `/${command}`;
791
+ await sessions.sendMessage(req.params.id, cmd);
792
+ return { ok: true };
793
+ }
794
+ catch (e) {
795
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
796
+ }
797
+ });
798
+ // Bash mode
799
+ app.post('/v1/sessions/:id/bash', async (req, reply) => {
800
+ const parsed = bashSchema.safeParse(req.body);
801
+ if (!parsed.success)
802
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
803
+ const { command } = parsed.data;
804
+ try {
805
+ const cmd = command.startsWith('!') ? command : `!${command}`;
806
+ await sessions.sendMessage(req.params.id, cmd);
807
+ return { ok: true };
808
+ }
809
+ catch (e) {
810
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
811
+ }
812
+ });
813
+ app.post('/sessions/:id/bash', async (req, reply) => {
814
+ const parsed = bashSchema.safeParse(req.body);
815
+ if (!parsed.success)
816
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
817
+ const { command } = parsed.data;
818
+ try {
819
+ const cmd = command.startsWith('!') ? command : `!${command}`;
820
+ await sessions.sendMessage(req.params.id, cmd);
821
+ return { ok: true };
822
+ }
823
+ catch (e) {
824
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
825
+ }
826
+ });
827
+ // Session summary (Issue #35)
828
+ app.get('/v1/sessions/:id/summary', async (req, reply) => {
829
+ try {
830
+ return await sessions.getSummary(req.params.id);
831
+ }
832
+ catch (e) {
833
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
834
+ }
835
+ });
836
+ app.get('/sessions/:id/summary', async (req, reply) => {
837
+ try {
838
+ return await sessions.getSummary(req.params.id);
839
+ }
840
+ catch (e) {
841
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
842
+ }
843
+ });
844
+ // Paginated transcript read
845
+ app.get('/v1/sessions/:id/transcript', async (req, reply) => {
846
+ try {
847
+ const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
848
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
849
+ const roleFilter = req.query.role;
850
+ return await sessions.readTranscript(req.params.id, page, limit, roleFilter);
851
+ }
852
+ catch (e) {
853
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
854
+ }
855
+ });
856
+ // Screenshot capture (Issue #22)
857
+ app.post('/v1/sessions/:id/screenshot', async (req, reply) => {
858
+ const parsed = screenshotSchema.safeParse(req.body);
859
+ if (!parsed.success)
860
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
861
+ const { url, fullPage, width, height } = parsed.data;
862
+ const urlError = validateScreenshotUrl(url);
863
+ if (urlError)
864
+ return reply.status(400).send({ error: urlError });
865
+ // Post-DNS-resolution check: resolve hostname and reject private IPs
866
+ const ipError = await resolveAndCheckIp(new URL(url).hostname);
867
+ if (ipError)
868
+ return reply.status(400).send({ error: ipError });
869
+ // Validate session exists
870
+ const session = sessions.getSession(req.params.id);
871
+ if (!session)
872
+ return reply.status(404).send({ error: 'Session not found' });
873
+ if (!isPlaywrightAvailable()) {
874
+ return reply.status(501).send({
875
+ error: 'Playwright is not installed',
876
+ message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
877
+ });
878
+ }
879
+ try {
880
+ const result = await captureScreenshot({ url, fullPage, width, height });
881
+ return reply.status(200).send(result);
882
+ }
883
+ catch (e) {
884
+ return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
885
+ }
886
+ });
887
+ app.post('/sessions/:id/screenshot', async (req, reply) => {
888
+ const parsed = screenshotSchema.safeParse(req.body);
889
+ if (!parsed.success)
890
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
891
+ const { url, fullPage, width, height } = parsed.data;
892
+ const urlError = validateScreenshotUrl(url);
893
+ if (urlError)
894
+ return reply.status(400).send({ error: urlError });
895
+ // Post-DNS-resolution check: resolve hostname and reject private IPs
896
+ const dnsError = await resolveAndCheckIp(new URL(url).hostname);
897
+ if (dnsError)
898
+ return reply.status(400).send({ error: dnsError });
899
+ const session = sessions.getSession(req.params.id);
900
+ if (!session)
901
+ return reply.status(404).send({ error: 'Session not found' });
902
+ if (!isPlaywrightAvailable()) {
903
+ return reply.status(501).send({
904
+ error: 'Playwright is not installed',
905
+ message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
906
+ });
907
+ }
908
+ try {
909
+ const result = await captureScreenshot({ url, fullPage, width, height });
910
+ return reply.status(200).send(result);
911
+ }
912
+ catch (e) {
913
+ return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
914
+ }
915
+ });
916
+ // SSE event stream (Issue #32)
917
+ app.get('/v1/sessions/:id/events', async (req, reply) => {
918
+ const session = sessions.getSession(req.params.id);
919
+ if (!session)
920
+ return reply.status(404).send({ error: 'Session not found' });
921
+ const clientIp = req.ip;
922
+ const acquireResult = sseLimiter.acquire(clientIp);
923
+ if (!acquireResult.allowed) {
924
+ const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
925
+ return reply.status(status).send({
926
+ error: acquireResult.reason === 'per_ip_limit'
927
+ ? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
928
+ : `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
929
+ reason: acquireResult.reason,
930
+ });
931
+ }
932
+ // Issue #505: Subscribe BEFORE writing response headers so that if
933
+ // subscription fails, we can still return a proper HTTP error.
934
+ let unsubscribe;
935
+ const connectionId = acquireResult.connectionId;
936
+ let writer;
937
+ // Queue events that arrive between subscription and writer creation
938
+ const pendingEvents = [];
939
+ let subscriptionReady = false;
940
+ try {
941
+ const handler = (event) => {
942
+ if (!subscriptionReady) {
943
+ pendingEvents.push(event);
944
+ return;
945
+ }
946
+ const id = event.id != null ? `id: ${event.id}\n` : '';
947
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
948
+ };
949
+ unsubscribe = eventBus.subscribe(req.params.id, handler);
950
+ }
951
+ catch (err) {
952
+ req.log.error({ err, sessionId: req.params.id }, 'SSE subscription failed — unable to create event listener');
953
+ sseLimiter.release(connectionId);
954
+ return reply.status(500).send({ error: 'Failed to create SSE subscription' });
955
+ }
956
+ reply.raw.writeHead(200, {
957
+ 'Content-Type': 'text/event-stream',
958
+ 'Cache-Control': 'no-cache',
959
+ 'Connection': 'keep-alive',
960
+ 'X-Accel-Buffering': 'no',
961
+ });
962
+ writer = new SSEWriter(reply.raw, req.raw, () => {
963
+ unsubscribe?.();
964
+ sseLimiter.release(connectionId);
965
+ });
966
+ // Now safe to deliver events — flush any queued during setup
967
+ subscriptionReady = true;
968
+ for (const event of pendingEvents) {
969
+ const id = event.id != null ? `id: ${event.id}\n` : '';
970
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
971
+ }
972
+ // Send initial connected event
973
+ writer.write(`data: ${JSON.stringify({ event: 'connected', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
974
+ // Issue #308: Replay missed events if client sends Last-Event-ID
975
+ const lastEventId = req.headers['last-event-id'];
976
+ if (lastEventId) {
977
+ const missed = eventBus.getEventsSince(req.params.id, parseInt(lastEventId, 10) || 0);
978
+ for (const event of missed) {
979
+ const id = event.id != null ? `id: ${event.id}\n` : '';
980
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
981
+ }
982
+ }
983
+ writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
984
+ // Don't let Fastify auto-send (we manage the response manually)
985
+ await reply;
986
+ });
987
+ // ── Claude Code Hook Endpoints (Issue #161) ─────────────────────────
988
+ // POST /v1/sessions/:id/hooks/permission — PermissionRequest hook from CC
989
+ app.post('/v1/sessions/:id/hooks/permission', async (req, reply) => {
990
+ const session = sessions.getSession(req.params.id);
991
+ if (!session)
992
+ return reply.status(404).send({ error: 'Session not found' });
993
+ const parsed = permissionHookSchema.safeParse(req.body);
994
+ if (!parsed.success)
995
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
996
+ const { tool_name, tool_input, permission_mode } = parsed.data;
997
+ // Update session status
998
+ session.status = 'permission_prompt';
999
+ session.lastActivity = Date.now();
1000
+ await sessions.save();
1001
+ // Notify channels and SSE
1002
+ const detail = tool_name
1003
+ ? `Permission request: ${tool_name}${permission_mode ? ` (${permission_mode})` : ''}`
1004
+ : 'Permission requested';
1005
+ await channels.statusChange({
1006
+ event: 'status.permission',
1007
+ timestamp: new Date().toISOString(),
1008
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1009
+ detail,
1010
+ meta: { tool_name, tool_input, permission_mode },
1011
+ });
1012
+ eventBus.emitApproval(session.id, detail);
1013
+ return reply.status(200).send({});
1014
+ });
1015
+ // POST /v1/sessions/:id/hooks/stop — Stop hook from CC
1016
+ app.post('/v1/sessions/:id/hooks/stop', async (req, reply) => {
1017
+ const session = sessions.getSession(req.params.id);
1018
+ if (!session)
1019
+ return reply.status(404).send({ error: 'Session not found' });
1020
+ const parsed = stopHookSchema.safeParse(req.body);
1021
+ if (!parsed.success)
1022
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1023
+ const { stop_reason } = parsed.data;
1024
+ // Update session status
1025
+ session.status = 'idle';
1026
+ session.lastActivity = Date.now();
1027
+ await sessions.save();
1028
+ // Notify channels and SSE
1029
+ const detail = stop_reason
1030
+ ? `Claude Code stopped: ${stop_reason}`
1031
+ : 'Claude Code session ended normally';
1032
+ await channels.statusChange({
1033
+ event: 'status.idle',
1034
+ timestamp: new Date().toISOString(),
1035
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1036
+ detail,
1037
+ meta: { stop_reason },
1038
+ });
1039
+ eventBus.emitStatus(session.id, 'idle', detail);
1040
+ return reply.status(200).send({});
1041
+ });
1042
+ // Batch create (Issue #36)
1043
+ app.post('/v1/sessions/batch', async (req, reply) => {
1044
+ const parsed = batchSessionSchema.safeParse(req.body);
1045
+ if (!parsed.success)
1046
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1047
+ const specs = parsed.data.sessions;
1048
+ for (const spec of specs) {
1049
+ const safeWorkDir = await validateWorkDirWithConfig(spec.workDir);
1050
+ if (typeof safeWorkDir === 'object') {
1051
+ return reply.status(400).send({ error: `Invalid workDir "${spec.workDir}": ${safeWorkDir.error}`, code: safeWorkDir.code });
1052
+ }
1053
+ spec.workDir = safeWorkDir;
1054
+ }
1055
+ const result = await pipelines.batchCreate(specs);
1056
+ return reply.status(201).send(result);
1057
+ });
1058
+ // Pipeline create (Issue #36)
1059
+ app.post('/v1/pipelines', async (req, reply) => {
1060
+ const parsed = pipelineSchema.safeParse(req.body);
1061
+ if (!parsed.success)
1062
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1063
+ const pipeConfig = parsed.data;
1064
+ const safeWorkDir = await validateWorkDirWithConfig(pipeConfig.workDir);
1065
+ if (typeof safeWorkDir === 'object') {
1066
+ return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
1067
+ }
1068
+ pipeConfig.workDir = safeWorkDir;
1069
+ try {
1070
+ const pipeline = await pipelines.createPipeline(pipeConfig);
1071
+ return reply.status(201).send(pipeline);
1072
+ }
1073
+ catch (e) {
1074
+ return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
1075
+ }
1076
+ });
1077
+ // Pipeline status
1078
+ app.get('/v1/pipelines/:id', async (req, reply) => {
1079
+ const pipeline = pipelines.getPipeline(req.params.id);
1080
+ if (!pipeline)
1081
+ return reply.status(404).send({ error: 'Pipeline not found' });
1082
+ return pipeline;
1083
+ });
1084
+ // List pipelines
1085
+ app.get('/v1/pipelines', async () => pipelines.listPipelines());
1086
+ // ── Session Reaper ──────────────────────────────────────────────────
1087
+ async function reapStaleSessions(maxAgeMs) {
1088
+ const now = Date.now();
1089
+ // Snapshot list before iterating — killSession() modifies the sessions map
1090
+ const snapshot = [...sessions.listSessions()];
1091
+ for (const session of snapshot) {
1092
+ // Guard: session may have been deleted by DELETE handler between snapshot and here
1093
+ if (!sessions.getSession(session.id))
1094
+ continue;
1095
+ const age = now - session.createdAt;
1096
+ if (age > maxAgeMs) {
1097
+ const ageMin = Math.round(age / 60000);
1098
+ console.log(`Reaper: killing session ${session.windowName} (${session.id.slice(0, 8)}) — age ${ageMin}min`);
1099
+ try {
1100
+ await channels.sessionEnded({
1101
+ event: 'session.ended',
1102
+ timestamp: new Date().toISOString(),
1103
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1104
+ detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
1105
+ });
1106
+ await sessions.killSession(session.id);
1107
+ monitor.removeSession(session.id);
1108
+ metrics.cleanupSession(session.id);
1109
+ }
1110
+ catch (e) {
1111
+ console.error(`Reaper: failed to kill session ${session.id}:`, e);
1112
+ }
1113
+ }
1114
+ }
1115
+ }
1116
+ // ── Zombie Reaper (Issue #283) ──────────────────────────────────────
1117
+ const ZOMBIE_REAP_DELAY_MS = parseIntSafe(process.env.ZOMBIE_REAP_DELAY_MS, 60000);
1118
+ const ZOMBIE_REAP_INTERVAL_MS = parseIntSafe(process.env.ZOMBIE_REAP_INTERVAL_MS, 60000);
1119
+ async function reapZombieSessions() {
1120
+ const now = Date.now();
1121
+ // Snapshot list before iterating — killSession() modifies the sessions map
1122
+ const snapshot = [...sessions.listSessions()];
1123
+ for (const session of snapshot) {
1124
+ // Guard: session may have been deleted between snapshot and here
1125
+ if (!sessions.getSession(session.id))
1126
+ continue;
1127
+ if (!session.lastDeadAt)
1128
+ continue;
1129
+ const deadDuration = now - session.lastDeadAt;
1130
+ if (deadDuration < ZOMBIE_REAP_DELAY_MS)
1131
+ continue;
1132
+ console.log(`Reaper: removing zombie session ${session.windowName} (${session.id.slice(0, 8)})`);
1133
+ try {
1134
+ monitor.removeSession(session.id);
1135
+ await sessions.killSession(session.id);
1136
+ metrics.cleanupSession(session.id);
1137
+ await channels.sessionEnded({
1138
+ event: 'session.ended',
1139
+ timestamp: new Date().toISOString(),
1140
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1141
+ detail: `Zombie reaped: dead for ${Math.round(deadDuration / 1000)}s`,
1142
+ });
1143
+ }
1144
+ catch (e) {
1145
+ console.error(`Reaper: failed to reap zombie session ${session.id}:`, e);
1146
+ }
1147
+ }
1148
+ }
1149
+ // ── Helpers ──────────────────────────────────────────────────────────
1150
+ /** Issue #20: Add actionHints to session response for interactive states. */
1151
+ function addActionHints(session) {
1152
+ // #357: Convert Set to array for JSON serialization
1153
+ const result = {
1154
+ ...session,
1155
+ activeSubagents: session.activeSubagents ? [...session.activeSubagents] : undefined,
1156
+ };
1157
+ if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
1158
+ result.actionHints = {
1159
+ approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
1160
+ reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
1161
+ };
1162
+ }
1163
+ return result;
1164
+ }
1165
+ function makePayload(event, sessionId, detail, meta) {
1166
+ const session = sessions.getSession(sessionId);
1167
+ return {
1168
+ event,
1169
+ timestamp: new Date().toISOString(),
1170
+ session: {
1171
+ id: sessionId,
1172
+ name: session?.windowName || 'unknown',
1173
+ workDir: session?.workDir || '',
1174
+ },
1175
+ detail,
1176
+ ...(meta && { meta }),
1177
+ };
1178
+ }
1179
+ // ── Start ────────────────────────────────────────────────────────────
1180
+ /** Register notification channels from config */
1181
+ function registerChannels(cfg) {
1182
+ // Telegram (optional)
1183
+ if (cfg.tgBotToken && cfg.tgGroupId) {
1184
+ channels.register(new TelegramChannel({
1185
+ botToken: cfg.tgBotToken,
1186
+ groupChatId: cfg.tgGroupId,
1187
+ allowedUserIds: cfg.tgAllowedUsers,
1188
+ }));
1189
+ }
1190
+ // Webhooks (optional)
1191
+ if (cfg.webhooks.length > 0) {
1192
+ const webhookChannel = new WebhookChannel({
1193
+ endpoints: cfg.webhooks.map(url => ({ url })),
1194
+ });
1195
+ channels.register(webhookChannel);
1196
+ }
1197
+ }
1198
+ // ── PID file (peer Aegis detection) ───────────────────────────────────
1199
+ let pidFilePath = '';
1200
+ function writePidFile() {
1201
+ try {
1202
+ pidFilePath = path.join(config.stateDir, 'aegis.pid');
1203
+ writeFileSync(pidFilePath, String(process.pid));
1204
+ }
1205
+ catch { /* non-critical */ }
1206
+ }
1207
+ function readPidFile() {
1208
+ try {
1209
+ const p = path.join(config.stateDir, 'aegis.pid');
1210
+ const content = readFileSync(p, 'utf-8').trim();
1211
+ const pid = parseInt(content, 10);
1212
+ return isNaN(pid) ? null : pid;
1213
+ }
1214
+ catch { /* pid file missing or unreadable */
1215
+ return null;
1216
+ }
1217
+ }
1218
+ // ── Port conflict recovery (Issue #99, #162) ──────────────────────────
1219
+ /**
1220
+ * Check if a PID exists using `process.kill(pid, 0)`.
1221
+ */
1222
+ function pidExists(pid) {
1223
+ try {
1224
+ process.kill(pid, 0);
1225
+ return true;
1226
+ }
1227
+ catch { /* ESRCH — process does not exist */
1228
+ return false;
1229
+ }
1230
+ }
1231
+ /**
1232
+ * Check if a PID is an ancestor of the current process.
1233
+ */
1234
+ function isAncestorPid(pid) {
1235
+ try {
1236
+ let current = process.ppid;
1237
+ for (let depth = 0; depth < 10 && current > 1; depth++) {
1238
+ if (current === pid)
1239
+ return true;
1240
+ try {
1241
+ current = parseInt(readFileSync(`/proc/${current}/stat`, 'utf-8').split(' ')[1], 10);
1242
+ }
1243
+ catch { /* /proc unavailable or process gone — stop walking */
1244
+ break;
1245
+ }
1246
+ }
1247
+ }
1248
+ catch { /* ignore */ }
1249
+ return false;
1250
+ }
1251
+ /**
1252
+ * Wait for a port to be released with exponential backoff.
1253
+ */
1254
+ async function waitForPortRelease(port, maxWaitMs = 5_000) {
1255
+ const net = await import('node:net');
1256
+ const start = Date.now();
1257
+ let delay = 200;
1258
+ while (Date.now() - start < maxWaitMs) {
1259
+ try {
1260
+ await new Promise((resolve, reject) => {
1261
+ const sock = net.createServer();
1262
+ sock.once('error', reject);
1263
+ sock.listen(port, '127.0.0.1', () => {
1264
+ sock.close();
1265
+ reject(new Error('port free')); // signal success
1266
+ });
1267
+ });
1268
+ }
1269
+ catch (err) {
1270
+ if (err instanceof Error && err.message === 'port free')
1271
+ return;
1272
+ }
1273
+ await new Promise(resolve => setTimeout(resolve, delay));
1274
+ delay = Math.min(delay * 1.5, 1_000);
1275
+ }
1276
+ }
1277
+ /**
1278
+ * Kill stale process holding a port. Returns true if a process was killed.
1279
+ * Uses `lsof` to find the PID, verifies it exists, skips ancestors,
1280
+ * and tries SIGTERM before SIGKILL.
1281
+ */
1282
+ async function killStalePortHolder(port) {
1283
+ // Small random delay to reduce race window with systemd restarts
1284
+ await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 400));
1285
+ try {
1286
+ const output = execSync(`lsof -ti tcp:${port}`, { encoding: 'utf-8', timeout: 5_000 }).trim();
1287
+ if (!output)
1288
+ return false;
1289
+ const pids = output.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
1290
+ if (pids.length === 0)
1291
+ return false;
1292
+ let killed = false;
1293
+ for (const pid of pids) {
1294
+ // Skip own PID
1295
+ if (pid === process.pid)
1296
+ continue;
1297
+ // Skip ancestors to avoid killing parent process (e.g. systemd supervisor)
1298
+ if (isAncestorPid(pid)) {
1299
+ console.warn(`EADDRINUSE recovery: skipping ancestor PID ${pid} on port ${port}`);
1300
+ continue;
1301
+ }
1302
+ // Skip peer Aegis instance (another Aegis process that wrote the PID file)
1303
+ const pidFilePid = readPidFile();
1304
+ if (pidFilePid !== null && pid === pidFilePid && pid !== process.pid) {
1305
+ console.warn(`EADDRINUSE recovery: skipping peer Aegis PID ${pid} (PID file match) on port ${port}`);
1306
+ continue;
1307
+ }
1308
+ // Verify PID exists before attempting to kill
1309
+ if (!pidExists(pid))
1310
+ continue;
1311
+ console.warn(`EADDRINUSE recovery: killing stale process PID ${pid} on port ${port}`);
1312
+ // Try SIGTERM first for graceful shutdown
1313
+ try {
1314
+ process.kill(pid, 'SIGTERM');
1315
+ await new Promise(resolve => setTimeout(resolve, 2_000));
1316
+ // Check if process exited after SIGTERM
1317
+ if (!pidExists(pid)) {
1318
+ killed = true;
1319
+ continue;
1320
+ }
1321
+ }
1322
+ catch { /* process may have already exited */ }
1323
+ // Fallback to SIGKILL if SIGTERM didn't work
1324
+ try {
1325
+ process.kill(pid, 'SIGKILL');
1326
+ killed = true;
1327
+ }
1328
+ catch { /* already dead */ }
1329
+ }
1330
+ if (killed) {
1331
+ await waitForPortRelease(port);
1332
+ }
1333
+ return killed;
1334
+ }
1335
+ catch {
1336
+ // lsof not found or no process on port — that's fine
1337
+ return false;
1338
+ }
1339
+ }
1340
+ /**
1341
+ * Listen with EADDRINUSE recovery: if port is taken, kill the stale holder and retry once.
1342
+ */
1343
+ async function listenWithRetry(app, port, host, maxRetries = 1) {
1344
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1345
+ try {
1346
+ await app.listen({ port, host });
1347
+ return;
1348
+ }
1349
+ catch (err) {
1350
+ if (!(err instanceof Error && 'code' in err && err.code === 'EADDRINUSE') || attempt >= maxRetries) {
1351
+ throw err;
1352
+ }
1353
+ console.error(`EADDRINUSE on port ${port} — attempting recovery (attempt ${attempt + 1}/${maxRetries})`);
1354
+ const killed = await killStalePortHolder(port);
1355
+ if (!killed) {
1356
+ console.error(`EADDRINUSE recovery failed: no stale process found on port ${port}`);
1357
+ throw err;
1358
+ }
1359
+ }
1360
+ }
1361
+ }
1362
+ async function main() {
1363
+ // Load configuration
1364
+ config = await loadConfig();
1365
+ // Initialize core components with config
1366
+ tmux = new TmuxManager(config.tmuxSession);
1367
+ sessions = new SessionManager(tmux, config);
1368
+ sseLimiter = new SSEConnectionLimiter({ maxConnections: config.sseMaxConnections, maxPerIp: config.sseMaxPerIp });
1369
+ monitor = new SessionMonitor(sessions, channels, { ...DEFAULT_MONITOR_CONFIG, pollIntervalMs: 5000 });
1370
+ // Register channels
1371
+ registerChannels(config);
1372
+ // Setup auth (Issue #39: multi-key + backward compat)
1373
+ const { join } = await import('node:path');
1374
+ auth = new AuthManager(join(config.stateDir, 'keys.json'), config.authToken);
1375
+ await auth.load();
1376
+ setupAuth(auth);
1377
+ // Register WebSocket plugin for live terminal streaming (Issue #108)
1378
+ await app.register(fastifyWebsocket);
1379
+ registerWsTerminalRoute(app, sessions, tmux, auth);
1380
+ // #217: CORS configuration — restrictive by default
1381
+ const corsOrigin = process.env.CORS_ORIGIN;
1382
+ await app.register(fastifyCors, {
1383
+ origin: corsOrigin ? corsOrigin.split(',').map(s => s.trim()) : false,
1384
+ });
1385
+ // Load persisted sessions
1386
+ await sessions.load();
1387
+ await tmux.ensureSession();
1388
+ // Initialize channels
1389
+ await channels.init(handleInbound);
1390
+ // Wire SSE event bus (Issue #32)
1391
+ monitor.setEventBus(eventBus);
1392
+ // Issue #84: Wire JSONL watcher for fs.watch-based message detection
1393
+ jsonlWatcher = new JsonlWatcher();
1394
+ monitor.setJsonlWatcher(jsonlWatcher);
1395
+ // Start watching JSONL files for already-discovered sessions
1396
+ for (const session of sessions.listSessions()) {
1397
+ if (session.jsonlPath) {
1398
+ jsonlWatcher.watch(session.id, session.jsonlPath, session.monitorOffset);
1399
+ }
1400
+ }
1401
+ // Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
1402
+ registerHookRoutes(app, { sessions, eventBus, metrics });
1403
+ // Initialize pipeline manager (Issue #36)
1404
+ pipelines = new PipelineManager(sessions, eventBus);
1405
+ // Initialize metrics (Issue #40)
1406
+ metrics = new MetricsCollector(join(config.stateDir, 'metrics.json'));
1407
+ await metrics.load();
1408
+ // Issue #361: Store interval refs so graceful shutdown can clear them
1409
+ const reaperInterval = setInterval(() => reapStaleSessions(config.maxSessionAgeMs), config.reaperIntervalMs);
1410
+ const zombieReaperInterval = setInterval(() => reapZombieSessions(), ZOMBIE_REAP_INTERVAL_MS);
1411
+ const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
1412
+ // #357: Prune stale IP rate-limit entries every minute
1413
+ const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
1414
+ // Issue #361: Graceful shutdown handler
1415
+ // Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
1416
+ let shuttingDown = false;
1417
+ async function gracefulShutdown(signal) {
1418
+ console.log(`${signal} received, shutting down gracefully...`);
1419
+ // 1. Stop accepting new requests
1420
+ try {
1421
+ await app.close();
1422
+ }
1423
+ catch (e) {
1424
+ console.error('Error closing server:', e);
1425
+ }
1426
+ // 2. Stop background monitors and intervals
1427
+ monitor.stop();
1428
+ swarmMonitor.stop();
1429
+ clearInterval(reaperInterval);
1430
+ clearInterval(zombieReaperInterval);
1431
+ clearInterval(metricsSaveInterval);
1432
+ clearInterval(ipPruneInterval);
1433
+ // 3. Destroy channels (awaits Telegram poll loop)
1434
+ try {
1435
+ await channels.destroy();
1436
+ }
1437
+ catch (e) {
1438
+ console.error('Error destroying channels:', e);
1439
+ }
1440
+ // 4. Save session state
1441
+ try {
1442
+ await sessions.save();
1443
+ }
1444
+ catch (e) {
1445
+ console.error('Error saving sessions:', e);
1446
+ }
1447
+ // 5. Save metrics
1448
+ try {
1449
+ await metrics.save();
1450
+ }
1451
+ catch (e) {
1452
+ console.error('Error saving metrics:', e);
1453
+ }
1454
+ // 6. Cleanup PID file
1455
+ try {
1456
+ if (pidFilePath) {
1457
+ unlinkSync(pidFilePath);
1458
+ }
1459
+ }
1460
+ catch { /* non-critical */ }
1461
+ console.log('Graceful shutdown complete');
1462
+ process.exit(0);
1463
+ }
1464
+ process.on('SIGTERM', () => { if (!shuttingDown) {
1465
+ shuttingDown = true;
1466
+ void gracefulShutdown('SIGTERM');
1467
+ } });
1468
+ process.on('SIGINT', () => { if (!shuttingDown) {
1469
+ shuttingDown = true;
1470
+ void gracefulShutdown('SIGINT');
1471
+ } });
1472
+ process.on('unhandledRejection', (reason) => {
1473
+ console.error('unhandledRejection:', reason);
1474
+ });
1475
+ // Start monitor
1476
+ monitor.start();
1477
+ // Issue #81: Start swarm monitor for agent swarm awareness
1478
+ swarmMonitor = new SwarmMonitor(sessions);
1479
+ swarmMonitor.onEvent((event) => {
1480
+ if (!event.swarm.parentSession)
1481
+ return;
1482
+ const parentId = event.swarm.parentSession.id;
1483
+ const teammate = event.teammate;
1484
+ if (event.type === 'teammate_spawned') {
1485
+ const detail = `🔧 Teammate ${teammate.windowName} spawned`;
1486
+ eventBus.emit(parentId, {
1487
+ event: 'subagent_start',
1488
+ sessionId: parentId,
1489
+ timestamp: new Date().toISOString(),
1490
+ data: { teammate: teammate.windowName, windowId: teammate.windowId },
1491
+ });
1492
+ channels.swarmEvent(makePayload('swarm.teammate_spawned', parentId, detail, {
1493
+ teammateName: teammate.windowName,
1494
+ teammateWindowId: teammate.windowId,
1495
+ teammateCwd: teammate.cwd,
1496
+ }));
1497
+ }
1498
+ else if (event.type === 'teammate_finished') {
1499
+ const detail = `✅ Teammate ${teammate.windowName} finished`;
1500
+ eventBus.emit(parentId, {
1501
+ event: 'subagent_stop',
1502
+ sessionId: parentId,
1503
+ timestamp: new Date().toISOString(),
1504
+ data: { teammate: teammate.windowName },
1505
+ });
1506
+ channels.swarmEvent(makePayload('swarm.teammate_finished', parentId, detail, {
1507
+ teammateName: teammate.windowName,
1508
+ }));
1509
+ }
1510
+ });
1511
+ swarmMonitor.start();
1512
+ // Issue #71: Wire swarm monitor into Telegram channel for /swarm command
1513
+ for (const ch of channels.getChannels()) {
1514
+ if ('setSwarmMonitor' in ch && typeof ch.setSwarmMonitor === 'function') {
1515
+ ch.setSwarmMonitor(swarmMonitor);
1516
+ }
1517
+ }
1518
+ // Start reaper (intervals already created above with stored refs for graceful shutdown)
1519
+ console.log(`Session reaper active: max age ${config.maxSessionAgeMs / 3600000}h, check every ${config.reaperIntervalMs / 60000}min`);
1520
+ // Start zombie reaper (Issue #283)
1521
+ console.log(`Zombie reaper active: grace period ${ZOMBIE_REAP_DELAY_MS / 1000}s, check every ${ZOMBIE_REAP_INTERVAL_MS / 1000}s`);
1522
+ // #127: Serve dashboard static files (Issue #105) — graceful if missing
1523
+ // Issue #539: Dashboard is copied into dist/dashboard/ during build
1524
+ const dashboardRoot = path.join(__dirname, "dashboard");
1525
+ let dashboardAvailable = false;
1526
+ try {
1527
+ await fs.access(dashboardRoot);
1528
+ dashboardAvailable = true;
1529
+ }
1530
+ catch {
1531
+ console.warn("Dashboard directory not found — skipping dashboard serving. Run 'npm run build:dashboard' to enable.");
1532
+ }
1533
+ if (dashboardAvailable) {
1534
+ await app.register(fastifyStatic, {
1535
+ root: dashboardRoot,
1536
+ prefix: "/dashboard/",
1537
+ // #146: Cache hashed assets aggressively, no-cache for index.html
1538
+ setHeaders: (reply, pathname) => {
1539
+ // Security headers (#145)
1540
+ reply.setHeader('X-Frame-Options', 'DENY');
1541
+ reply.setHeader('X-Content-Type-Options', 'nosniff');
1542
+ reply.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
1543
+ // Issue #349: Content-Security-Policy for dashboard
1544
+ reply.setHeader('Content-Security-Policy', DASHBOARD_CSP);
1545
+ // Cache control (#146)
1546
+ if (pathname === '/index.html' || pathname === '/') {
1547
+ reply.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1548
+ }
1549
+ else {
1550
+ reply.setHeader('Cache-Control', 'public, max-age=604800, immutable');
1551
+ }
1552
+ },
1553
+ });
1554
+ }
1555
+ // SPA fallback for dashboard routes (Issue #105)
1556
+ app.setNotFoundHandler(async (req, reply) => {
1557
+ if (dashboardAvailable && (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?"))) {
1558
+ // Issue #349: CSP header for SPA dashboard responses
1559
+ reply.header('Content-Security-Policy', DASHBOARD_CSP);
1560
+ return reply.sendFile("index.html", dashboardRoot);
1561
+ }
1562
+ return reply.status(404).send({ error: "Not found" });
1563
+ });
1564
+ await listenWithRetry(app, config.port, config.host);
1565
+ writePidFile();
1566
+ console.log(`Aegis running on http://${config.host}:${config.port}`);
1567
+ console.log(`Channels: ${channels.count} registered`);
1568
+ console.log(`State dir: ${config.stateDir}`);
1569
+ console.log(`Claude projects dir: ${config.claudeProjectsDir}`);
1570
+ if (config.authToken)
1571
+ console.log('Auth: Bearer token required');
1572
+ }
1573
+ main().catch(err => {
1574
+ console.error('Failed to start Aegis:', err);
1575
+ process.exit(1);
1576
+ });
1577
+ //# sourceMappingURL=server.js.map