aegis-bridge 2.6.3 → 2.7.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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Aegis Dashboard</title>
7
7
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>" />
8
- <script type="module" crossorigin src="/dashboard/assets/index-Bfabq3q-.js"></script>
8
+ <script type="module" crossorigin src="/dashboard/assets/index-SoKhTCVa.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/dashboard/assets/index-9Hkkvm_I.css">
10
10
  </head>
11
11
  <body class="bg-[#0a0a0f] text-gray-200 antialiased">
@@ -19,12 +19,11 @@
19
19
  * for Aegis status detection and event forwarding.
20
20
  *
21
21
  * Excluded (low value for Aegis):
22
- * - InstructionsLoaded, ConfigChange, CwdChanged, FileChanged (informational)
22
+ * - InstructionsLoaded, ConfigChange (informational)
23
23
  * - WorktreeCreate, WorktreeRemove (worktree management)
24
24
  * - Elicitation, ElicitationResult (MCP-specific)
25
- * - PreCompact, PostCompact (internal optimization)
26
25
  */
27
- declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "Notification", "TeammateIdle"];
26
+ declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "PreCompact", "PostCompact", "FileChanged", "CwdChanged", "Notification", "TeammateIdle"];
28
27
  export { HTTP_HOOK_EVENTS };
29
28
  export type HttpHookEvent = typeof HTTP_HOOK_EVENTS[number];
30
29
  /** Shape of a single HTTP hook entry in CC settings.json. */
