aegis-bridge 2.5.4 → 2.6.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.
package/README.md CHANGED
@@ -114,6 +114,7 @@ All endpoints under `/v1/`.
114
114
  | `POST` | `/v1/sessions/:id/interrupt` | Ctrl+C |
115
115
  | `DELETE` | `/v1/sessions/:id` | Kill session |
116
116
  | `POST` | `/v1/sessions/batch` | Batch create |
117
+ | `POST` | `/v1/handshake` | Capability negotiation |
117
118
  | `POST` | `/v1/pipelines` | Create pipeline |
118
119
 
119
120
  <details>
@@ -124,6 +125,7 @@ All endpoints under `/v1/`.
124
125
  | `GET` | `/v1/sessions/:id/pane` | Raw terminal capture |
125
126
  | `GET` | `/v1/sessions/:id/health` | Health check with actionable hints |
126
127
  | `GET` | `/v1/sessions/:id/summary` | Condensed transcript summary |
128
+ | `GET` | `/v1/sessions/:id/transcript/cursor` | Cursor-based transcript replay |
127
129
  | `POST` | `/v1/sessions/:id/screenshot` | Screenshot a URL (Playwright) |
128
130
  | `POST` | `/v1/sessions/:id/escape` | Send Escape |
129
131
  | `GET` | `/v1/pipelines` | List all pipelines |
@@ -175,6 +177,80 @@ Only **idle** sessions are reused. Working, stalled, or permission-prompt sessio
175
177
 
176
178
  </details>
177
179
 
180
+ <details>
181
+ <summary>Capability Handshake</summary>
182
+
183
+ Before using advanced integration paths, clients can negotiate capabilities with Aegis via `POST /v1/handshake`. This prevents version-drift breakage.
184
+
185
+ ```bash
186
+ curl -X POST http://localhost:9100/v1/handshake \
187
+ -H "Content-Type: application/json" \
188
+ -d '{"protocolVersion": "1", "clientCapabilities": ["session.create", "session.transcript.cursor"]}'
189
+ ```
190
+
191
+ **Response** (200 OK when compatible):
192
+
193
+ ```json
194
+ {
195
+ "protocolVersion": "1",
196
+ "serverCapabilities": ["session.create", "session.resume", "session.approve", "session.transcript", "session.transcript.cursor", "session.events.sse", "session.screenshot", "hooks.pre_tool_use", "hooks.post_tool_use", "hooks.notification", "hooks.stop", "swarm", "metrics"],
197
+ "negotiatedCapabilities": ["session.create", "session.transcript.cursor"],
198
+ "warnings": [],
199
+ "compatible": true
200
+ }
201
+ ```
202
+
203
+ | Field | Description |
204
+ |-------|-------------|
205
+ | `protocolVersion` | Server's protocol version (`"1"` currently) |
206
+ | `serverCapabilities` | Full list of server-supported capabilities |
207
+ | `negotiatedCapabilities` | Intersection of client + server capabilities |
208
+ | `warnings` | Non-fatal issues (unknown caps, version skew) |
209
+ | `compatible` | `true` (200) or `false` (409 Conflict) |
210
+
211
+ Returns **409** if the client's `protocolVersion` is below the server minimum.
212
+
213
+ </details>
214
+
215
+ <details>
216
+ <summary>Cursor-Based Transcript Replay</summary>
217
+
218
+ Stable pagination for long transcripts that doesn't skip or duplicate messages under concurrent appends. Use instead of offset-based `/read` when you need reliable back-paging.
219
+
220
+ ```bash
221
+ # Get the newest 50 messages
222
+ curl http://localhost:9100/v1/sessions/abc123/transcript/cursor
223
+
224
+ # Get the next page (pass oldest_id from previous response)
225
+ curl "http://localhost:9100/v1/sessions/abc123/transcript/cursor?before_id=16&limit=50"
226
+
227
+ # Filter by role
228
+ curl "http://localhost:9100/v1/sessions/abc123/transcript/cursor?role=user"
229
+ ```
230
+
231
+ **Query params:**
232
+
233
+ | Param | Default | Description |
234
+ |-------|---------|-------------|
235
+ | `before_id` | (none) | Cursor ID to page before. Omit for newest entries. |
236
+ | `limit` | `50` | Entries per page (1–200). |
237
+ | `role` | (none) | Filter: `user`, `assistant`, or `system`. |
238
+
239
+ **Response:**
240
+
241
+ ```json
242
+ {
243
+ "messages": [...],
244
+ "has_more": true,
245
+ "oldest_id": 16,
246
+ "newest_id": 25
247
+ }
248
+ ```
249
+
250
+ Cursor IDs are stable — they won't shift when new messages are appended. Use `oldest_id` from one response as `before_id` in the next to page backwards without gaps or overlaps.
251
+
252
+ </details>
253
+
178
254
  ---
179
255
 
180
256
  ### Telegram
package/dist/config.d.ts CHANGED
@@ -53,6 +53,13 @@ export interface Config {
53
53
  * Empty array = all directories allowed (backward compatible).
54
54
  * Paths are resolved and symlink-resolved before checking. */
55
55
  allowedWorkDirs: string[];
56
+ /** Issue #884: Enable worktree-aware continuation metadata lookup (default: false).
57
+ * When true, Aegis fans out to sibling worktree project dirs when the primary
58
+ * directory lookup fails to find a session file. */
59
+ worktreeAwareContinuation: boolean;
60
+ /** Issue #884: Additional Claude projects directories to search during worktree fanout.
61
+ * Paths are expanded (~) and checked for existence before searching. */
62
+ worktreeSiblingDirs: string[];
56
63
  }
57
64
  /** Compute stall threshold from env var or default (Issue #392).
58
65
  * If CLAUDE_STREAM_IDLE_TIMEOUT_MS is set, uses Math.max(120000, parseInt(val) * 1.5).
package/dist/config.js CHANGED
@@ -45,6 +45,8 @@ const defaults = {
45
45
  sseMaxConnections: 100,
46
46
  sseMaxPerIp: 10,
47
47
  allowedWorkDirs: [],
48
+ worktreeAwareContinuation: false,
49
+ worktreeSiblingDirs: [],
48
50
  };
49
51
  /** Parse CLI args for --config flag */
