aegis-bridge 0.1.0

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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -0
  3. package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
  4. package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
  5. package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/api-contracts.d.ts +229 -0
  8. package/dist/api-contracts.js +7 -0
  9. package/dist/api-contracts.typecheck.d.ts +14 -0
  10. package/dist/api-contracts.typecheck.js +1 -0
  11. package/dist/api-error-envelope.d.ts +15 -0
  12. package/dist/api-error-envelope.js +80 -0
  13. package/dist/auth.d.ts +87 -0
  14. package/dist/auth.js +276 -0
  15. package/dist/channels/index.d.ts +8 -0
  16. package/dist/channels/index.js +8 -0
  17. package/dist/channels/manager.d.ts +47 -0
  18. package/dist/channels/manager.js +115 -0
  19. package/dist/channels/telegram-style.d.ts +118 -0
  20. package/dist/channels/telegram-style.js +202 -0
  21. package/dist/channels/telegram.d.ts +91 -0
  22. package/dist/channels/telegram.js +1518 -0
  23. package/dist/channels/types.d.ts +77 -0
  24. package/dist/channels/types.js +8 -0
  25. package/dist/channels/webhook.d.ts +60 -0
  26. package/dist/channels/webhook.js +216 -0
  27. package/dist/cli.d.ts +8 -0
  28. package/dist/cli.js +252 -0
  29. package/dist/config.d.ts +90 -0
  30. package/dist/config.js +214 -0
  31. package/dist/consensus.d.ts +16 -0
  32. package/dist/consensus.js +19 -0
  33. package/dist/continuation-pointer.d.ts +11 -0
  34. package/dist/continuation-pointer.js +65 -0
  35. package/dist/diagnostics.d.ts +27 -0
  36. package/dist/diagnostics.js +95 -0
  37. package/dist/error-categories.d.ts +39 -0
  38. package/dist/error-categories.js +73 -0
  39. package/dist/events.d.ts +133 -0
  40. package/dist/events.js +389 -0
  41. package/dist/fault-injection.d.ts +29 -0
  42. package/dist/fault-injection.js +115 -0
  43. package/dist/file-utils.d.ts +2 -0
  44. package/dist/file-utils.js +37 -0
  45. package/dist/handshake.d.ts +60 -0
  46. package/dist/handshake.js +124 -0
  47. package/dist/hook-settings.d.ts +80 -0
  48. package/dist/hook-settings.js +272 -0
  49. package/dist/hook.d.ts +19 -0
  50. package/dist/hook.js +231 -0
  51. package/dist/hooks.d.ts +32 -0
  52. package/dist/hooks.js +364 -0
  53. package/dist/jsonl-watcher.d.ts +59 -0
  54. package/dist/jsonl-watcher.js +166 -0
  55. package/dist/logger.d.ts +35 -0
  56. package/dist/logger.js +65 -0
  57. package/dist/mcp-server.d.ts +123 -0
  58. package/dist/mcp-server.js +869 -0
  59. package/dist/memory-bridge.d.ts +27 -0
  60. package/dist/memory-bridge.js +137 -0
  61. package/dist/memory-routes.d.ts +3 -0
  62. package/dist/memory-routes.js +100 -0
  63. package/dist/metrics.d.ts +126 -0
  64. package/dist/metrics.js +286 -0
  65. package/dist/model-router.d.ts +53 -0
  66. package/dist/model-router.js +150 -0
  67. package/dist/monitor.d.ts +103 -0
  68. package/dist/monitor.js +820 -0
  69. package/dist/path-utils.d.ts +11 -0
  70. package/dist/path-utils.js +21 -0
  71. package/dist/permission-evaluator.d.ts +10 -0
  72. package/dist/permission-evaluator.js +48 -0
  73. package/dist/permission-guard.d.ts +51 -0
  74. package/dist/permission-guard.js +196 -0
  75. package/dist/permission-request-manager.d.ts +12 -0
  76. package/dist/permission-request-manager.js +36 -0
  77. package/dist/permission-routes.d.ts +7 -0
  78. package/dist/permission-routes.js +28 -0
  79. package/dist/pipeline.d.ts +97 -0
  80. package/dist/pipeline.js +291 -0
  81. package/dist/process-utils.d.ts +4 -0
  82. package/dist/process-utils.js +73 -0
  83. package/dist/question-manager.d.ts +54 -0
  84. package/dist/question-manager.js +80 -0
  85. package/dist/retry.d.ts +11 -0
  86. package/dist/retry.js +34 -0
  87. package/dist/safe-json.d.ts +12 -0
  88. package/dist/safe-json.js +22 -0
  89. package/dist/screenshot.d.ts +28 -0
  90. package/dist/screenshot.js +60 -0
  91. package/dist/server.d.ts +10 -0
  92. package/dist/server.js +1973 -0
  93. package/dist/session-cleanup.d.ts +18 -0
  94. package/dist/session-cleanup.js +11 -0
  95. package/dist/session.d.ts +379 -0
  96. package/dist/session.js +1568 -0
  97. package/dist/shutdown-utils.d.ts +5 -0
  98. package/dist/shutdown-utils.js +24 -0
  99. package/dist/signal-cleanup-helper.d.ts +48 -0
  100. package/dist/signal-cleanup-helper.js +117 -0
  101. package/dist/sse-limiter.d.ts +47 -0
  102. package/dist/sse-limiter.js +61 -0
  103. package/dist/sse-writer.d.ts +31 -0
  104. package/dist/sse-writer.js +94 -0
  105. package/dist/ssrf.d.ts +102 -0
  106. package/dist/ssrf.js +267 -0
  107. package/dist/startup.d.ts +6 -0
  108. package/dist/startup.js +162 -0
  109. package/dist/suppress.d.ts +33 -0
  110. package/dist/suppress.js +79 -0
  111. package/dist/swarm-monitor.d.ts +117 -0
  112. package/dist/swarm-monitor.js +300 -0
  113. package/dist/template-store.d.ts +45 -0
  114. package/dist/template-store.js +142 -0
  115. package/dist/terminal-parser.d.ts +16 -0
  116. package/dist/terminal-parser.js +346 -0
  117. package/dist/tmux-capture-cache.d.ts +18 -0
  118. package/dist/tmux-capture-cache.js +34 -0
  119. package/dist/tmux.d.ts +183 -0
  120. package/dist/tmux.js +906 -0
  121. package/dist/tool-registry.d.ts +40 -0
  122. package/dist/tool-registry.js +83 -0
  123. package/dist/transcript.d.ts +63 -0
  124. package/dist/transcript.js +284 -0
  125. package/dist/utils/circular-buffer.d.ts +11 -0
  126. package/dist/utils/circular-buffer.js +37 -0
  127. package/dist/utils/redact-headers.d.ts +13 -0
  128. package/dist/utils/redact-headers.js +54 -0
  129. package/dist/validation.d.ts +406 -0
  130. package/dist/validation.js +415 -0
  131. package/dist/verification.d.ts +2 -0
  132. package/dist/verification.js +72 -0
  133. package/dist/worktree-lookup.d.ts +24 -0
  134. package/dist/worktree-lookup.js +71 -0
  135. package/dist/ws-terminal.d.ts +32 -0
  136. package/dist/ws-terminal.js +348 -0
  137. package/package.json +83 -0
