aegis-bridge 2.4.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -146,12 +146,19 @@ function applyEnvOverrides(config) {
146
146
  case 'tgAllowedUsers':
147
147
  config[key] = value.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n) && n > 0);
148
148
  break;
149
+ // All remaining env-mapped keys are string-typed — assign directly.
150
+ case 'host':
151
+ case 'authToken':
152
+ case 'tmuxSession':
153
+ case 'stateDir':
154
+ case 'claudeProjectsDir':
155
+ case 'tgBotToken':
156
+ case 'tgGroupId':
157
+ config[key] = value;
158
+ break;
149
159
  default:
150
160
  // Skip complex types (Record<string,string>) that can't be set from a single env var
151
- if (typeof config[key] === 'object' && config[key] !== null && !Array.isArray(config[key]))
152
- break;
153
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
- config[key] = value;
161
+ break;
155
162
  }
156
163
  }
157
164
  return config;
package/dist/events.d.ts CHANGED
@@ -74,6 +74,8 @@ export declare class SessionEventBus {
74
74
  subscriberCount(sessionId: string): number;
75
75
  /** Global emitter for aggregating events across all sessions. */
76
76
  private globalEmitter;
77
+ /** #689: Pending setImmediate timers for cleanup on destroy. */
78
+ private pendingTimers;
77
79
  /** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
78
80
  subscribeGlobal(handler: (event: GlobalSSEEvent) => void): () => void;
79
81
  /** Emit a session created event to global subscribers. */
package/dist/events.js CHANGED
@@ -98,7 +98,11 @@ export class SessionEventBus {
98
98
  }
99
99
  const emitter = this.emitters.get(sessionId);
100
100
  if (emitter) {
101
- setImmediate(() => emitter.emit('event', event));
101
+ const imm = setImmediate(() => {
102
+ this.pendingTimers.delete(imm);
103
+ emitter.emit('event', event);
104
+ });
105
+ this.pendingTimers.add(imm);
102
106
  }
103
107
  // Forward to global subscribers
104
108
  if (this.globalEmitter) {
@@ -108,7 +112,11 @@ export class SessionEventBus {
108
112
  if (this.globalEventBuffer.length > SessionEventBus.BUFFER_SIZE) {
109
113
  this.globalEventBuffer.splice(0, this.globalEventBuffer.length - SessionEventBus.BUFFER_SIZE);
110
114
  }
111
- setImmediate(() => this.globalEmitter.emit('event', globalEvent));
115
+ const imm = setImmediate(() => {
116
+ this.pendingTimers.delete(imm);
117
+ this.globalEmitter?.emit('event', globalEvent);
118
+ });
119
+ this.pendingTimers.add(imm);
112
120
  }
113
121
  }
114
122
  /** Get events emitted after the given event ID for a session. */
@@ -217,6 +225,8 @@ export class SessionEventBus {
217
225
  // ── Global (all-session) SSE ──────────────────────────────────────
218
226
  /** Global emitter for aggregating events across all sessions. */
219
227
  globalEmitter = null;
228
+ /** #689: Pending setImmediate timers for cleanup on destroy. */
229
+ pendingTimers = new Set();
220
230
  /** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
221
231
  subscribeGlobal(handler) {
222
232
  if (!this.globalEmitter) {
@@ -226,6 +236,10 @@ export class SessionEventBus {
226
236
  this.globalEmitter.on('event', handler);
227
237
  return () => {
228
238
  this.globalEmitter?.off('event', handler);
239
+ // #689: Nullify globalEmitter when all subscribers leave
240
+ if (this.globalEmitter && this.globalEmitter.listenerCount('event') === 0) {
241
+ this.globalEmitter = null;
242
+ }
229
243
  };
230
244
  }
231
245
  /** Emit a session created event to global subscribers. */
@@ -262,6 +276,11 @@ export class SessionEventBus {
262
276
  }
263
277
  /** Clean up all emitters. */
264
278
  destroy() {
279
+ // #689: Clear pending setImmediate timers before removing listeners
280
+ for (const imm of this.pendingTimers) {
281
+ clearImmediate(imm);
282
+ }
283
+ this.pendingTimers.clear();
265
284
  for (const emitter of this.emitters.values()) {
266
285
  emitter.removeAllListeners();
267
286
  }
package/dist/hooks.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * Issue #169: Phase 1 — HTTP hooks infrastructure.
15
15
  * Issue #169: Phase 3 — Hook-driven status detection.
16
16
  */
17
- import { isValidUUID } from './validation.js';
17
+ import { isValidUUID, hookBodySchema } from './validation.js';
18
18
  /** CC hook events that require a decision response. */
19
19
  const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
20
20
  /** Permission modes that should be auto-approved via hook response. */
@@ -114,10 +114,15 @@ export function registerHookRoutes(app, deps) {
114
114
  if (!session) {
115
115
  return reply.status(404).send({ error: `Session ${sessionId} not found` });
116
116
  }
117
+ // Issue #665: Validate hook body with Zod instead of unsafe casts
118
+ const parseResult = hookBodySchema.safeParse(req.body);
119
+ if (!parseResult.success) {
120
+ return reply.status(400).send({ error: `Invalid hook body: ${parseResult.error.message}` });
121
+ }
122
+ const hookBody = parseResult.data;
117
123
  // Issue #88: Track active subagents
118
- const hookBody = req.body;
119
124
  if (eventName === 'SubagentStart') {
120
- const agentName = hookBody?.agent_name || hookBody?.tool_input?.command || 'unknown';
125
+ const agentName = hookBody.agent_name || hookBody.tool_input?.command || 'unknown';
121
126
  deps.sessions.addSubagent(sessionId, agentName);
122
127
  deps.eventBus.emit(sessionId, {
123
128
  event: 'subagent_start',
@@ -127,7 +132,7 @@ export function registerHookRoutes(app, deps) {
127
132
  });
128
133
  }
129
134
  else if (eventName === 'SubagentStop') {
130
- const agentName = hookBody?.agent_name || 'unknown';
135
+ const agentName = hookBody.agent_name || 'unknown';
131
136
  deps.sessions.removeSubagent(sessionId, agentName);
132
137
  deps.eventBus.emit(sessionId, {
133
138
  event: 'subagent_stop',
@@ -149,16 +154,15 @@ export function registerHookRoutes(app, deps) {
149
154
  if (eventName === 'PreCompact' || eventName === 'PostCompact') {
150
155
  session.lastActivity = Date.now();
151
156
  }
152
- // Forward the raw hook event to SSE subscribers
153
- deps.eventBus.emitHook(sessionId, eventName, req.body);
157
+ // Forward the validated hook event to SSE subscribers
158
+ deps.eventBus.emitHook(sessionId, eventName, hookBody);
154
159
  // Issue #89 L25: Capture model field from hook payload for dashboard display
155
- const hookPayload = req.body;
156
- if (hookPayload?.model && typeof hookPayload.model === 'string') {
157
- deps.sessions.updateSessionModel(sessionId, hookPayload.model);
160
+ if (hookBody.model) {
161
+ deps.sessions.updateSessionModel(sessionId, hookBody.model);
158
162
  }
159
163
  // Issue #89 L24: Validate permission_mode from PermissionRequest hook
160
164
  if (eventName === 'PermissionRequest') {
161
- const rawMode = hookBody?.permission_mode;
165
+ const rawMode = hookBody.permission_mode;
162
166
  if (rawMode !== undefined && !VALID_PERMISSION_MODES.has(rawMode)) {
163
167
  console.warn(`Hooks: invalid permission_mode "${rawMode}" from PermissionRequest, using "default"`);
164
168
  hookBody.permission_mode = 'default';
@@ -167,8 +171,8 @@ export function registerHookRoutes(app, deps) {
167
171
  // Issue #169 Phase 3: Update session status from hook event
168
172
  // Issue #87: Extract timestamp from hook payload for latency calculation
169
173
  const hookReceivedAt = Date.now();
170
- const hookEventTimestamp = hookPayload?.timestamp
171
- ? new Date(hookPayload.timestamp).getTime()
174
+ const hookEventTimestamp = hookBody.timestamp
175
+ ? new Date(hookBody.timestamp).getTime()
172
176
  : undefined;
173
177
  // Issue #87: Record hook latency if we have a timestamp from the payload
174
178
  if (hookEventTimestamp && deps.metrics) {
@@ -202,22 +206,20 @@ export function registerHookRoutes(app, deps) {
202
206
  deps.eventBus.emitStatus(sessionId, 'working', 'Elicitation result received (hook: ElicitationResult)');
203
207
  break;
204
208
  case 'PermissionRequest':
205
- deps.eventBus.emitApproval(sessionId, req.body?.permission_prompt
206
- || 'Permission requested (hook)');
209
+ deps.eventBus.emitApproval(sessionId, hookBody.permission_prompt || 'Permission requested (hook)');
207
210
  break;
208
211
  }
209
212
  }
210
213
  // Decision events need a response body that CC uses
211
214
  // Format: { hookSpecificOutput: { hookEventName, permissionDecision, reason? } }
212
215
  if (DECISION_EVENTS.has(eventName)) {
213
- const hookBody = req.body;
214
- const toolName = hookBody?.tool_name || '';
215
- const permissionPrompt = hookBody?.permission_prompt || '';
216
+ const toolName = hookBody.tool_name || '';
217
+ const permissionPrompt = hookBody.permission_prompt || '';
216
218
  if (eventName === 'PreToolUse') {
217
219
  // Issue #336: Intercept AskUserQuestion for headless question answering
218
220
  if (toolName === 'AskUserQuestion') {
219
- const toolInput = hookBody?.tool_input;
220
- const toolUseId = hookBody?.tool_use_id || '';
221
+ const toolInput = hookBody.tool_input;
222
+ const toolUseId = hookBody.tool_use_id || '';
221
223
  const questionText = extractQuestionText(toolInput);
222
224
  // Emit ask_question SSE event for external clients
223
225
  deps.eventBus.emit(sessionId, {
package/dist/monitor.js CHANGED
@@ -234,7 +234,7 @@ export class SessionMonitor {
234
234
  await this.channels.statusChange(this.makePayload('status.permission_timeout', session, detail));
235
235
  }
236
236
  catch (e) {
237
- console.error(`Monitor: auto-reject failed for session ${session.id}: ${e.message}`);
237
+ console.error(`Monitor: auto-reject failed for session ${session.id}: ${e instanceof Error ? e.message : String(e)}`);
238
238
  }
239
239
  }
240
240
  }
package/dist/server.js CHANGED
@@ -236,11 +236,11 @@ const createSessionSchema = z.object({
236
236
  claudeCommand: z.string().max(10_000).optional(),
237
237
  env: z.record(z.string(), z.string()).optional(),
238
238
  stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
239
- permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
239
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
240
240
  autoApprove: z.boolean().optional(),
241
241
  }).strict();
242
242
  // Health — Issue #397: includes tmux server health check
243
- app.get('/v1/health', async () => {
243
+ async function healthHandler() {
244
244
  const pkg = await import('../package.json', { with: { type: 'json' } });
245
245
  const activeCount = sessions.listSessions().length;
246
246
  const totalCount = metrics.getTotalSessionsCreated();
@@ -254,23 +254,9 @@ app.get('/v1/health', async () => {
254
254
  tmux: tmuxHealth,
255
255
  timestamp: new Date().toISOString(),
256
256
  };
257
- });
258
- // Backwards compat: unversioned health
259
- app.get('/health', async () => {
260
- const pkg = await import('../package.json', { with: { type: 'json' } });
261
- const activeCount = sessions.listSessions().length;
262
- const totalCount = metrics.getTotalSessionsCreated();
263
- const tmuxHealth = await tmux.isServerHealthy();
264
- const status = tmuxHealth.healthy ? 'ok' : 'degraded';
265
- return {
266
- status,
267
- version: pkg.default.version,
268
- uptime: process.uptime(),
269
- sessions: { active: activeCount, total: totalCount },
270
- tmux: tmuxHealth,
271
- timestamp: new Date().toISOString(),
272
- };
273
- });
257
+ }
258
+ app.get('/v1/health', healthHandler);
259
+ app.get('/health', healthHandler);
274
260
  // Issue #81: Swarm awareness — list all detected CC swarms and their teammates
275
261
  app.get('/v1/swarm', async () => {
276
262
  const result = await swarmMonitor.scan();
@@ -335,7 +321,7 @@ app.get('/v1/sessions/:id/metrics', async (req, reply) => {
335
321
  // Issue #89 L14: Webhook dead letter queue
336
322
  app.get('/v1/webhooks/dead-letter', async () => {
337
323
  for (const ch of channels.getChannels()) {
338
- if (ch.name === 'webhook' && 'getDeadLetterQueue' in ch) {
324
+ if (ch.name === 'webhook' && typeof ch.getDeadLetterQueue === 'function') {
339
325
  return ch.getDeadLetterQueue();
340
326
  }
341
327
  }
@@ -457,7 +443,7 @@ app.get('/sessions', async () => sessions.listSessions());
457
443
  /** Validate workDir — delegates to validation.ts (Issue #435). */
458
444
  const validateWorkDirWithConfig = (workDir) => validateWorkDir(workDir, config.allowedWorkDirs);
459
445
  // Create session (Issue #607: reuse idle session for same workDir)
460
- app.post('/v1/sessions', async (req, reply) => {
446
+ async function createSessionHandler(req, reply) {
461
447
  const parsed = createSessionSchema.safeParse(req.body);
462
448
  if (!parsed.success) {
463
449
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
@@ -508,58 +494,18 @@ app.post('/v1/sessions', async (req, reply) => {
508
494
  console.timeEnd("POST_SEND_INITIAL_PROMPT");
509
495
  }
510
496
  return reply.status(201).send({ ...session, promptDelivery });
511
- });
512
- // Backwards compat (Issue #607: same reuse logic as v1 route)
513
- app.post('/sessions', async (req, reply) => {
514
- const parsed = createSessionSchema.safeParse(req.body);
515
- if (!parsed.success) {
516
- return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
517
- }
518
- const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
519
- if (!workDir)
520
- return reply.status(400).send({ error: 'workDir is required' });
521
- const safeWorkDir = await validateWorkDirWithConfig(workDir);
522
- if (typeof safeWorkDir === 'object')
523
- return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
524
- // Issue #607: Check for an existing idle session with the same workDir
525
- const existing = sessions.findIdleSessionByWorkDir(safeWorkDir);
526
- if (existing) {
527
- let promptDelivery;
528
- if (prompt) {
529
- promptDelivery = await sessions.sendInitialPrompt(existing.id, prompt);
530
- metrics.promptSent(promptDelivery.delivered);
531
- }
532
- return reply.status(200).send({ ...existing, reused: true, promptDelivery });
533
- }
534
- const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
535
- // Issue #46: Topic first, then prompt (same fix as v1 route)
536
- await channels.sessionCreated({
537
- event: 'session.created',
538
- timestamp: new Date().toISOString(),
539
- session: { id: session.id, name: session.windowName, workDir },
540
- detail: `Session created: ${session.windowName}`,
541
- meta: prompt ? { prompt: prompt.slice(0, 200), permissionMode: permissionMode ?? (autoApprove ? 'bypassPermissions' : undefined) } : undefined,
542
- });
543
- let promptDelivery;
544
- if (prompt) {
545
- promptDelivery = await sessions.sendInitialPrompt(session.id, prompt);
546
- metrics.promptSent(promptDelivery.delivered);
547
- }
548
- return reply.status(201).send({ ...session, promptDelivery });
549
- });
497
+ }
498
+ app.post('/v1/sessions', createSessionHandler);
499
+ app.post('/sessions', createSessionHandler);
550
500
  // Get session (Issue #20: includes actionHints for interactive states)
551
- app.get('/v1/sessions/:id', async (req, reply) => {
501
+ async function getSessionHandler(req, reply) {
552
502
  const session = sessions.getSession(req.params.id);
553
503
  if (!session)
554
504
  return reply.status(404).send({ error: 'Session not found' });
555
505
  return addActionHints(session, sessions);
556
- });
557
- app.get('/sessions/:id', async (req, reply) => {
558
- const session = sessions.getSession(req.params.id);
559
- if (!session)
560
- return reply.status(404).send({ error: 'Session not found' });
561
- return addActionHints(session, sessions);
562
- });
506
+ }
507
+ app.get('/v1/sessions/:id', getSessionHandler);
508
+ app.get('/sessions/:id', getSessionHandler);
563
509
  // #128: Bulk health check — returns health for all sessions in one request
564
510
  app.get('/v1/sessions/health', async () => {
565
511
  const allSessions = sessions.listSessions();
@@ -580,43 +526,18 @@ app.get('/v1/sessions/health', async () => {
580
526
  return results;
581
527
  });
582
528
  // Session health check (Issue #2)
583
- app.get('/v1/sessions/:id/health', async (req, reply) => {
529
+ async function sessionHealthHandler(req, reply) {
584
530
  try {
585
531
  return await sessions.getHealth(req.params.id);
586
532
  }
587
533
  catch (e) {
588
534
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
589
535
  }
590
- });
591
- app.get('/sessions/:id/health', async (req, reply) => {
592
- try {
593
- return await sessions.getHealth(req.params.id);
594
- }
595
- catch (e) {
596
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
597
- }
598
- });
536
+ }
537
+ app.get('/v1/sessions/:id/health', sessionHealthHandler);
538
+ app.get('/sessions/:id/health', sessionHealthHandler);
599
539
  // Send message (with delivery verification — Issue #1)
600
- app.post('/v1/sessions/:id/send', async (req, reply) => {
601
- const parsed = sendMessageSchema.safeParse(req.body);
602
- if (!parsed.success)
603
- return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
604
- const { text } = parsed.data;
605
- try {
606
- const result = await sessions.sendMessage(req.params.id, text);
607
- await channels.message({
608
- event: 'message.user',
609
- timestamp: new Date().toISOString(),
610
- session: { id: req.params.id, name: '', workDir: '' },
611
- detail: text,
612
- });
613
- return { ok: true, delivered: result.delivered, attempts: result.attempts };
614
- }
615
- catch (e) {
616
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
617
- }
618
- });
619
- app.post('/sessions/:id/send', async (req, reply) => {
540
+ async function sendMessageHandler(req, reply) {
620
541
  const parsed = sendMessageSchema.safeParse(req.body);
621
542
  if (!parsed.success)
622
543
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
@@ -634,26 +555,22 @@ app.post('/sessions/:id/send', async (req, reply) => {
634
555
  catch (e) {
635
556
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
636
557
  }
637
- });
558
+ }
559
+ app.post('/v1/sessions/:id/send', sendMessageHandler);
560
+ app.post('/sessions/:id/send', sendMessageHandler);
638
561
  // Read messages
639
- app.get('/v1/sessions/:id/read', async (req, reply) => {
562
+ async function readMessagesHandler(req, reply) {
640
563
  try {
641
564
  return await sessions.readMessages(req.params.id);
642
565
  }
643
566
  catch (e) {
644
567
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
645
568
  }
646
- });
647
- app.get('/sessions/:id/read', async (req, reply) => {
648
- try {
649
- return await sessions.readMessages(req.params.id);
650
- }
651
- catch (e) {
652
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
653
- }
654
- });
569
+ }
570
+ app.get('/v1/sessions/:id/read', readMessagesHandler);
571
+ app.get('/sessions/:id/read', readMessagesHandler);
655
572
  // Approve
656
- app.post('/v1/sessions/:id/approve', async (req, reply) => {
573
+ async function approveHandler(req, reply) {
657
574
  try {
658
575
  await sessions.approve(req.params.id);
659
576
  // Issue #87: Record permission response latency
@@ -666,22 +583,11 @@ app.post('/v1/sessions/:id/approve', async (req, reply) => {
666
583
  catch (e) {
667
584
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
668
585
  }
669
- });
670
- app.post('/sessions/:id/approve', async (req, reply) => {
671
- try {
672
- await sessions.approve(req.params.id);
673
- const lat = sessions.getLatencyMetrics(req.params.id);
674
- if (lat !== null && lat.permission_response_ms !== null) {
675
- metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
676
- }
677
- return { ok: true };
678
- }
679
- catch (e) {
680
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
681
- }
682
- });
586
+ }
587
+ app.post('/v1/sessions/:id/approve', approveHandler);
588
+ app.post('/sessions/:id/approve', approveHandler);
683
589
  // Reject
684
- app.post('/v1/sessions/:id/reject', async (req, reply) => {
590
+ async function rejectHandler(req, reply) {
685
591
  try {
686
592
  await sessions.reject(req.params.id);
687
593
  const lat = sessions.getLatencyMetrics(req.params.id);
@@ -693,20 +599,9 @@ app.post('/v1/sessions/:id/reject', async (req, reply) => {
693
599
  catch (e) {
694
600
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
695
601
  }
696
- });
697
- app.post('/sessions/:id/reject', async (req, reply) => {
698
- try {
699
- await sessions.reject(req.params.id);
700
- const lat = sessions.getLatencyMetrics(req.params.id);
701
- if (lat !== null && lat.permission_response_ms !== null) {
702
- metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
703
- }
704
- return { ok: true };
705
- }
706
- catch (e) {
707
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
708
- }
709
- });
602
+ }
603
+ app.post('/v1/sessions/:id/reject', rejectHandler);
604
+ app.post('/sessions/:id/reject', rejectHandler);
710
605
  // Issue #336: Answer pending AskUserQuestion
711
606
  app.post('/v1/sessions/:id/answer', async (req, reply) => {
712
607
  const { questionId, answer } = req.body || {};
@@ -723,16 +618,7 @@ app.post('/v1/sessions/:id/answer', async (req, reply) => {
723
618
  return { ok: true };
724
619
  });
725
620
  // Escape
726
- app.post('/v1/sessions/:id/escape', async (req, reply) => {
727
- try {
728
- await sessions.escape(req.params.id);
729
- return { ok: true };
730
- }
731
- catch (e) {
732
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
733
- }
734
- });
735
- app.post('/sessions/:id/escape', async (req, reply) => {
621
+ async function escapeHandler(req, reply) {
736
622
  try {
737
623
  await sessions.escape(req.params.id);
738
624
  return { ok: true };
@@ -740,9 +626,11 @@ app.post('/sessions/:id/escape', async (req, reply) => {
740
626
  catch (e) {
741
627
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
742
628
  }
743
- });
629
+ }
630
+ app.post('/v1/sessions/:id/escape', escapeHandler);
631
+ app.post('/sessions/:id/escape', escapeHandler);
744
632
  // Interrupt (Ctrl+C)
745
- app.post('/v1/sessions/:id/interrupt', async (req, reply) => {
633
+ async function interruptHandler(req, reply) {
746
634
  try {
747
635
  await sessions.interrupt(req.params.id);
748
636
  return { ok: true };
@@ -750,18 +638,11 @@ app.post('/v1/sessions/:id/interrupt', async (req, reply) => {
750
638
  catch (e) {
751
639
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
752
640
  }
753
- });
754
- app.post('/sessions/:id/interrupt', async (req, reply) => {
755
- try {
756
- await sessions.interrupt(req.params.id);
757
- return { ok: true };
758
- }
759
- catch (e) {
760
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
761
- }
762
- });
641
+ }
642
+ app.post('/v1/sessions/:id/interrupt', interruptHandler);
643
+ app.post('/sessions/:id/interrupt', interruptHandler);
763
644
  // Kill session
764
- app.delete('/v1/sessions/:id', async (req, reply) => {
645
+ async function killSessionHandler(req, reply) {
765
646
  if (!sessions.getSession(req.params.id)) {
766
647
  return reply.status(404).send({ error: 'Session not found' });
767
648
  }
@@ -776,54 +657,21 @@ app.delete('/v1/sessions/:id', async (req, reply) => {
776
657
  catch (e) {
777
658
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
778
659
  }
779
- });
780
- app.delete('/sessions/:id', async (req, reply) => {
781
- if (!sessions.getSession(req.params.id)) {
782
- return reply.status(404).send({ error: 'Session not found' });
783
- }
784
- try {
785
- eventBus.emitEnded(req.params.id, 'killed');
786
- await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
787
- await sessions.killSession(req.params.id);
788
- monitor.removeSession(req.params.id);
789
- metrics.cleanupSession(req.params.id);
790
- return { ok: true };
791
- }
792
- catch (e) {
793
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
794
- }
795
- });
660
+ }
661
+ app.delete('/v1/sessions/:id', killSessionHandler);
662
+ app.delete('/sessions/:id', killSessionHandler);
796
663
  // Capture raw pane
797
- app.get('/v1/sessions/:id/pane', async (req, reply) => {
664
+ async function capturePaneHandler(req, reply) {
798
665
  const session = sessions.getSession(req.params.id);
799
666
  if (!session)
800
667
  return reply.status(404).send({ error: 'Session not found' });
801
668
  const pane = await tmux.capturePane(session.windowId);
802
669
  return { pane };
803
- });
804
- app.get('/sessions/:id/pane', async (req, reply) => {
805
- const session = sessions.getSession(req.params.id);
806
- if (!session)
807
- return reply.status(404).send({ error: 'Session not found' });
808
- const pane = await tmux.capturePane(session.windowId);
809
- return { pane };
810
- });
670
+ }
671
+ app.get('/v1/sessions/:id/pane', capturePaneHandler);
672
+ app.get('/sessions/:id/pane', capturePaneHandler);
811
673
  // Slash command
812
- app.post('/v1/sessions/:id/command', async (req, reply) => {
813
- const parsed = commandSchema.safeParse(req.body);
814
- if (!parsed.success)
815
- return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
816
- const { command } = parsed.data;
817
- try {
818
- const cmd = command.startsWith('/') ? command : `/${command}`;
819
- await sessions.sendMessage(req.params.id, cmd);
820
- return { ok: true };
821
- }
822
- catch (e) {
823
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
824
- }
825
- });
826
- app.post('/sessions/:id/command', async (req, reply) => {
674
+ async function commandHandler(req, reply) {
827
675
  const parsed = commandSchema.safeParse(req.body);
828
676
  if (!parsed.success)
829
677
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
@@ -836,23 +684,11 @@ app.post('/sessions/:id/command', async (req, reply) => {
836
684
  catch (e) {
837
685
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
838
686
  }
839
- });
687
+ }
688
+ app.post('/v1/sessions/:id/command', commandHandler);
689
+ app.post('/sessions/:id/command', commandHandler);
840
690
  // Bash mode
841
- app.post('/v1/sessions/:id/bash', async (req, reply) => {
842
- const parsed = bashSchema.safeParse(req.body);
843
- if (!parsed.success)
844
- return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
845
- const { command } = parsed.data;
846
- try {
847
- const cmd = command.startsWith('!') ? command : `!${command}`;
848
- await sessions.sendMessage(req.params.id, cmd);
849
- return { ok: true };
850
- }
851
- catch (e) {
852
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
853
- }
854
- });
855
- app.post('/sessions/:id/bash', async (req, reply) => {
691
+ async function bashHandler(req, reply) {
856
692
  const parsed = bashSchema.safeParse(req.body);
857
693
  if (!parsed.success)
858
694
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
@@ -865,24 +701,20 @@ app.post('/sessions/:id/bash', async (req, reply) => {
865
701
  catch (e) {
866
702
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
867
703
  }
868
- });
704
+ }
705
+ app.post('/v1/sessions/:id/bash', bashHandler);
706
+ app.post('/sessions/:id/bash', bashHandler);
869
707
  // Session summary (Issue #35)
870
- app.get('/v1/sessions/:id/summary', async (req, reply) => {
871
- try {
872
- return await sessions.getSummary(req.params.id);
873
- }
874
- catch (e) {
875
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
876
- }
877
- });
878
- app.get('/sessions/:id/summary', async (req, reply) => {
708
+ async function summaryHandler(req, reply) {
879
709
  try {
880
710
  return await sessions.getSummary(req.params.id);
881
711
  }
882
712
  catch (e) {
883
713
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
884
714
  }
885
- });
715
+ }
716
+ app.get('/v1/sessions/:id/summary', summaryHandler);
717
+ app.get('/sessions/:id/summary', summaryHandler);
886
718
  // Paginated transcript read
887
719
  app.get('/v1/sessions/:id/transcript', async (req, reply) => {
888
720
  try {
@@ -896,7 +728,7 @@ app.get('/v1/sessions/:id/transcript', async (req, reply) => {
896
728
  }
897
729
  });
898
730
  // Screenshot capture (Issue #22)
899
- app.post('/v1/sessions/:id/screenshot', async (req, reply) => {
731
+ async function screenshotHandler(req, reply) {
900
732
  const parsed = screenshotSchema.safeParse(req.body);
901
733
  if (!parsed.success)
902
734
  return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
@@ -925,36 +757,9 @@ app.post('/v1/sessions/:id/screenshot', async (req, reply) => {
925
757
  catch (e) {
926
758
  return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
927
759
  }
928
- });
929
- app.post('/sessions/:id/screenshot', async (req, reply) => {
930
- const parsed = screenshotSchema.safeParse(req.body);
931
- if (!parsed.success)
932
- return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
933
- const { url, fullPage, width, height } = parsed.data;
934
- const urlError = validateScreenshotUrl(url);
935
- if (urlError)
936
- return reply.status(400).send({ error: urlError });
937
- // Post-DNS-resolution check: resolve hostname and reject private IPs
938
- const dnsError = await resolveAndCheckIp(new URL(url).hostname);
939
- if (dnsError)
940
- return reply.status(400).send({ error: dnsError });
941
- const session = sessions.getSession(req.params.id);
942
- if (!session)
943
- return reply.status(404).send({ error: 'Session not found' });
944
- if (!isPlaywrightAvailable()) {
945
- return reply.status(501).send({
946
- error: 'Playwright is not installed',
947
- message: 'Install Playwright to enable screenshots: npx playwright install chromium && npm install -D playwright',
948
- });
949
- }
950
- try {
951
- const result = await captureScreenshot({ url, fullPage, width, height });
952
- return reply.status(200).send(result);
953
- }
954
- catch (e) {
955
- return reply.status(500).send({ error: `Screenshot failed: ${e instanceof Error ? e.message : String(e)}` });
956
- }
957
- });
760
+ }
761
+ app.post('/v1/sessions/:id/screenshot', screenshotHandler);
762
+ app.post('/sessions/:id/screenshot', screenshotHandler);
958
763
  // SSE event stream (Issue #32)
959
764
  app.get('/v1/sessions/:id/events', async (req, reply) => {
960
765
  const session = sessions.getSession(req.params.id);
package/dist/session.js CHANGED
@@ -14,6 +14,18 @@ import { computeStallThreshold } from './config.js';
14
14
  import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
15
15
  import { persistedStateSchema, sessionMapSchema } from './validation.js';
16
16
  import { writeHookSettingsFile, cleanupHookSettingsFile } from './hook-settings.js';
17
+ /** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
18
+ function hydrateSessions(raw) {
19
+ const sessions = {};
20
+ for (const [id, s] of Object.entries(raw)) {
21
+ const { activeSubagents, ...rest } = s;
22
+ sessions[id] = {
23
+ ...rest,
24
+ activeSubagents: activeSubagents ? new Set(activeSubagents) : undefined,
25
+ };
26
+ }
27
+ return sessions;
28
+ }
17
29
  /**
18
30
  * Detect whether CC is showing numbered permission options (e.g. "1. Yes, 2. No")
19
31
  * vs a simple y/N prompt. Returns the approval method to use.
@@ -99,7 +111,7 @@ export class SessionManager {
99
111
  const raw = await readFile(this.stateFile, 'utf-8');
100
112
  const parsed = persistedStateSchema.safeParse(JSON.parse(raw));
101
113
  if (parsed.success && this.isValidState({ sessions: parsed.data })) {
102
- this.state = { sessions: parsed.data };
114
+ this.state = { sessions: hydrateSessions(parsed.data) };
103
115
  }
104
116
  else {
105
117
  console.warn('State file failed validation, attempting backup restore');
@@ -110,7 +122,7 @@ export class SessionManager {
110
122
  const backupRaw = await readFile(backupFile, 'utf-8');
111
123
  const backupParsed = persistedStateSchema.safeParse(JSON.parse(backupRaw));
112
124
  if (backupParsed.success && this.isValidState({ sessions: backupParsed.data })) {
113
- this.state = { sessions: backupParsed.data };
125
+ this.state = { sessions: hydrateSessions(backupParsed.data) };
114
126
  console.log('Restored state from backup');
115
127
  }
116
128
  else {
@@ -130,12 +142,6 @@ export class SessionManager {
130
142
  this.state = { sessions: {} };
131
143
  }
132
144
  }
133
- // #357: Convert deserialized activeSubagents arrays to Sets
134
- for (const session of Object.values(this.state.sessions)) {
135
- if (Array.isArray(session.activeSubagents)) {
136
- session.activeSubagents = new Set(session.activeSubagents);
137
- }
138
- }
139
145
  // Create backup of successfully loaded state
140
146
  try {
141
147
  await writeFile(`${this.stateFile}.bak`, JSON.stringify(this.state, null, 2));
@@ -142,11 +142,13 @@ export function detectUIState(paneText) {
142
142
  return 'waiting_for_input';
143
143
  return 'unknown';
144
144
  }
145
+ /** Number of lines from the bottom of the pane to scan for active spinners. */
146
+ const SPINNER_SEARCH_LINES = 30;
145
147
  /** Check if any line in the pane has an active spinner character followed by working text. */
146
148
  function hasSpinnerAnywhere(lines) {
147
149
  // Only check lines in the content area (not the very bottom few which are prompt/footer)
148
150
  const searchEnd = Math.max(0, lines.length - 3);
149
- for (let i = Math.max(0, lines.length - 20); i < searchEnd; i++) {
151
+ for (let i = Math.max(0, lines.length - SPINNER_SEARCH_LINES); i < searchEnd; i++) {
150
152
  const stripped = lines[i].trim();
151
153
  if (!stripped)
152
154
  continue;
package/dist/tmux.js CHANGED
@@ -39,7 +39,7 @@ export class TmuxManager {
39
39
  this.socketName = socketName ?? `aegis-${process.pid}`;
40
40
  }
41
41
  /** Promise-chain queue that serializes all tmux CLI calls to prevent race conditions. */
42
- queue = Promise.resolve(undefined);
42
+ queue = Promise.resolve();
43
43
  /** #403: Counter of in-flight createWindow calls — direct methods must queue when > 0. */
44
44
  _creatingCount = 0;
45
45
  /** #357: Short-lived cache for window existence checks to reduce CLI calls. */
@@ -40,6 +40,25 @@ export declare const webhookEndpointSchema: z.ZodObject<{
40
40
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
41
41
  timeoutMs: z.ZodOptional<z.ZodNumber>;
42
42
  }, z.core.$strict>;
43
+ /** POST /v1/hooks/:eventName — CC hook event payload (Issue #665). */
44
+ export declare const hookBodySchema: z.ZodObject<{
45
+ session_id: z.ZodOptional<z.ZodString>;
46
+ agent_name: z.ZodOptional<z.ZodString>;
47
+ agent_type: z.ZodOptional<z.ZodString>;
48
+ tool_name: z.ZodOptional<z.ZodString>;
49
+ tool_input: z.ZodOptional<z.ZodObject<{
50
+ command: z.ZodOptional<z.ZodString>;
51
+ }, z.core.$loose>>;
52
+ tool_use_id: z.ZodOptional<z.ZodString>;
53
+ permission_prompt: z.ZodOptional<z.ZodString>;
54
+ permission_mode: z.ZodOptional<z.ZodString>;
55
+ hook_event_name: z.ZodOptional<z.ZodString>;
56
+ model: z.ZodOptional<z.ZodString>;
57
+ timestamp: z.ZodOptional<z.ZodString>;
58
+ stop_reason: z.ZodOptional<z.ZodString>;
59
+ cwd: z.ZodOptional<z.ZodString>;
60
+ command: z.ZodOptional<z.ZodString>;
61
+ }, z.core.$loose>;
43
62
  /** POST /v1/sessions/:id/hooks/permission */
44
63
  export declare const permissionHookSchema: z.ZodObject<{
45
64
  session_id: z.ZodOptional<z.ZodString>;
@@ -64,6 +83,9 @@ export declare const batchSessionSchema: z.ZodObject<{
64
83
  default: "default";
65
84
  bypassPermissions: "bypassPermissions";
66
85
  plan: "plan";
86
+ acceptEdits: "acceptEdits";
87
+ dontAsk: "dontAsk";
88
+ auto: "auto";
67
89
  }>>;
68
90
  autoApprove: z.ZodOptional<z.ZodBoolean>;
69
91
  stallThresholdMs: z.ZodOptional<z.ZodNumber>;
@@ -82,6 +104,9 @@ export declare const pipelineSchema: z.ZodObject<{
82
104
  default: "default";
83
105
  bypassPermissions: "bypassPermissions";
84
106
  plan: "plan";
107
+ acceptEdits: "acceptEdits";
108
+ dontAsk: "dontAsk";
109
+ auto: "auto";
85
110
  }>>;
86
111
  autoApprove: z.ZodOptional<z.ZodBoolean>;
87
112
  }, z.core.$strip>>;
@@ -104,9 +129,9 @@ export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<
104
129
  monitorOffset: z.ZodNumber;
105
130
  status: z.ZodEnum<{
106
131
  unknown: "unknown";
132
+ permission_prompt: "permission_prompt";
107
133
  idle: "idle";
108
134
  working: "working";
109
- permission_prompt: "permission_prompt";
110
135
  bash_approval: "bash_approval";
111
136
  plan_mode: "plan_mode";
112
137
  ask_question: "ask_question";
@@ -116,7 +141,14 @@ export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<
116
141
  lastActivity: z.ZodNumber;
117
142
  stallThresholdMs: z.ZodNumber;
118
143
  permissionStallMs: z.ZodDefault<z.ZodNumber>;
119
- permissionMode: z.ZodString;
144
+ permissionMode: z.ZodEnum<{
145
+ default: "default";
146
+ bypassPermissions: "bypassPermissions";
147
+ plan: "plan";
148
+ acceptEdits: "acceptEdits";
149
+ dontAsk: "dontAsk";
150
+ auto: "auto";
151
+ }>;
120
152
  settingsPatched: z.ZodOptional<z.ZodBoolean>;
121
153
  hookSettingsFile: z.ZodOptional<z.ZodString>;
122
154
  lastHookAt: z.ZodOptional<z.ZodNumber>;
@@ -43,6 +43,23 @@ export const webhookEndpointSchema = z.object({
43
43
  headers: z.record(z.string(), z.string()).optional(),
44
44
  timeoutMs: z.number().int().positive().optional(),
45
45
  }).strict();
46
+ /** POST /v1/hooks/:eventName — CC hook event payload (Issue #665). */
47
+ export const hookBodySchema = z.object({
48
+ session_id: z.string().optional(),
49
+ agent_name: z.string().optional(),
50
+ agent_type: z.string().optional(),
51
+ tool_name: z.string().optional(),
52
+ tool_input: z.object({ command: z.string().optional() }).passthrough().optional(),
53
+ tool_use_id: z.string().optional(),
54
+ permission_prompt: z.string().optional(),
55
+ permission_mode: z.string().optional(),
56
+ hook_event_name: z.string().optional(),
57
+ model: z.string().optional(),
58
+ timestamp: z.string().optional(),
59
+ stop_reason: z.string().optional(),
60
+ cwd: z.string().optional(),
61
+ command: z.string().optional(),
62
+ }).passthrough();
46
63
  /** POST /v1/sessions/:id/hooks/permission */
47
64
  export const permissionHookSchema = z.object({
48
65
  session_id: z.string().optional(),
@@ -61,7 +78,7 @@ const batchSessionSpecSchema = z.object({
61
78
  name: z.string().max(200).optional(),
62
79
  workDir: z.string().min(1),
63
80
  prompt: z.string().max(100_000).optional(),
64
- permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
81
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
65
82
  autoApprove: z.boolean().optional(),
66
83
  stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
67
84
  });
@@ -74,7 +91,7 @@ const pipelineStageSchema = z.object({
74
91
  workDir: z.string().min(1).optional(),
75
92
  prompt: z.string().min(1).max(MAX_INPUT_LENGTH),
76
93
  dependsOn: z.array(z.string()).optional(),
77
- permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(),
94
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
78
95
  autoApprove: z.boolean().optional(),
79
96
  });
80
97
  /** POST /v1/pipelines */
@@ -122,7 +139,7 @@ export const persistedStateSchema = z.record(z.string(), z.object({
122
139
  lastActivity: z.number(),
123
140
  stallThresholdMs: z.number(),
124
141
  permissionStallMs: z.number().default(300_000),
125
- permissionMode: z.string(),
142
+ permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']),
126
143
  settingsPatched: z.boolean().optional(),
127
144
  hookSettingsFile: z.string().optional(),
128
145
  lastHookAt: z.number().optional(),
@@ -31,7 +31,8 @@ const sessionPolls = new Map();
31
31
  /** Reset all internal state (for testing). */
32
32
  export function _resetForTesting() {
33
33
  for (const poll of sessionPolls.values()) {
34
- clearInterval(poll.timer);
34
+ if (poll.timer)
35
+ clearInterval(poll.timer);
35
36
  }
36
37
  sessionPolls.clear();
37
38
  }
@@ -282,7 +283,8 @@ function evictSubscriber(sessionId, socket, sub) {
282
283
  poll.subscribers.delete(socket);
283
284
  // If no more subscribers, clean up the poll timer
284
285
  if (poll.subscribers.size === 0) {
285
- clearInterval(poll.timer);
286
+ if (poll.timer)
287
+ clearInterval(poll.timer);
286
288
  sessionPolls.delete(sessionId);
287
289
  }
288
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",