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 +76 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +2 -0
- package/dist/handshake.d.ts +40 -0
- package/dist/handshake.js +90 -0
- package/dist/monitor.js +8 -5
- package/dist/server.js +52 -28
- package/dist/session.d.ts +23 -0
- package/dist/session.js +64 -8
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/package.json +1 -1
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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
async
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/suppress.js
ADDED
|
@@ -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
|
+
}
|