50
52
  function getConfigPathFromArgv() {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * handshake.ts — Capability handshake schema and negotiation for Aegis/Claude Code.
3
+ *
4
+ * Issue #885: Defines a formal protocolVersion + capabilities negotiation so
5
+ * that clients and Aegis can agree on supported feature set before using
6
+ * advanced integration paths. Prevents version-drift breakage.
7
+ */
8
+ /** Current protocol version advertised by this Aegis build. */
9
+ export declare const AEGIS_PROTOCOL_VERSION = "1";
10
+ /** Minimum protocol version this Aegis build still accepts. */
11
+ export declare const AEGIS_MIN_PROTOCOL_VERSION = "1";
12
+ /**
13
+ * All capabilities Aegis supports in this build.
14
+ * Capabilities are additive; absence means the feature is unavailable/disabled.
15
+ */
16
+ export declare const AEGIS_CAPABILITIES: readonly ["session.create", "session.resume", "session.approve", "session.transcript", "session.transcript.cursor", "session.events.sse", "session.screenshot", "hooks.pre_tool_use", "hooks.post_tool_use", "hooks.notification", "hooks.stop", "swarm", "metrics"];
17
+ export type AegisCapability = (typeof AEGIS_CAPABILITIES)[number];
18
+ /** Request body for POST /v1/handshake */
19
+ export interface HandshakeRequest {
20
+ protocolVersion: string;
21
+ clientCapabilities?: string[];
22
+ clientVersion?: string;
23
+ }
24
+ /** Response shape for POST /v1/handshake */
25
+ export interface HandshakeResponse {
26
+ protocolVersion: string;
27
+ serverCapabilities: AegisCapability[];
28
+ negotiatedCapabilities: AegisCapability[];
29
+ warnings: string[];
30
+ compatible: boolean;
31
+ }
32
+ /**
33
+ * Negotiate capabilities between a client request and this Aegis build.
34
+ *
35
+ * Rules:
36
+ * - If client protocolVersion < AEGIS_MIN_PROTOCOL_VERSION → not compatible, add warning, return empty negotiatedCapabilities
37
+ * - If client protocolVersion > AEGIS_PROTOCOL_VERSION → compatible but add forward-compat warning
38
+ * - negotiatedCapabilities = intersection of server caps and clientCapabilities (or all server caps if client sends none)
39
+ */
40
+ export declare function negotiate(req: HandshakeRequest): HandshakeResponse;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * handshake.ts — Capability handshake schema and negotiation for Aegis/Claude Code.
3
+ *
4
+ * Issue #885: Defines a formal protocolVersion + capabilities negotiation so
5
+ * that clients and Aegis can agree on supported feature set before using
6
+ * advanced integration paths. Prevents version-drift breakage.
7
+ */
8
+ /** Current protocol version advertised by this Aegis build. */
9
+ export const AEGIS_PROTOCOL_VERSION = '1';
10
+ /** Minimum protocol version this Aegis build still accepts. */
11
+ export const AEGIS_MIN_PROTOCOL_VERSION = '1';
12
+ /**
13
+ * All capabilities Aegis supports in this build.
14
+ * Capabilities are additive; absence means the feature is unavailable/disabled.
15
+ */
16
+ export const AEGIS_CAPABILITIES = [
17
+ 'session.create',
18
+ 'session.resume',
19
+ 'session.approve',
20
+ 'session.transcript',
21
+ 'session.transcript.cursor', // Issue #883: cursor-based replay
22
+ 'session.events.sse',
23
+ 'session.screenshot',
24
+ 'hooks.pre_tool_use',
25
+ 'hooks.post_tool_use',
26
+ 'hooks.notification',
27
+ 'hooks.stop',
28
+ 'swarm',
29
+ 'metrics',
30
+ ];
31
+ /**
32
+ * Negotiate capabilities between a client request and this Aegis build.
33
+ *
34
+ * Rules:
35
+ * - If client protocolVersion < AEGIS_MIN_PROTOCOL_VERSION → not compatible, add warning, return empty negotiatedCapabilities
36
+ * - If client protocolVersion > AEGIS_PROTOCOL_VERSION → compatible but add forward-compat warning
37
+ * - negotiatedCapabilities = intersection of server caps and clientCapabilities (or all server caps if client sends none)
38
+ */
39
+ export function negotiate(req) {
40
+ const warnings = [];
41
+ const serverCapabilities = [...AEGIS_CAPABILITIES];
42
+ // Parse major version numbers for comparison
43
+ const clientMajor = parseInt(req.protocolVersion, 10);
44
+ const serverMajor = parseInt(AEGIS_PROTOCOL_VERSION, 10);
45
+ const minMajor = parseInt(AEGIS_MIN_PROTOCOL_VERSION, 10);
46
+ if (isNaN(clientMajor)) {
47
+ return {
48
+ protocolVersion: AEGIS_PROTOCOL_VERSION,
49
+ serverCapabilities,
50
+ negotiatedCapabilities: [],
51
+ warnings: [`Unrecognized protocolVersion format: "${req.protocolVersion}". Expected integer string.`],
52
+ compatible: false,
53
+ };
54
+ }
55
+ if (clientMajor < minMajor) {
56
+ return {
57
+ protocolVersion: AEGIS_PROTOCOL_VERSION,
58
+ serverCapabilities,
59
+ negotiatedCapabilities: [],
60
+ warnings: [
61
+ `Client protocolVersion ${req.protocolVersion} is below minimum supported version ${AEGIS_MIN_PROTOCOL_VERSION}. Upgrade required.`,
62
+ ],
63
+ compatible: false,
64
+ };
65
+ }
66
+ if (clientMajor > serverMajor) {
67
+ warnings.push(`Client protocolVersion ${req.protocolVersion} is newer than server version ${AEGIS_PROTOCOL_VERSION}. Some client features may be unavailable.`);
68
+ }
69
+ // Intersect: client declares what it supports; server only enables what it also supports
70
+ let negotiatedCapabilities;
71
+ if (!req.clientCapabilities || req.clientCapabilities.length === 0) {
72
+ // Client omitted capabilities → assume full server capability set
73
+ negotiatedCapabilities = serverCapabilities;
74
+ }
75
+ else {
76
+ const serverSet = new Set(serverCapabilities);
77
+ const unknown = req.clientCapabilities.filter(c => !serverSet.has(c));
78
+ if (unknown.length > 0) {
79
+ warnings.push(`Unknown client capabilities ignored: ${unknown.join(', ')}`);
80
+ }
81
+ negotiatedCapabilities = req.clientCapabilities.filter((c) => serverSet.has(c));
82
+ }
83
+ return {
84
+ protocolVersion: AEGIS_PROTOCOL_VERSION,
85
+ serverCapabilities,
86
+ negotiatedCapabilities,
87
+ warnings,
88
+ compatible: true,
89
+ };
90
+ }
package/dist/monitor.js CHANGED
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { stopSignalsSchema } from './validation.js';
14
+ import { suppressedCatch } from './suppress.js';
14
15
  /** Issue #89 L4: Debounce interval for status change broadcasts (ms). */
15
16
  const STATUS_CHANGE_DEBOUNCE_MS = 500;
16
17
  export const DEFAULT_MONITOR_CONFIG = {
@@ -124,8 +125,8 @@ export class SessionMonitor {
124
125
  }
125
126
  await this.checkSession(session);
126
127
  }
127
- catch {
128
- // Session may have been killed during poll
128
+ catch (e) {
129
+ suppressedCatch(e, 'monitor.checkSession');
129
130
  }
130
131
  }
131
132
  // Stall detection: run less frequently than message polling
@@ -372,7 +373,9 @@ export class SessionMonitor {
372
373
  }
373
374
  }
