aegis-bridge 2.6.3 → 2.7.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/dashboard/dist/assets/{index-Bfabq3q-.js → index-SoKhTCVa.js} +25 -25
- package/dashboard/dist/index.html +1 -1
- package/dist/hook-settings.d.ts +12 -3
- package/dist/hook-settings.js +63 -2
- package/dist/monitor.d.ts +10 -0
- package/dist/monitor.js +53 -31
- package/dist/server.js +87 -3
- package/dist/session.d.ts +9 -0
- package/dist/session.js +38 -2
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.js +16 -6
- package/dist/validation.d.ts +36 -0
- package/dist/validation.js +15 -0
- package/package.json +1 -1
- package/dist/dashboard/assets/index-9Hkkvm_I.css +0 -32
- package/dist/dashboard/assets/index-Bfabq3q-.js +0 -262
- package/dist/dashboard/index.html +0 -14
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Aegis Dashboard</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>" />
|
|
8
|
-
<script type="module" crossorigin src="/dashboard/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/dashboard/assets/index-SoKhTCVa.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-9Hkkvm_I.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body class="bg-[#0a0a0f] text-gray-200 antialiased">
|
package/dist/hook-settings.d.ts
CHANGED
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
* for Aegis status detection and event forwarding.
|
|
20
20
|
*
|
|
21
21
|
* Excluded (low value for Aegis):
|
|
22
|
-
* - InstructionsLoaded, ConfigChange
|
|
22
|
+
* - InstructionsLoaded, ConfigChange (informational)
|
|
23
23
|
* - WorktreeCreate, WorktreeRemove (worktree management)
|
|
24
24
|
* - Elicitation, ElicitationResult (MCP-specific)
|
|
25
|
-
* - PreCompact, PostCompact (internal optimization)
|
|
26
25
|
*/
|
|
27
|
-
declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "Notification", "TeammateIdle"];
|
|
26
|
+
declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "PreCompact", "PostCompact", "FileChanged", "CwdChanged", "Notification", "TeammateIdle"];
|
|
28
27
|
export { HTTP_HOOK_EVENTS };
|
|
29
28
|
export type HttpHookEvent = typeof HTTP_HOOK_EVENTS[number];
|
|
30
29
|
/** Shape of a single HTTP hook entry in CC settings.json. */
|
|
@@ -66,3 +65,13 @@ export declare function writeHookSettingsFile(baseUrl: string, sessionId: string
|
|
|
66
65
|
* @param filePath - Path to the temporary settings file
|
|
67
66
|
*/
|
|
68
67
|
export declare function cleanupHookSettingsFile(filePath: string): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
|
|
70
|
+
*
|
|
71
|
+
* When sessions die, their hook URLs remain in settings.local.json.
|
|
72
|
+
* On restart, CC loads these dead hooks and crashes.
|
|
73
|
+
*
|
|
74
|
+
* @param workDir - Project working directory
|
|
75
|
+
* @param activeSessionIds - Set of currently active session IDs
|
|
76
|
+
*/
|
|
77
|
+
export declare function cleanupStaleSessionHooks(workDir: string, activeSessionIds: Set<string>): Promise<void>;
|
package/dist/hook-settings.js
CHANGED
|
@@ -43,10 +43,9 @@ function validateWorkDirPath(workDir) {
|
|
|
43
43
|
* for Aegis status detection and event forwarding.
|
|
44
44
|
*
|
|
45
45
|
* Excluded (low value for Aegis):
|
|
46
|
-
* - InstructionsLoaded, ConfigChange
|
|
46
|
+
* - InstructionsLoaded, ConfigChange (informational)
|
|
47
47
|
* - WorktreeCreate, WorktreeRemove (worktree management)
|
|
48
48
|
* - Elicitation, ElicitationResult (MCP-specific)
|
|
49
|
-
* - PreCompact, PostCompact (internal optimization)
|
|
50
49
|
*/
|
|
51
50
|
const HTTP_HOOK_EVENTS = [
|
|
52
51
|
// Status detection (highest value)
|
|
@@ -64,6 +63,12 @@ const HTTP_HOOK_EVENTS = [
|
|
|
64
63
|
// Subagent tracking
|
|
65
64
|
'SubagentStart',
|
|
66
65
|
'SubagentStop',
|
|
66
|
+
// Context management
|
|
67
|
+
'PreCompact',
|
|
68
|
+
'PostCompact',
|
|
69
|
+
// File & directory changes
|
|
70
|
+
'FileChanged',
|
|
71
|
+
'CwdChanged',
|
|
67
72
|
// Notifications
|
|
68
73
|
'Notification',
|
|
69
74
|
'TeammateIdle',
|
|
@@ -135,6 +140,10 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
|
|
|
135
140
|
mergedHooks[event] = [...(existingHooks[event] ?? []), ...entries];
|
|
136
141
|
}
|
|
137
142
|
const combined = { ...merged, hooks: mergedHooks };
|
|
143
|
+
// Issue #931: Always inject MCP_CONNECTION_NONBLOCKING so CC does not block
|
|
144
|
+
// on MCP server connections when launched via Aegis orchestration.
|
|
145
|
+
(combined.env = (combined.env || {}));
|
|
146
|
+
(combined.env || {})["MCP_CONNECTION_NONBLOCKING"] = "true";
|
|
138
147
|
// Issue #648: Use unpredictable directory name and restrictive permissions
|
|
139
148
|
// to prevent symlink attacks and information disclosure in /tmp.
|
|
140
149
|
const suffix = randomBytes(4).toString('hex');
|
|
@@ -164,3 +173,55 @@ export async function cleanupHookSettingsFile(filePath) {
|
|
|
164
173
|
// Non-fatal: temp file cleanup failed
|
|
165
174
|
}
|
|
166
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
|
|
178
|
+
*
|
|
179
|
+
* When sessions die, their hook URLs remain in settings.local.json.
|
|
180
|
+
* On restart, CC loads these dead hooks and crashes.
|
|
181
|
+
*
|
|
182
|
+
* @param workDir - Project working directory
|
|
183
|
+
* @param activeSessionIds - Set of currently active session IDs
|
|
184
|
+
*/
|
|
185
|
+
export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
|
|
186
|
+
const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
|
|
187
|
+
if (!safeWorkDir)
|
|
188
|
+
return;
|
|
189
|
+
const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
|
|
190
|
+
if (!existsSync(projectSettingsPath))
|
|
191
|
+
return;
|
|
192
|
+
try {
|
|
193
|
+
const raw = await readFile(projectSettingsPath, 'utf-8');
|
|
194
|
+
const parsed = ccSettingsSchema.safeParse(JSON.parse(raw));
|
|
195
|
+
if (!parsed.success)
|
|
196
|
+
return;
|
|
197
|
+
const settings = parsed.data;
|
|
198
|
+
const hooks = settings.hooks;
|
|
199
|
+
if (!hooks)
|
|
200
|
+
return;
|
|
201
|
+
let changed = false;
|
|
202
|
+
for (const [event, eventHooks] of Object.entries(hooks)) {
|
|
203
|
+
const filtered = eventHooks.filter(entry => {
|
|
204
|
+
const httpHook = entry.hooks?.find(h => h.type === 'http');
|
|
205
|
+
if (!httpHook)
|
|
206
|
+
return true;
|
|
207
|
+
const url = httpHook.url;
|
|
208
|
+
const match = url.match(/[?&]sessionId=([^&]+)/);
|
|
209
|
+
if (!match)
|
|
210
|
+
return true;
|
|
211
|
+
const sessionId = match[1];
|
|
212
|
+
if (!activeSessionIds.has(sessionId)) {
|
|
213
|
+
changed = true;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
});
|
|
218
|
+
hooks[event] = filtered;
|
|
219
|
+
}
|
|
220
|
+
if (changed) {
|
|
221
|
+
await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Non-fatal: cleanup failed
|
|
226
|
+
}
|
|
227
|
+
}
|
package/dist/monitor.d.ts
CHANGED
|
@@ -31,6 +31,16 @@ export declare class SessionMonitor {
|
|
|
31
31
|
private lastStatus;
|
|
32
32
|
private lastBytesSeen;
|
|
33
33
|
private stallNotified;
|
|
34
|
+
/** Issue #663: O(1) stall notification check. */
|
|
35
|
+
private stallHas;
|
|
36
|
+
/** Issue #663: O(1) stall notification add. */
|
|
37
|
+
private stallAdd;
|
|
38
|
+
/** Issue #663: O(1) stall notification delete. */
|
|
39
|
+
private stallDelete;
|
|
40
|
+
/** Issue #663: Delete all stall notifications for a session. */
|
|
41
|
+
private stallDeleteAll;
|
|
42
|
+
/** Issue #663: Delete specific stall types for a session. */
|
|
43
|
+
private stallDeleteTypes;
|
|
34
44
|
private lastStallCheck;
|
|
35
45
|
private lastDeadCheck;
|
|
36
46
|
private idleNotified;
|
package/dist/monitor.js
CHANGED
|
@@ -34,7 +34,38 @@ export class SessionMonitor {
|
|
|
34
34
|
running = false;
|
|
35
35
|
lastStatus = new Map();
|
|
36
36
|
lastBytesSeen = new Map();
|
|
37
|
-
|
|
37
|
+
// Issue #663: Nested Map for O(1) per-session stall lookup (was Set with O(n) prefix scan)
|
|
38
|
+
stallNotified = new Map(); // sessionId → Set<stallType>
|
|
39
|
+
/** Issue #663: O(1) stall notification check. */
|
|
40
|
+
stallHas(sessionId, stallType) {
|
|
41
|
+
return this.stallNotified.get(sessionId)?.has(stallType) ?? false;
|
|
42
|
+
}
|
|
43
|
+
/** Issue #663: O(1) stall notification add. */
|
|
44
|
+
stallAdd(sessionId, stallType) {
|
|
45
|
+
const set = this.stallNotified.get(sessionId);
|
|
46
|
+
if (set) {
|
|
47
|
+
set.add(stallType);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
this.stallNotified.set(sessionId, new Set([stallType]));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Issue #663: O(1) stall notification delete. */
|
|
54
|
+
stallDelete(sessionId, stallType) {
|
|
55
|
+
this.stallNotified.get(sessionId)?.delete(stallType);
|
|
56
|
+
}
|
|
57
|
+
/** Issue #663: Delete all stall notifications for a session. */
|
|
58
|
+
stallDeleteAll(sessionId) {
|
|
59
|
+
this.stallNotified.delete(sessionId);
|
|
60
|
+
}
|
|
61
|
+
/** Issue #663: Delete specific stall types for a session. */
|
|
62
|
+
stallDeleteTypes(sessionId, types) {
|
|
63
|
+
const set = this.stallNotified.get(sessionId);
|
|
64
|
+
if (!set)
|
|
65
|
+
return;
|
|
66
|
+
for (const t of types)
|
|
67
|
+
set.delete(t);
|
|
68
|
+
}
|
|
38
69
|
lastStallCheck = 0;
|
|
39
70
|
lastDeadCheck = 0;
|
|
40
71
|
idleNotified = new Set(); // prevent idle spam
|
|
@@ -196,13 +227,13 @@ export class SessionMonitor {
|
|
|
196
227
|
}
|
|
197
228
|
if (currentBytes > prev.bytes) {
|
|
198
229
|
this.lastBytesSeen.set(session.id, { bytes: currentBytes, at: now });
|
|
199
|
-
this.
|
|
230
|
+
this.stallDelete(session.id, 'jsonl');
|
|
200
231
|
}
|
|
201
232
|
else {
|
|
202
233
|
const stallDuration = now - prev.at;
|
|
203
234
|
const threshold = session.stallThresholdMs || this.config.stallThresholdMs;
|
|
204
|
-
if (stallDuration >= threshold && !this.
|
|
205
|
-
this.
|
|
235
|
+
if (stallDuration >= threshold && !this.stallHas(session.id, 'jsonl')) {
|
|
236
|
+
this.stallAdd(session.id, 'jsonl');
|
|
206
237
|
const minutes = Math.round(stallDuration / 60000);
|
|
207
238
|
const detail = `Session stalled: "working" for ${minutes}min with no new output. ` +
|
|
208
239
|
`Last activity: ${new Date(session.lastActivity).toISOString()}`;
|
|
@@ -213,15 +244,15 @@ export class SessionMonitor {
|
|
|
213
244
|
}
|
|
214
245
|
else {
|
|
215
246
|
// Reset JSONL stall tracking when not working
|
|
216
|
-
this.
|
|
247
|
+
this.stallDelete(session.id, 'jsonl');
|
|
217
248
|
}
|
|
218
249
|
// --- Type 2: Permission stall (waiting for approval too long) ---
|
|
219
250
|
if (currentStatus === 'permission_prompt' || currentStatus === 'bash_approval') {
|
|
220
251
|
const entry = this.stateSince.get(session.id);
|
|
221
252
|
const permDuration = entry ? now - entry.since : 0;
|
|
222
253
|
if (permDuration >= this.config.permissionStallMs) {
|
|
223
|
-
if (!this.
|
|
224
|
-
this.
|
|
254
|
+
if (!this.stallHas(session.id, 'permission')) {
|
|
255
|
+
this.stallAdd(session.id, 'permission');
|
|
225
256
|
const minutes = Math.round(permDuration / 60000);
|
|
226
257
|
const detail = `Session stalled: waiting for permission approval for ${minutes}min. ` +
|
|
227
258
|
`Auto-approve this session or POST /v1/sessions/${session.id}/approve`;
|
|
@@ -231,8 +262,8 @@ export class SessionMonitor {
|
|
|
231
262
|
}
|
|
232
263
|
// L9: Auto-reject permission after timeout
|
|
233
264
|
if (permDuration >= this.config.permissionTimeoutMs) {
|
|
234
|
-
if (!this.
|
|
235
|
-
this.
|
|
265
|
+
if (!this.stallHas(session.id, 'permission_timeout')) {
|
|
266
|
+
this.stallAdd(session.id, 'permission_timeout');
|
|
236
267
|
const minutes = Math.round(permDuration / 60000);
|
|
237
268
|
logger.warn({
|
|
238
269
|
component: 'monitor',
|
|
@@ -264,8 +295,8 @@ export class SessionMonitor {
|
|
|
264
295
|
const entry = this.stateSince.get(session.id);
|
|
265
296
|
const unkDuration = entry ? now - entry.since : 0;
|
|
266
297
|
if (unkDuration >= this.config.unknownStallMs) {
|
|
267
|
-
if (!this.
|
|
268
|
-
this.
|
|
298
|
+
if (!this.stallHas(session.id, 'unknown')) {
|
|
299
|
+
this.stallAdd(session.id, 'unknown');
|
|
269
300
|
const minutes = Math.round(unkDuration / 60000);
|
|
270
301
|
const detail = `Session stalled: in "unknown" state for ${minutes}min. ` +
|
|
271
302
|
`CC may be stuck. Try: POST /v1/sessions/${session.id}/interrupt or /kill`;
|
|
@@ -280,8 +311,8 @@ export class SessionMonitor {
|
|
|
280
311
|
const stateDuration = entry ? now - entry.since : 0;
|
|
281
312
|
const extendedThreshold = this.config.stallThresholdMs * 2;
|
|
282
313
|
if (stateDuration >= extendedThreshold) {
|
|
283
|
-
if (!this.
|
|
284
|
-
this.
|
|
314
|
+
if (!this.stallHas(session.id, 'extended')) {
|
|
315
|
+
this.stallAdd(session.id, 'extended');
|
|
285
316
|
const minutes = Math.round(stateDuration / 60000);
|
|
286
317
|
const detail = `Session stalled: "${currentStatus}" state for ${minutes}min. ` +
|
|
287
318
|
`May need intervention: /interrupt, /approve, or /kill`;
|
|
@@ -297,8 +328,8 @@ export class SessionMonitor {
|
|
|
297
328
|
if (entry && entry.state === 'working') {
|
|
298
329
|
const workingDuration = now - entry.since;
|
|
299
330
|
const maxWorkingMs = this.config.stallThresholdMs * 3; // 15 min default
|
|
300
|
-
if (workingDuration >= maxWorkingMs && !this.
|
|
301
|
-
this.
|
|
331
|
+
if (workingDuration >= maxWorkingMs && !this.stallHas(session.id, 'extended_working')) {
|
|
332
|
+
this.stallAdd(session.id, 'extended_working');
|
|
302
333
|
const minutes = Math.round(workingDuration / 60000);
|
|
303
334
|
const detail = `Session stalled: in "working" state for ${minutes}min. ` +
|
|
304
335
|
`CC may be stuck in an internal loop (e.g., Misting). Consider: POST /v1/sessions/${session.id}/interrupt or /kill`;
|
|
@@ -312,23 +343,18 @@ export class SessionMonitor {
|
|
|
312
343
|
const exitedPermission = prevStallStatus === 'permission_prompt' || prevStallStatus === 'bash_approval';
|
|
313
344
|
const exitedUnknown = prevStallStatus === 'unknown';
|
|
314
345
|
if (exitedPermission) {
|
|
315
|
-
this.
|
|
316
|
-
this.stallNotified.delete(`${session.id}:stall:permission_timeout`);
|
|
346
|
+
this.stallDeleteTypes(session.id, ['permission', 'permission_timeout']);
|
|
317
347
|
}
|
|
318
348
|
if (exitedUnknown) {
|
|
319
|
-
this.
|
|
349
|
+
this.stallDelete(session.id, 'unknown');
|
|
320
350
|
}
|
|
321
351
|
}
|
|
322
352
|
// Clean up all state tracking when idle (catch-all)
|
|
323
353
|
if (currentStatus === 'idle') {
|
|
324
354
|
this.rateLimitedSessions.delete(session.id);
|
|
325
355
|
this.stateSince.delete(session.id);
|
|
326
|
-
// Clean stall notifications (session recovered)
|
|
327
|
-
|
|
328
|
-
if (key.startsWith(session.id)) {
|
|
329
|
-
this.stallNotified.delete(key);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
356
|
+
// Clean stall notifications (session recovered) — O(1) with Map
|
|
357
|
+
this.stallDeleteAll(session.id);
|
|
332
358
|
}
|
|
333
359
|
// Update prevStatusForStall for next cycle
|
|
334
360
|
if (currentStatus) {
|
|
@@ -432,7 +458,7 @@ export class SessionMonitor {
|
|
|
432
458
|
if (event.messages.length > 0) {
|
|
433
459
|
// Real output — reset stall timer
|
|
434
460
|
this.lastBytesSeen.set(event.sessionId, { bytes: event.newOffset, at: now });
|
|
435
|
-
this.
|
|
461
|
+
this.stallDelete(event.sessionId, 'jsonl');
|
|
436
462
|
}
|
|
437
463
|
else {
|
|
438
464
|
// File grew but no messages — only update bytes, keep timestamp
|
|
@@ -666,12 +692,8 @@ export class SessionMonitor {
|
|
|
666
692
|
clearTimeout(pending);
|
|
667
693
|
this.statusChangeDebounce.delete(sessionId);
|
|
668
694
|
}
|
|
669
|
-
// Clean all stall notifications for this session
|
|
670
|
-
|
|
671
|
-
if (key.startsWith(sessionId)) {
|
|
672
|
-
this.stallNotified.delete(key);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
695
|
+
// Clean all stall notifications for this session — O(1) with Map
|
|
696
|
+
this.stallDeleteAll(sessionId);
|
|
675
697
|
this.idleNotified.delete(sessionId);
|
|
676
698
|
this.idleSince.delete(sessionId);
|
|
677
699
|
this.stateSince.delete(sessionId);
|
package/dist/server.js
CHANGED
|
@@ -24,11 +24,12 @@ import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/ind
|
|
|
24
24
|
import { loadConfig } from './config.js';
|
|
25
25
|
import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
|
|
26
26
|
import { validateScreenshotUrl, resolveAndCheckIp, buildHostResolverRule } from './ssrf.js';
|
|
27
|
-
import { validateWorkDir } from './validation.js';
|
|
27
|
+
import { validateWorkDir, permissionRuleSchema } from './validation.js';
|
|
28
28
|
import { SessionEventBus } from './events.js';
|
|
29
29
|
import { SSEWriter } from './sse-writer.js';
|
|
30
30
|
import { SSEConnectionLimiter } from './sse-limiter.js';
|
|
31
31
|
import { PipelineManager } from './pipeline.js';
|
|
32
|
+
import { ToolRegistry } from './tool-registry.js';
|
|
32
33
|
import { AuthManager } from './auth.js';
|
|
33
34
|
import { MetricsCollector } from './metrics.js';
|
|
34
35
|
import { registerPermissionRoutes } from './permission-routes.js';
|
|
@@ -57,6 +58,7 @@ const channels = new ChannelManager();
|
|
|
57
58
|
const eventBus = new SessionEventBus();
|
|
58
59
|
let sseLimiter;
|
|
59
60
|
let pipelines;
|
|
61
|
+
let toolRegistry;
|
|
60
62
|
let auth;
|
|
61
63
|
let metrics;
|
|
62
64
|
let swarmMonitor;
|
|
@@ -320,6 +322,7 @@ const createSessionSchema = z.object({
|
|
|
320
322
|
stallThresholdMs: z.number().int().positive().max(3_600_000).optional(),
|
|
321
323
|
permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(),
|
|
322
324
|
autoApprove: z.boolean().optional(),
|
|
325
|
+
parentId: z.string().uuid().optional(),
|
|
323
326
|
}).strict();
|
|
324
327
|
// Health — Issue #397: includes tmux server health check
|
|
325
328
|
async function healthHandler() {
|
|
@@ -420,6 +423,29 @@ app.get('/v1/sessions/:id/metrics', async (req, reply) => {
|
|
|
420
423
|
return reply.status(404).send({ error: 'No metrics for this session' });
|
|
421
424
|
return m;
|
|
422
425
|
});
|
|
426
|
+
// Issue #704: Tool usage endpoints
|
|
427
|
+
app.get('/v1/sessions/:id/tools', async (req, reply) => {
|
|
428
|
+
const session = sessions.getSession(req.params.id);
|
|
429
|
+
if (!session)
|
|
430
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
431
|
+
// Parse JSONL on-demand for tool usage
|
|
432
|
+
const { readNewEntries } = await import('./transcript.js');
|
|
433
|
+
if (session.jsonlPath) {
|
|
434
|
+
try {
|
|
435
|
+
const result = await readNewEntries(session.jsonlPath, 0);
|
|
436
|
+
const entries = result.entries;
|
|
437
|
+
toolRegistry.processEntries(req.params.id, entries);
|
|
438
|
+
}
|
|
439
|
+
catch { /* JSONL not available */ }
|
|
440
|
+
}
|
|
441
|
+
const tools = toolRegistry.getSessionTools(req.params.id);
|
|
442
|
+
return { sessionId: req.params.id, tools, totalCalls: tools.reduce((sum, t) => sum + t.count, 0) };
|
|
443
|
+
});
|
|
444
|
+
app.get('/v1/tools', async () => {
|
|
445
|
+
const definitions = toolRegistry.getToolDefinitions();
|
|
446
|
+
const categories = [...new Set(definitions.map(t => t.category))];
|
|
447
|
+
return { tools: definitions, categories, totalTools: definitions.length };
|
|
448
|
+
});
|
|
423
449
|
// Issue #89 L14: Webhook dead letter queue
|
|
424
450
|
app.get('/v1/webhooks/dead-letter', async () => {
|
|
425
451
|
for (const ch of channels.getChannels()) {
|
|
@@ -550,7 +576,7 @@ async function createSessionHandler(req, reply) {
|
|
|
550
576
|
if (!parsed.success) {
|
|
551
577
|
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
552
578
|
}
|
|
553
|
-
const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove } = parsed.data;
|
|
579
|
+
const { workDir, name, prompt, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId } = parsed.data;
|
|
554
580
|
if (!workDir)
|
|
555
581
|
return reply.status(400).send({ error: 'workDir is required' });
|
|
556
582
|
// Issue #564: Validate installed Claude Code version
|
|
@@ -588,7 +614,7 @@ async function createSessionHandler(req, reply) {
|
|
|
588
614
|
}
|
|
589
615
|
}
|
|
590
616
|
console.time("POST_CREATE_SESSION");
|
|
591
|
-
const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
|
|
617
|
+
const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove, parentId });
|
|
592
618
|
console.timeEnd("POST_CREATE_SESSION");
|
|
593
619
|
console.time("POST_CHANNEL_CREATED");
|
|
594
620
|
// Issue #625: Track session in metrics so sessionsCreated counter is accurate
|
|
@@ -682,6 +708,61 @@ async function sendMessageHandler(req, reply) {
|
|
|
682
708
|
}
|
|
683
709
|
app.post('/v1/sessions/:id/send', sendMessageHandler);
|
|
684
710
|
app.post('/sessions/:id/send', sendMessageHandler);
|
|
711
|
+
// Issue #702: GET children sessions
|
|
712
|
+
async function getChildrenHandler(req, reply) {
|
|
713
|
+
const session = sessions.getSession(req.params.id);
|
|
714
|
+
if (!session)
|
|
715
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
716
|
+
const children = (session.children ?? []).map(id => {
|
|
717
|
+
const child = sessions.getSession(id);
|
|
718
|
+
if (!child)
|
|
719
|
+
return null;
|
|
720
|
+
return { id: child.id, windowName: child.windowName, status: child.status, createdAt: child.createdAt };
|
|
721
|
+
}).filter(Boolean);
|
|
722
|
+
return { children };
|
|
723
|
+
}
|
|
724
|
+
app.get('/v1/sessions/:id/children', getChildrenHandler);
|
|
725
|
+
app.get('/sessions/:id/children', getChildrenHandler);
|
|
726
|
+
async function spawnChildHandler(req, reply) {
|
|
727
|
+
const parentId = req.params.id;
|
|
728
|
+
const parent = sessions.getSession(parentId);
|
|
729
|
+
if (!parent)
|
|
730
|
+
return reply.status(404).send({ error: 'Parent session not found' });
|
|
731
|
+
const { name, prompt, workDir, permissionMode } = req.body ?? {};
|
|
732
|
+
const childName = name ?? `${parent.windowName ?? 'session'}-child`;
|
|
733
|
+
const childWorkDir = workDir ?? parent.workDir;
|
|
734
|
+
const childPermMode = permissionMode ?? parent.permissionMode ?? 'bypassPermissions';
|
|
735
|
+
const childSession = await sessions.createSession({ workDir: childWorkDir, name: childName, parentId, permissionMode: childPermMode });
|
|
736
|
+
let promptDelivery;
|
|
737
|
+
if (prompt) {
|
|
738
|
+
promptDelivery = await sessions.sendInitialPrompt(childSession.id, prompt);
|
|
739
|
+
}
|
|
740
|
+
return reply.status(201).send({ ...childSession, promptDelivery });
|
|
741
|
+
}
|
|
742
|
+
app.post('/v1/sessions/:id/spawn', spawnChildHandler);
|
|
743
|
+
app.post('/sessions/:id/spawn', spawnChildHandler);
|
|
744
|
+
async function getPermissionPolicyHandler(req, reply) {
|
|
745
|
+
const session = sessions.getSession(req.params.id);
|
|
746
|
+
if (!session)
|
|
747
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
748
|
+
return { permissionPolicy: session.permissionPolicy ?? [] };
|
|
749
|
+
}
|
|
750
|
+
async function updatePermissionPolicyHandler(req, reply) {
|
|
751
|
+
const session = sessions.getSession(req.params.id);
|
|
752
|
+
if (!session)
|
|
753
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
754
|
+
const policy = req.body ?? [];
|
|
755
|
+
const result = permissionRuleSchema.array().safeParse(policy);
|
|
756
|
+
if (!result.success)
|
|
757
|
+
return reply.status(400).send({ error: 'Invalid permission policy', details: result.error.issues });
|
|
758
|
+
session.permissionPolicy = policy;
|
|
759
|
+
await sessions.save();
|
|
760
|
+
return { permissionPolicy: policy };
|
|
761
|
+
}
|
|
762
|
+
app.get('/v1/sessions/:id/permissions', getPermissionPolicyHandler);
|
|
763
|
+
app.put('/v1/sessions/:id/permissions', updatePermissionPolicyHandler);
|
|
764
|
+
app.get('/sessions/:id/permissions', getPermissionPolicyHandler);
|
|
765
|
+
app.put('/sessions/:id/permissions', updatePermissionPolicyHandler);
|
|
685
766
|
// Read messages
|
|
686
767
|
async function readMessagesHandler(req, reply) {
|
|
687
768
|
try {
|
|
@@ -1109,6 +1190,7 @@ async function reapStaleSessions(maxAgeMs) {
|
|
|
1109
1190
|
});
|
|
1110
1191
|
monitor.removeSession(session.id);
|
|
1111
1192
|
metrics.cleanupSession(session.id);
|
|
1193
|
+
toolRegistry.cleanupSession(session.id);
|
|
1112
1194
|
}
|
|
1113
1195
|
catch (e) {
|
|
1114
1196
|
console.error(`Reaper: failed to kill session ${session.id}:`, e);
|
|
@@ -1138,6 +1220,7 @@ async function reapZombieSessions() {
|
|
|
1138
1220
|
eventBus.cleanupSession(session.id);
|
|
1139
1221
|
await sessions.killSession(session.id);
|
|
1140
1222
|
metrics.cleanupSession(session.id);
|
|
1223
|
+
toolRegistry.cleanupSession(session.id);
|
|
1141
1224
|
await channels.sessionEnded({
|
|
1142
1225
|
event: 'session.ended',
|
|
1143
1226
|
timestamp: new Date().toISOString(),
|
|
@@ -1537,6 +1620,7 @@ async function main() {
|
|
|
1537
1620
|
monitor.start();
|
|
1538
1621
|
// Issue #81: Start swarm monitor for agent swarm awareness
|
|
1539
1622
|
swarmMonitor = new SwarmMonitor(sessions);
|
|
1623
|
+
toolRegistry = new ToolRegistry();
|
|
1540
1624
|
swarmMonitor.onEvent((event) => {
|
|
1541
1625
|
if (!event.swarm.parentSession)
|
|
1542
1626
|
return;
|
package/dist/session.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { TmuxManager } from './tmux.js';
|
|
|
8
8
|
import { type ParsedEntry } from './transcript.js';
|
|
9
9
|
import { type UIState } from './terminal-parser.js';
|
|
10
10
|
import type { Config } from './config.js';
|
|
11
|
+
import { type PermissionPolicy } from './validation.js';
|
|
11
12
|
export interface SessionInfo {
|
|
12
13
|
id: string;
|
|
13
14
|
windowId: string;
|
|
@@ -35,6 +36,9 @@ export interface SessionInfo {
|
|
|
35
36
|
model?: string;
|
|
36
37
|
lastDeadAt?: number;
|
|
37
38
|
ccPid?: number;
|
|
39
|
+
parentId?: string;
|
|
40
|
+
children?: string[];
|
|
41
|
+
permissionPolicy?: PermissionPolicy;
|
|
38
42
|
}
|
|
39
43
|
export interface SessionState {
|
|
40
44
|
sessions: Record<string, SessionInfo>;
|
|
@@ -66,6 +70,7 @@ export declare class SessionManager {
|
|
|
66
70
|
private pendingQuestions;
|
|
67
71
|
private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
|
|
68
72
|
private parsedEntriesCache;
|
|
73
|
+
private sessionsListCache;
|
|
69
74
|
private readonly sessionAcquireMutex;
|
|
70
75
|
constructor(tmux: TmuxManager, config: Config);
|
|
71
76
|
/**
|
|
@@ -135,6 +140,8 @@ export declare class SessionManager {
|
|
|
135
140
|
permissionMode?: string;
|
|
136
141
|
/** @deprecated Use permissionMode instead. Maps true→bypassPermissions, false→default. */
|
|
137
142
|
autoApprove?: boolean;
|
|
143
|
+
/** Issue #702: Parent session ID for sub-agent hierarchy */
|
|
144
|
+
parentId?: string;
|
|
138
145
|
}): Promise<SessionInfo>;
|
|
139
146
|
/** Get a session by ID. */
|
|
140
147
|
getSession(id: string): SessionInfo | null;
|
|
@@ -165,6 +172,8 @@ export declare class SessionManager {
|
|
|
165
172
|
* so the current pane PID is the shell (alive). Checking ccPid catches
|
|
166
173
|
* the crash within seconds instead of waiting for the 5-min stall timer. */
|
|
167
174
|
isWindowAlive(id: string): Promise<boolean>;
|
|
175
|
+
/** Issue #657: Invalidate the sessions list cache. Call on any mutation. */
|
|
176
|
+
private invalidateSessionsListCache;
|
|
168
177
|
/** List all sessions. */
|
|
169
178
|
listSessions(): SessionInfo[];
|
|
170
179
|
/** Issue #607: Find an idle session for the given workDir.
|
package/dist/session.js
CHANGED
|
@@ -16,7 +16,7 @@ import { computeStallThreshold } from './config.js';
|
|
|
16
16
|
import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
|
|
17
17
|
import { persistedStateSchema } from './validation.js';
|
|
18
18
|
import { loadContinuationPointers } from './continuation-pointer.js';
|
|
19
|
-
import { writeHookSettingsFile, cleanupHookSettingsFile } from './hook-settings.js';
|
|
19
|
+
import { writeHookSettingsFile, cleanupHookSettingsFile, cleanupStaleSessionHooks } from './hook-settings.js';
|
|
20
20
|
import { Mutex } from 'async-mutex';
|
|
21
21
|
import { maybeInjectFault } from './fault-injection.js';
|
|
22
22
|
/** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
|
|
@@ -67,6 +67,8 @@ export class SessionManager {
|
|
|
67
67
|
// #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
|
|
68
68
|
static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
|
|
69
69
|
parsedEntriesCache = new Map();
|
|
70
|
+
// Issue #657: Cached session list to avoid allocating a new array per call
|
|
71
|
+
sessionsListCache = null;
|
|
70
72
|
// Issue #840/#880: Explicit mutex to prevent TOCTOU races in session acquisition.
|
|
71
73
|
sessionAcquireMutex = new Mutex();
|
|
72
74
|
constructor(tmux, config) {
|
|
@@ -168,6 +170,8 @@ export class SessionManager {
|
|
|
168
170
|
await writeFile(`${this.stateFile}.bak`, JSON.stringify(this.state, null, 2));
|
|
169
171
|
}
|
|
170
172
|
catch { /* non-critical */ }
|
|
173
|
+
// Issue #657: Invalidate sessions list cache after loading state
|
|
174
|
+
this.invalidateSessionsListCache();
|
|
171
175
|
// Reconcile: verify tmux windows still exist, clean up dead sessions
|
|
172
176
|
await this.reconcile();
|
|
173
177
|
}
|
|
@@ -190,6 +194,7 @@ export class SessionManager {
|
|
|
190
194
|
await cleanOrphanedBackup(session.workDir);
|
|
191
195
|
}
|
|
192
196
|
delete this.state.sessions[id];
|
|
197
|
+
this.invalidateSessionsListCache();
|
|
193
198
|
changed = true;
|
|
194
199
|
}
|
|
195
200
|
else if (!windowIdAlive && windowNameAlive) {
|
|
@@ -245,6 +250,7 @@ export class SessionManager {
|
|
|
245
250
|
permissionMode: 'default',
|
|
246
251
|
};
|
|
247
252
|
this.state.sessions[id] = session;
|
|
253
|
+
this.invalidateSessionsListCache();
|
|
248
254
|
console.log(`Reconcile: adopted orphaned window ${win.windowName} (${win.windowId}) as ${id.slice(0, 8)}`);
|
|
249
255
|
this.startSessionIdDiscovery(id);
|
|
250
256
|
this.startFilesystemDiscovery(id, session.workDir);
|
|
@@ -506,6 +512,17 @@ export class SessionManager {
|
|
|
506
512
|
const hookSecret = randomBytes(32).toString('hex');
|
|
507
513
|
// Issue #169 Phase 2: Generate HTTP hook settings for this session.
|
|
508
514
|
// Writes a temp file with hooks pointing to Aegis's hook receiver.
|
|
515
|
+
// Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
|
|
516
|
+
// This prevents CC from loading dead hook URLs on restart.
|
|
517
|
+
try {
|
|
518
|
+
const activeIds = new Set(this.listSessions().map(s => s.id));
|
|
519
|
+
if (activeIds.size > 0) {
|
|
520
|
+
await cleanupStaleSessionHooks(opts.workDir, activeIds);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
console.warn(`Hook cleanup: failed to clean stale hooks: ${e.message}`);
|
|
525
|
+
}
|
|
509
526
|
let hookSettingsFile;
|
|
510
527
|
try {
|
|
511
528
|
const baseUrl = `http://${this.config.host}:${this.config.port}`;
|
|
@@ -545,7 +562,18 @@ export class SessionManager {
|
|
|
545
562
|
hookSecret,
|
|
546
563
|
};
|
|
547
564
|
this.state.sessions[id] = session;
|
|
565
|
+
this.invalidateSessionsListCache();
|
|
548
566
|
await this.save();
|
|
567
|
+
// Issue #702: Register child with parent
|
|
568
|
+
if (opts.parentId) {
|
|
569
|
+
const parent = this.state.sessions[opts.parentId];
|
|
570
|
+
if (parent) {
|
|
571
|
+
if (!parent.children)
|
|
572
|
+
parent.children = [];
|
|
573
|
+
parent.children.push(id);
|
|
574
|
+
await this.save();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
549
577
|
// Issue #353: Fetch CC process PID for swarm parent matching.
|
|
550
578
|
// Fire-and-forget — PID is not needed synchronously.
|
|
551
579
|
// Issue #574: Add .catch() to prevent unhandled rejection if tmux fails mid-lookup.
|
|
@@ -744,9 +772,16 @@ export class SessionManager {
|
|
|
744
772
|
return false;
|
|
745
773
|
}
|
|
746
774
|
}
|
|
775
|
+
/** Issue #657: Invalidate the sessions list cache. Call on any mutation. */
|
|
776
|
+
invalidateSessionsListCache() {
|
|
777
|
+
this.sessionsListCache = null;
|
|
778
|
+
}
|
|
747
779
|
/** List all sessions. */
|
|
748
780
|
listSessions() {
|
|
749
|
-
|
|
781
|
+
if (!this.sessionsListCache) {
|
|
782
|
+
this.sessionsListCache = Object.values(this.state.sessions);
|
|
783
|
+
}
|
|
784
|
+
return this.sessionsListCache;
|
|
750
785
|
}
|
|
751
786
|
/** Issue #607: Find an idle session for the given workDir.
|
|
752
787
|
* Returns the most recently active idle session, or null if none found.
|
|
@@ -1320,6 +1355,7 @@ export class SessionManager {
|
|
|
1320
1355
|
// #405: Clean up all tracking maps (pollTimers, pendingPermissions, pendingQuestions, parsedEntriesCache)
|
|
1321
1356
|
this.cleanupSession(id);
|
|
1322
1357
|
delete this.state.sessions[id];
|
|
1358
|
+
this.invalidateSessionsListCache();
|
|
1323
1359
|
// #357: Cancel any pending debounced save before doing an immediate save
|
|
1324
1360
|
if (this.saveDebounceTimer !== null) {
|
|
1325
1361
|
clearTimeout(this.saveDebounceTimer);
|