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 +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/pipeline.d.ts +1 -0
- package/dist/pipeline.js +11 -2
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/server.js +62 -32
- package/dist/session.d.ts +25 -2
- package/dist/session.js +70 -22
- 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 +2 -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/pipeline.d.ts
CHANGED
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';
|
package/dist/retry.d.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
604
|
-
async
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
66
|
-
sessionAcquireMutex =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-bridge",
|
|
3
|
-
"version": "2.
|
|
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
|
},
|