374
375
  }
375
- catch { /* ignore parse errors */ }
376
+ catch (e) {
377
+ suppressedCatch(e, 'monitor.checkStopSignals.parseEntry');
378
+ }
376
379
  }
377
380
  /** Issue #84: Handle new entries from the fs.watch-based JSONL watcher.
378
381
  * Forwards messages to channels and updates stall tracking. */
@@ -555,8 +558,8 @@ export class SessionMonitor {
555
558
  try {
556
559
  await this.sessions.killSession(session.id);
557
560
  }
558
- catch {
559
- // Window already gone — that's fine, session is dead
561
+ catch (e) {
562
+ suppressedCatch(e, 'monitor.checkDeadSessions.killSession');
560
563
  }
561
564
  }
562
565
  }
@@ -61,6 +61,7 @@ export interface PipelineState {
61
61
  export declare class PipelineManager {
62
62
  private sessions;
63
63
  private eventBus?;
64
+ private static readonly PIPELINE_RETRY_MAX_ATTEMPTS;
64
65
  private pipelines;
65
66
  private pipelineConfigs;
66
67
  private pollInterval;
package/dist/pipeline.js CHANGED
@@ -5,9 +5,12 @@
5
5
  * sequential pipelines with stage dependencies.
6
6
  */
7
7
  import { getErrorMessage } from './validation.js';
8
+ import { shouldRetry } from './error-categories.js';
9
+ import { retryWithJitter } from './retry.js';
8
10
  export class PipelineManager {
9
11
  sessions;
10
12
  eventBus;
13
+ static PIPELINE_RETRY_MAX_ATTEMPTS = 3;
11
14
  pipelines = new Map();
12
15
  pipelineConfigs = new Map(); // #219: preserve original stage config
13
16
  pollInterval = null;
@@ -127,14 +130,20 @@ export class PipelineManager {
127
130
  if (!stageConfig)
128
131
  continue;
129
132
  try {
130
- const session = await this.sessions.createSession({
133
+ const session = await retryWithJitter(async () => this.sessions.createSession({
131
134
  workDir: stageConfig.workDir || config.workDir,
132
135
  name: `pipeline-${config.name}-${stage.name}`,
133
136
  permissionMode: stageConfig.permissionMode,
134
137
  autoApprove: stageConfig.autoApprove,
138
+ }), {
139
+ maxAttempts: PipelineManager.PIPELINE_RETRY_MAX_ATTEMPTS,
140
+ shouldRetry: (error) => shouldRetry(error),
135
141
  });
136
142
  if (stageConfig.prompt) {
137
- await this.sessions.sendInitialPrompt(session.id, stageConfig.prompt);
143
+ await retryWithJitter(async () => this.sessions.sendInitialPrompt(session.id, stageConfig.prompt), {
144
+ maxAttempts: PipelineManager.PIPELINE_RETRY_MAX_ATTEMPTS,
145
+ shouldRetry: (error) => shouldRetry(error),
146
+ });
138
147
  }
139
148
  stage.sessionId = session.id;
140
149
  stage.status = 'running';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * retry.ts — shared retry helper with bounded exponential backoff + jitter.
3
+ */
4
+ export interface RetryOptions {
5
+ maxAttempts?: number;
6
+ baseDelayMs?: number;
7
+ maxDelayMs?: number;
8
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
9
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
10
+ }
11
+ export declare function retryWithJitter<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
package/dist/retry.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * retry.ts — shared retry helper with bounded exponential backoff + jitter.
3
+ */
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+ function computeDelayMs(attempt, baseDelayMs, maxDelayMs) {
8
+ const exponential = Math.min(baseDelayMs * (2 ** (attempt - 1)), maxDelayMs);
9
+ const jitterMultiplier = 0.5 + (Math.random() * 0.5);
10
+ return Math.round(exponential * jitterMultiplier);
11
+ }
12
+ export async function retryWithJitter(fn, options = {}) {
13
+ const maxAttempts = options.maxAttempts ?? 3;
14
+ const baseDelayMs = options.baseDelayMs ?? 250;
15
+ const maxDelayMs = options.maxDelayMs ?? 3_000;
16
+ let lastError;
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
18
+ try {
19
+ return await fn();
20
+ }
21
+ catch (error) {
22
+ lastError = error;
23
+ const isLastAttempt = attempt >= maxAttempts;
24
+ const canRetry = options.shouldRetry ? options.shouldRetry(error, attempt) : true;
25
+ if (isLastAttempt || !canRetry) {
26
+ throw error;
27
+ }
28
+ const delayMs = computeDelayMs(attempt, baseDelayMs, maxDelayMs);
29
+ options.onRetry?.(error, attempt, delayMs);
30
+ await sleep(delayMs);
31
+ }
32
+ }
33
+ throw lastError;
34
+ }
package/dist/server.js CHANGED
@@ -36,6 +36,7 @@ import { registerWsTerminalRoute } from './ws-terminal.js';
36
36
  import { SwarmMonitor } from './swarm-monitor.js';
37
37
  import { killAllSessions } from './signal-cleanup-helper.js';
38
38
  import { execFileSync } from 'node:child_process';
39
+ import { negotiate } from './handshake.js';
39
40
  import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, parseIntSafe, isValidUUID, } from './validation.js';
40
41
  const __filename = fileURLToPath(import.meta.url);
41
42
  const __dirname = path.dirname(__filename);
@@ -70,8 +71,10 @@ async function handleInbound(cmd) {
70
71
  await sessions.escape(cmd.sessionId);
71
72
  break;
72
73
  case 'kill':
73
- await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
74
+ // #842: killSession first, then notify — avoids race where channels
75
+ // reference a session that is still being destroyed.
74
76
  await sessions.killSession(cmd.sessionId);
77
+ await channels.sessionEnded(makePayload('session.ended', cmd.sessionId, 'killed'));
75
78
  monitor.removeSession(cmd.sessionId);
76
79
  metrics.cleanupSession(cmd.sessionId);
77
80
  break;
@@ -283,6 +286,18 @@ async function healthHandler() {
283
286
  }
284
287
  app.get('/v1/health', healthHandler);
285
288
  app.get('/health', healthHandler);
289
+ app.post('/v1/handshake', async (req, reply) => {
290
+ const { protocolVersion, clientCapabilities, clientVersion } = req.body ?? {};
291
+ if (typeof protocolVersion !== 'string' || !protocolVersion.trim()) {
292
+ return reply.status(400).send({ error: 'protocolVersion is required' });
293
+ }
294
+ if (clientCapabilities !== undefined && !Array.isArray(clientCapabilities)) {
295
+ return reply.status(400).send({ error: 'clientCapabilities must be an array' });
296
+ }
297
+ const result = negotiate({ protocolVersion, clientCapabilities, clientVersion });
298
+ return reply.status(result.compatible ? 200 : 409).send(result);
299
+ });
300
+ // Issue #81: Swarm awareness
286
301
  // Issue #81: Swarm awareness — list all detected CC swarms and their teammates
287
302
  app.get('/v1/swarm', async () => {
288
303
  const result = await swarmMonitor.scan();
@@ -600,39 +615,29 @@ async function readMessagesHandler(req, reply) {
600
615
  }
601
616
  app.get('/v1/sessions/:id/read', readMessagesHandler);
602
617
  app.get('/sessions/:id/read', readMessagesHandler);
603
- // Approve
604
- async function approveHandler(req, reply) {
605
- try {
606
- await sessions.approve(req.params.id);
607
- // Issue #87: Record permission response latency
608
- const lat = sessions.getLatencyMetrics(req.params.id);
609
- if (lat !== null && lat.permission_response_ms !== null) {
610
- metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
618
+ function makePermissionHandler(action) {
619
+ return async (req, reply) => {
620
+ try {
621
+ const op = action === 'approve' ? sessions.approve.bind(sessions) : sessions.reject.bind(sessions);
622
+ await op(req.params.id);
623
+ // Issue #87: Record permission response latency
624
+ const lat = sessions.getLatencyMetrics(req.params.id);
625
+ if (lat !== null && lat.permission_response_ms !== null) {
626
+ metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
627
+ }
628
+ return { ok: true };
611
629
  }
612
- return { ok: true };
613
- }
614
- catch (e) {
615
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
616
- }
617
- }
618
- app.post('/v1/sessions/:id/approve', approveHandler);
619
- app.post('/sessions/:id/approve', approveHandler);
620
- // Reject
621
- async function rejectHandler(req, reply) {
622
- try {
623
- await sessions.reject(req.params.id);
624
- const lat = sessions.getLatencyMetrics(req.params.id);
625
- if (lat !== null && lat.permission_response_ms !== null) {
626
- metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
630
+ catch (e) {
631
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
627
632
  }
628
- return { ok: true };
629
- }
630
- catch (e) {
631
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
632
- }
633
+ };
633
634
  }
635
+ const approveHandler = makePermissionHandler('approve');
636
+ const rejectHandler = makePermissionHandler('reject');
634
637
  app.post('/v1/sessions/:id/reject', rejectHandler);
635
638
  app.post('/sessions/:id/reject', rejectHandler);
639
+ app.post('/v1/sessions/:id/approve', approveHandler);
640
+ app.post('/sessions/:id/approve', approveHandler);
636
641
  // Issue #336: Answer pending AskUserQuestion
637
642
  app.post('/v1/sessions/:id/answer', async (req, reply) => {
638
643
  const { questionId, answer } = req.body || {};
@@ -678,9 +683,11 @@ async function killSessionHandler(req, reply) {
678
683
  return reply.status(404).send({ error: 'Session not found' });
679
684
  }
680
685
  try {
686
+ // #842: killSession first, then notify — avoids race where channels
687
+ // reference a session that is still being destroyed.
688
+ await sessions.killSession(req.params.id);
681
689
  eventBus.emitEnded(req.params.id, 'killed');
682
690
  await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed'));
683
- await sessions.killSession(req.params.id);
684
691
  monitor.removeSession(req.params.id);
685
692
  metrics.cleanupSession(req.params.id);
686
693
  return { ok: true };
@@ -762,6 +769,27 @@ app.get('/v1/sessions/:id/transcript', async (req, reply) => {
762
769
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
763
770
  }
764
771
  });
772
+ // Cursor-based transcript replay (Issue #883): stable pagination under concurrent appends.
773
+ // GET /v1/sessions/:id/transcript/cursor?before_id=N&limit=50&role=user|assistant|system
774
+ app.get('/v1/sessions/:id/transcript/cursor', async (req, reply) => {
775
+ try {
776
+ const rawBeforeId = req.query.before_id;
777
+ const beforeId = rawBeforeId !== undefined ? parseInt(rawBeforeId, 10) : undefined;
778
+ if (beforeId !== undefined && (!Number.isInteger(beforeId) || beforeId < 1)) {
779
+ return reply.status(400).send({ error: 'before_id must be a positive integer' });
780
+ }
781
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || '50', 10) || 50));
782
+ const allowedRoles = new Set(['user', 'assistant', 'system']);
783
+ const roleFilter = req.query.role;
784
+ if (roleFilter && !allowedRoles.has(roleFilter)) {
785
+ return reply.status(400).send({ error: `Invalid role filter: ${roleFilter}. Allowed values: user, assistant, system` });
786
+ }
787
+ return await sessions.readTranscriptCursor(req.params.id, beforeId, limit, roleFilter);
788
+ }
789
+ catch (e) {
790
+ return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
791
+ }
792
+ });
765
793
  // Screenshot capture (Issue #22)
766
794
  async function screenshotHandler(req, reply) {
767
795
  const parsed = screenshotSchema.safeParse(req.body);
@@ -997,14 +1025,16 @@ async function reapStaleSessions(maxAgeMs) {
997
1025
  const ageMin = Math.round(age / 60000);
998
1026
  console.log(`Reaper: killing session ${session.windowName} (${session.id.slice(0, 8)}) — age ${ageMin}min`);
999
1027
  try {
1028
+ // #842: killSession first, then notify — avoids race where channels
1029
+ // reference a session that is still being destroyed.
1030
+ await sessions.killSession(session.id);
1031
+ eventBus.cleanupSession(session.id);
1000
1032
  await channels.sessionEnded({
1001
1033
  event: 'session.ended',
1002
1034
  timestamp: new Date().toISOString(),
1003
1035
  session: { id: session.id, name: session.windowName, workDir: session.workDir },
1004
1036
  detail: `Auto-killed: exceeded ${maxAgeMs / 3600000}h time limit`,
1005
1037
  });
1006
- eventBus.cleanupSession(session.id);
1007
- await sessions.killSession(session.id);
1008
1038
  monitor.removeSession(session.id);
1009
1039
  metrics.cleanupSession(session.id);
1010
1040
  }
package/dist/session.d.ts CHANGED
@@ -65,8 +65,14 @@ export declare class SessionManager {
65
65
  private pendingQuestions;
66
66
  private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
67
67
  private parsedEntriesCache;
68
- private sessionAcquireMutex;
68
+ private readonly sessionAcquireMutex;
69
69
  constructor(tmux: TmuxManager, config: Config);
70
+ /**
71
+ * Issue #884: Worktree-aware session file lookup.
72
+ * When `worktreeAwareContinuation` is enabled, fans out to sibling worktree
73
+ * project dirs; otherwise falls back to the existing single-directory search.
74
+ */
75
+ private findSessionFileMaybeWorktree;
70
76
  /** Validate that parsed data looks like a valid SessionState. */
71
77
  private isValidState;
72
78
  /** Clean up stale .tmp files left by crashed writes. */
@@ -164,7 +170,7 @@ export declare class SessionManager {
164
170
  * Returns the most recently active idle session, or null if none found.
165
171
  * Used to resume existing sessions instead of creating duplicates.
166
172
  * Issue #636: Verifies tmux window is still alive before returning.
167
- * Issue #840: Atomically acquires the session under a mutex to prevent TOCTOU race. */
173
+ * Issue #840/#880: Atomically acquires the session under a mutex to prevent TOCTOU race. */
168
174
  findIdleSessionByWorkDir(workDir: string): Promise<SessionInfo | null>;
169
175
  /** Release a session claim after the reuse path completes (success or failure). */
170
176
  releaseSessionClaim(id: string): void;
@@ -298,6 +304,23 @@ export declare class SessionManager {
298
304
  limit: number;
299
305
  hasMore: boolean;
300
306
  }>;
307
+ /**
308
+ * Cursor-based transcript read — stable under concurrent appends.
309
+ *
310
+ * Uses 1-based sequential entry indices as cursors.
311
+ * - `beforeId`: exclusive upper bound (fetch entries with index < beforeId).
312
+ * If omitted, fetch the newest `limit` entries.
313
+ * - `limit`: max entries to return (capped at 200).
314
+ * - Returns entries in ascending order (oldest first) within the window.
315
+ */
316
+ readTranscriptCursor(id: string, beforeId?: number, limit?: number, roleFilter?: 'user' | 'assistant' | 'system'): Promise<{
317
+ messages: (ParsedEntry & {
318
+ _cursor_id: number;
319
+ })[];
320
+ has_more: boolean;
321
+ oldest_id: number | null;
322
+ newest_id: number | null;
323
+ }>;
301
324
  /** #405: Clean up all tracking maps for a session to prevent memory leaks. */
302
325
  private cleanupSession;
303
326
  /** Kill a session. */
package/dist/session.js CHANGED
@@ -9,11 +9,13 @@ import { existsSync, unlinkSync, readdirSync } from 'node:fs';
9
9
  import { join, dirname } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { findSessionFile, readNewEntries } from './transcript.js';
12
+ import { findSessionFileWithFanout } from './worktree-lookup.js';
12
13
  import { detectUIState, extractInteractiveContent, parseStatusLine } from './terminal-parser.js';
13
14
  import { computeStallThreshold } from './config.js';
14
15
  import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
15
16
  import { persistedStateSchema, sessionMapSchema } from './validation.js';
16
17
  import { writeHookSettingsFile, cleanupHookSettingsFile } from './hook-settings.js';
18
+ import { Mutex } from 'async-mutex';
17
19
  /** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
18
20
  function hydrateSessions(raw) {
19
21
  const sessions = {};
@@ -62,14 +64,25 @@ export class SessionManager {
62
64
  // #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
63
65
  static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
64
66
  parsedEntriesCache = new Map();
65
- // Issue #840: Mutex to prevent TOCTOU race in findIdleSessionByWorkDir
66
- sessionAcquireMutex = Promise.resolve();
67
+ // Issue #840/#880: Explicit mutex to prevent TOCTOU races in session acquisition.
68
+ sessionAcquireMutex = new Mutex();
67
69
  constructor(tmux, config) {
68
70
  this.tmux = tmux;
69
71
  this.config = config;
70
72
  this.stateFile = join(config.stateDir, 'state.json');
71
73
  this.sessionMapFile = join(config.stateDir, 'session_map.json');
72
74
  }
75
+ /**
76
+ * Issue #884: Worktree-aware session file lookup.
77
+ * When `worktreeAwareContinuation` is enabled, fans out to sibling worktree
78
+ * project dirs; otherwise falls back to the existing single-directory search.
79
+ */
80
+ findSessionFileMaybeWorktree(sessionId) {
81
+ if (this.config.worktreeAwareContinuation && this.config.worktreeSiblingDirs.length > 0) {
82
+ return findSessionFileWithFanout(sessionId, this.config.claudeProjectsDir, this.config.worktreeSiblingDirs);
83
+ }
84
+ return findSessionFile(sessionId, this.config.claudeProjectsDir);
85
+ }
73
86
  /** Validate that parsed data looks like a valid SessionState. */
74
87
  isValidState(data) {
75
88
  if (typeof data !== 'object' || data === null)
@@ -705,15 +718,9 @@ export class SessionManager {
705
718
  * Returns the most recently active idle session, or null if none found.
706
719
  * Used to resume existing sessions instead of creating duplicates.
707
720
  * Issue #636: Verifies tmux window is still alive before returning.
708
- * Issue #840: Atomically acquires the session under a mutex to prevent TOCTOU race. */
721
+ * Issue #840/#880: Atomically acquires the session under a mutex to prevent TOCTOU race. */
709
722
  async findIdleSessionByWorkDir(workDir) {
710
- // Issue #840: Acquire mutex — chain onto the previous operation
711
- let release;
712
- const lock = new Promise((resolve) => { release = resolve; });
713
- const previous = this.sessionAcquireMutex;
714
- this.sessionAcquireMutex = lock;
715
- await previous.catch(() => { }); // tolerate prior rejection
716
- try {
723
+ return this.sessionAcquireMutex.runExclusive(async () => {
717
724
  const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
718
725
  if (candidates.length === 0)
719
726
  return null;
@@ -729,10 +736,7 @@ export class SessionManager {
729
736
  }
730
737
  }
731
738
  return null;
732
- }
733
- finally {
734
- release();
735
- }
739
+ });
736
740
  }
737
741
  /** Release a session claim after the reuse path completes (success or failure). */
738
742
  releaseSessionClaim(id) {
@@ -1040,9 +1044,9 @@ export class SessionManager {
1040
1044
  const interactive = extractInteractiveContent(paneText);
1041
1045
  session.status = status;
1042
1046
  session.lastActivity = Date.now();
1043
- // Try to find JSONL if we don't have it yet
1047
+ // Try to find JSONL if we don't have it yet (Issue #884: worktree-aware)
1044
1048
  if (!session.jsonlPath && session.claudeSessionId) {
1045
- const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1049
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1046
1050
  if (path) {
1047
1051
  session.jsonlPath = path;
1048
1052
  session.byteOffset = 0;
@@ -1081,9 +1085,9 @@ export class SessionManager {
1081
1085
  const statusText = parseStatusLine(paneText);
1082
1086
  const interactive = extractInteractiveContent(paneText);
1083
1087
  session.status = status;
1084
- // Try to find JSONL if we don't have it yet
1088
+ // Try to find JSONL if we don't have it yet (Issue #884: worktree-aware)
1085
1089
  if (!session.jsonlPath && session.claudeSessionId) {
1086
- const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1090
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1087
1091
  if (path) {
1088
1092
  session.jsonlPath = path;
1089
1093
  session.monitorOffset = 0;
@@ -1171,9 +1175,9 @@ export class SessionManager {
1171
1175
  const session = this.state.sessions[id];
1172
1176
  if (!session)
1173
1177
  throw new Error(`Session ${id} not found`);
1174
- // Discover JSONL path if not yet known
1178
+ // Discover JSONL path if not yet known (Issue #884: worktree-aware)
1175
1179
  if (!session.jsonlPath && session.claudeSessionId) {
1176
- const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1180
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1177
1181
  if (path) {
1178
1182
  session.jsonlPath = path;
1179
1183
  session.byteOffset = 0;
@@ -1197,6 +1201,50 @@ export class SessionManager {
1197
1201
  hasMore,
1198
1202
  };
1199
1203
  }
1204
+ /**
1205
+ * Cursor-based transcript read — stable under concurrent appends.
1206
+ *
1207
+ * Uses 1-based sequential entry indices as cursors.
1208
+ * - `beforeId`: exclusive upper bound (fetch entries with index < beforeId).
1209
+ * If omitted, fetch the newest `limit` entries.
1210
+ * - `limit`: max entries to return (capped at 200).
1211
+ * - Returns entries in ascending order (oldest first) within the window.
1212
+ */
1213
+ async readTranscriptCursor(id, beforeId, limit = 50, roleFilter) {
1214
+ const session = this.state.sessions[id];
1215
+ if (!session)
1216
+ throw new Error(`Session ${id} not found`);
1217
+ // Discover JSONL path if not yet known
1218
+ if (!session.jsonlPath && session.claudeSessionId) {
1219
+ const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1220
+ if (path) {
1221
+ session.jsonlPath = path;
1222
+ session.byteOffset = 0;
1223
+ }
1224
+ }
1225
+ let allEntries = await this.getCachedEntries(session);
1226
+ if (roleFilter) {
1227
+ allEntries = allEntries.filter(e => e.role === roleFilter);
1228
+ }
1229
+ const total = allEntries.length;
1230
+ const clampedLimit = Math.min(200, Math.max(1, limit));
1231
+ // Determine exclusive upper index (0-based)
1232
+ const upperExclusive = beforeId !== undefined
1233
+ ? Math.min(beforeId - 1, total) // beforeId is 1-based
1234
+ : total;
1235
+ const lowerInclusive = Math.max(0, upperExclusive - clampedLimit);
1236
+ const slice = allEntries.slice(lowerInclusive, upperExclusive);
1237
+ const messages = slice.map((entry, i) => ({
1238
+ ...entry,
1239
+ _cursor_id: lowerInclusive + i + 1, // 1-based stable index
1240
+ }));
1241
+ return {
1242
+ messages,
1243
+ has_more: lowerInclusive > 0,
1244
+ oldest_id: messages.length > 0 ? messages[0]._cursor_id : null,
1245
+ newest_id: messages.length > 0 ? messages[messages.length - 1]._cursor_id : null,
1246
+ };
1247
+ }
1200
1248
  /** #405: Clean up all tracking maps for a session to prevent memory leaks. */
1201
1249
  cleanupSession(id) {
1202
1250
  // Clear polling timers (both regular and filesystem discovery variants)
@@ -1340,9 +1388,9 @@ export class SessionManager {
1340
1388
  }
1341
1389
  try {
1342
1390
  await this.syncSessionMap();
1343
- // If we have claudeSessionId but no jsonlPath, try finding it
1391
+ // If we have claudeSessionId but no jsonlPath, try finding it (Issue #884: worktree-aware)
1344
1392
  if (session.claudeSessionId && !session.jsonlPath) {
1345
- const jsonlPath = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1393
+ const jsonlPath = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1346
1394
  if (jsonlPath) {
1347
1395
  session.jsonlPath = jsonlPath;
1348
1396
  session.byteOffset = 0;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * suppress.ts — Explicit suppression predicate for expected runtime races.
3
+ *
4
+ * Issue #882: Replaces silent empty catches with a documented, testable
5
+ * suppression contract. Suppressible errors (expected races, killed sessions,
6
+ * missing tmux panes) are forwarded as rate-limited diagnostics events.
7
+ * Non-suppressible errors are surfaced at warn level.
8
+ */
9
+ /** Contexts where suppressible races may occur. */
10
+ export type SuppressContext = 'monitor.checkSession' | 'monitor.checkDeadSessions.killSession' | 'monitor.checkStopSignals.parseEntry' | 'session.cleanup' | 'tmux.capturePane' | string;
11
+ /** Exported for tests — clears all rate-limit counters. */
12
+ export declare function _resetSuppressRateLimit(): void;
13
+ /**
14
+ * Returns true if the error is an expected transient race that
15
+ * should be swallowed without surfacing a warning.
16
+ *
17
+ * Categories of suppressible errors:
18
+ * - Session killed while in-flight (SESSION_NOT_FOUND-class messages)
19
+ * - File not found (ENOENT) — session JSONL removed after kill
20
+ * - Tmux pane/window gone — dead-session race
21
+ * - SyntaxError from truncated JSONL reads during rotation
22
+ */
23
+ export declare function isSuppressible(error: unknown, _context: SuppressContext): boolean;
24
+ /**
25
+ * Handle a caught error using the explicit suppression policy.
26
+ *
27
+ * - Suppressible errors: console.debug with rate limiting (max 10/min per context).
28
+ * - Non-suppressible errors: console.warn — always visible.
29
+ *
30
+ * Does NOT rethrow. Call isSuppressible() directly if you need rethrow control.
31
+ */
32
+ export declare function suppressedCatch(error: unknown, context: SuppressContext): void;
33
+ export declare function _errorMessage(error: unknown): string;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * suppress.ts — Explicit suppression predicate for expected runtime races.
3
+ *
4
+ * Issue #882: Replaces silent empty catches with a documented, testable
5
+ * suppression contract. Suppressible errors (expected races, killed sessions,
6
+ * missing tmux panes) are forwarded as rate-limited diagnostics events.
7
+ * Non-suppressible errors are surfaced at warn level.
8
+ */
9
+ /** Rate-limit state: max N suppressed debug events per context per minute. */
10
+ const suppressRateLimit = new Map();
11
+ /** Exported for tests — clears all rate-limit counters. */
12
+ export function _resetSuppressRateLimit() {
13
+ suppressRateLimit.clear();
14
+ }
15
+ const SUPPRESS_MAX_PER_MINUTE = 10;
16
+ /**
17
+ * Returns true if the error is an expected transient race that
18
+ * should be swallowed without surfacing a warning.
19
+ *
20
+ * Categories of suppressible errors:
21
+ * - Session killed while in-flight (SESSION_NOT_FOUND-class messages)
22
+ * - File not found (ENOENT) — session JSONL removed after kill
23
+ * - Tmux pane/window gone — dead-session race
24
+ * - SyntaxError from truncated JSONL reads during rotation
25
+ */
26
+ export function isSuppressible(error, _context) {
27
+ if (error instanceof SyntaxError)
28
+ return true;
29
+ if (error instanceof Error) {
30
+ const code = error.code;
31
+ if (code === 'ENOENT')
32
+ return true;
33
+ const msg = error.message.toLowerCase();
34
+ if (msg.includes('session not found'))
35
+ return true;
36
+ if (msg.includes('no session with id'))
37
+ return true;
38
+ if (msg.includes('no such window'))
39
+ return true;
40
+ if (msg.includes('no such pane'))
41
+ return true;
42
+ if (msg.includes('no such session'))
43
+ return true;
44
+ if (msg.includes("can't find window"))
45
+ return true;
46
+ if (msg.includes('window already dead'))
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ /**
52
+ * Handle a caught error using the explicit suppression policy.
53
+ *
54
+ * - Suppressible errors: console.debug with rate limiting (max 10/min per context).
55
+ * - Non-suppressible errors: console.warn — always visible.
56
+ *
57
+ * Does NOT rethrow. Call isSuppressible() directly if you need rethrow control.
58
+ */
59
+ export function suppressedCatch(error, context) {
60
+ if (isSuppressible(error, context)) {
61
+ const now = Date.now();
62
+ const state = suppressRateLimit.get(context);
63
+ if (!state || now >= state.resetAt) {
64
+ suppressRateLimit.set(context, { count: 1, resetAt: now + 60_000 });
65
+ console.debug(`[suppress] ${context}: ${_errorMessage(error)}`);
66
+ }
67
+ else if (state.count < SUPPRESS_MAX_PER_MINUTE) {
68
+ state.count++;
69
+ console.debug(`[suppress] ${context}: ${_errorMessage(error)}`);
70
+ }
71
+ // rate limit exceeded for this window — drop silently
72
+ }
73
+ else {
74
+ console.warn(`[unexpected] ${context}: ${_errorMessage(error)}`, error);
75
+ }
76
+ }
77
+ export function _errorMessage(error) {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * worktree-lookup.ts — Worktree-aware session file discovery.
3
+ *
4
+ * Issue #884: Extends the single-directory findSessionFile with bounded fanout
5
+ * across sibling worktree project directories. Returns the freshest (most
6
+ * recently modified) matching JSONL file across all candidate dirs.
7
+ */
8
+ /**
9
+ * Find the freshest JSONL file for a given sessionId across multiple
10
+ * Claude projects directories.
11
+ *
12
+ * Search order:
13
+ * 1. Primary directory (existing `claudeProjectsDir` — normal path)
14
+ * 2. Sibling directories (fanout, bounded by maxCandidates)
15
+ *
16
+ * Returns the path with the highest mtime, or null if not found.
17
+ * Silently ignores unreadable/missing directories.
18
+ *
19
+ * @param sessionId Claude session UUID
20
+ * @param primaryDir Primary `~/.claude/projects` directory (searched first)
21
+ * @param siblingDirs Additional directories to search (fanout)
22
+ * @param maxCandidates Upper bound on sibling candidates to evaluate (default: 5)
23
+ */
24
+ export declare function findSessionFileWithFanout(sessionId: string, primaryDir: string, siblingDirs: string[], maxCandidates?: number): Promise<string | null>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * worktree-lookup.ts — Worktree-aware session file discovery.
3
+ *
4
+ * Issue #884: Extends the single-directory findSessionFile with bounded fanout
5
+ * across sibling worktree project directories. Returns the freshest (most
6
+ * recently modified) matching JSONL file across all candidate dirs.
7
+ */
8
+ import { stat, readdir } from 'node:fs/promises';
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ /** Expand leading ~ to home directory. */
13
+ function expandTilde(p) {
14
+ return p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
15
+ }
16
+ /**
17
+ * Find the freshest JSONL file for a given sessionId across multiple
18
+ * Claude projects directories.
19
+ *
20
+ * Search order:
21
+ * 1. Primary directory (existing `claudeProjectsDir` — normal path)
22
+ * 2. Sibling directories (fanout, bounded by maxCandidates)
23
+ *
24
+ * Returns the path with the highest mtime, or null if not found.
25
+ * Silently ignores unreadable/missing directories.
26
+ *
27
+ * @param sessionId Claude session UUID
28
+ * @param primaryDir Primary `~/.claude/projects` directory (searched first)
29
+ * @param siblingDirs Additional directories to search (fanout)
30
+ * @param maxCandidates Upper bound on sibling candidates to evaluate (default: 5)
31
+ */
32
+ export async function findSessionFileWithFanout(sessionId, primaryDir, siblingDirs, maxCandidates = 5) {
33
+ const candidates = [];
34
+ // Helper: scan one projects dir for sessionId.jsonl files
35
+ async function scanDir(dir) {
36
+ const expanded = expandTilde(dir);
37
+ if (!existsSync(expanded))
38
+ return;
39
+ let entries;
40
+ try {
41
+ entries = await readdir(expanded, { withFileTypes: true, encoding: 'utf8' });
42
+ }
43
+ catch {
44
+ return; // unreadable directory — skip
45
+ }
46
+ for (const entry of entries) {
47
+ if (!entry.isDirectory())
48
+ continue;
49
+ const jsonlPath = join(expanded, entry.name, `${sessionId}.jsonl`);
50
+ if (existsSync(jsonlPath)) {
51
+ try {
52
+ const { mtimeMs } = await stat(jsonlPath);
53
+ candidates.push({ path: jsonlPath, mtimeMs });
54
+ }
55
+ catch {
56
+ // stat failed — entry may have been deleted between existsSync and stat
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // Always scan primary first
62
+ await scanDir(primaryDir);
63
+ // Fanout to siblings (bounded)
64
+ const bounded = siblingDirs.slice(0, maxCandidates);
65
+ await Promise.all(bounded.map(d => scanDir(d)));
66
+ if (candidates.length === 0)
67
+ return null;
68
+ // Return path with the highest mtime (freshest)
69
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
70
+ return candidates[0].path;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.5.4",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",
@@ -48,6 +48,7 @@
48
48
  "@fastify/static": "^9.0.0",
49
49
  "@fastify/websocket": "^11.2.0",
50
50
  "@modelcontextprotocol/sdk": "^1.28.0",
51
+ "async-mutex": "^0.5.0",
51
52
  "fastify": "^5.8.2",
52
53
  "zod": "^4.3.6"
53
54
  },