@@ -66,3 +65,13 @@ export declare function writeHookSettingsFile(baseUrl: string, sessionId: string
66
65
  * @param filePath - Path to the temporary settings file
67
66
  */
68
67
  export declare function cleanupHookSettingsFile(filePath: string): Promise<void>;
68
+ /**
69
+ * Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
70
+ *
71
+ * When sessions die, their hook URLs remain in settings.local.json.
72
+ * On restart, CC loads these dead hooks and crashes.
73
+ *
74
+ * @param workDir - Project working directory
75
+ * @param activeSessionIds - Set of currently active session IDs
76
+ */
77
+ export declare function cleanupStaleSessionHooks(workDir: string, activeSessionIds: Set<string>): Promise<void>;
@@ -43,10 +43,9 @@ function validateWorkDirPath(workDir) {
43
43
  * for Aegis status detection and event forwarding.
44
44
  *
45
45
  * Excluded (low value for Aegis):
46
- * - InstructionsLoaded, ConfigChange, CwdChanged, FileChanged (informational)
46
+ * - InstructionsLoaded, ConfigChange (informational)
47
47
  * - WorktreeCreate, WorktreeRemove (worktree management)
48
48
  * - Elicitation, ElicitationResult (MCP-specific)
49
- * - PreCompact, PostCompact (internal optimization)
50
49
  */
51
50
  const HTTP_HOOK_EVENTS = [
52
51
  // Status detection (highest value)
@@ -64,6 +63,12 @@ const HTTP_HOOK_EVENTS = [
64
63
  // Subagent tracking
65
64
  'SubagentStart',
66
65
  'SubagentStop',
66
+ // Context management
67
+ 'PreCompact',
68
+ 'PostCompact',
69
+ // File & directory changes
70
+ 'FileChanged',
71
+ 'CwdChanged',
67
72
  // Notifications
68
73
  'Notification',
69
74
  'TeammateIdle',
@@ -135,6 +140,10 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
135
140
  mergedHooks[event] = [...(existingHooks[event] ?? []), ...entries];
136
141
  }
137
142
  const combined = { ...merged, hooks: mergedHooks };
143
+ // Issue #931: Always inject MCP_CONNECTION_NONBLOCKING so CC does not block
144
+ // on MCP server connections when launched via Aegis orchestration.
145
+ (combined.env = (combined.env || {}));
146
+ (combined.env || {})["MCP_CONNECTION_NONBLOCKING"] = "true";
138
147
  // Issue #648: Use unpredictable directory name and restrictive permissions
139
148
  // to prevent symlink attacks and information disclosure in /tmp.
140
149
  const suffix = randomBytes(4).toString('hex');
@@ -164,3 +173,55 @@ export async function cleanupHookSettingsFile(filePath) {
164
173
  // Non-fatal: temp file cleanup failed
165
174
  }
166
175
  }
176
+ /**
177
+ * Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
178
+ *
179
+ * When sessions die, their hook URLs remain in settings.local.json.
180
+ * On restart, CC loads these dead hooks and crashes.
181
+ *
182
+ * @param workDir - Project working directory
183
+ * @param activeSessionIds - Set of currently active session IDs
184
+ */
185
+ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
186
+ const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
187
+ if (!safeWorkDir)
188
+ return;
189
+ const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
190
+ if (!existsSync(projectSettingsPath))
191
+ return;
192
+ try {
193
+ const raw = await readFile(projectSettingsPath, 'utf-8');
194
+ const parsed = ccSettingsSchema.safeParse(JSON.parse(raw));
195
+ if (!parsed.success)
196
+ return;
197
+ const settings = parsed.data;
198
+ const hooks = settings.hooks;
199
+ if (!hooks)
200
+ return;
201
+ let changed = false;
202
+ for (const [event, eventHooks] of Object.entries(hooks)) {
203
+ const filtered = eventHooks.filter(entry => {
204
+ const httpHook = entry.hooks?.find(h => h.type === 'http');
205
+ if (!httpHook)
206
+ return true;
207
+ const url = httpHook.url;
208
+ const match = url.match(/[?&]sessionId=([^&]+)/);
209
+ if (!match)
210
+ return true;
211
+ const sessionId = match[1];
212
+ if (!activeSessionIds.has(sessionId)) {
213
+ changed = true;
214
+ return false;
215
+ }
216
+ return true;
217
+ });
218
+ hooks[event] = filtered;
219
+ }
220
+ if (changed) {
221
+ await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
222
+ }
223
+ }
224
+ catch {
225
+ // Non-fatal: cleanup failed
226
+ }
227
+ }
package/dist/monitor.d.ts CHANGED
@@ -31,6 +31,16 @@ export declare class SessionMonitor {
31
31
  private lastStatus;
32
32
  private lastBytesSeen;
33
33
  private stallNotified;
34
+ /** Issue #663: O(1) stall notification check. */
35
+ private stallHas;
36
+ /** Issue #663: O(1) stall notification add. */
37
+ private stallAdd;
38
+ /** Issue #663: O(1) stall notification delete. */
39
+ private stallDelete;
40
+ /** Issue #663: Delete all stall notifications for a session. */
41
+ private stallDeleteAll;
42
+ /** Issue #663: Delete specific stall types for a session. */
43
+ private stallDeleteTypes;
34
44
  private lastStallCheck;
35
45
  private lastDeadCheck;
36
46
  private idleNotified;
package/dist/monitor.js CHANGED
@@ -34,7 +34,38 @@ export class SessionMonitor {
34
34
  running = false;
35
35
  lastStatus = new Map();
36
36
  lastBytesSeen = new Map();
37
- stallNotified = new Set(); // don't spam stall events
37
+ // Issue #663: Nested Map for O(1) per-session stall lookup (was Set with O(n) prefix scan)
38
+ stallNotified = new Map(); // sessionId → Set<stallType>
39
+ /** Issue #663: O(1) stall notification check. */
40
+ stallHas(sessionId, stallType) {
41
+ return this.stallNotified.get(sessionId)?.has(stallType) ?? false;
42
+ }
43
+ /** Issue #663: O(1) stall notification add. */
44
+ stallAdd(sessionId, stallType) {
45
+ const set = this.stallNotified.get(sessionId);
46
+ if (set) {
47
+ set.add(stallType);
48
+ }
49
+ else {
50
+ this.stallNotified.set(sessionId, new Set([stallType]));
51
+ }
52
+ }
53
+ /** Issue #663: O(1) stall notification delete. */
54
+ stallDelete(sessionId, stallType) {
55
+ this.stallNotified.get(sessionId)?.delete(stallType);
56
+ }
57
+ /** Issue #663: Delete all stall notifications for a session. */
58
+ stallDeleteAll(sessionId) {
59
+ this.stallNotified.delete(sessionId);
60
+ }
61
+ /** Issue #663: Delete specific stall types for a session. */
62
+ stallDeleteTypes(sessionId, types) {
63
+ const set = this.stallNotified.get(sessionId);
64
+ if (!set)
65
+ return;
66
+ for (const t of types)
67
+ set.delete(t);
68
+ }
38
69
  lastStallCheck = 0;
39
70
  lastDeadCheck = 0;
40
71
  idleNotified = new Set(); // prevent idle spam
@@ -196,13 +227,13 @@ export class SessionMonitor {
196
227
  }
197
228
  if (currentBytes > prev.bytes) {
198
229
  this.lastBytesSeen.set(session.id, { bytes: currentBytes, at: now });
199
- this.stallNotified.delete(`${session.id}:stall:jsonl`);
230
+ this.stallDelete(session.id, 'jsonl');
200
231
  }
201
232
  else {
202
233
  const stallDuration = now - prev.at;
203
234
  const threshold = session.stallThresholdMs || this.config.stallThresholdMs;
204
- if (stallDuration >= threshold && !this.stallNotified.has(`${session.id}:stall:jsonl`)) {
205
- this.stallNotified.add(`${session.id}:stall:jsonl`);
235
+ if (stallDuration >= threshold && !this.stallHas(session.id, 'jsonl')) {
236
+ this.stallAdd(session.id, 'jsonl');
206
237
  const minutes = Math.round(stallDuration / 60000);
207
238
  const detail = `Session stalled: "working" for ${minutes}min with no new output. ` +
208
239
  `Last activity: ${new Date(session.lastActivity).toISOString()}`;
@@ -213,15 +244,15 @@ export class SessionMonitor {
213
244
  }
214
245
  else {
215
246
  // Reset JSONL stall tracking when not working
216
- this.stallNotified.delete(`${session.id}:stall:jsonl`);
247
+ this.stallDelete(session.id, 'jsonl');
217
248
  }
218
249
  // --- Type 2: Permission stall (waiting for approval too long) ---
219
250
  if (currentStatus === 'permission_prompt' || currentStatus === 'bash_approval') {
220
251
  const entry = this.stateSince.get(session.id);
221
252
  const permDuration = entry ? now - entry.since : 0;
222
253
  if (permDuration >= this.config.permissionStallMs) {
223
- if (!this.stallNotified.has(`${session.id}:stall:permission`)) {
224
- this.stallNotified.add(`${session.id}:stall:permission`);
254
+ if (!this.stallHas(session.id, 'permission')) {
255
+ this.stallAdd(session.id, 'permission');
225
256
  const minutes = Math.round(permDuration / 60000);
226
257
  const detail = `Session stalled: waiting for permission approval for ${minutes}min. ` +
227
258
  `Auto-approve this session or POST /v1/sessions/${session.id}/approve`;
@@ -231,8 +262,8 @@ export class SessionMonitor {
231
262
  }
232
263
  // L9: Auto-reject permission after timeout
233
264
  if (permDuration >= this.config.permissionTimeoutMs) {
234
- if (!this.stallNotified.has(`${session.id}:stall:permission_timeout`)) {
235
- this.stallNotified.add(`${session.id}:stall:permission_timeout`);
265
+ if (!this.stallHas(session.id, 'permission_timeout')) {
266
+ this.stallAdd(session.id, 'permission_timeout');
236
267
  const minutes = Math.round(permDuration / 60000);
237
268
  logger.warn({
238
269
  component: 'monitor',
@@ -264,8 +295,8 @@ export class SessionMonitor {
264
295
  const entry = this.stateSince.get(session.id);
265
296
  const unkDuration = entry ? now - entry.since : 0;
266
297
  if (unkDuration >= this.config.unknownStallMs) {
267
- if (!this.stallNotified.has(`${session.id}:stall:unknown`)) {
268
- this.stallNotified.add(`${session.id}:stall:unknown`);
298
+ if (!this.stallHas(session.id, 'unknown')) {
299
+ this.stallAdd(session.id, 'unknown');
269
300
  const minutes = Math.round(unkDuration / 60000);
270
301
  const detail = `Session stalled: in "unknown" state for ${minutes}min. ` +
271
302
  `CC may be stuck. Try: POST /v1/sessions/${session.id}/interrupt or /kill`;
@@ -280,8 +311,8 @@ export class SessionMonitor {
280
311
  const stateDuration = entry ? now - entry.since : 0;
281
312
  const extendedThreshold = this.config.stallThresholdMs * 2;
282
313
  if (stateDuration >= extendedThreshold) {
283
- if (!this.stallNotified.has(`${session.id}:stall:extended`)) {
284
- this.stallNotified.add(`${session.id}:stall:extended`);
314
+ if (!this.stallHas(session.id, 'extended')) {
315
+ this.stallAdd(session.id, 'extended');
285
316
  const minutes = Math.round(stateDuration / 60000);
286
317
  const detail = `Session stalled: "${currentStatus}" state for ${minutes}min. ` +
287
318
  `May need intervention: /interrupt, /approve, or /kill`;
@@ -297,8 +328,8 @@ export class SessionMonitor {
297
328
  if (entry && entry.state === 'working') {
298
329
  const workingDuration = now - entry.since;
299
330
  const maxWorkingMs = this.config.stallThresholdMs * 3; // 15 min default
300
- if (workingDuration >= maxWorkingMs && !this.stallNotified.has(`${session.id}:stall:extended_working`)) {
301
- this.stallNotified.add(`${session.id}:stall:extended_working`);
331
+ if (workingDuration >= maxWorkingMs && !this.stallHas(session.id, 'extended_working')) {
332
+ this.stallAdd(session.id, 'extended_working');
302
333
  const minutes = Math.round(workingDuration / 60000);
303
334
  const detail = `Session stalled: in "working" state for ${minutes}min. ` +
304
335
  `CC may be stuck in an internal loop (e.g., Misting). Consider: POST /v1/sessions/${session.id}/interrupt or /kill`;
@@ -312,23 +343,18 @@ export class SessionMonitor {
312
343
  const exitedPermission = prevStallStatus === 'permission_prompt' || prevStallStatus === 'bash_approval';
313
344
  const exitedUnknown = prevStallStatus === 'unknown';
314
345
  if (exitedPermission) {
315
- this.stallNotified.delete(`${session.id}:stall:permission`);
316
- this.stallNotified.delete(`${session.id}:stall:permission_timeout`);
346
+ this.stallDeleteTypes(session.id, ['permission', 'permission_timeout']);
317
347
  }
318
348
  if (exitedUnknown) {
319
- this.stallNotified.delete(`${session.id}:stall:unknown`);
349
+ this.stallDelete(session.id, 'unknown');
320
350
  }
321
351
  }
322
352
  // Clean up all state tracking when idle (catch-all)
323
353
  if (currentStatus === 'idle') {
324
354
  this.rateLimitedSessions.delete(session.id);
325
355
  this.stateSince.delete(session.id);
326
- // Clean stall notifications (session recovered)
327
- for (const key of this.stallNotified) {
328
- if (key.startsWith(session.id)) {
329
- this.stallNotified.delete(key);
330
- }
331
- }
356
+ // Clean stall notifications (session recovered) — O(1) with Map
357
+ this.stallDeleteAll(session.id);
332
358
  }
333
359
  // Update prevStatusForStall for next cycle
334
360
  if (currentStatus) {
@@ -432,7 +458,7 @@ export class SessionMonitor {
432
458
  if (event.messages.length > 0) {
433
459
  // Real output — reset stall timer
434
460
  this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: now });
435
- this.stallNotified.delete(`${event.sessionId}:stall:jsonl`);
461
+ this.stallDelete(event.sessionId, 'jsonl');
436
462
  }
437
463
  else {
438
464
  // File grew but no messages — only update bytes, keep timestamp
@@ -666,12 +692,8 @@ export class SessionMonitor {
666
692
  clearTimeout(pending);
667
693
  this.statusChangeDebounce.delete(sessionId);
668
694
  }
669
- // Clean all stall notifications for this session
670
- for (const key of this.stallNotified) {
671
- if (key.startsWith(sessionId)) {
672
- this.stallNotified.delete(key);
673
- }
674
- }
695
+ // Clean all stall notifications for this session — O(1) with Map
696
+ this.stallDeleteAll(sessionId);
675
697
  this.idleNotified.delete(sessionId);
676
698
  this.idleSince.delete(sessionId);
677
699
  this.stateSince.delete(sessionId);
package/dist/server.js CHANGED
@@ -24,11 +24,12 @@ import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/ind
24
24
  import { loadConfig } from './config.js';
25
25
  import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
26
26
  import { validateScreenshotUrl, resolveAndCheckIp, buildHostResolverRule } from './ssrf.js';
27
- import { validateWorkDir } from './validation.js';
27
+ import { validateWorkDir, permissionRuleSchema } from './validation.js';
28
28
  import { SessionEventBus } from './events.js';
29
29
  import { SSEWriter } from './sse-writer.js';
30
30
  import { SSEConnectionLimiter } from './sse-limiter.js';
31
31
  import { PipelineManager } from './pipeline.js';
32
+ import { ToolRegistry } from './tool-registry.js';
32
33
  import { AuthManager } from './auth.js';
33
34
  import { MetricsCollector } from './metrics.js';
34
35
  import { registerPermissionRoutes } from './permission-routes.js';
@@ -57,6 +58,7 @@ const channels = new ChannelManager();
57
58
  const eventBus = new SessionEventBus();
58
59
  let sseLimiter;
59
60
  let pipelines;
61
+ let toolRegistry;
60
62
  let auth;
61
63
  let metrics;
62
64
  let swarmMonitor;
@@ -320,6 +322,7 @@ const createSessionSchema = z.object({
320
322
  stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
321
323
  permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
322
324
  autoApprove: z.boolean().optional(),
325
+ parentId: z.string().uuid().optional(),
323
326
  }).strict();
324
327
  // Health — Issue #397: includes tmux server health check
325
328
  async function healthHandler() {
@@ -420,6 +423,29 @@ app.get('/v1/sessions/:id/metrics', async (req, reply) => {
420
423
  return reply.status(404).send({ error: 'No metrics for this session' });
421
424
  return m;
422
425
  });
426
+ // Issue #704: Tool usage endpoints
427
+ app.get('/v1/sessions/:id/tools', async (req, reply) => {
428
+ const session = sessions.getSession(req.params.id);
429
+ if (!session)
430
+ return reply.status(404).send({ error: 'Session not found' });
431
+ // Parse JSONL on-demand for tool usage
432
+ const { readNewEntries } = await import('./transcript.js');
433
+ if (session.jsonlPath) {
434
+ try {
435
+ const result = await readNewEntries(session.jsonlPath, 0);
436
+ const entries = result.entries;
437
+ toolRegistry.processEntries(req.params.id, entries);
438
+ }
439
+ catch { /* JSONL not available */ }
440
+ }
441
+ const tools = toolRegistry.getSessionTools(req.params.id);
442
+ return { sessionId: req.params.id, tools, totalCalls: tools.reduce((sum, t) => sum + t.count, 0) };
443
+ });
444
+ app.get('/v1/tools', async () => {
445
+ const definitions = toolRegistry.getToolDefinitions();
446
+ const categories = [...new Set(definitions.map(t => t.category))];
447
+ return { tools: definitions, categories, totalTools: definitions.length };
448
+ });
423
449
  // Issue #89 L14: Webhook dead letter queue
424
450
  app.get('/v1/webhooks/dead-letter', async () => {
425
451
  for (const ch of channels.getChannels()) {
@@ -550,7 +576,7 @@ async function createSessionHandler(req, reply) {
550
576
  if (!parsed.success) {
551
577
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
552
578
  }
553
- const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
579
+ const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId } = parsed.data;
554
580
  if (!workDir)
555
581
  return reply.status(400).send({ error: 'workDir is required' });
556
582
  // Issue #564: Validate installed Claude Code version
@@ -588,7 +614,7 @@ async function createSessionHandler(req, reply) {
588
614
  }
589
615
  }
590
616
  console.time("POST_CREATE_SESSION");
591
- const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
617
+ const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
592
618
  console.timeEnd("POST_CREATE_SESSION");
593
619
  console.time("POST_CHANNEL_CREATED");
594
620
  // Issue #625: Track session in metrics so sessionsCreated counter is accurate
@@ -682,6 +708,61 @@ async function sendMessageHandler(req, reply) {
682
708
  }
683
709
  app.post('/v1/sessions/:id/send', sendMessageHandler);
684
710
  app.post('/sessions/:id/send', sendMessageHandler);
711
+ // Issue #702: GET children sessions
712
+ async function getChildrenHandler(req, reply) {
713
+ const session = sessions.getSession(req.params.id);
714
+ if (!session)
715
+ return reply.status(404).send({ error: 'Session not found' });
716
+ const children = (session.children ?? []).map(id => {
717
+ const child = sessions.getSession(id);
718
+ if (!child)
719
+ return null;
720
+ return { id: child.id, windowName: child.windowName, status: child.status, createdAt: child.createdAt };
721
+ }).filter(Boolean);
722
+ return { children };
723
+ }
724
+ app.get('/v1/sessions/:id/children', getChildrenHandler);
725
+ app.get('/sessions/:id/children', getChildrenHandler);
726
+ async function spawnChildHandler(req, reply) {
727
+ const parentId = req.params.id;
728
+ const parent = sessions.getSession(parentId);
729
+ if (!parent)
730
+ return reply.status(404).send({ error: 'Parent session not found' });
731
+ const { name, prompt, workDir, permissionMode } = req.body ?? {};
732
+ const childName = name ?? `${parent.windowName ?? 'session'}-child`;
733
+ const childWorkDir = workDir ?? parent.workDir;
734
+ const childPermMode = permissionMode ?? parent.permissionMode ?? 'bypassPermissions';
735
+ const childSession = await sessions.createSession({ workDir: childWorkDir, name: childName, parentId, permissionMode: childPermMode });
736
+ let promptDelivery;
737
+ if (prompt) {
738
+ promptDelivery = await sessions.sendInitialPrompt(childSession.id, prompt);
739
+ }
740
+ return reply.status(201).send({ ...childSession, promptDelivery });
741
+ }
742
+ app.post('/v1/sessions/:id/spawn', spawnChildHandler);
743
+ app.post('/sessions/:id/spawn', spawnChildHandler);
744
+ async function getPermissionPolicyHandler(req, reply) {
745
+ const session = sessions.getSession(req.params.id);
746
+ if (!session)
747
+ return reply.status(404).send({ error: 'Session not found' });
748
+ return { permissionPolicy: session.permissionPolicy ?? [] };
749
+ }
750
+ async function updatePermissionPolicyHandler(req, reply) {
751
+ const session = sessions.getSession(req.params.id);
752
+ if (!session)
753
+ return reply.status(404).send({ error: 'Session not found' });
754
+ const policy = req.body ?? [];
755
+ const result = permissionRuleSchema.array().safeParse(policy);
756
+ if (!result.success)
757
+ return reply.status(400).send({ error: 'Invalid permission policy', details: result.error.issues });
758
+ session.permissionPolicy = policy;
759
+ await sessions.save();
760
+ return { permissionPolicy: policy };
761
+ }
762
+ app.get('/v1/sessions/:id/permissions', getPermissionPolicyHandler);
763
+ app.put('/v1/sessions/:id/permissions', updatePermissionPolicyHandler);
764
+ app.get('/sessions/:id/permissions', getPermissionPolicyHandler);
765
+ app.put('/sessions/:id/permissions', updatePermissionPolicyHandler);
685
766
  // Read messages
686
767
  async function readMessagesHandler(req, reply) {
687
768
  try {
@@ -1109,6 +1190,7 @@ async function reapStaleSessions(maxAgeMs) {
1109
1190
  });
1110
1191
  monitor.removeSession(session.id);
1111
1192
  metrics.cleanupSession(session.id);
1193
+ toolRegistry.cleanupSession(session.id);
1112
1194
  }
1113
1195
  catch (e) {
1114
1196
  console.error(`Reaper: failed to kill session ${session.id}:`, e);
@@ -1138,6 +1220,7 @@ async function reapZombieSessions() {
1138
1220
  eventBus.cleanupSession(session.id);
1139
1221
  await sessions.killSession(session.id);
1140
1222
  metrics.cleanupSession(session.id);
1223
+ toolRegistry.cleanupSession(session.id);
1141
1224
  await channels.sessionEnded({
1142
1225
  event: 'session.ended',
1143
1226
  timestamp: new Date().toISOString(),
@@ -1537,6 +1620,7 @@ async function main() {
1537
1620
  monitor.start();
1538
1621
  // Issue #81: Start swarm monitor for agent swarm awareness
1539
1622
  swarmMonitor = new SwarmMonitor(sessions);
1623
+ toolRegistry = new ToolRegistry();
1540
1624
  swarmMonitor.onEvent((event) => {
1541
1625
  if (!event.swarm.parentSession)
1542
1626
  return;
package/dist/session.d.ts CHANGED
@@ -8,6 +8,7 @@ import { TmuxManager } from './tmux.js';
8
8
  import { type ParsedEntry } from './transcript.js';
9
9
  import { type UIState } from './terminal-parser.js';
10
10
  import type { Config } from './config.js';
11
+ import { type PermissionPolicy } from './validation.js';
11
12
  export interface SessionInfo {
12
13
  id: string;
13
14
  windowId: string;
@@ -35,6 +36,9 @@ export interface SessionInfo {
35
36
  model?: string;
36
37
  lastDeadAt?: number;
37
38
  ccPid?: number;
39
+ parentId?: string;
40
+ children?: string[];
41
+ permissionPolicy?: PermissionPolicy;
38
42
  }
39
43
  export interface SessionState {
40
44
  sessions: Record<string, SessionInfo>;
@@ -66,6 +70,7 @@ export declare class SessionManager {
66
70
  private pendingQuestions;
67
71
  private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
68
72
  private parsedEntriesCache;
73
+ private sessionsListCache;
69
74
  private readonly sessionAcquireMutex;
70
75
  constructor(tmux: TmuxManager, config: Config);
71
76
  /**
@@ -135,6 +140,8 @@ export declare class SessionManager {
135
140
  permissionMode?: string;
136
141
  /** @deprecated Use permissionMode instead. Maps true→bypassPermissions, false→default. */
137
142
  autoApprove?: boolean;
143
+ /** Issue #702: Parent session ID for sub-agent hierarchy */
144
+ parentId?: string;
138
145
  }): Promise<SessionInfo>;
139
146
  /** Get a session by ID. */
140
147
  getSession(id: string): SessionInfo | null;
@@ -165,6 +172,8 @@ export declare class SessionManager {
165
172
  * so the current pane PID is the shell (alive). Checking ccPid catches
166
173
  * the crash within seconds instead of waiting for the 5-min stall timer. */
167
174
  isWindowAlive(id: string): Promise<boolean>;
175
+ /** Issue #657: Invalidate the sessions list cache. Call on any mutation. */
176
+ private invalidateSessionsListCache;
168
177
  /** List all sessions. */
169
178
  listSessions(): SessionInfo[];
170
179
  /** Issue #607: Find an idle session for the given workDir.
package/dist/session.js CHANGED
@@ -16,7 +16,7 @@ import { computeStallThreshold } from './config.js';
16
16
  import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
17
17
  import { persistedStateSchema } from './validation.js';
18
18
  import { loadContinuationPointers } from './continuation-pointer.js';
19
- import { writeHookSettingsFile, cleanupHookSettingsFile } from './hook-settings.js';
19
+ import { writeHookSettingsFile, cleanupHookSettingsFile, cleanupStaleSessionHooks } from './hook-settings.js';
20
20
  import { Mutex } from 'async-mutex';
21
21
  import { maybeInjectFault } from './fault-injection.js';
22
22
  /** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
@@ -67,6 +67,8 @@ export class SessionManager {
67
67
  // #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
68
68
  static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
69
69
  parsedEntriesCache = new Map();
70
+ // Issue #657: Cached session list to avoid allocating a new array per call
71
+ sessionsListCache = null;
70
72
  // Issue #840/#880: Explicit mutex to prevent TOCTOU races in session acquisition.
71
73
  sessionAcquireMutex = new Mutex();
72
74
  constructor(tmux, config) {
@@ -168,6 +170,8 @@ export class SessionManager {
168
170
  await writeFile(`${this.stateFile}.bak`, JSON.stringify(this.state, null, 2));
169
171
  }
170
172
  catch { /* non-critical */ }
173
+ // Issue #657: Invalidate sessions list cache after loading state
174
+ this.invalidateSessionsListCache();
171
175
  // Reconcile: verify tmux windows still exist, clean up dead sessions
172
176
  await this.reconcile();
173
177
  }
@@ -190,6 +194,7 @@ export class SessionManager {
190
194
  await cleanOrphanedBackup(session.workDir);
191
195
  }
192
196
  delete this.state.sessions[id];
197
+ this.invalidateSessionsListCache();
193
198
  changed = true;
194
199
  }
195
200
  else if (!windowIdAlive && windowNameAlive) {
@@ -245,6 +250,7 @@ export class SessionManager {
245
250
  permissionMode: 'default',
246
251
  };
247
252
  this.state.sessions[id] = session;
253
+ this.invalidateSessionsListCache();
248
254
  console.log(`Reconcile: adopted orphaned window ${win.windowName} (${win.windowId}) as ${id.slice(0, 8)}`);
249
255
  this.startSessionIdDiscovery(id);
250
256
  this.startFilesystemDiscovery(id, session.workDir);
@@ -506,6 +512,17 @@ export class SessionManager {
506
512
  const hookSecret = randomBytes(32).toString('hex');
507
513
  // Issue #169 Phase 2: Generate HTTP hook settings for this session.
508
514
  // Writes a temp file with hooks pointing to Aegis's hook receiver.
515
+ // Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
516
+ // This prevents CC from loading dead hook URLs on restart.
517
+ try {
518
+ const activeIds = new Set(this.listSessions().map(s => s.id));
519
+ if (activeIds.size > 0) {
520
+ await cleanupStaleSessionHooks(opts.workDir, activeIds);
521
+ }
522
+ }
523
+ catch (e) {
524
+ console.warn(`Hook cleanup: failed to clean stale hooks: ${e.message}`);
525
+ }
509
526
  let hookSettingsFile;
510
527
  try {
511
528
  const baseUrl = `http://${this.config.host}:${this.config.port}`;
@@ -545,7 +562,18 @@ export class SessionManager {
545
562
  hookSecret,
546
563
  };
547
564
  this.state.sessions[id] = session;
565
+ this.invalidateSessionsListCache();
548
566
  await this.save();
567
+ // Issue #702: Register child with parent
568
+ if (opts.parentId) {
569
+ const parent = this.state.sessions[opts.parentId];
570
+ if (parent) {
571
+ if (!parent.children)
572
+ parent.children = [];
573
+ parent.children.push(id);
574
+ await this.save();
575
+ }
576
+ }
549
577
  // Issue #353: Fetch CC process PID for swarm parent matching.
550
578
  // Fire-and-forget — PID is not needed synchronously.
551
579
  // Issue #574: Add .catch() to prevent unhandled rejection if tmux fails mid-lookup.
@@ -744,9 +772,16 @@ export class SessionManager {
744
772
  return false;
745
773
  }
746
774
  }
775
+ /** Issue #657: Invalidate the sessions list cache. Call on any mutation. */
776
+ invalidateSessionsListCache() {
777
+ this.sessionsListCache = null;
778
+ }
747
779
  /** List all sessions. */
748
780
  listSessions() {
749
- return Object.values(this.state.sessions);
781
+ if (!this.sessionsListCache) {
782
+ this.sessionsListCache = Object.values(this.state.sessions);
783
+ }
784
+ return this.sessionsListCache;
750
785
  }
751
786
  /** Issue #607: Find an idle session for the given workDir.
752
787
  * Returns the most recently active idle session, or null if none found.
@@ -1320,6 +1355,7 @@ export class SessionManager {
1320
1355
  // #405: Clean up all tracking maps (pollTimers, pendingPermissions, pendingQuestions, parsedEntriesCache)
1321
1356
  this.cleanupSession(id);
1322
1357
  delete this.state.sessions[id];
1358
+ this.invalidateSessionsListCache();
1323
1359
  // #357: Cancel any pending debounced save before doing an immediate save
1324
1360
  if (this.saveDebounceTimer !== null) {
1325
1361
  clearTimeout(this.saveDebounceTimer);