package/dist/server.js ADDED
@@ -0,0 +1,1973 @@
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 fastifyStatic from '@fastify/static';
13
+ import fastifyWebsocket from '@fastify/websocket';
14
+ import fastifyCors from '@fastify/cors';
15
+ import { z } from 'zod';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { TmuxManager } from './tmux.js';
19
+ import { SessionManager } from './session.js';
20
+ import { SessionMonitor, DEFAULT_MONITOR_CONFIG } from './monitor.js';
21
+ import { JsonlWatcher } from './jsonl-watcher.js';
22
+ import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/index.js';
23
+ import { loadConfig } from './config.js';
24
+ import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
25
+ import { validateScreenshotUrl, resolveAndCheckIp, buildHostResolverRule } from './ssrf.js';
26
+ import { validateWorkDir, permissionRuleSchema } from './validation.js';
27
+ import { SessionEventBus } from './events.js';
28
+ import { runVerification } from './verification.js';
29
+ import { SSEWriter } from './sse-writer.js';
30
+ import { SSEConnectionLimiter } from './sse-limiter.js';
31
+ import { PipelineManager } from './pipeline.js';
32
+ import { ToolRegistry } from './tool-registry.js';
33
+ import { AuthManager, classifyBearerTokenForRoute } from './auth.js';
34
+ import { MetricsCollector } from './metrics.js';
35
+ import { registerPermissionRoutes } from './permission-routes.js';
36
+ import { registerHookRoutes } from './hooks.js';
37
+ import { registerWsTerminalRoute } from './ws-terminal.js';
38
+ import { registerMemoryRoutes } from './memory-routes.js';
39
+ import { registerModelRouterRoutes } from './model-router.js';
40
+ import { buildConsensusPrompt } from './consensus.js';
41
+ import * as templateStore from './template-store.js';
42
+ import { SwarmMonitor } from './swarm-monitor.js';
43
+ import { killAllSessions } from './signal-cleanup-helper.js';
44
+ import { execFileSync } from 'node:child_process';
45
+ import { negotiate } from './handshake.js';
46
+ import { diagnosticsBus } from './diagnostics.js';
47
+ import { setStructuredLogSink } from './logger.js';
48
+ import { MemoryBridge } from './memory-bridge.js';
49
+ import { cleanupTerminatedSessionState } from './session-cleanup.js';
50
+ import { normalizeApiErrorPayload } from './api-error-envelope.js';
51
+ import { listenWithRetry, removePidFile, writePidFile } from './startup.js';
52
+ import { isWindowsShutdownMessage, parseShutdownTimeoutMs } from './shutdown-utils.js';
53
+ import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, permissionProfileSchema, } from './validation.js';
54
+ const __filename = fileURLToPath(import.meta.url);
55
+ const __dirname = path.dirname(__filename);
56
+ const consensusRequests = new Map();
57
+ /** #1091: TTL for consensus request entries (1 hour) */
58
+ const CONSENSUS_REQUEST_TTL_MS = 60 * 60 * 1000;
59
+ /** #1091: Prune consensus requests older than the TTL to prevent unbounded memory growth. */
60
+ function pruneConsensusRequests() {
61
+ const cutoff = Date.now() - CONSENSUS_REQUEST_TTL_MS;
62
+ for (const [id, request] of consensusRequests) {
63
+ if (request.createdAt < cutoff) {
64
+ consensusRequests.delete(id);
65
+ }
66
+ }
67
+ }
68
+ // ── Configuration ────────────────────────────────────────────────────
69
+ // Issue #349: CSP policy for dashboard responses (shared between static and SPA fallback)
70
+ const DASHBOARD_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: https://registry.npmjs.org";
71
+ // Config loaded at startup; env vars override file values
72
+ let config;
73
+ // These will be initialized after config is loaded
74
+ let tmux;
75
+ let sessions;
76
+ let monitor;
77
+ let jsonlWatcher;
78
+ const channels = new ChannelManager();
79
+ const eventBus = new SessionEventBus();
80
+ let memoryBridge = null;
81
+ let sseLimiter;
82
+ let pipelines;
83
+ let toolRegistry;
84
+ let auth;
85
+ let metrics;
86
+ let swarmMonitor;
87
+ // ── Inbound command handler ─────────────────────────────────────────
88
+ async function handleInbound(cmd) {
89
+ try {
90
+ switch (cmd.action) {
91
+ case 'approve':
92
+ await sessions.approve(cmd.sessionId);
93
+ break;
94
+ case 'reject':
95
+ await sessions.reject(cmd.sessionId);
96
+ break;
97
+ case 'escape':
98
+ await sessions.escape(cmd.sessionId);
99
+ break;
100
+ case 'kill':
101
+ // #842: killSession first, then notify — avoids race where channels
102
+ // reference a session that is still being destroyed.
103
+ await sessions.killSession(cmd.sessionId);
104
+ await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
105
+ cleanupTerminatedSessionState(cmd.sessionId, { monitor, metrics, toolRegistry });
106
+ break;
107
+ case 'message':
108
+ case 'command':
109
+ if (cmd.text)
110
+ await sessions.sendMessage(cmd.sessionId, cmd.text);
111
+ break;
112
+ }
113
+ }
114
+ catch (e) {
115
+ console.error(`Inbound command error [${cmd.action}]:`, e);
116
+ }
117
+ }
118
+ // ── HTTP Server ─────────────────────────────────────────────────────
119
+ const app = Fastify({
120
+ bodyLimit: 1048576, // 1MB — Issue #349: explicit body size limit
121
+ trustProxy: process.env.TRUST_PROXY === 'true', // #633: Only trust X-Forwarded-For when explicitly enabled
122
+ logger: {
123
+ // #230: Redact auth tokens from request logs
124
+ serializers: {
125
+ req(req) {
126
+ const url = req.url?.includes('token=')
127
+ ? req.url.replace(/token=[^&]*/g, 'token=[REDACTED]')
128
+ : req.url;
129
+ return {
130
+ method: req.method,
131
+ url,
132
+ // ...rest intentionally omitted — prevents token leakage via headers
133
+ };
134
+ },
135
+ },
136
+ },
137
+ });
138
+ setStructuredLogSink({
139
+ info: (record) => app.log.info(record),
140
+ warn: (record) => app.log.warn(record),
141
+ error: (record) => app.log.error(record),
142
+ });
143
+ // #227: Security headers on all API responses (skip SSE)
144
+ app.addHook('onSend', (req, reply, payload, done) => {
145
+ const contentType = reply.getHeader('content-type');
146
+ if (typeof contentType === 'string' && contentType.includes('text/event-stream')) {
147
+ return done();
148
+ }
149
+ reply.header('X-Content-Type-Options', 'nosniff');
150
+ reply.header('X-Frame-Options', 'DENY');
151
+ reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
152
+ reply.header('Permissions-Policy', 'camera=(), microphone=()');
153
+ const normalizedPayload = normalizeApiErrorPayload({
154
+ payload,
155
+ statusCode: reply.statusCode,
156
+ requestId: req.id,
157
+ contentType: typeof contentType === 'string' ? contentType : undefined,
158
+ });
159
+ done(null, normalizedPayload);
160
+ });
161
+ const ipRateLimits = new Map();
162
+ const IP_WINDOW_MS = 60_000;
163
+ const IP_LIMIT_NORMAL = 120; // per minute for regular keys
164
+ const IP_LIMIT_MASTER = 300; // per minute for master token
165
+ const MAX_IP_ENTRIES = 10_000; // #844: Cap tracked IPs to prevent memory exhaustion
166
+ function checkIpRateLimit(ip, isMaster) {
167
+ const now = Date.now();
168
+ const cutoff = now - IP_WINDOW_MS;
169
+ const bucket = ipRateLimits.get(ip) || { entries: [], start: 0 };
170
+ // O(1) prune: advance start index past expired entries
171
+ while (bucket.start < bucket.entries.length && bucket.entries[bucket.start] < cutoff) {
172
+ bucket.start++;
173
+ }
174
+ // Compact when the leading garbage exceeds 50% of the allocated array
175
+ if (bucket.start > bucket.entries.length >>> 1) {
176
+ bucket.entries = bucket.entries.slice(bucket.start);
177
+ bucket.start = 0;
178
+ }
179
+ bucket.entries.push(now);
180
+ ipRateLimits.set(ip, bucket);
181
+ // #844: Evict oldest IPs when map exceeds cap to prevent unbounded memory growth
182
+ if (ipRateLimits.size > MAX_IP_ENTRIES) {
183
+ let oldestIp = '';
184
+ let oldestTime = Infinity;
185
+ for (const [trackedIp, trackedBucket] of ipRateLimits) {
186
+ const lastTs = trackedBucket.entries[trackedBucket.entries.length - 1];
187
+ if (lastTs !== undefined && lastTs < oldestTime) {
188
+ oldestTime = lastTs;
189
+ oldestIp = trackedIp;
190
+ }
191
+ }
192
+ if (oldestIp)
193
+ ipRateLimits.delete(oldestIp);
194
+ }
195
+ const activeCount = bucket.entries.length - bucket.start;
196
+ const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL;
197
+ return activeCount > limit;
198
+ }
199
+ const authFailLimits = new Map();
200
+ const AUTH_FAIL_WINDOW_MS = 60_000;
201
+ const AUTH_FAIL_MAX = 5;
202
+ const MAX_AUTH_FAIL_IP_ENTRIES = 10_000;
203
+ function checkAuthFailRateLimit(ip) {
204
+ const now = Date.now();
205
+ const cutoff = now - AUTH_FAIL_WINDOW_MS;
206
+ const bucket = authFailLimits.get(ip) || { timestamps: [] };
207
+ // Prune expired entries
208
+ bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
209
+ bucket.timestamps.push(now);
210
+ authFailLimits.set(ip, bucket);
211
+ if (authFailLimits.size > MAX_AUTH_FAIL_IP_ENTRIES) {
212
+ let oldestIp = '';
213
+ let oldestTime = Infinity;
214
+ for (const [trackedIp, trackedBucket] of authFailLimits) {
215
+ const lastTs = trackedBucket.timestamps[trackedBucket.timestamps.length - 1];
216
+ if (lastTs !== undefined && lastTs < oldestTime) {
217
+ oldestTime = lastTs;
218
+ oldestIp = trackedIp;
219
+ }
220
+ }
221
+ if (oldestIp)
222
+ authFailLimits.delete(oldestIp);
223
+ }
224
+ return bucket.timestamps.length > AUTH_FAIL_MAX;
225
+ }
226
+ function recordAuthFailure(ip) {
227
+ checkAuthFailRateLimit(ip);
228
+ }
229
+ /** #632: Prune stale auth-failure buckets. */
230
+ function pruneAuthFailLimits() {
231
+ const cutoff = Date.now() - AUTH_FAIL_WINDOW_MS;
232
+ for (const [ip, bucket] of authFailLimits) {
233
+ bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff);
234
+ if (bucket.timestamps.length === 0)
235
+ authFailLimits.delete(ip);
236
+ }
237
+ }
238
+ /** #357: Prune IPs whose timestamp arrays are entirely outside the rate-limit window. */
239
+ function pruneIpRateLimits() {
240
+ const cutoff = Date.now() - IP_WINDOW_MS;
241
+ for (const [ip, bucket] of ipRateLimits) {
242
+ // All timestamps are old — remove the entry entirely
243
+ const last = bucket.entries[bucket.entries.length - 1];
244
+ if (bucket.entries.length - bucket.start === 0 || (last !== undefined && last < cutoff)) {
245
+ ipRateLimits.delete(ip);
246
+ }
247
+ }
248
+ }
249
+ /** #583: Track keyId per request for batch rate limiting. */
250
+ const requestKeyMap = new Map();
251
+ // #839: Clean up requestKeyMap entries after response to prevent unbounded memory leak.
252
+ app.addHook('onResponse', (req, _reply, done) => {
253
+ requestKeyMap.delete(req.id);
254
+ done();
255
+ });
256
+ function setupAuth(authManager) {
257
+ app.addHook('onRequest', async (req, reply) => {
258
+ // Skip auth for health endpoint and dashboard (Issue #349: exact path matching)
259
+ // #126: Dashboard is served as public static files; API endpoints are protected
260
+ const urlPath = req.url?.split('?')[0] ?? '';
261
+ if (urlPath === '/health' || urlPath === '/v1/health')
262
+ return;
263
+ if (urlPath === '/dashboard' || urlPath.startsWith('/dashboard/'))
264
+ return;
265
+ // Hook routes — exact match: /v1/hooks/{eventName} (alpha only, no path traversal)
266
+ // Issue #394: Require valid X-Session-Id for known sessions instead of blanket bypass.
267
+ // Issue #580: Validate UUID format before getSession lookup.
268
+ // Issue #629: Validate per-session hook secret to prevent replay with known session ID.
269
+ // CC hooks run from localhost and always include the session ID they were started with.
270
+ const hookMatch = /^\/v1\/hooks\/[A-Za-z]+$/.exec(urlPath);
271
+ if (hookMatch) {
272
+ const hookSessionId = req.headers['x-session-id']
273
+ || req.query?.sessionId;
274
+ if (hookSessionId && !isValidUUID(hookSessionId)) {
275
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
276
+ }
277
+ if (hookSessionId) {
278
+ const session = sessions.getSession(hookSessionId);
279
+ if (session) {
280
+ // Issue #629/#1131: Validate hook secret from X-Hook-Secret header (query param fallback)
281
+ const hookSecret = req.headers['x-hook-secret']
282
+ || req.query?.secret;
283
+ if (!hookSecret || hookSecret !== session.hookSecret) {
284
+ return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' });
285
+ }
286
+ return; // valid session + secret — allow
287
+ }
288
+ }
289
+ // No valid session context — reject even when auth is disabled
290
+ return reply.status(401).send({ error: 'Unauthorized — hook endpoint requires valid session ID' });
291
+ }
292
+ // #303: WS terminal routes have their own preHandler for auth (supports ?token=)
293
+ // Exact match: /v1/sessions/{id}/terminal
294
+ if (/^\/v1\/sessions\/[^/]+\/terminal$/.test(urlPath))
295
+ return;
296
+ // If no auth configured (no master token, no keys), allow all
297
+ if (!authManager.authEnabled)
298
+ return;
299
+ // #124/#125: Accept token from Authorization header; ?token= query param
300
+ // only on SSE routes where EventSource cannot set headers.
301
+ // #297: SSE routes also accept short-lived SSE tokens via ?token=.
302
+ const isSSERoute = req.url?.includes('/events');
303
+ let token;
304
+ const header = req.headers.authorization;
305
+ if (header?.startsWith('Bearer ')) {
306
+ token = header.slice(7);
307
+ }
308
+ else if (isSSERoute) {
309
+ token = req.query.token;
310
+ }
311
+ if (!token) {
312
+ return reply.status(401).send({ error: 'Unauthorized — Bearer token required' });
313
+ }
314
+ // #633: Only use req.ip — trustProxy controls whether X-Forwarded-For is considered
315
+ const clientIp = req.ip ?? 'unknown';
316
+ // #632: Block IPs that exceeded auth failure rate limit (5 attempts/min)
317
+ if (checkAuthFailRateLimit(clientIp)) {
318
+ return reply.status(429).send({ error: 'Too many auth failures — try again later' });
319
+ }
320
+ const tokenMode = classifyBearerTokenForRoute(token, !!isSSERoute);
321
+ // #408: SSE endpoints require short-lived single-use SSE tokens.
322
+ // Do not fall back to validating long-lived bearer/master tokens on /events.
323
+ if (tokenMode === 'sse') {
324
+ if (await authManager.validateSSEToken(token)) {
325
+ return; // authenticated via short-lived SSE token
326
+ }
327
+ recordAuthFailure(clientIp);
328
+ return reply.status(401).send({ error: 'Unauthorized — SSE token invalid or expired' });
329
+ }
330
+ if (tokenMode === 'reject') {
331
+ recordAuthFailure(clientIp);
332
+ return reply.status(401).send({ error: 'Unauthorized — SSE token required for event streams' });
333
+ }
334
+ const result = authManager.validate(token);
335
+ if (!result.valid) {
336
+ recordAuthFailure(clientIp);
337
+ return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
338
+ }
339
+ if (result.rateLimited) {
340
+ return reply.status(429).send({ error: 'Rate limit exceeded — 100 req/min per key' });
341
+ }
342
+ // #583: Store keyId for batch rate limiting
343
+ // #634: Store validated keyId for SSE token endpoint to reuse
344
+ requestKeyMap.set(req.id, result.keyId ?? 'anonymous');
345
+ req.authKeyId = result.keyId;
346
+ // #228: Per-IP rate limiting (applies to all authenticated requests)
347
+ // #633: Only use req.ip — trustProxy controls whether X-Forwarded-For is considered
348
+ const isMaster = result.keyId === 'master';
349
+ if (checkIpRateLimit(clientIp, isMaster)) {
350
+ return reply.status(429).send({ error: 'Rate limit exceeded — IP throttled' });
351
+ }
352
+ });
353
+ }
354
+ // ── v1 API Routes ───────────────────────────────────────────────────
355
+ // #412: Reject non-UUID session IDs at the routing layer
356
+ app.addHook('onRequest', async (req, reply) => {
357
+ const id = req.params.id;
358
+ if (id !== undefined && !isValidUUID(id)) {
359
+ return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
360
+ }
361
+ });
362
+ // #226: Zod schema for session creation
363
+ const createSessionSchema = z.object({
364
+ workDir: z.string().min(1),
365
+ name: z.string().max(200).optional(),
366
+ prompt: z.string().max(100_000).optional(),
367
+ prd: z.string().max(100_000).optional(),
368
+ resumeSessionId: z.string().uuid().optional(),
369
+ claudeCommand: z.string().max(10_000).optional(),
370
+ env: z.record(z.string(), z.string()).optional(),
371
+ stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
372
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
373
+ autoApprove: z.boolean().optional(),
374
+ parentId: z.string().uuid().optional(),
375
+ memoryKeys: z.array(z.string()).max(50).optional(),
376
+ }).strict();
377
+ // Health — Issue #397: includes tmux server health check
378
+ async function healthHandler() {
379
+ const pkg = await import('../package.json', { with: { type: 'json' } });
380
+ const activeCount = sessions.listSessions().length;
381
+ const totalCount = metrics.getTotalSessionsCreated();
382
+ const tmuxHealth = await tmux.isServerHealthy();
383
+ const status = tmuxHealth.healthy ? 'ok' : 'degraded';
384
+ return {
385
+ status,
386
+ version: pkg.default.version,
387
+ platform: process.platform,
388
+ uptime: process.uptime(),
389
+ sessions: { active: activeCount, total: totalCount },
390
+ tmux: tmuxHealth,
391
+ timestamp: new Date().toISOString(),
392
+ };
393
+ }
394
+ app.get('/v1/health', healthHandler);
395
+ app.get('/health', healthHandler);
396
+ app.post('/v1/handshake', async (req, reply) => {
397
+ const parsed = handshakeRequestSchema.safeParse(req.body ?? {});
398
+ if (!parsed.success) {
399
+ return reply.status(400).send({ error: 'Invalid handshake request', details: parsed.error.issues });
400
+ }
401
+ const result = negotiate(parsed.data);
402
+ return reply.status(result.compatible ? 200 : 409).send(result);
403
+ });
404
+ // Issue #81: Swarm awareness
405
+ // Issue #81: Swarm awareness — list all detected CC swarms and their teammates
406
+ app.get('/v1/swarm', async () => {
407
+ const result = await swarmMonitor.scan();
408
+ return result;
409
+ });
410
+ // API key management (Issue #39)
411
+ // Security: reject all auth key operations when auth is not enabled
412
+ app.post('/v1/auth/keys', async (req, reply) => {
413
+ if (!auth.authEnabled)
414
+ return reply.status(403).send({ error: 'Auth is not enabled' });
415
+ const parsed = authKeySchema.safeParse(req.body);
416
+ if (!parsed.success)
417
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
418
+ const { name, rateLimit } = parsed.data;
419
+ const result = await auth.createKey(name, rateLimit);
420
+ return reply.status(201).send(result);
421
+ });
422
+ app.get('/v1/auth/keys', async (req, reply) => {
423
+ if (!auth.authEnabled)
424
+ return reply.status(403).send({ error: 'Auth is not enabled' });
425
+ return auth.listKeys();
426
+ });
427
+ app.delete('/v1/auth/keys/:id', async (req, reply) => {
428
+ if (!auth.authEnabled)
429
+ return reply.status(403).send({ error: 'Auth is not enabled' });
430
+ const revoked = await auth.revokeKey(req.params.id);
431
+ if (!revoked)
432
+ return reply.status(404).send({ error: 'Key not found' });
433
+ return { ok: true };
434
+ });
435
+ // #297: SSE token endpoint — generates short-lived, single-use token
436
+ // to avoid exposing long-lived bearer tokens in SSE URL query params.
437
+ // Issue #634: Reuse keyId from auth middleware result to avoid double-increment.
438
+ // of rate limit counter.
439
+ app.post('/v1/auth/sse-token', async (req, reply) => {
440
+ // This route goes through the onRequest auth hook, so the caller is
441
+ // already authenticated. Reuse stored keyId to avoid calling auth.validate() again.
442
+ const storedKeyId = req?.authKeyId;
443
+ const keyId = (typeof storedKeyId === 'string' ? storedKeyId : 'anonymous');
444
+ try {
445
+ const sseToken = await auth.generateSSEToken(keyId);
446
+ return reply.status(201).send(sseToken);
447
+ }
448
+ catch (e) {
449
+ return reply.status(429).send({ error: e instanceof Error ? e.message : 'SSE token limit reached' });
450
+ }
451
+ });
452
+ const diagnosticsQuerySchema = z.object({
453
+ limit: z.coerce.number().int().min(1).max(100).optional(),
454
+ });
455
+ // Global metrics (Issue #40)
456
+ app.get('/v1/metrics', async () => metrics.getGlobalMetrics(sessions.listSessions().length));
457
+ // Bounded no-PII diagnostics channel (Issue #881)
458
+ app.get('/v1/diagnostics', async (req, reply) => {
459
+ const parsed = diagnosticsQuerySchema.safeParse(req.query ?? {});
460
+ if (!parsed.success) {
461
+ return reply.status(400).send({
462
+ error: 'Invalid diagnostics query params',
463
+ details: parsed.error.issues,
464
+ });
465
+ }
466
+ const limit = parsed.data.limit ?? 50;
467
+ const events = diagnosticsBus.getRecent(limit);
468
+ return { count: events.length, events };
469
+ });
470
+ // Per-session metrics (Issue #40)
471
+ app.get('/v1/sessions/:id/metrics', async (req, reply) => {
472
+ const m = metrics.getSessionMetrics(req.params.id);
473
+ if (!m)
474
+ return reply.status(404).send({ error: 'No metrics for this session' });
475
+ return m;
476
+ });
477
+ // Issue #704: Tool usage endpoints
478
+ app.get('/v1/sessions/:id/tools', async (req, reply) => {
479
+ const sessionId = req.params.id;
480
+ const session = sessions.getSession(sessionId);
481
+ if (!session)
482
+ return reply.status(404).send({ error: 'Session not found' });
483
+ // Parse JSONL on-demand for tool usage
484
+ const { readNewEntries } = await import('./transcript.js');
485
+ if (session.jsonlPath) {
486
+ try {
487
+ const result = await readNewEntries(session.jsonlPath, 0);
488
+ const entries = result.entries;
489
+ toolRegistry.processEntries(req.params.id, entries);
490
+ }
491
+ catch { /* JSONL not available */ }
492
+ }
493
+ const tools = toolRegistry.getSessionTools(req.params.id);
494
+ return { sessionId: req.params.id, tools, totalCalls: tools.reduce((sum, t) => sum + t.count, 0) };
495
+ });
496
+ app.get('/v1/tools', async () => {
497
+ const definitions = toolRegistry.getToolDefinitions();
498
+ const categories = [...new Set(definitions.map(t => t.category))];
499
+ return { tools: definitions, categories, totalTools: definitions.length };
500
+ });
501
+ // Issue #89 L14: Webhook dead letter queue
502
+ app.get('/v1/webhooks/dead-letter', async () => {
503
+ for (const ch of channels.getChannels()) {
504
+ if (ch.name === 'webhook' && typeof ch.getDeadLetterQueue === 'function') {
505
+ return ch.getDeadLetterQueue();
506
+ }
507
+ }
508
+ return [];
509
+ });
510
+ // Issue #89 L15: Per-channel health reporting
511
+ app.get('/v1/channels/health', async () => {
512
+ return channels.getChannels().map(ch => {
513
+ const health = ch.getHealth?.();
514
+ if (health)
515
+ return health;
516
+ return { channel: ch.name, healthy: true, lastSuccess: null, lastError: null, pendingCount: 0 };
517
+ });
518
+ });
519
+ // Issue #87: Per-session latency metrics
520
+ app.get('/v1/sessions/:id/latency', async (req, reply) => {
521
+ const sessionId = req.params.id;
522
+ const session = sessions.getSession(sessionId);
523
+ if (!session)
524
+ return reply.status(404).send({ error: 'Session not found' });
525
+ const realtimeLatency = sessions.getLatencyMetrics(req.params.id);
526
+ const aggregatedLatency = metrics.getSessionLatency(req.params.id);
527
+ return {
528
+ sessionId: req.params.id,
529
+ realtime: realtimeLatency,
530
+ aggregated: aggregatedLatency,
531
+ };
532
+ });
533
+ // Global SSE event stream — aggregates events from ALL active sessions
534
+ app.get('/v1/events', async (req, reply) => {
535
+ const clientIp = req.ip;
536
+ const acquireResult = sseLimiter.acquire(clientIp);
537
+ if (!acquireResult.allowed) {
538
+ const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
539
+ return reply.status(status).send({
540
+ error: acquireResult.reason === 'per_ip_limit'
541
+ ? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
542
+ : `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
543
+ reason: acquireResult.reason,
544
+ });
545
+ }
546
+ // Issue #505: Subscribe BEFORE writing response headers so that if
547
+ // subscription fails, we can still return a proper HTTP error.
548
+ let unsubscribe;
549
+ const connectionId = acquireResult.connectionId;
550
+ let writer;
551
+ // Queue events that arrive between subscription and writer creation
552
+ const pendingEvents = [];
553
+ let subscriptionReady = false;
554
+ const handler = (event) => {
555
+ if (!subscriptionReady) {
556
+ pendingEvents.push(event);
557
+ return;
558
+ }
559
+ const id = event.id != null ? `id: ${event.id}\n` : '';
560
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
561
+ };
562
+ try {
563
+ unsubscribe = eventBus.subscribeGlobal(handler);
564
+ }
565
+ catch (err) {
566
+ req.log.error({ err }, 'Global SSE subscription failed');
567
+ sseLimiter.release(connectionId);
568
+ return reply.status(500).send({ error: 'Failed to create SSE subscription' });
569
+ }
570
+ reply.raw.writeHead(200, {
571
+ 'Content-Type': 'text/event-stream',
572
+ 'Cache-Control': 'no-cache',
573
+ 'Connection': 'keep-alive',
574
+ 'X-Accel-Buffering': 'no',
575
+ });
576
+ writer = new SSEWriter(reply.raw, req.raw, () => {
577
+ unsubscribe?.();
578
+ sseLimiter.release(connectionId);
579
+ });
580
+ // Now safe to deliver events — flush any queued during setup
581
+ subscriptionReady = true;
582
+ for (const event of pendingEvents) {
583
+ const id = event.id != null ? `id: ${event.id}\n` : '';
584
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
585
+ }
586
+ writer.write(`data: ${JSON.stringify({
587
+ event: 'connected',
588
+ timestamp: new Date().toISOString(),
589
+ data: { activeSessions: sessions.listSessions().length },
590
+ })}\n\n`);
591
+ // Issue #301: Replay missed global events if client sends Last-Event-ID
592
+ const lastEventId = req.headers['last-event-id'];
593
+ if (lastEventId) {
594
+ const missed = eventBus.getGlobalEventsSince(parseInt(lastEventId, 10) || 0);
595
+ for (const { id, event: globalEvent } of missed) {
596
+ writer.write(`id: ${id}\ndata: ${JSON.stringify(globalEvent)}\n\n`);
597
+ }
598
+ }
599
+ writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
600
+ await reply;
601
+ });
602
+ // List sessions (with pagination, status filter, and project filter)
603
+ app.get('/v1/sessions', async (req) => {
604
+ const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
605
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
606
+ const statusFilter = req.query.status;
607
+ const projectFilter = req.query.project;
608
+ let all = sessions.listSessions();
609
+ if (statusFilter) {
610
+ all = all.filter(s => s.status === statusFilter);
611
+ }
612
+ // Issue #754: filter by project (workDir prefix/substring match)
613
+ if (projectFilter) {
614
+ const lower = projectFilter.toLowerCase();
615
+ all = all.filter(s => s.workDir.toLowerCase().includes(lower));
616
+ }
617
+ // Sort by createdAt descending (newest first)
618
+ all.sort((a, b) => b.createdAt - a.createdAt);
619
+ const total = all.length;
620
+ const start = (page - 1) * limit;
621
+ const items = all.slice(start, start + limit);
622
+ const totalPages = Math.ceil(total / limit);
623
+ return {
624
+ sessions: items,
625
+ pagination: { page, limit, total, totalPages },
626
+ };
627
+ });
628
+ // Issue #754: Session statistics endpoint
629
+ app.get('/v1/sessions/stats', async () => {
630
+ const all = sessions.listSessions();
631
+ const byStatus = {};
632
+ for (const s of all) {
633
+ byStatus[s.status] = (byStatus[s.status] ?? 0) + 1;
634
+ }
635
+ const global = metrics.getGlobalMetrics(all.length);
636
+ return {
637
+ active: all.length,
638
+ byStatus,
639
+ totalCreated: global.sessions.total_created,
640
+ totalCompleted: global.sessions.completed,
641
+ totalFailed: global.sessions.failed,
642
+ };
643
+ });
644
+ // Issue #754: Bulk-delete sessions by IDs and/or status
645
+ const batchDeleteSchema = z.object({
646
+ ids: z.array(z.string().uuid()).max(100).optional(),
647
+ status: z.enum([
648
+ 'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
649
+ 'permission_prompt', 'plan_mode', 'ask_question', 'bash_approval',
650
+ 'settings', 'error', 'unknown',
651
+ ]).optional(),
652
+ }).refine(d => d.ids !== undefined || d.status !== undefined, {
653
+ message: 'At least one of "ids" or "status" is required',
654
+ });
655
+ app.delete('/v1/sessions/batch', async (req, reply) => {
656
+ const parsed = batchDeleteSchema.safeParse(req.body);
657
+ if (!parsed.success) {
658
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
659
+ }
660
+ const { ids, status } = parsed.data;
661
+ // Collect target session IDs
662
+ const targets = new Set(ids ?? []);
663
+ if (status) {
664
+ for (const s of sessions.listSessions()) {
665
+ if (s.status === status)
666
+ targets.add(s.id);
667
+ }
668
+ }
669
+ let deleted = 0;
670
+ const notFound = [];
671
+ const errors = [];
672
+ for (const id of targets) {
673
+ if (!sessions.getSession(id)) {
674
+ notFound.push(id);
675
+ continue;
676
+ }
677
+ try {
678
+ await sessions.killSession(id);
679
+ eventBus.emitEnded(id, 'killed');
680
+ void channels.sessionEnded(makePayload('session.ended', id, 'killed'));
681
+ cleanupTerminatedSessionState(id, { monitor, metrics, toolRegistry });
682
+ deleted++;
683
+ }
684
+ catch (e) {
685
+ errors.push(`${id}: ${e instanceof Error ? e.message : String(e)}`);
686
+ }
687
+ }
688
+ return reply.status(200).send({ deleted, notFound, errors });
689
+ });
690
+ // Backwards compat: /sessions (no prefix) returns raw array
691
+ app.get('/sessions', async () => sessions.listSessions());
692
+ /** Validate workDir — delegates to validation.ts (Issue #435). */
693
+ const validateWorkDirWithConfig = (workDir) => validateWorkDir(workDir, config.allowedWorkDirs);
694
+ // Create session (Issue #607: reuse idle session for same workDir)
695
+ async function createSessionHandler(req, reply) {
696
+ const parsed = createSessionSchema.safeParse(req.body);
697
+ if (!parsed.success) {
698
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
699
+ }
700
+ const { workDir, name, prompt, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId, memoryKeys } = parsed.data;
701
+ if (!workDir)
702
+ return reply.status(400).send({ error: 'workDir is required' });
703
+ // Issue #564: Validate installed Claude Code version
704
+ try {
705
+ const raw = execFileSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
706
+ const ccVer = extractCCVersion(raw);
707
+ if (ccVer !== null && compareSemver(ccVer, MIN_CC_VERSION) < 0) {
708
+ return reply.status(422).send({
709
+ error: `Claude Code version ${ccVer} is below minimum supported version ${MIN_CC_VERSION}. Please upgrade.`,
710
+ code: 'CC_VERSION_TOO_OLD',
711
+ upgrade: 'Run: claude update or npm install -g @anthropic-ai/claude-code@latest',
712
+ });
713
+ }
714
+ }
715
+ catch {
716
+ // claude CLI not found or timed out — skip version check (fails open)
717
+ }
718
+ const safeWorkDir = await validateWorkDirWithConfig(workDir);
719
+ if (typeof safeWorkDir === 'object')
720
+ return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
721
+ // Issue #607: Check for an existing idle session with the same workDir
722
+ const existing = await sessions.findIdleSessionByWorkDir(safeWorkDir);
723
+ if (existing) {
724
+ try {
725
+ // Send prompt to the existing session if provided
726
+ let promptDelivery;
727
+ if (prompt) {
728
+ let finalPrompt = prompt;
729
+ if (memoryKeys && memoryKeys.length > 0 && memoryBridge) {
730
+ const resolved = memoryBridge.resolveKeys(memoryKeys);
731
+ if (resolved.size > 0) {
732
+ const lines = ['[Memory context]'];
733
+ for (const [k, v] of resolved)
734
+ lines.push(`${k}: ${v}`);
735
+ lines.push('', prompt);
736
+ finalPrompt = lines.join('\n');
737
+ }
738
+ }
739
+ promptDelivery = await sessions.sendInitialPrompt(existing.id, finalPrompt);
740
+ metrics.promptSent(promptDelivery.delivered);
741
+ }
742
+ return reply.status(200).send({ ...existing, reused: true, promptDelivery });
743
+ }
744
+ finally {
745
+ sessions.releaseSessionClaim(existing.id);
746
+ }
747
+ }
748
+ console.time("POST_CREATE_SESSION");
749
+ const session = await sessions.createSession({ workDir: safeWorkDir, name, prd, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
750
+ console.timeEnd("POST_CREATE_SESSION");
751
+ console.time("POST_CHANNEL_CREATED");
752
+ // Issue #625: Track session in metrics so sessionsCreated counter is accurate
753
+ metrics.sessionCreated(session.id);
754
+ // Issue #46: Create Telegram topic BEFORE sending prompt.
755
+ // The monitor starts polling immediately after createSession().
756
+ // If we wait for sendInitialPrompt (up to 15s), the monitor may find
757
+ // new messages but can't forward them because no topic exists yet.
758
+ // Those messages are lost forever (monitorOffset advances past them).
759
+ await channels.sessionCreated({
760
+ event: 'session.created',
761
+ timestamp: new Date().toISOString(),
762
+ session: { id: session.id, name: session.windowName, workDir },
763
+ detail: `Session created: ${session.windowName}`,
764
+ meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
765
+ });
766
+ console.timeEnd("POST_CHANNEL_CREATED");
767
+ console.time("POST_SEND_INITIAL_PROMPT");
768
+ // Now send the prompt (topic exists, monitor can forward messages)
769
+ let promptDelivery;
770
+ if (prompt) {
771
+ // Issue #783: Inject resolved memory values into prompt
772
+ let finalPrompt = prompt;
773
+ if (memoryKeys && memoryKeys.length > 0 && memoryBridge) {
774
+ const resolved = memoryBridge.resolveKeys(memoryKeys);
775
+ if (resolved.size > 0) {
776
+ const lines = ['[Memory context]'];
777
+ for (const [k, v] of resolved)
778
+ lines.push(`${k}: ${v}`);
779
+ lines.push('', prompt);
780
+ finalPrompt = lines.join('\n');
781
+ }
782
+ }
783
+ promptDelivery = await sessions.sendInitialPrompt(session.id, finalPrompt);
784
+ console.timeEnd("POST_SEND_INITIAL_PROMPT");
785
+ metrics.promptSent(promptDelivery.delivered);
786
+ }
787
+ else {
788
+ console.timeEnd("POST_SEND_INITIAL_PROMPT");
789
+ }
790
+ return reply.status(201).send({ ...session, promptDelivery });
791
+ }
792
+ app.post('/v1/sessions', createSessionHandler);
793
+ app.post('/sessions', createSessionHandler);
794
+ // Get session (Issue #20: includes actionHints for interactive states)
795
+ async function getSessionHandler(req, reply) {
796
+ const sessionId = req.params.id;
797
+ const session = sessions.getSession(sessionId);
798
+ if (!session)
799
+ return reply.status(404).send({ error: 'Session not found' });
800
+ return addActionHints(session, sessions);
801
+ }
802
+ app.get('/v1/sessions/:id', getSessionHandler);
803
+ app.get('/sessions/:id', getSessionHandler);
804
+ // #128: Bulk health check — returns health for all sessions in one request
805
+ app.get('/v1/sessions/health', async () => {
806
+ const allSessions = sessions.listSessions();
807
+ const results = {};
808
+ await Promise.all(allSessions.map(async (s) => {
809
+ try {
810
+ results[s.id] = await sessions.getHealth(s.id);
811
+ }
812
+ catch { /* health check failed — report error state */
813
+ results[s.id] = {
814
+ alive: false, windowExists: false, claudeRunning: false,
815
+ paneCommand: null, status: 'unknown', hasTranscript: false,
816
+ lastActivity: 0, lastActivityAgo: 0, sessionAge: 0,
817
+ details: 'Error fetching health',
818
+ };
819
+ }
820
+ }));
821
+ return results;
822
+ });
823
+ // Session health check (Issue #2)
824
+ async function sessionHealthHandler(req, reply) {
825
+ try {
826
+ return await sessions.getHealth(req.params.id);
827
+ }
828
+ catch (e) {
829
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
830
+ }
831
+ }
832
+ app.get('/v1/sessions/:id/health', sessionHealthHandler);
833
+ app.get('/sessions/:id/health', sessionHealthHandler);
834
+ // Send message (with delivery verification — Issue #1)
835
+ async function sendMessageHandler(req, reply) {
836
+ const parsed = sendMessageSchema.safeParse(req.body);
837
+ if (!parsed.success)
838
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
839
+ const { text } = parsed.data;
840
+ try {
841
+ const result = await sessions.sendMessage(req.params.id, text);
842
+ await channels.message({
843
+ event: 'message.user',
844
+ timestamp: new Date().toISOString(),
845
+ session: { id: req.params.id, name: '', workDir: '' },
846
+ detail: text,
847
+ });
848
+ return { ok: true, delivered: result.delivered, attempts: result.attempts };
849
+ }
850
+ catch (e) {
851
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
852
+ }
853
+ }
854
+ app.post('/v1/sessions/:id/send', sendMessageHandler);
855
+ app.post('/sessions/:id/send', sendMessageHandler);
856
+ // Issue #702: GET children sessions
857
+ async function getChildrenHandler(req, reply) {
858
+ const sessionId = req.params.id;
859
+ const session = sessions.getSession(sessionId);
860
+ if (!session)
861
+ return reply.status(404).send({ error: 'Session not found' });
862
+ const children = (session.children ?? []).map(id => {
863
+ const child = sessions.getSession(id);
864
+ if (!child)
865
+ return null;
866
+ return { id: child.id, windowName: child.windowName, status: child.status, createdAt: child.createdAt };
867
+ }).filter(Boolean);
868
+ return { children };
869
+ }
870
+ app.get('/v1/sessions/:id/children', getChildrenHandler);
871
+ app.get('/sessions/:id/children', getChildrenHandler);
872
+ async function spawnChildHandler(req, reply) {
873
+ const parentId = req.params.id;
874
+ const parent = sessions.getSession(parentId);
875
+ if (!parent)
876
+ return reply.status(404).send({ error: 'Parent session not found' });
877
+ const { name, prompt, workDir, permissionMode } = req.body ?? {};
878
+ const childName = name ?? `${parent.windowName ?? 'session'}-child`;
879
+ const requestedWorkDir = workDir ?? parent.workDir;
880
+ const safeChildWorkDir = await validateWorkDirWithConfig(requestedWorkDir);
881
+ if (typeof safeChildWorkDir === 'object') {
882
+ return reply.status(400).send({ error: `Invalid workDir: ${safeChildWorkDir.error}`, code: safeChildWorkDir.code });
883
+ }
884
+ const childPermMode = permissionMode ?? parent.permissionMode ?? 'default';
885
+ const childSession = await sessions.createSession({ workDir: safeChildWorkDir, name: childName, parentId, permissionMode: childPermMode });
886
+ let promptDelivery;
887
+ if (prompt) {
888
+ promptDelivery = await sessions.sendInitialPrompt(childSession.id, prompt);
889
+ }
890
+ return reply.status(201).send({ ...childSession, promptDelivery });
891
+ }
892
+ app.post('/v1/sessions/:id/spawn', spawnChildHandler);
893
+ app.post('/sessions/:id/spawn', spawnChildHandler);
894
+ async function forkSessionHandler(req, reply) {
895
+ const parentId = req.params.id;
896
+ const parent = sessions.getSession(parentId);
897
+ if (!parent)
898
+ return reply.status(404).send({ error: 'Parent session not found' });
899
+ const { name, prompt } = req.body ?? {};
900
+ const forkName = name ?? `${parent.windowName ?? 'session'}-fork`;
901
+ // Inherit: workDir, permissionMode, env (collect from parent's env vars if stored)
902
+ // Note: Parent's env vars are passed during creation but not stored in SessionInfo
903
+ // For now, we inherit structural settings (workDir, permissionMode)
904
+ const forkedSession = await sessions.createSession({
905
+ workDir: parent.workDir,
906
+ name: forkName,
907
+ permissionMode: parent.permissionMode,
908
+ });
909
+ let promptDelivery;
910
+ if (prompt) {
911
+ promptDelivery = await sessions.sendInitialPrompt(forkedSession.id, prompt);
912
+ }
913
+ await channels.sessionCreated({
914
+ event: 'session.created',
915
+ timestamp: new Date().toISOString(),
916
+ session: { id: forkedSession.id, name: forkedSession.windowName, workDir: parent.workDir },
917
+ detail: `Session forked from ${parentId}`,
918
+ });
919
+ return reply.status(201).send({ ...forkedSession, forkedFrom: parentId, promptDelivery });
920
+ }
921
+ app.post('/v1/sessions/:id/fork', forkSessionHandler);
922
+ app.post('/sessions/:id/fork', forkSessionHandler);
923
+ async function createConsensusHandler(req, reply) {
924
+ const targetSessionId = req.params.id;
925
+ const target = sessions.getSession(targetSessionId);
926
+ if (!target)
927
+ return reply.status(404).send({ error: 'Target session not found' });
928
+ const focusAreas = (req.body?.focusAreas && req.body.focusAreas.length > 0)
929
+ ? req.body.focusAreas
930
+ : ['correctness', 'security', 'performance'];
931
+ const reviewerCount = Math.min(5, Math.max(1, req.body?.reviewerCount ?? focusAreas.length));
932
+ const selectedFocus = focusAreas.slice(0, reviewerCount);
933
+ const reviewerIds = [];
934
+ for (let i = 0; i < selectedFocus.length; i += 1) {
935
+ const focus = selectedFocus[i];
936
+ const child = await sessions.createSession({
937
+ workDir: target.workDir,
938
+ name: `consensus-${focus}-${targetSessionId.slice(0, 6)}`,
939
+ parentId: targetSessionId,
940
+ permissionMode: target.permissionMode,
941
+ });
942
+ reviewerIds.push(child.id);
943
+ await sessions.sendInitialPrompt(child.id, buildConsensusPrompt(targetSessionId, focus));
944
+ }
945
+ const consensusId = crypto.randomUUID();
946
+ const record = {
947
+ id: consensusId,
948
+ targetSessionId,
949
+ reviewerIds,
950
+ focusAreas: selectedFocus,
951
+ status: 'running',
952
+ createdAt: Date.now(),
953
+ };
954
+ consensusRequests.set(consensusId, record);
955
+ return reply.status(202).send(record);
956
+ }
957
+ function getConsensusHandler(req, reply) {
958
+ const item = consensusRequests.get(req.params.id);
959
+ if (!item)
960
+ return reply.status(404).send({ error: 'Consensus request not found' });
961
+ return item;
962
+ }
963
+ app.post('/v1/sessions/:id/consensus', createConsensusHandler);
964
+ app.get('/v1/consensus/:id', getConsensusHandler);
965
+ async function getPermissionPolicyHandler(req, reply) {
966
+ const sessionId = req.params.id;
967
+ const session = sessions.getSession(sessionId);
968
+ if (!session)
969
+ return reply.status(404).send({ error: 'Session not found' });
970
+ return { permissionPolicy: session.permissionPolicy ?? [] };
971
+ }
972
+ async function updatePermissionPolicyHandler(req, reply) {
973
+ const sessionId = req.params.id;
974
+ const session = sessions.getSession(sessionId);
975
+ if (!session)
976
+ return reply.status(404).send({ error: 'Session not found' });
977
+ const policy = req.body ?? [];
978
+ const result = permissionRuleSchema.array().safeParse(policy);
979
+ if (!result.success)
980
+ return reply.status(400).send({ error: 'Invalid permission policy', details: result.error.issues });
981
+ session.permissionPolicy = policy;
982
+ await sessions.save();
983
+ return { permissionPolicy: policy };
984
+ }
985
+ app.get('/v1/sessions/:id/permissions', getPermissionPolicyHandler);
986
+ app.put('/v1/sessions/:id/permissions', updatePermissionPolicyHandler);
987
+ app.get('/sessions/:id/permissions', getPermissionPolicyHandler);
988
+ app.put('/sessions/:id/permissions', updatePermissionPolicyHandler);
989
+ async function getPermissionProfileHandler(req, reply) {
990
+ const sessionId = req.params.id;
991
+ const session = sessions.getSession(sessionId);
992
+ if (!session)
993
+ return reply.status(404).send({ error: 'Session not found' });
994
+ return { permissionProfile: session.permissionProfile ?? null };
995
+ }
996
+ async function updatePermissionProfileHandler(req, reply) {
997
+ const sessionId = req.params.id;
998
+ const session = sessions.getSession(sessionId);
999
+ if (!session)
1000
+ return reply.status(404).send({ error: 'Session not found' });
1001
+ const parsed = permissionProfileSchema.safeParse(req.body ?? {});
1002
+ if (!parsed.success)
1003
+ return reply.status(400).send({ error: 'Invalid permission profile', details: parsed.error.issues });
1004
+ session.permissionProfile = parsed.data;
1005
+ await sessions.save();
1006
+ return { permissionProfile: parsed.data };
1007
+ }
1008
+ app.get('/v1/sessions/:id/permission-profile', getPermissionProfileHandler);
1009
+ app.put('/v1/sessions/:id/permission-profile', updatePermissionProfileHandler);
1010
+ app.get('/sessions/:id/permission-profile', getPermissionProfileHandler);
1011
+ app.put('/sessions/:id/permission-profile', updatePermissionProfileHandler);
1012
+ // Read messages
1013
+ async function readMessagesHandler(req, reply) {
1014
+ try {
1015
+ return await sessions.readMessages(req.params.id);
1016
+ }
1017
+ catch (e) {
1018
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1019
+ }
1020
+ }
1021
+ app.get('/v1/sessions/:id/read', readMessagesHandler);
1022
+ app.get('/sessions/:id/read', readMessagesHandler);
1023
+ registerPermissionRoutes(app, {
1024
+ approve: async (id) => sessions.approve(id),
1025
+ reject: async (id) => sessions.reject(id),
1026
+ getLatencyMetrics: (id) => sessions.getLatencyMetrics(id),
1027
+ }, {
1028
+ recordPermissionResponse: (id, latencyMs) => metrics.recordPermissionResponse(id, latencyMs),
1029
+ });
1030
+ // Issue #336: Answer pending AskUserQuestion
1031
+ app.post('/v1/sessions/:id/answer', async (req, reply) => {
1032
+ const { questionId, answer } = req.body || {};
1033
+ if (!questionId || answer === undefined || answer === null) {
1034
+ return reply.status(400).send({ error: 'questionId and answer are required' });
1035
+ }
1036
+ const sessionId = req.params.id;
1037
+ const session = sessions.getSession(sessionId);
1038
+ if (!session)
1039
+ return reply.status(404).send({ error: 'Session not found' });
1040
+ const resolved = sessions.submitAnswer(req.params.id, questionId, answer);
1041
+ if (!resolved) {
1042
+ return reply.status(409).send({ error: 'No pending question matching this questionId' });
1043
+ }
1044
+ return { ok: true };
1045
+ });
1046
+ // Escape
1047
+ async function escapeHandler(req, reply) {
1048
+ try {
1049
+ await sessions.escape(req.params.id);
1050
+ return { ok: true };
1051
+ }
1052
+ catch (e) {
1053
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1054
+ }
1055
+ }
1056
+ app.post('/v1/sessions/:id/escape', escapeHandler);
1057
+ app.post('/sessions/:id/escape', escapeHandler);
1058
+ // Interrupt (Ctrl+C)
1059
+ async function interruptHandler(req, reply) {
1060
+ try {
1061
+ await sessions.interrupt(req.params.id);
1062
+ return { ok: true };
1063
+ }
1064
+ catch (e) {
1065
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1066
+ }
1067
+ }
1068
+ app.post('/v1/sessions/:id/interrupt', interruptHandler);
1069
+ app.post('/sessions/:id/interrupt', interruptHandler);
1070
+ // Kill session
1071
+ async function killSessionHandler(req, reply) {
1072
+ if (!sessions.getSession(req.params.id)) {
1073
+ return reply.status(404).send({ error: 'Session not found' });
1074
+ }
1075
+ try {
1076
+ // #842: killSession first, then notify — avoids race where channels
1077
+ // reference a session that is still being destroyed.
1078
+ await sessions.killSession(req.params.id);
1079
+ eventBus.emitEnded(req.params.id, 'killed');
1080
+ await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
1081
+ cleanupTerminatedSessionState(req.params.id, { monitor, metrics, toolRegistry });
1082
+ return { ok: true };
1083
+ }
1084
+ catch (e) {
1085
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1086
+ }
1087
+ }
1088
+ app.delete('/v1/sessions/:id', killSessionHandler);
1089
+ app.delete('/sessions/:id', killSessionHandler);
1090
+ // Capture raw pane
1091
+ async function capturePaneHandler(req, reply) {
1092
+ const sessionId = req.params.id;
1093
+ const session = sessions.getSession(sessionId);
1094
+ if (!session)
1095
+ return reply.status(404).send({ error: 'Session not found' });
1096
+ const pane = await tmux.capturePane(session.windowId);
1097
+ return { pane };
1098
+ }
1099
+ app.get('/v1/sessions/:id/pane', capturePaneHandler);
1100
+ app.get('/sessions/:id/pane', capturePaneHandler);
1101
+ // Slash command
1102
+ async function commandHandler(req, reply) {
1103
+ const parsed = commandSchema.safeParse(req.body);
1104
+ if (!parsed.success)
1105
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1106
+ const { command } = parsed.data;
1107
+ try {
1108
+ const cmd = command.startsWith('/') ? command : `/${command}`;
1109
+ await sessions.sendMessage(req.params.id, cmd);
1110
+ return { ok: true };
1111
+ }
1112
+ catch (e) {
1113
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1114
+ }
1115
+ }
1116
+ app.post('/v1/sessions/:id/command', commandHandler);
1117
+ app.post('/sessions/:id/command', commandHandler);
1118
+ // Bash mode
1119
+ async function bashHandler(req, reply) {
1120
+ const parsed = bashSchema.safeParse(req.body);
1121
+ if (!parsed.success)
1122
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1123
+ const { command } = parsed.data;
1124
+ try {
1125
+ const cmd = command.startsWith('!') ? command : `!${command}`;
1126
+ await sessions.sendMessage(req.params.id, cmd);
1127
+ return { ok: true };
1128
+ }
1129
+ catch (e) {
1130
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1131
+ }
1132
+ }
1133
+ app.post('/v1/sessions/:id/bash', bashHandler);
1134
+ app.post('/sessions/:id/bash', bashHandler);
1135
+ // Session summary (Issue #35)
1136
+ async function summaryHandler(req, reply) {
1137
+ try {
1138
+ return await sessions.getSummary(req.params.id);
1139
+ }
1140
+ catch (e) {
1141
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1142
+ }
1143
+ }
1144
+ app.get('/v1/sessions/:id/summary', summaryHandler);
1145
+ app.get('/sessions/:id/summary', summaryHandler);
1146
+ // Paginated transcript read
1147
+ app.get('/v1/sessions/:id/transcript', async (req, reply) => {
1148
+ try {
1149
+ const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
1150
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
1151
+ const allowedRoles = new Set(['user', 'assistant', 'system']);
1152
+ const roleFilter = req.query.role;
1153
+ if (roleFilter && !allowedRoles.has(roleFilter)) {
1154
+ return reply.status(400).send({ error: `Invalid role filter: ${roleFilter}. Allowed values: user, assistant, system` });
1155
+ }
1156
+ return await sessions.readTranscript(req.params.id, page, limit, roleFilter);
1157
+ }
1158
+ catch (e) {
1159
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1160
+ }
1161
+ });
1162
+ // Cursor-based transcript replay (Issue #883): stable pagination under concurrent appends.
1163
+ // GET /v1/sessions/:id/transcript/cursor?before_id=N&limit=50&role=user|assistant|system
1164
+ app.get('/v1/sessions/:id/transcript/cursor', async (req, reply) => {
1165
+ try {
1166
+ const rawBeforeId = req.query.before_id;
1167
+ const beforeId = rawBeforeId !== undefined ? parseInt(rawBeforeId, 10) : undefined;
1168
+ if (beforeId !== undefined && (!Number.isInteger(beforeId) || beforeId < 1)) {
1169
+ return reply.status(400).send({ error: 'before_id must be a positive integer' });
1170
+ }
1171
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
1172
+ const allowedRoles = new Set(['user', 'assistant', 'system']);
1173
+ const roleFilter = req.query.role;
1174
+ if (roleFilter && !allowedRoles.has(roleFilter)) {
1175
+ return reply.status(400).send({ error: `Invalid role filter: ${roleFilter}. Allowed values: user, assistant, system` });
1176
+ }
1177
+ return await sessions.readTranscriptCursor(req.params.id, beforeId, limit, roleFilter);
1178
+ }
1179
+ catch (e) {
1180
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
1181
+ }
1182
+ });
1183
+ // Screenshot capture (Issue #22)
1184
+ async function screenshotHandler(req, reply) {
1185
+ const parsed = screenshotSchema.safeParse(req.body);
1186
+ if (!parsed.success)
1187
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1188
+ const { url, fullPage, width, height } = parsed.data;
1189
+ const urlError = validateScreenshotUrl(url);
1190
+ if (urlError)
1191
+ return reply.status(400).send({ error: urlError });
1192
+ // DNS-resolution check: resolve hostname and reject private IPs.
1193
+ // Returns the resolved IP so we can pin it via --host-resolver-rules to prevent
1194
+ // DNS rebinding (TOCTOU) between validation and page.goto().
1195
+ const hostname = new URL(url).hostname;
1196
+ const dnsResult = await resolveAndCheckIp(hostname);
1197
+ if (dnsResult.error)
1198
+ return reply.status(400).send({ error: dnsResult.error });
1199
+ // Validate session exists
1200
+ const sessionId = req.params.id;
1201
+ const session = sessions.getSession(sessionId);
1202
+ if (!session)
1203
+ return reply.status(404).send({ error: 'Session not found' });
1204
+ if (!isPlaywrightAvailable()) {
1205
+ return reply.status(501).send({
1206
+ error: 'Playwright is not installed',
1207
+ message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
1208
+ });
1209
+ }
1210
+ try {
1211
+ // Pin the validated IP via host-resolver-rules to prevent DNS rebinding
1212
+ const hostResolverRule = dnsResult.resolvedIp
1213
+ ? buildHostResolverRule(hostname, dnsResult.resolvedIp)
1214
+ : undefined;
1215
+ const result = await captureScreenshot({ url, fullPage, width, height, hostResolverRule });
1216
+ return reply.status(200).send(result);
1217
+ }
1218
+ catch (e) {
1219
+ return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
1220
+ }
1221
+ }
1222
+ app.post('/v1/sessions/:id/screenshot', screenshotHandler);
1223
+ app.post('/sessions/:id/screenshot', screenshotHandler);
1224
+ // SSE event stream (Issue #32)
1225
+ app.get('/v1/sessions/:id/events', async (req, reply) => {
1226
+ const sessionId = req.params.id;
1227
+ const session = sessions.getSession(sessionId);
1228
+ if (!session)
1229
+ return reply.status(404).send({ error: 'Session not found' });
1230
+ const clientIp = req.ip;
1231
+ const acquireResult = sseLimiter.acquire(clientIp);
1232
+ if (!acquireResult.allowed) {
1233
+ const status = acquireResult.reason === 'per_ip_limit' ? 429 : 503;
1234
+ return reply.status(status).send({
1235
+ error: acquireResult.reason === 'per_ip_limit'
1236
+ ? `Per-IP connection limit reached (${acquireResult.current}/${acquireResult.limit})`
1237
+ : `Global connection limit reached (${acquireResult.current}/${acquireResult.limit})`,
1238
+ reason: acquireResult.reason,
1239
+ });
1240
+ }
1241
+ // Issue #505: Subscribe BEFORE writing response headers so that if
1242
+ // subscription fails, we can still return a proper HTTP error.
1243
+ let unsubscribe;
1244
+ const connectionId = acquireResult.connectionId;
1245
+ let writer;
1246
+ // Queue events that arrive between subscription and writer creation
1247
+ const pendingEvents = [];
1248
+ let subscriptionReady = false;
1249
+ try {
1250
+ const handler = (event) => {
1251
+ if (!subscriptionReady) {
1252
+ pendingEvents.push(event);
1253
+ return;
1254
+ }
1255
+ const id = event.id != null ? `id: ${event.id}\n` : '';
1256
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
1257
+ };
1258
+ unsubscribe = eventBus.subscribe(req.params.id, handler);
1259
+ }
1260
+ catch (err) {
1261
+ req.log.error({ err, sessionId: req.params.id }, 'SSE subscription failed — unable to create event listener');
1262
+ sseLimiter.release(connectionId);
1263
+ return reply.status(500).send({ error: 'Failed to create SSE subscription' });
1264
+ }
1265
+ reply.raw.writeHead(200, {
1266
+ 'Content-Type': 'text/event-stream',
1267
+ 'Cache-Control': 'no-cache',
1268
+ 'Connection': 'keep-alive',
1269
+ 'X-Accel-Buffering': 'no',
1270
+ });
1271
+ writer = new SSEWriter(reply.raw, req.raw, () => {
1272
+ unsubscribe?.();
1273
+ sseLimiter.release(connectionId);
1274
+ });
1275
+ // Now safe to deliver events — flush any queued during setup
1276
+ subscriptionReady = true;
1277
+ for (const event of pendingEvents) {
1278
+ const id = event.id != null ? `id: ${event.id}\n` : '';
1279
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
1280
+ }
1281
+ // Send initial connected event
1282
+ writer.write(`data: ${JSON.stringify({ event: 'connected', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
1283
+ // Issue #308: Replay missed events if client sends Last-Event-ID
1284
+ const lastEventId = req.headers['last-event-id'];
1285
+ if (lastEventId) {
1286
+ const missed = eventBus.getEventsSince(req.params.id, parseInt(lastEventId, 10) || 0);
1287
+ for (const event of missed) {
1288
+ const id = event.id != null ? `id: ${event.id}\n` : '';
1289
+ writer.write(`${id}data: ${JSON.stringify(event)}\n\n`);
1290
+ }
1291
+ }
1292
+ writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', sessionId: session.id, timestamp: new Date().toISOString() })}\n\n`);
1293
+ // Don't let Fastify auto-send (we manage the response manually)
1294
+ await reply;
1295
+ });
1296
+ // ── Claude Code Hook Endpoints (Issue #161) ─────────────────────────
1297
+ // POST /v1/sessions/:id/hooks/permission — PermissionRequest hook from CC
1298
+ app.post('/v1/sessions/:id/hooks/permission', async (req, reply) => {
1299
+ const sessionId = req.params.id;
1300
+ const session = sessions.getSession(sessionId);
1301
+ if (!session)
1302
+ return reply.status(404).send({ error: 'Session not found' });
1303
+ const parsed = permissionHookSchema.safeParse(req.body);
1304
+ if (!parsed.success)
1305
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1306
+ const { tool_name, tool_input, permission_mode } = parsed.data;
1307
+ // Update session status
1308
+ session.status = 'permission_prompt';
1309
+ session.lastActivity = Date.now();
1310
+ await sessions.save();
1311
+ // Notify channels and SSE
1312
+ const detail = tool_name
1313
+ ? `Permission request: ${tool_name}${permission_mode ? ` (${permission_mode})` : ''}`
1314
+ : 'Permission requested';
1315
+ await channels.statusChange({
1316
+ event: 'status.permission',
1317
+ timestamp: new Date().toISOString(),
1318
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1319
+ detail,
1320
+ meta: { tool_name, tool_input, permission_mode },
1321
+ });
1322
+ eventBus.emitApproval(session.id, detail);
1323
+ return reply.status(200).send({});
1324
+ });
1325
+ // POST /v1/sessions/:id/hooks/stop — Stop hook from CC
1326
+ app.post('/v1/sessions/:id/hooks/stop', async (req, reply) => {
1327
+ const sessionId = req.params.id;
1328
+ const session = sessions.getSession(sessionId);
1329
+ if (!session)
1330
+ return reply.status(404).send({ error: 'Session not found' });
1331
+ const parsed = stopHookSchema.safeParse(req.body);
1332
+ if (!parsed.success)
1333
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1334
+ const { stop_reason } = parsed.data;
1335
+ // Update session status
1336
+ session.status = 'idle';
1337
+ session.lastActivity = Date.now();
1338
+ await sessions.save();
1339
+ // Notify channels and SSE
1340
+ const detail = stop_reason
1341
+ ? `Claude Code stopped: ${stop_reason}`
1342
+ : 'Claude Code session ended normally';
1343
+ await channels.statusChange({
1344
+ event: 'status.idle',
1345
+ timestamp: new Date().toISOString(),
1346
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1347
+ detail,
1348
+ meta: { stop_reason },
1349
+ });
1350
+ eventBus.emitStatus(session.id, 'idle', detail);
1351
+ return reply.status(200).send({});
1352
+ });
1353
+ // Batch create (Issue #36, #583: per-key batch rate limit + global session cap)
1354
+ const MAX_CONCURRENT_SESSIONS = 200;
1355
+ app.post('/v1/sessions/batch', async (req, reply) => {
1356
+ const parsed = batchSessionSchema.safeParse(req.body);
1357
+ if (!parsed.success)
1358
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1359
+ const specs = parsed.data.sessions;
1360
+ // #583: Per-key batch rate limit (max 1 batch per 5 seconds)
1361
+ const keyId = requestKeyMap.get(req.id) ?? 'anonymous';
1362
+ if (auth.checkBatchRateLimit(keyId)) {
1363
+ return reply.status(429).send({ error: 'Batch rate limit exceeded — 1 batch per 5 seconds per key' });
1364
+ }
1365
+ // #583: Global concurrent session cap
1366
+ const currentCount = sessions.listSessions().length;
1367
+ if (currentCount + specs.length > MAX_CONCURRENT_SESSIONS) {
1368
+ return reply.status(429).send({ error: `Session cap exceeded — ${currentCount} active, max ${MAX_CONCURRENT_SESSIONS}` });
1369
+ }
1370
+ for (const spec of specs) {
1371
+ const safeWorkDir = await validateWorkDirWithConfig(spec.workDir);
1372
+ if (typeof safeWorkDir === 'object') {
1373
+ return reply.status(400).send({ error: `Invalid workDir "${spec.workDir}": ${safeWorkDir.error}`, code: safeWorkDir.code });
1374
+ }
1375
+ spec.workDir = safeWorkDir;
1376
+ }
1377
+ const result = await pipelines.batchCreate(specs);
1378
+ return reply.status(201).send(result);
1379
+ });
1380
+ // Issue #740: Verification Protocol — run quality gate (tsc + build + test) on a session's workDir
1381
+ app.post('/v1/sessions/:id/verify', async (req, reply) => {
1382
+ const sessionId = req.params.id;
1383
+ const session = sessions.getSession(sessionId);
1384
+ if (!session)
1385
+ return reply.status(404).send({ error: 'Session not found' });
1386
+ const { workDir } = session;
1387
+ if (!workDir)
1388
+ return reply.status(400).send({ error: 'Session has no workDir' });
1389
+ const criticalOnly = config.verificationProtocol?.criticalOnly ?? false;
1390
+ eventBus.emitStatus(sessionId, 'working', `Running verification (criticalOnly=${criticalOnly})…`);
1391
+ try {
1392
+ const result = await runVerification(workDir, criticalOnly);
1393
+ eventBus.emitVerification(sessionId, result);
1394
+ const httpStatus = result.ok ? 200 : 422;
1395
+ return reply.status(httpStatus).send(result);
1396
+ }
1397
+ catch (e) {
1398
+ const msg = e instanceof Error ? e.message : String(e);
1399
+ return reply.status(500).send({ ok: false, summary: `Verification error: ${msg}` });
1400
+ }
1401
+ });
1402
+ // Pipeline create (Issue #36)
1403
+ app.post('/v1/pipelines', async (req, reply) => {
1404
+ const parsed = pipelineSchema.safeParse(req.body);
1405
+ if (!parsed.success)
1406
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1407
+ const pipeConfig = parsed.data;
1408
+ const safeWorkDir = await validateWorkDirWithConfig(pipeConfig.workDir);
1409
+ if (typeof safeWorkDir === 'object') {
1410
+ return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
1411
+ }
1412
+ pipeConfig.workDir = safeWorkDir;
1413
+ // Validate per-stage workDir overrides for path traversal (#631)
1414
+ for (const stage of pipeConfig.stages) {
1415
+ if (stage.workDir) {
1416
+ const safeStageWorkDir = await validateWorkDirWithConfig(stage.workDir);
1417
+ if (typeof safeStageWorkDir === 'object') {
1418
+ return reply.status(400).send({ error: `Invalid workDir for stage "${stage.name}": ${safeStageWorkDir.error}`, code: safeStageWorkDir.code });
1419
+ }
1420
+ stage.workDir = safeStageWorkDir;
1421
+ }
1422
+ }
1423
+ try {
1424
+ const pipeline = await pipelines.createPipeline(pipeConfig);
1425
+ return reply.status(201).send(pipeline);
1426
+ }
1427
+ catch (e) {
1428
+ return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
1429
+ }
1430
+ });
1431
+ // Pipeline status
1432
+ app.get('/v1/pipelines/:id', async (req, reply) => {
1433
+ const pipeline = pipelines.getPipeline(req.params.id);
1434
+ if (!pipeline)
1435
+ return reply.status(404).send({ error: 'Pipeline not found' });
1436
+ return pipeline;
1437
+ });
1438
+ // List pipelines
1439
+ app.get('/v1/pipelines', async () => pipelines.listPipelines());
1440
+ const createTemplateSchema = z.object({
1441
+ name: z.string().max(100),
1442
+ description: z.string().max(500).optional(),
1443
+ sessionId: z.string().uuid().optional(),
1444
+ workDir: z.string().min(1).optional(),
1445
+ prompt: z.string().max(100_000).optional(),
1446
+ claudeCommand: z.string().max(10_000).optional(),
1447
+ env: z.record(z.string(), z.string()).optional(),
1448
+ stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
1449
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
1450
+ autoApprove: z.boolean().optional(),
1451
+ memoryKeys: z.array(z.string()).max(50).optional(),
1452
+ }).strict();
1453
+ // POST /v1/templates — Create a new template
1454
+ app.post('/v1/templates', async (req, reply) => {
1455
+ const parsed = createTemplateSchema.safeParse(req.body);
1456
+ if (!parsed.success) {
1457
+ return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1458
+ }
1459
+ const { name, description, sessionId, ...templateData } = parsed.data;
1460
+ // If sessionId is provided, fill in missing fields from the session
1461
+ let finalData = { ...templateData };
1462
+ if (sessionId) {
1463
+ const session = sessions.getSession(sessionId);
1464
+ if (!session) {
1465
+ return reply.status(404).send({ error: 'Session not found' });
1466
+ }
1467
+ // Use session's workDir if not explicitly provided
1468
+ if (!finalData.workDir) {
1469
+ finalData.workDir = session.workDir;
1470
+ }
1471
+ if (!finalData.stallThresholdMs && session.stallThresholdMs) {
1472
+ finalData.stallThresholdMs = session.stallThresholdMs;
1473
+ }
1474
+ if (!finalData.permissionMode && session.permissionMode !== 'default') {
1475
+ finalData.permissionMode = session.permissionMode;
1476
+ }
1477
+ }
1478
+ if (!finalData.workDir) {
1479
+ return reply.status(400).send({ error: 'workDir is required (provide sessionId or explicit workDir)' });
1480
+ }
1481
+ // Issue #1125: Validate workDir for path traversal at template creation time
1482
+ const safeWorkDir = await validateWorkDirWithConfig(finalData.workDir);
1483
+ if (typeof safeWorkDir === 'object') {
1484
+ return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}`, code: safeWorkDir.code });
1485
+ }
1486
+ try {
1487
+ const template = await templateStore.createTemplate({
1488
+ name,
1489
+ description,
1490
+ workDir: safeWorkDir,
1491
+ prompt: finalData.prompt,
1492
+ claudeCommand: finalData.claudeCommand,
1493
+ env: finalData.env,
1494
+ stallThresholdMs: finalData.stallThresholdMs,
1495
+ permissionMode: finalData.permissionMode,
1496
+ autoApprove: finalData.autoApprove,
1497
+ memoryKeys: finalData.memoryKeys,
1498
+ });
1499
+ return reply.status(201).send(template);
1500
+ }
1501
+ catch (e) {
1502
+ return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to create template' });
1503
+ }
1504
+ });
1505
+ // GET /v1/templates — List all templates
1506
+ app.get('/v1/templates', async () => {
1507
+ try {
1508
+ return await templateStore.listTemplates();
1509
+ }
1510
+ catch (e) {
1511
+ return [];
1512
+ }
1513
+ });
1514
+ // GET /v1/templates/:id — Get a specific template
1515
+ app.get('/v1/templates/:id', async (req, reply) => {
1516
+ try {
1517
+ const template = await templateStore.getTemplate(req.params.id);
1518
+ if (!template) {
1519
+ return reply.status(404).send({ error: 'Template not found' });
1520
+ }
1521
+ return template;
1522
+ }
1523
+ catch (e) {
1524
+ return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to get template' });
1525
+ }
1526
+ });
1527
+ // PUT /v1/templates/:id — Update a template
1528
+ app.put('/v1/templates/:id', async (req, reply) => {
1529
+ try {
1530
+ const updates = createTemplateSchema.partial().safeParse(req.body);
1531
+ if (!updates.success) {
1532
+ return reply.status(400).send({ error: 'Invalid request body', details: updates.error.issues });
1533
+ }
1534
+ const template = await templateStore.updateTemplate(req.params.id, updates.data);
1535
+ if (!template) {
1536
+ return reply.status(404).send({ error: 'Template not found' });
1537
+ }
1538
+ return template;
1539
+ }
1540
+ catch (e) {
1541
+ return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to update template' });
1542
+ }
1543
+ });
1544
+ // DELETE /v1/templates/:id — Delete a template
1545
+ app.delete('/v1/templates/:id', async (req, reply) => {
1546
+ try {
1547
+ const deleted = await templateStore.deleteTemplate(req.params.id);
1548
+ if (!deleted) {
1549
+ return reply.status(404).send({ error: 'Template not found' });
1550
+ }
1551
+ return { ok: true };
1552
+ }
1553
+ catch (e) {
1554
+ return reply.status(500).send({ error: e instanceof Error ? e.message : 'Failed to delete template' });
1555
+ }
1556
+ });
1557
+ // ── Session Reaper ──────────────────────────────────────────────────
1558
+ async function reapStaleSessions(maxAgeMs) {
1559
+ const now = Date.now();
1560
+ // Snapshot list before iterating — killSession() modifies the sessions map
1561
+ const snapshot = [...sessions.listSessions()];
1562
+ for (const session of snapshot) {
1563
+ // Guard: session may have been deleted by DELETE handler between snapshot and here
1564
+ if (!sessions.getSession(session.id))
1565
+ continue;
1566
+ const age = now - session.createdAt;
1567
+ if (age > maxAgeMs) {
1568
+ const ageMin = Math.round(age / 60000);
1569
+ console.log(`Reaper: killing session ${session.windowName} (${session.id.slice(0, 8)}) — age ${ageMin}min`);
1570
+ try {
1571
+ // #842: killSession first, then notify — avoids race where channels
1572
+ // reference a session that is still being destroyed.
1573
+ await sessions.killSession(session.id);
1574
+ eventBus.cleanupSession(session.id);
1575
+ await channels.sessionEnded({
1576
+ event: 'session.ended',
1577
+ timestamp: new Date().toISOString(),
1578
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1579
+ detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
1580
+ });
1581
+ cleanupTerminatedSessionState(session.id, { monitor, metrics, toolRegistry });
1582
+ }
1583
+ catch (e) {
1584
+ console.error(`Reaper: failed to kill session ${session.id}:`, e);
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ // ── Zombie Reaper (Issue #283) ──────────────────────────────────────
1590
+ const ZOMBIE_REAP_DELAY_MS = parseIntSafe(process.env.ZOMBIE_REAP_DELAY_MS, 60000);
1591
+ const ZOMBIE_REAP_INTERVAL_MS = parseIntSafe(process.env.ZOMBIE_REAP_INTERVAL_MS, 60000);
1592
+ async function reapZombieSessions() {
1593
+ const now = Date.now();
1594
+ // Snapshot list before iterating — killSession() modifies the sessions map
1595
+ const snapshot = [...sessions.listSessions()];
1596
+ for (const session of snapshot) {
1597
+ // Guard: session may have been deleted between snapshot and here
1598
+ if (!sessions.getSession(session.id))
1599
+ continue;
1600
+ if (!session.lastDeadAt)
1601
+ continue;
1602
+ const deadDuration = now - session.lastDeadAt;
1603
+ if (deadDuration < ZOMBIE_REAP_DELAY_MS)
1604
+ continue;
1605
+ console.log(`Reaper: removing zombie session ${session.windowName} (${session.id.slice(0, 8)})`);
1606
+ try {
1607
+ eventBus.cleanupSession(session.id);
1608
+ await sessions.killSession(session.id);
1609
+ cleanupTerminatedSessionState(session.id, { monitor, metrics, toolRegistry });
1610
+ await channels.sessionEnded({
1611
+ event: 'session.ended',
1612
+ timestamp: new Date().toISOString(),
1613
+ session: { id: session.id, name: session.windowName, workDir: session.workDir },
1614
+ detail: `Zombie reaped: dead for ${Math.round(deadDuration / 1000)}s`,
1615
+ });
1616
+ }
1617
+ catch (e) {
1618
+ console.error(`Reaper: failed to reap zombie session ${session.id}:`, e);
1619
+ }
1620
+ }
1621
+ }
1622
+ // ── Helpers ──────────────────────────────────────────────────────────
1623
+ /** Issue #20: Add actionHints to session response for interactive states. */
1624
+ function addActionHints(session, sessions) {
1625
+ // #357: Convert Set to array for JSON serialization
1626
+ const result = {
1627
+ ...session,
1628
+ activeSubagents: session.activeSubagents ? [...session.activeSubagents] : undefined,
1629
+ };
1630
+ if (session.status === 'permission_prompt' || session.status === 'bash_approval') {
1631
+ result.actionHints = {
1632
+ approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
1633
+ reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
1634
+ };
1635
+ }
1636
+ // #599: Expose pending question data for MCP/REST callers
1637
+ if (session.status === 'ask_question' && sessions) {
1638
+ const info = sessions.getPendingQuestionInfo(session.id);
1639
+ if (info) {
1640
+ result.pendingQuestion = {
1641
+ toolUseId: info.toolUseId,
1642
+ content: info.question,
1643
+ options: extractQuestionOptions(info.question),
1644
+ since: info.timestamp,
1645
+ };
1646
+ }
1647
+ }
1648
+ return result;
1649
+ }
1650
+ /** #599: Extract selectable options from AskUserQuestion text. */
1651
+ function extractQuestionOptions(text) {
1652
+ // Numbered options: "1. Foo\n2. Bar"
1653
+ const numberedRegex = /^\s*(\d+)\.\s+(.+)$/gm;
1654
+ const options = [];
1655
+ let m;
1656
+ while ((m = numberedRegex.exec(text)) !== null) {
1657
+ options.push(m[2].trim());
1658
+ }
1659
+ if (options.length >= 2)
1660
+ return options.slice(0, 4);
1661
+ return null;
1662
+ }
1663
+ function makePayload(event, sessionId, detail, meta) {
1664
+ const session = sessions.getSession(sessionId);
1665
+ return {
1666
+ event,
1667
+ timestamp: new Date().toISOString(),
1668
+ session: {
1669
+ id: sessionId,
1670
+ name: session?.windowName || 'unknown',
1671
+ workDir: session?.workDir || '',
1672
+ },
1673
+ detail,
1674
+ ...(meta && { meta }),
1675
+ };
1676
+ }
1677
+ // ── Start ────────────────────────────────────────────────────────────
1678
+ /** Register notification channels from config */
1679
+ function registerChannels(cfg) {
1680
+ // Telegram (optional)
1681
+ if (cfg.tgBotToken && cfg.tgGroupId) {
1682
+ channels.register(new TelegramChannel({
1683
+ botToken: cfg.tgBotToken,
1684
+ groupChatId: cfg.tgGroupId,
1685
+ allowedUserIds: cfg.tgAllowedUsers,
1686
+ topicTtlMs: cfg.tgTopicTtlMs,
1687
+ }));
1688
+ }
1689
+ // Webhooks (optional)
1690
+ if (cfg.webhooks.length > 0) {
1691
+ const webhookChannel = new WebhookChannel({
1692
+ endpoints: cfg.webhooks.map(url => ({ url })),
1693
+ });
1694
+ channels.register(webhookChannel);
1695
+ }
1696
+ }
1697
+ // Preserve public export used by tests and external imports.
1698
+ export { readParentPid as readPpid } from './process-utils.js';
1699
+ async function main() {
1700
+ // Load configuration
1701
+ config = await loadConfig();
1702
+ // Initialize core components with config
1703
+ tmux = new TmuxManager(config.tmuxSession);
1704
+ sessions = new SessionManager(tmux, config);
1705
+ // Memory bridge (Issue #783)
1706
+ if (config.memoryBridge?.enabled) {
1707
+ const persistPath = config.memoryBridge.persistPath ?? path.join(config.stateDir, 'memory.json');
1708
+ memoryBridge = new MemoryBridge(persistPath, config.memoryBridge.reaperIntervalMs);
1709
+ await memoryBridge.load();
1710
+ memoryBridge.startReaper();
1711
+ registerMemoryRoutes(app, memoryBridge);
1712
+ console.error(`Memory bridge enabled, persisted at: ${persistPath}`);
1713
+ }
1714
+ sseLimiter = new SSEConnectionLimiter({ maxConnections: config.sseMaxConnections, maxPerIp: config.sseMaxPerIp });
1715
+ monitor = new SessionMonitor(sessions, channels, { ...DEFAULT_MONITOR_CONFIG, pollIntervalMs: 5000 });
1716
+ // Register channels
1717
+ registerChannels(config);
1718
+ // Setup auth (Issue #39: multi-key + backward compat)
1719
+ const { join } = await import('node:path');
1720
+ auth = new AuthManager(path.join(config.stateDir, 'keys.json'), config.authToken);
1721
+ await auth.load();
1722
+ setupAuth(auth);
1723
+ // Register WebSocket plugin for live terminal streaming (Issue #108)
1724
+ await app.register(fastifyWebsocket);
1725
+ registerWsTerminalRoute(app, sessions, tmux, auth);
1726
+ // #217: CORS configuration — restrictive by default
1727
+ // #413: Reject wildcard CORS_ORIGIN — * is insecure and allows any origin
1728
+ const corsOrigin = process.env.CORS_ORIGIN;
1729
+ if (corsOrigin === '*') {
1730
+ throw new Error('CORS_ORIGIN=* wildcard is not allowed. Specify explicit origins (comma-separated) or leave unset to disable CORS.');
1731
+ }
1732
+ await app.register(fastifyCors, {
1733
+ origin: corsOrigin ? corsOrigin.split(',').map(s => s.trim()) : false,
1734
+ });
1735
+ // Load persisted sessions
1736
+ await sessions.load();
1737
+ await tmux.ensureSession();
1738
+ // Initialize channels
1739
+ await channels.init(handleInbound);
1740
+ // Wire SSE event bus (Issue #32)
1741
+ monitor.setEventBus(eventBus);
1742
+ // Issue #397: Wire TmuxManager for tmux health monitoring
1743
+ monitor.setTmuxManager(tmux);
1744
+ // Issue #84: Wire JSONL watcher for fs.watch-based message detection
1745
+ jsonlWatcher = new JsonlWatcher();
1746
+ monitor.setJsonlWatcher(jsonlWatcher);
1747
+ // Issue #488: Accumulate token usage from JSONL events into per-session metrics.
1748
+ jsonlWatcher.onEntries((event) => {
1749
+ const { tokenUsageDelta } = event;
1750
+ if (tokenUsageDelta.inputTokens > 0 || tokenUsageDelta.outputTokens > 0) {
1751
+ if (metrics) {
1752
+ const model = sessions.getSession(event.sessionId)?.model;
1753
+ metrics.recordTokenUsage(event.sessionId, tokenUsageDelta, model);
1754
+ }
1755
+ }
1756
+ });
1757
+ // Start watching JSONL files for already-discovered sessions
1758
+ for (const session of sessions.listSessions()) {
1759
+ if (session.jsonlPath) {
1760
+ jsonlWatcher.watch(session.id, session.jsonlPath, session.monitorOffset);
1761
+ }
1762
+ }
1763
+ // Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
1764
+ registerHookRoutes(app, { sessions, eventBus, metrics });
1765
+ // Issue #743: Register model-routing endpoints
1766
+ registerModelRouterRoutes(app);
1767
+ // Initialize pipeline manager (Issue #36)
1768
+ pipelines = new PipelineManager(sessions, eventBus);
1769
+ // Initialize batch rate limiter (Issue #583)
1770
+ // Initialize metrics (Issue #40)
1771
+ metrics = new MetricsCollector(path.join(config.stateDir, 'metrics.json'));
1772
+ await metrics.load();
1773
+ // Issue #361: Store interval refs so graceful shutdown can clear them
1774
+ const reaperInterval = setInterval(() => reapStaleSessions(config.maxSessionAgeMs), config.reaperIntervalMs);
1775
+ const zombieReaperInterval = setInterval(() => reapZombieSessions(), ZOMBIE_REAP_INTERVAL_MS);
1776
+ const metricsSaveInterval = setInterval(() => { void metrics.save(); }, 5 * 60 * 1000);
1777
+ // #357: Prune stale IP rate-limit entries every minute
1778
+ const ipPruneInterval = setInterval(pruneIpRateLimits, 60_000);
1779
+ // #632: Prune stale auth failure rate-limit buckets every minute
1780
+ const authFailPruneInterval = setInterval(pruneAuthFailLimits, 60_000);
1781
+ // #398: Sweep stale API key rate limit buckets every 5 minutes
1782
+ const authSweepInterval = setInterval(() => auth.sweepStaleRateLimits(), 5 * 60_000);
1783
+ // #1091: Prune stale consensus requests every minute
1784
+ const consensusPruneInterval = setInterval(pruneConsensusRequests, 60_000);
1785
+ let pidFilePath = '';
1786
+ // Issue #361: Graceful shutdown handler
1787
+ // Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
1788
+ let shuttingDown = false;
1789
+ const shutdownTimeoutMs = parseShutdownTimeoutMs(process.env.AEGIS_SHUTDOWN_TIMEOUT_MS);
1790
+ async function gracefulShutdown(signal) {
1791
+ console.log(`${signal} received, shutting down gracefully...`);
1792
+ const forceExitTimer = setTimeout(() => {
1793
+ console.error(`Graceful shutdown timed out after ${shutdownTimeoutMs}ms — forcing process exit`);
1794
+ process.exit(1);
1795
+ }, shutdownTimeoutMs);
1796
+ forceExitTimer.unref?.();
1797
+ try {
1798
+ // 1. Stop accepting new requests
1799
+ try {
1800
+ await app.close();
1801
+ }
1802
+ catch (e) {
1803
+ console.error('Error closing server:', e);
1804
+ }
1805
+ // 2. Stop background monitors and intervals
1806
+ monitor.stop();
1807
+ swarmMonitor.stop();
1808
+ clearInterval(reaperInterval);
1809
+ clearInterval(zombieReaperInterval);
1810
+ clearInterval(metricsSaveInterval);
1811
+ clearInterval(ipPruneInterval);
1812
+ clearInterval(authFailPruneInterval);
1813
+ clearInterval(authSweepInterval);
1814
+ clearInterval(consensusPruneInterval);
1815
+ // Issue #569: Kill all CC sessions and tmux windows before exit
1816
+ try {
1817
+ await killAllSessions(sessions, tmux);
1818
+ }
1819
+ catch (e) {
1820
+ console.error('Error killing sessions:', e);
1821
+ }
1822
+ // 3. Destroy channels (awaits Telegram poll loop)
1823
+ try {
1824
+ await channels.destroy();
1825
+ }
1826
+ catch (e) {
1827
+ console.error('Error destroying channels:', e);
1828
+ }
1829
+ // 4. Save session state
1830
+ try {
1831
+ await sessions.save();
1832
+ }
1833
+ catch (e) {
1834
+ console.error('Error saving sessions:', e);
1835
+ }
1836
+ // 5. Save metrics
1837
+ try {
1838
+ await metrics.save();
1839
+ }
1840
+ catch (e) {
1841
+ console.error('Error saving metrics:', e);
1842
+ }
1843
+ // 6. Cleanup PID file
1844
+ removePidFile(pidFilePath);
1845
+ console.log('Graceful shutdown complete');
1846
+ process.exit(0);
1847
+ }
1848
+ finally {
1849
+ clearTimeout(forceExitTimer);
1850
+ }
1851
+ }
1852
+ process.on('SIGTERM', () => { if (!shuttingDown) {
1853
+ shuttingDown = true;
1854
+ void gracefulShutdown('SIGTERM');
1855
+ } });
1856
+ process.on('SIGINT', () => { if (!shuttingDown) {
1857
+ shuttingDown = true;
1858
+ void gracefulShutdown('SIGINT');
1859
+ } });
1860
+ if (process.platform === 'win32') {
1861
+ process.on('message', (message) => {
1862
+ if (!shuttingDown && isWindowsShutdownMessage(message)) {
1863
+ shuttingDown = true;
1864
+ void gracefulShutdown('WINMSG');
1865
+ }
1866
+ });
1867
+ }
1868
+ process.on('unhandledRejection', (reason) => {
1869
+ console.error('unhandledRejection:', reason);
1870
+ });
1871
+ // Start monitor
1872
+ monitor.start();
1873
+ // Issue #81: Start swarm monitor for agent swarm awareness
1874
+ swarmMonitor = new SwarmMonitor(sessions);
1875
+ toolRegistry = new ToolRegistry();
1876
+ swarmMonitor.onEvent((event) => {
1877
+ if (!event.swarm.parentSession)
1878
+ return;
1879
+ const parentId = event.swarm.parentSession.id;
1880
+ const teammate = event.teammate;
1881
+ if (event.type === 'teammate_spawned') {
1882
+ const detail = `🔧 Teammate ${teammate.windowName} spawned`;
1883
+ eventBus.emit(parentId, {
1884
+ event: 'subagent_start',
1885
+ sessionId: parentId,
1886
+ timestamp: new Date().toISOString(),
1887
+ data: { teammate: teammate.windowName, windowId: teammate.windowId },
1888
+ });
1889
+ channels.swarmEvent(makePayload('swarm.teammate_spawned', parentId, detail, {
1890
+ teammateName: teammate.windowName,
1891
+ teammateWindowId: teammate.windowId,
1892
+ teammateCwd: teammate.cwd,
1893
+ }));
1894
+ }
1895
+ else if (event.type === 'teammate_finished') {
1896
+ const detail = `✅ Teammate ${teammate.windowName} finished`;
1897
+ eventBus.emit(parentId, {
1898
+ event: 'subagent_stop',
1899
+ sessionId: parentId,
1900
+ timestamp: new Date().toISOString(),
1901
+ data: { teammate: teammate.windowName },
1902
+ });
1903
+ channels.swarmEvent(makePayload('swarm.teammate_finished', parentId, detail, {
1904
+ teammateName: teammate.windowName,
1905
+ }));
1906
+ }
1907
+ });
1908
+ swarmMonitor.start();
1909
+ // Issue #71: Wire swarm monitor into Telegram channel for /swarm command
1910
+ for (const ch of channels.getChannels()) {
1911
+ if ('setSwarmMonitor' in ch && typeof ch.setSwarmMonitor === 'function') {
1912
+ ch.setSwarmMonitor(swarmMonitor);
1913
+ }
1914
+ }
1915
+ // Start reaper (intervals already created above with stored refs for graceful shutdown)
1916
+ console.log(`Session reaper active: max age ${config.maxSessionAgeMs / 3600000}h, check every ${config.reaperIntervalMs / 60000}min`);
1917
+ // Start zombie reaper (Issue #283)
1918
+ console.log(`Zombie reaper active: grace period ${ZOMBIE_REAP_DELAY_MS / 1000}s, check every ${ZOMBIE_REAP_INTERVAL_MS / 1000}s`);
1919
+ // #127: Serve dashboard static files (Issue #105) — graceful if missing
1920
+ // Issue #539: Dashboard is copied into dist/dashboard/ during build
1921
+ const dashboardRoot = path.join(__dirname, "dashboard");
1922
+ let dashboardAvailable = false;
1923
+ try {
1924
+ await fs.access(dashboardRoot);
1925
+ dashboardAvailable = true;
1926
+ }
1927
+ catch {
1928
+ console.warn("Dashboard directory not found — skipping dashboard serving. Run 'npm run build:dashboard' to enable.");
1929
+ }
1930
+ if (dashboardAvailable) {
1931
+ await app.register(fastifyStatic, {
1932
+ root: dashboardRoot,
1933
+ prefix: "/dashboard/",
1934
+ // #146: Cache hashed assets aggressively, no-cache for index.html
1935
+ setHeaders: (reply, pathname) => {
1936
+ // Security headers (#145)
1937
+ reply.setHeader('X-Frame-Options', 'DENY');
1938
+ reply.setHeader('X-Content-Type-Options', 'nosniff');
1939
+ reply.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
1940
+ // Issue #349: Content-Security-Policy for dashboard
1941
+ reply.setHeader('Content-Security-Policy', DASHBOARD_CSP);
1942
+ // Cache control (#146)
1943
+ if (pathname === '/index.html' || pathname === '/') {
1944
+ reply.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1945
+ }
1946
+ else {
1947
+ reply.setHeader('Cache-Control', 'public, max-age=604800, immutable');
1948
+ }
1949
+ },
1950
+ });
1951
+ }
1952
+ // SPA fallback for dashboard routes (Issue #105)
1953
+ app.setNotFoundHandler(async (req, reply) => {
1954
+ if (dashboardAvailable && (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?"))) {
1955
+ // Issue #349: CSP header for SPA dashboard responses
1956
+ reply.header('Content-Security-Policy', DASHBOARD_CSP);
1957
+ return reply.sendFile("index.html", dashboardRoot);
1958
+ }
1959
+ return reply.status(404).send({ error: "Not found" });
1960
+ });
1961
+ await listenWithRetry(app, config.port, config.host, config.stateDir);
1962
+ pidFilePath = writePidFile(config.stateDir);
1963
+ console.log(`Aegis running on http://${config.host}:${config.port}`);
1964
+ console.log(`Channels: ${channels.count} registered`);
1965
+ console.log(`State dir: ${config.stateDir}`);
1966
+ console.log(`Claude projects dir: ${config.claudeProjectsDir}`);
1967
+ if (config.authToken)
1968
+ console.log('Auth: Bearer token required');
1969
+ }
1970
+ main().catch(err => {
1971
+ console.error('Failed to start Aegis:', err);
1972
+ process.exit(1);
1973
+ });