aegis-bridge 2.5.5 → 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
  }
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);
@@ -285,6 +286,18 @@ async function healthHandler() {
285
286
  }
286
287
  app.get('/v1/health', healthHandler);
287
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
288
301
  // Issue #81: Swarm awareness — list all detected CC swarms and their teammates
289
302
  app.get('/v1/swarm', async () => {
290
303
  const result = await swarmMonitor.scan();
@@ -602,39 +615,29 @@ async function readMessagesHandler(req, reply) {
602
615
  }
603
616
  app.get('/v1/sessions/:id/read', readMessagesHandler);
604
617
  app.get('/sessions/:id/read', readMessagesHandler);
605
- // Approve
606
- async function approveHandler(req, reply) {
607
- try {
608
- await sessions.approve(req.params.id);
609
- // Issue #87: Record permission response latency
610
- const lat = sessions.getLatencyMetrics(req.params.id);
611
- if (lat !== null && lat.permission_response_ms !== null) {
612
- 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 };
613
629
  }
614
- return { ok: true };
615
- }
616
- catch (e) {
617
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
618
- }
619
- }
620
- app.post('/v1/sessions/:id/approve', approveHandler);
621
- app.post('/sessions/:id/approve', approveHandler);
622
- // Reject
623
- async function rejectHandler(req, reply) {
624
- try {
625
- await sessions.reject(req.params.id);
626
- const lat = sessions.getLatencyMetrics(req.params.id);
627
- if (lat !== null && lat.permission_response_ms !== null) {
628
- 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) });
629
632
  }
630
- return { ok: true };
631
- }
632
- catch (e) {
633
- return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
634
- }
633
+ };
635
634
  }
635
+ const approveHandler = makePermissionHandler('approve');
636
+ const rejectHandler = makePermissionHandler('reject');
636
637
  app.post('/v1/sessions/:id/reject', rejectHandler);
637
638
  app.post('/sessions/:id/reject', rejectHandler);
639
+ app.post('/v1/sessions/:id/approve', approveHandler);
640
+ app.post('/sessions/:id/approve', approveHandler);
638
641
  // Issue #336: Answer pending AskUserQuestion
639
642
  app.post('/v1/sessions/:id/answer', async (req, reply) => {
640
643
  const { questionId, answer } = req.body || {};
@@ -766,6 +769,27 @@ app.get('/v1/sessions/:id/transcript', async (req, reply) => {
766
769
  return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
767
770
  }
768
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
+ });
769
793
  // Screenshot capture (Issue #22)
770
794
  async function screenshotHandler(req, reply) {
771
795
  const parsed = screenshotSchema.safeParse(req.body);
package/dist/session.d.ts CHANGED
@@ -67,6 +67,12 @@ export declare class SessionManager {
67
67
  private parsedEntriesCache;
68
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. */
@@ -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,6 +9,7 @@ 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';
@@ -71,6 +72,17 @@ export class SessionManager {
71
72
  this.stateFile = join(config.stateDir, 'state.json');
72
73
  this.sessionMapFile = join(config.stateDir, 'session_map.json');
73
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
+ }
74
86
  /** Validate that parsed data looks like a valid SessionState. */
75
87
  isValidState(data) {
76
88
  if (typeof data !== 'object' || data === null)
@@ -1032,9 +1044,9 @@ export class SessionManager {
1032
1044
  const interactive = extractInteractiveContent(paneText);
1033
1045
  session.status = status;
1034
1046
  session.lastActivity = Date.now();
1035
- // 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)
1036
1048
  if (!session.jsonlPath && session.claudeSessionId) {
1037
- const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1049
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1038
1050
  if (path) {
1039
1051
  session.jsonlPath = path;
1040
1052
  session.byteOffset = 0;
@@ -1073,9 +1085,9 @@ export class SessionManager {
1073
1085
  const statusText = parseStatusLine(paneText);
1074
1086
  const interactive = extractInteractiveContent(paneText);
1075
1087
  session.status = status;
1076
- // 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)
1077
1089
  if (!session.jsonlPath && session.claudeSessionId) {
1078
- const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1090
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1079
1091
  if (path) {
1080
1092
  session.jsonlPath = path;
1081
1093
  session.monitorOffset = 0;
@@ -1163,9 +1175,9 @@ export class SessionManager {
1163
1175
  const session = this.state.sessions[id];
1164
1176
  if (!session)
1165
1177
  throw new Error(`Session ${id} not found`);
1166
- // Discover JSONL path if not yet known
1178
+ // Discover JSONL path if not yet known (Issue #884: worktree-aware)
1167
1179
  if (!session.jsonlPath && session.claudeSessionId) {
1168
- const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1180
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1169
1181
  if (path) {
1170
1182
  session.jsonlPath = path;
1171
1183
  session.byteOffset = 0;
@@ -1189,6 +1201,50 @@ export class SessionManager {
1189
1201
  hasMore,
1190
1202
  };
1191
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
+ }
1192
1248
  /** #405: Clean up all tracking maps for a session to prevent memory leaks. */
1193
1249
  cleanupSession(id) {
1194
1250
  // Clear polling timers (both regular and filesystem discovery variants)
@@ -1332,9 +1388,9 @@ export class SessionManager {
1332
1388
  }
1333
1389
  try {
1334
1390
  await this.syncSessionMap();
1335
- // 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)
1336
1392
  if (session.claudeSessionId && !session.jsonlPath) {
1337
- const jsonlPath = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1393
+ const jsonlPath = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1338
1394
  if (jsonlPath) {
1339
1395
  session.jsonlPath = jsonlPath;
1340
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.5",
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",