aegis-bridge 2.12.0 → 2.12.2
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-DhUXvnKe.js +302 -0
- package/dashboard/dist/assets/index-hJxI_Ze7.css +32 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/api-contracts.d.ts +197 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/channels/telegram.d.ts +12 -1
- package/dist/channels/telegram.js +123 -3
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/events.d.ts +11 -0
- package/dist/events.js +42 -0
- package/dist/hook-settings.d.ts +1 -1
- package/dist/hook-settings.js +5 -13
- package/dist/hooks.js +2 -13
- package/dist/metrics.d.ts +2 -1
- package/dist/monitor.js +33 -6
- package/dist/server.js +21 -2
- package/dist/session.d.ts +12 -5
- package/dist/session.js +109 -119
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +24 -15
- package/dist/validation.d.ts +11 -7
- package/dist/validation.js +53 -8
- package/package.json +4 -1
- package/dashboard/dist/assets/index--qcihe0P.css +0 -32
- package/dashboard/dist/assets/index-DFdRqV0R.js +0 -302
package/dist/session.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Tracks: session ID, window ID, byte offset for JSONL reading, status.
|
|
6
6
|
*/
|
|
7
7
|
import { randomBytes } from 'node:crypto';
|
|
8
|
-
import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';
|
|
8
|
+
import { readFile, writeFile, rename, mkdir, stat, readdir } from 'node:fs/promises';
|
|
9
9
|
import { existsSync, unlinkSync, readdirSync } from 'node:fs';
|
|
10
10
|
import { join, dirname } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
@@ -58,6 +58,8 @@ export class SessionManager {
|
|
|
58
58
|
stateFile;
|
|
59
59
|
sessionMapFile;
|
|
60
60
|
pollTimers = new Map();
|
|
61
|
+
/** Next filesystem-scan time (ms epoch) for each discovery poller. */
|
|
62
|
+
discoveryNextFilesystemScanAt = new Map();
|
|
61
63
|
/** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
|
|
62
64
|
discoveryTimeouts = new Map();
|
|
63
65
|
saveQueue = Promise.resolve(); // #218: serialize concurrent saves
|
|
@@ -208,7 +210,7 @@ export class SessionManager {
|
|
|
208
210
|
console.log(`Reconcile: session ${session.windowName} re-attached: ${oldWindowId} → ${win.windowId}`);
|
|
209
211
|
// Restart discovery if needed
|
|
210
212
|
if (!session.claudeSessionId || !session.jsonlPath) {
|
|
211
|
-
this.
|
|
213
|
+
this.startDiscoveryPolling(id, session.workDir);
|
|
212
214
|
}
|
|
213
215
|
changed = true;
|
|
214
216
|
}
|
|
@@ -216,7 +218,7 @@ export class SessionManager {
|
|
|
216
218
|
// Session is alive — restart discovery if needed
|
|
217
219
|
if (!session.claudeSessionId || !session.jsonlPath) {
|
|
218
220
|
console.log(`Reconcile: session ${session.windowName} — restarting JSONL discovery`);
|
|
219
|
-
this.
|
|
221
|
+
this.startDiscoveryPolling(id, session.workDir);
|
|
220
222
|
}
|
|
221
223
|
else {
|
|
222
224
|
console.log(`Reconcile: session ${session.windowName} — alive, JSONL ready`);
|
|
@@ -254,8 +256,7 @@ export class SessionManager {
|
|
|
254
256
|
this.state.sessions[id] = session;
|
|
255
257
|
this.invalidateSessionsListCache();
|
|
256
258
|
console.log(`Reconcile: adopted orphaned window ${win.windowName} (${win.windowId}) as ${id.slice(0, 8)}`);
|
|
257
|
-
this.
|
|
258
|
-
this.startFilesystemDiscovery(id, session.workDir);
|
|
259
|
+
this.startDiscoveryPolling(id, session.workDir);
|
|
259
260
|
changed = true;
|
|
260
261
|
}
|
|
261
262
|
if (changed) {
|
|
@@ -292,8 +293,7 @@ export class SessionManager {
|
|
|
292
293
|
console.log(`Reconcile (crash): session ${session.windowName} re-attached: ${oldWindowId} → ${win.windowId}`);
|
|
293
294
|
// Restart discovery in case the session state is stale
|
|
294
295
|
if (!session.claudeSessionId || !session.jsonlPath) {
|
|
295
|
-
this.
|
|
296
|
-
this.startFilesystemDiscovery(id, session.workDir);
|
|
296
|
+
this.startDiscoveryPolling(id, session.workDir);
|
|
297
297
|
}
|
|
298
298
|
recovered++;
|
|
299
299
|
changed = true;
|
|
@@ -585,22 +585,16 @@ export class SessionManager {
|
|
|
585
585
|
void this.save().catch(e => console.error(`Session: failed to save PID for ${id}:`, e));
|
|
586
586
|
}
|
|
587
587
|
}).catch(e => console.error(`Session: failed to list pane PID for ${id}:`, e));
|
|
588
|
-
// Start
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// Issue #16: --bare flag skips hooks entirely
|
|
588
|
+
// Start coordinated discovery polling:
|
|
589
|
+
// - Hook/session_map sync: fast path
|
|
590
|
+
// - Filesystem scan fallback: works when hooks fail or are skipped (Issue #16)
|
|
592
591
|
// Field bug (Zeus 2026-03-22): hooks may not fire even without --bare
|
|
593
|
-
|
|
594
|
-
// If we already have the claudeSessionId (from --session-id), filesystem discovery
|
|
595
|
-
// will just find the JSONL path. Hook discovery may still run but won't override.
|
|
596
|
-
this.startFilesystemDiscovery(id, opts.workDir);
|
|
592
|
+
this.startDiscoveryPolling(id, opts.workDir);
|
|
597
593
|
// P0 fix: Clean stale entries from session_map.json for BOTH window name AND id.
|
|
598
594
|
// After archiving old .jsonl files, stale session_map entries would point
|
|
599
595
|
// to moved files, causing discovery to pick up ghost session IDs.
|
|
600
596
|
// Also cleans stale windowId entries that could collide after restart.
|
|
601
597
|
await this.cleanSessionMapForWindow(finalName, windowId);
|
|
602
|
-
// Start watching for the CC session ID via hook
|
|
603
|
-
this.startSessionIdDiscovery(id);
|
|
604
598
|
return session;
|
|
605
599
|
}
|
|
606
600
|
/** Get a session by ID. */
|
|
@@ -762,7 +756,13 @@ export class SessionManager {
|
|
|
762
756
|
// Issue #390: Fast crash detection via stored CC PID
|
|
763
757
|
if (session.ccPid && !this.tmux.isPidAlive(session.ccPid))
|
|
764
758
|
return false;
|
|
765
|
-
|
|
759
|
+
const windowHealth = await this.tmux.getWindowHealth(session.windowId);
|
|
760
|
+
if (!windowHealth.windowExists)
|
|
761
|
+
return false;
|
|
762
|
+
// Pane exit is a crash signal ONLY if the session was actively working (not idle).
|
|
763
|
+
// Normal CC exit after prompt → session goes idle first, paneDead is expected.
|
|
764
|
+
// CC crash mid-processing → session still working, paneDead means crash.
|
|
765
|
+
if (windowHealth.paneDead && session.status !== 'idle')
|
|
766
766
|
return false;
|
|
767
767
|
// Verify the process inside the pane is still alive
|
|
768
768
|
const panePid = await this.tmux.listPanePid(session.windowId);
|
|
@@ -831,16 +831,22 @@ export class SessionManager {
|
|
|
831
831
|
let status = 'unknown';
|
|
832
832
|
// Issue #69: Also check if the pane PID is alive (zombie window detection)
|
|
833
833
|
let processAlive = true;
|
|
834
|
+
const paneExited = !!windowHealth.paneDead;
|
|
834
835
|
if (windowHealth.windowExists) {
|
|
835
|
-
|
|
836
|
-
const panePid = await this.tmux.listPanePid(session.windowId);
|
|
837
|
-
if (panePid !== null) {
|
|
838
|
-
processAlive = this.tmux.isPidAlive(panePid);
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
catch { /* cannot list pane PID — assume dead */
|
|
836
|
+
if (paneExited) {
|
|
842
837
|
processAlive = false;
|
|
843
838
|
}
|
|
839
|
+
else {
|
|
840
|
+
try {
|
|
841
|
+
const panePid = await this.tmux.listPanePid(session.windowId);
|
|
842
|
+
if (panePid !== null) {
|
|
843
|
+
processAlive = this.tmux.isPidAlive(panePid);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch { /* cannot list pane PID — assume dead */
|
|
847
|
+
processAlive = false;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
844
850
|
}
|
|
845
851
|
if (windowHealth.windowExists && processAlive) {
|
|
846
852
|
try {
|
|
@@ -864,6 +870,9 @@ export class SessionManager {
|
|
|
864
870
|
if (!windowHealth.windowExists) {
|
|
865
871
|
details = 'Tmux window does not exist — session is dead';
|
|
866
872
|
}
|
|
873
|
+
else if (paneExited) {
|
|
874
|
+
details = 'Tmux pane has exited — session is dead';
|
|
875
|
+
}
|
|
867
876
|
else if (!processAlive) {
|
|
868
877
|
details = 'Tmux window exists but pane process is dead — session is dead (zombie window)';
|
|
869
878
|
}
|
|
@@ -1276,22 +1285,7 @@ export class SessionManager {
|
|
|
1276
1285
|
}
|
|
1277
1286
|
/** #405: Clean up all tracking maps for a session to prevent memory leaks. */
|
|
1278
1287
|
cleanupSession(id) {
|
|
1279
|
-
|
|
1280
|
-
for (const key of [id, `fs-${id}`]) {
|
|
1281
|
-
const timer = this.pollTimers.get(key);
|
|
1282
|
-
if (timer) {
|
|
1283
|
-
clearInterval(timer);
|
|
1284
|
-
this.pollTimers.delete(key);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
// #835: Clear discovery timeout timers to prevent orphan callbacks
|
|
1288
|
-
for (const key of [id, `fs-${id}`]) {
|
|
1289
|
-
const timeout = this.discoveryTimeouts.get(key);
|
|
1290
|
-
if (timeout) {
|
|
1291
|
-
clearTimeout(timeout);
|
|
1292
|
-
this.discoveryTimeouts.delete(key);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1288
|
+
this.stopDiscoveryPolling(id);
|
|
1295
1289
|
this.cleanupPendingPermission(id);
|
|
1296
1290
|
this.cleanupPendingQuestion(id);
|
|
1297
1291
|
this.parsedEntriesCache.delete(id);
|
|
@@ -1389,24 +1383,70 @@ export class SessionManager {
|
|
|
1389
1383
|
}
|
|
1390
1384
|
catch { /* ignore parse/write errors */ }
|
|
1391
1385
|
}
|
|
1392
|
-
/**
|
|
1393
|
-
|
|
1386
|
+
/** Stop and remove the coordinated discovery poller/timer for a session. */
|
|
1387
|
+
stopDiscoveryPolling(id) {
|
|
1388
|
+
const timer = this.pollTimers.get(id);
|
|
1389
|
+
if (timer) {
|
|
1390
|
+
clearInterval(timer);
|
|
1391
|
+
this.pollTimers.delete(id);
|
|
1392
|
+
}
|
|
1393
|
+
const timeout = this.discoveryTimeouts.get(id);
|
|
1394
|
+
if (timeout) {
|
|
1395
|
+
clearTimeout(timeout);
|
|
1396
|
+
this.discoveryTimeouts.delete(id);
|
|
1397
|
+
}
|
|
1398
|
+
this.discoveryNextFilesystemScanAt.delete(id);
|
|
1399
|
+
}
|
|
1400
|
+
/** Attempt filesystem-based discovery for a single session poll tick. */
|
|
1401
|
+
async maybeDiscoverFromFilesystem(session, workDir) {
|
|
1402
|
+
const projectHash = '-' + workDir.replace(/^\//, '').replace(/\//g, '-');
|
|
1403
|
+
const projectDir = join(this.config.claudeProjectsDir, projectHash);
|
|
1404
|
+
if (!existsSync(projectDir))
|
|
1405
|
+
return false;
|
|
1406
|
+
const files = await readdir(projectDir);
|
|
1407
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('.'));
|
|
1408
|
+
for (const file of jsonlFiles) {
|
|
1409
|
+
const filePath = join(projectDir, file);
|
|
1410
|
+
const fileStat = await stat(filePath);
|
|
1411
|
+
// Only consider files created after the session.
|
|
1412
|
+
if (fileStat.mtimeMs < session.createdAt)
|
|
1413
|
+
continue;
|
|
1414
|
+
// Extract session ID from filename (filename = sessionId.jsonl).
|
|
1415
|
+
const sessionId = file.replace('.jsonl', '');
|
|
1416
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId))
|
|
1417
|
+
continue;
|
|
1418
|
+
session.claudeSessionId = sessionId;
|
|
1419
|
+
session.jsonlPath = filePath;
|
|
1420
|
+
session.byteOffset = 0;
|
|
1421
|
+
console.log(`Discovery (filesystem): session ${session.windowName} mapped to ${sessionId.slice(0, 8)}...`);
|
|
1422
|
+
await this.save();
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
return false;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Coordinated discovery poller for a session.
|
|
1429
|
+
*
|
|
1430
|
+
* Consolidates hook/session_map sync and filesystem fallback into a single
|
|
1431
|
+
* interval loop per session, reducing duplicate independent pollers.
|
|
1432
|
+
*/
|
|
1433
|
+
startDiscoveryPolling(id, workDir) {
|
|
1434
|
+
// If a poller already exists, replace it to ensure only one active poller/session.
|
|
1435
|
+
this.stopDiscoveryPolling(id);
|
|
1394
1436
|
const interval = setInterval(async () => {
|
|
1395
1437
|
const session = this.state.sessions[id];
|
|
1396
1438
|
if (!session) {
|
|
1397
|
-
|
|
1398
|
-
this.pollTimers.delete(id);
|
|
1439
|
+
this.stopDiscoveryPolling(id);
|
|
1399
1440
|
return;
|
|
1400
1441
|
}
|
|
1401
|
-
// Stop when we have both session ID and JSONL path
|
|
1442
|
+
// Stop when we have both session ID and JSONL path.
|
|
1402
1443
|
if (session.claudeSessionId && session.jsonlPath) {
|
|
1403
|
-
|
|
1404
|
-
this.pollTimers.delete(id);
|
|
1444
|
+
this.stopDiscoveryPolling(id);
|
|
1405
1445
|
return;
|
|
1406
1446
|
}
|
|
1407
1447
|
try {
|
|
1408
1448
|
await this.syncSessionMap();
|
|
1409
|
-
// If we have claudeSessionId but no jsonlPath, try finding it (Issue #884: worktree-aware)
|
|
1449
|
+
// If we have claudeSessionId but no jsonlPath, try finding it (Issue #884: worktree-aware).
|
|
1410
1450
|
if (session.claudeSessionId && !session.jsonlPath) {
|
|
1411
1451
|
const jsonlPath = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
|
|
1412
1452
|
if (jsonlPath) {
|
|
@@ -1415,84 +1455,34 @@ export class SessionManager {
|
|
|
1415
1455
|
await this.save();
|
|
1416
1456
|
}
|
|
1417
1457
|
}
|
|
1458
|
+
// Filesystem fallback scan cadence (originally every 3s).
|
|
1459
|
+
const now = Date.now();
|
|
1460
|
+
const nextFsScanAt = this.discoveryNextFilesystemScanAt.get(id) ?? 0;
|
|
1461
|
+
if (now >= nextFsScanAt && (!session.claudeSessionId || !session.jsonlPath)) {
|
|
1462
|
+
this.discoveryNextFilesystemScanAt.set(id, now + 3_000);
|
|
1463
|
+
await this.maybeDiscoverFromFilesystem(session, workDir);
|
|
1464
|
+
}
|
|
1465
|
+
if (session.claudeSessionId && session.jsonlPath) {
|
|
1466
|
+
this.stopDiscoveryPolling(id);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
// best-effort polling; ignore transient errors
|
|
1418
1471
|
}
|
|
1419
|
-
|
|
1420
|
-
}, 2000);
|
|
1472
|
+
}, 2_000);
|
|
1421
1473
|
this.pollTimers.set(id, interval);
|
|
1422
|
-
|
|
1423
|
-
//
|
|
1474
|
+
this.discoveryNextFilesystemScanAt.set(id, Date.now());
|
|
1475
|
+
// P3 fix: Stop after 5 minutes if not found, log timeout.
|
|
1476
|
+
// #835: Track the timeout so cleanupSession can cancel it.
|
|
1424
1477
|
const discoveryTimeout = setTimeout(() => {
|
|
1425
|
-
this.discoveryTimeouts.delete(id);
|
|
1426
|
-
const timer = this.pollTimers.get(id);
|
|
1427
1478
|
const session = this.state.sessions[id];
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
// P3 fix: Log when discovery times out
|
|
1432
|
-
if (session && !session.claudeSessionId) {
|
|
1433
|
-
console.log(`Discovery: session ${session.windowName} — timed out after 5min, no session_id found`);
|
|
1434
|
-
}
|
|
1479
|
+
this.stopDiscoveryPolling(id);
|
|
1480
|
+
if (session && !session.claudeSessionId) {
|
|
1481
|
+
console.log(`Discovery: session ${session.windowName} — timed out after 5min, no session_id found`);
|
|
1435
1482
|
}
|
|
1436
1483
|
}, 5 * 60 * 1000);
|
|
1437
1484
|
this.discoveryTimeouts.set(id, discoveryTimeout);
|
|
1438
1485
|
}
|
|
1439
|
-
/** Issue #16: Filesystem-based discovery for --bare mode (no hooks).
|
|
1440
|
-
* Scans the Claude projects directory for new .jsonl files created after the session.
|
|
1441
|
-
*/
|
|
1442
|
-
startFilesystemDiscovery(id, workDir) {
|
|
1443
|
-
const projectHash = '-' + workDir.replace(/^\//, '').replace(/\//g, '-');
|
|
1444
|
-
const projectDir = join(this.config.claudeProjectsDir, projectHash);
|
|
1445
|
-
const interval = setInterval(async () => {
|
|
1446
|
-
const session = this.state.sessions[id];
|
|
1447
|
-
if (!session) {
|
|
1448
|
-
clearInterval(interval);
|
|
1449
|
-
this.pollTimers.delete(`fs-${id}`);
|
|
1450
|
-
return;
|
|
1451
|
-
}
|
|
1452
|
-
if (session.claudeSessionId && session.jsonlPath) {
|
|
1453
|
-
clearInterval(interval);
|
|
1454
|
-
this.pollTimers.delete(`fs-${id}`);
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
try {
|
|
1458
|
-
if (!existsSync(projectDir))
|
|
1459
|
-
return;
|
|
1460
|
-
const { readdir } = await import('node:fs/promises');
|
|
1461
|
-
const files = await readdir(projectDir);
|
|
1462
|
-
const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('.'));
|
|
1463
|
-
for (const file of jsonlFiles) {
|
|
1464
|
-
const filePath = join(projectDir, file);
|
|
1465
|
-
const fileStat = await stat(filePath);
|
|
1466
|
-
// Only consider files created after the session
|
|
1467
|
-
if (fileStat.mtimeMs < session.createdAt)
|
|
1468
|
-
continue;
|
|
1469
|
-
// Extract session ID from filename (filename = sessionId.jsonl)
|
|
1470
|
-
const sessionId = file.replace('.jsonl', '');
|
|
1471
|
-
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId))
|
|
1472
|
-
continue;
|
|
1473
|
-
session.claudeSessionId = sessionId;
|
|
1474
|
-
session.jsonlPath = filePath;
|
|
1475
|
-
session.byteOffset = 0;
|
|
1476
|
-
console.log(`Discovery (filesystem): session ${session.windowName} mapped to ${sessionId.slice(0, 8)}...`);
|
|
1477
|
-
await this.save();
|
|
1478
|
-
break;
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
catch { /* ignore */ }
|
|
1482
|
-
}, 3000);
|
|
1483
|
-
this.pollTimers.set(`fs-${id}`, interval);
|
|
1484
|
-
// Timeout after 5 minutes
|
|
1485
|
-
// #835: Track the timeout so cleanupSession can cancel it
|
|
1486
|
-
const fsDiscoveryTimeout = setTimeout(() => {
|
|
1487
|
-
this.discoveryTimeouts.delete(`fs-${id}`);
|
|
1488
|
-
const timer = this.pollTimers.get(`fs-${id}`);
|
|
1489
|
-
if (timer) {
|
|
1490
|
-
clearInterval(timer);
|
|
1491
|
-
this.pollTimers.delete(`fs-${id}`);
|
|
1492
|
-
}
|
|
1493
|
-
}, 5 * 60 * 1000);
|
|
1494
|
-
this.discoveryTimeouts.set(`fs-${id}`, fsDiscoveryTimeout);
|
|
1495
|
-
}
|
|
1496
1486
|
/** Sync CC session IDs from the hook-written session_map.json. */
|
|
1497
1487
|
async syncSessionMap() {
|
|
1498
1488
|
if (!existsSync(this.sessionMapFile))
|
package/dist/tmux.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface TmuxWindow {
|
|
|
13
13
|
windowName: string;
|
|
14
14
|
cwd: string;
|
|
15
15
|
paneCommand: string;
|
|
16
|
+
paneDead?: boolean;
|
|
16
17
|
}
|
|
17
18
|
export declare class TmuxManager {
|
|
18
19
|
private sessionName;
|
|
@@ -106,6 +107,7 @@ export declare class TmuxManager {
|
|
|
106
107
|
windowExists: boolean;
|
|
107
108
|
paneCommand: string | null;
|
|
108
109
|
claudeRunning: boolean;
|
|
110
|
+
paneDead: boolean;
|
|
109
111
|
}>;
|
|
110
112
|
/** Send text to a window's active pane. */
|
|
111
113
|
sendKeys(windowId: string, text: string, enter?: boolean): Promise<void>;
|
package/dist/tmux.js
CHANGED
|
@@ -27,6 +27,17 @@ export class TmuxTimeoutError extends Error {
|
|
|
27
27
|
this.name = 'TmuxTimeoutError';
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
const WINDOW_LIST_FORMAT = '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}\t#{pane_dead}';
|
|
31
|
+
function parseWindowListLine(line) {
|
|
32
|
+
const [windowId, windowName, cwd, paneCommand, paneDeadRaw] = line.split('\t');
|
|
33
|
+
return {
|
|
34
|
+
windowId,
|
|
35
|
+
windowName,
|
|
36
|
+
cwd,
|
|
37
|
+
paneCommand,
|
|
38
|
+
paneDead: paneDeadRaw === '1',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
30
41
|
export class TmuxManager {
|
|
31
42
|
sessionName;
|
|
32
43
|
/** tmux socket name (-L flag). Isolates sessions from other tmux instances. */
|
|
@@ -92,11 +103,9 @@ export class TmuxManager {
|
|
|
92
103
|
}
|
|
93
104
|
/** Compute an available window name by suffixing -2, -3, ... when needed. */
|
|
94
105
|
async resolveAvailableWindowName(baseName) {
|
|
95
|
-
const rawWindows = await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F',
|
|
96
|
-
const existing = (rawWindows ?? '').split('\n').filter(Boolean).map(
|
|
97
|
-
|
|
98
|
-
return { windowId, windowName, cwd, paneCommand };
|
|
99
|
-
}).filter((w) => w.windowName !== '_bridge_main');
|
|
106
|
+
const rawWindows = await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F', WINDOW_LIST_FORMAT);
|
|
107
|
+
const existing = (rawWindows ?? '').split('\n').filter(Boolean).map(parseWindowListLine)
|
|
108
|
+
.filter((w) => w.windowName !== '_bridge_main');
|
|
100
109
|
const existingNames = new Set(existing.map(w => w.windowName));
|
|
101
110
|
let name = baseName;
|
|
102
111
|
let counter = 2;
|
|
@@ -136,13 +145,11 @@ export class TmuxManager {
|
|
|
136
145
|
async listWindows() {
|
|
137
146
|
await this.ensureSession();
|
|
138
147
|
try {
|
|
139
|
-
const raw = await this.tmux('list-windows', '-t', this.sessionName, '-F',
|
|
148
|
+
const raw = await this.tmux('list-windows', '-t', this.sessionName, '-F', WINDOW_LIST_FORMAT);
|
|
140
149
|
if (!raw)
|
|
141
150
|
return [];
|
|
142
|
-
return raw.split('\n').filter(Boolean).map(
|
|
143
|
-
|
|
144
|
-
return { windowId, windowName, cwd, paneCommand };
|
|
145
|
-
}).filter(w => w.windowName !== '_bridge_main');
|
|
151
|
+
return raw.split('\n').filter(Boolean).map(parseWindowListLine)
|
|
152
|
+
.filter(w => w.windowName !== '_bridge_main');
|
|
146
153
|
}
|
|
147
154
|
catch (e) {
|
|
148
155
|
console.warn(`Tmux: listWindows failed: ${e.message}`);
|
|
@@ -184,13 +191,15 @@ export class TmuxManager {
|
|
|
184
191
|
await this.tmuxInternal('new-window', '-t', this.sessionName, '-n', name, '-c', opts.workDir, '-d');
|
|
185
192
|
// Prevent CC from renaming the window
|
|
186
193
|
await this.tmuxInternal('set-window-option', '-t', `${this.sessionName}:${name}`, 'allow-rename', 'off');
|
|
194
|
+
// Keep exited panes visible so pane_dead can signal Claude crashes quickly.
|
|
195
|
+
await this.tmuxInternal('set-window-option', '-t', `${this.sessionName}:${name}`, 'remain-on-exit', 'on');
|
|
187
196
|
// Issue #82: Set pane title to session name
|
|
188
197
|
await this.tmuxInternal('select-pane', '-t', `${this.sessionName}:${name}`, '-T', `aegis:${name}`);
|
|
189
198
|
// Get the window ID
|
|
190
199
|
const idRaw = await this.tmuxInternal('display-message', '-t', `${this.sessionName}:${name}`, '-p', '#{window_id}');
|
|
191
200
|
id = idRaw.trim();
|
|
192
201
|
// Verify the window actually exists after creation
|
|
193
|
-
const verifyRaw = await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F',
|
|
202
|
+
const verifyRaw = await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F', WINDOW_LIST_FORMAT);
|
|
194
203
|
const verified = verifyRaw.split('\n').filter(Boolean).some(line => {
|
|
195
204
|
const [wid] = line.split('\t');
|
|
196
205
|
return wid === id;
|
|
@@ -305,7 +314,7 @@ export class TmuxManager {
|
|
|
305
314
|
// - Color capabilities reduced to 256
|
|
306
315
|
// - Clipboard passthrough via tmux load-buffer instead of OSC 52
|
|
307
316
|
// Prefixing with 'unset' ensures CC gets a clean environment.
|
|
308
|
-
cmd = `unset TMUX TMUX_PANE && ${cmd}`;
|
|
317
|
+
cmd = `unset TMUX TMUX_PANE && exec ${cmd}`;
|
|
309
318
|
// Send the command to start Claude
|
|
310
319
|
await this.sendKeys(windowId, cmd, true);
|
|
311
320
|
// Issue #7: Verify Claude process started by checking pane command.
|
|
@@ -502,16 +511,16 @@ export class TmuxManager {
|
|
|
502
511
|
const windows = await this.listWindows();
|
|
503
512
|
const win = windows.find(w => w.windowId === windowId);
|
|
504
513
|
if (!win) {
|
|
505
|
-
return { windowExists: false, paneCommand: null, claudeRunning: false };
|
|
514
|
+
return { windowExists: false, paneCommand: null, claudeRunning: false, paneDead: false };
|
|
506
515
|
}
|
|
507
516
|
const paneCmd = win.paneCommand.toLowerCase();
|
|
508
517
|
// Claude runs as 'claude' or 'node' process
|
|
509
518
|
const claudeRunning = paneCmd === 'claude' || paneCmd === 'node';
|
|
510
|
-
return { windowExists: true, paneCommand: win.paneCommand, claudeRunning };
|
|
519
|
+
return { windowExists: true, paneCommand: win.paneCommand, claudeRunning, paneDead: win.paneDead ?? false };
|
|
511
520
|
}
|
|
512
521
|
catch (e) {
|
|
513
522
|
console.warn(`Tmux: getWindowHealth failed for ${windowId}: ${e.message}`);
|
|
514
|
-
return { windowExists: false, paneCommand: null, claudeRunning: false };
|
|
523
|
+
return { windowExists: false, paneCommand: null, claudeRunning: false, paneDead: false };
|
|
515
524
|
}
|
|
516
525
|
}
|
|
517
526
|
/** Send text to a window's active pane. */
|
package/dist/validation.d.ts
CHANGED
|
@@ -80,8 +80,8 @@ export declare const batchSessionSchema: z.ZodObject<{
|
|
|
80
80
|
workDir: z.ZodString;
|
|
81
81
|
prompt: z.ZodOptional<z.ZodString>;
|
|
82
82
|
permissionMode: z.ZodOptional<z.ZodEnum<{
|
|
83
|
-
default: "default";
|
|
84
83
|
bypassPermissions: "bypassPermissions";
|
|
84
|
+
default: "default";
|
|
85
85
|
plan: "plan";
|
|
86
86
|
acceptEdits: "acceptEdits";
|
|
87
87
|
dontAsk: "dontAsk";
|
|
@@ -101,8 +101,8 @@ export declare const pipelineSchema: z.ZodObject<{
|
|
|
101
101
|
prompt: z.ZodString;
|
|
102
102
|
dependsOn: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
103
103
|
permissionMode: z.ZodOptional<z.ZodEnum<{
|
|
104
|
-
default: "default";
|
|
105
104
|
bypassPermissions: "bypassPermissions";
|
|
105
|
+
default: "default";
|
|
106
106
|
plan: "plan";
|
|
107
107
|
acceptEdits: "acceptEdits";
|
|
108
108
|
dontAsk: "dontAsk";
|
|
@@ -152,26 +152,26 @@ export declare const persistedStateSchema: z.ZodRecord<z.ZodString, z.ZodObject<
|
|
|
152
152
|
byteOffset: z.ZodNumber;
|
|
153
153
|
monitorOffset: z.ZodNumber;
|
|
154
154
|
status: z.ZodEnum<{
|
|
155
|
-
error: "error";
|
|
156
|
-
unknown: "unknown";
|
|
157
|
-
permission_prompt: "permission_prompt";
|
|
158
155
|
idle: "idle";
|
|
159
156
|
working: "working";
|
|
160
157
|
compacting: "compacting";
|
|
161
158
|
context_warning: "context_warning";
|
|
162
159
|
waiting_for_input: "waiting_for_input";
|
|
163
|
-
|
|
160
|
+
permission_prompt: "permission_prompt";
|
|
164
161
|
plan_mode: "plan_mode";
|
|
165
162
|
ask_question: "ask_question";
|
|
163
|
+
bash_approval: "bash_approval";
|
|
166
164
|
settings: "settings";
|
|
165
|
+
error: "error";
|
|
166
|
+
unknown: "unknown";
|
|
167
167
|
}>;
|
|
168
168
|
createdAt: z.ZodNumber;
|
|
169
169
|
lastActivity: z.ZodNumber;
|
|
170
170
|
stallThresholdMs: z.ZodNumber;
|
|
171
171
|
permissionStallMs: z.ZodDefault<z.ZodNumber>;
|
|
172
172
|
permissionMode: z.ZodEnum<{
|
|
173
|
-
default: "default";
|
|
174
173
|
bypassPermissions: "bypassPermissions";
|
|
174
|
+
default: "default";
|
|
175
175
|
plan: "plan";
|
|
176
176
|
acceptEdits: "acceptEdits";
|
|
177
177
|
dontAsk: "dontAsk";
|
|
@@ -317,6 +317,10 @@ export declare function parseSemver(v: string): [number, number, number] | null;
|
|
|
317
317
|
export declare function compareSemver(a: string, b: string): number;
|
|
318
318
|
/** Extract version number from `claude --version` output. */
|
|
319
319
|
export declare function extractCCVersion(output: string): string | null;
|
|
320
|
+
/** Returns true when any path segment resolves to "..".
|
|
321
|
+
* Checks raw, separator-normalized, and percent-decoded forms to catch
|
|
322
|
+
* encoded traversal like %2e%2e and mixed slash/backslash payloads. */
|
|
323
|
+
export declare function containsTraversalSegment(inputPath: string): boolean;
|
|
320
324
|
/** Validate workDir to prevent path traversal attacks (Issue #435).
|
|
321
325
|
* 1. Reject raw strings containing ".." before any normalization.
|
|
322
326
|
* 2. Resolve to absolute path and resolve symlinks via fs.realpath().
|
package/dist/validation.js
CHANGED
|
@@ -302,11 +302,49 @@ function getDefaultSafeDirs() {
|
|
|
302
302
|
process.cwd(),
|
|
303
303
|
];
|
|
304
304
|
}
|
|
305
|
+
/** Returns true when any path segment resolves to "..".
|
|
306
|
+
* Checks raw, separator-normalized, and percent-decoded forms to catch
|
|
307
|
+
* encoded traversal like %2e%2e and mixed slash/backslash payloads. */
|
|
308
|
+
export function containsTraversalSegment(inputPath) {
|
|
309
|
+
const hasTraversalInSegments = (candidate) => {
|
|
310
|
+
const normalizedSeparators = candidate.replace(/[\\/]+/g, '/');
|
|
311
|
+
const segments = normalizedSeparators.split('/');
|
|
312
|
+
return segments.some((segment) => segment === '..');
|
|
313
|
+
};
|
|
314
|
+
let candidate = inputPath;
|
|
315
|
+
for (let i = 0; i < 4; i++) {
|
|
316
|
+
if (hasTraversalInSegments(candidate))
|
|
317
|
+
return true;
|
|
318
|
+
let decoded;
|
|
319
|
+
try {
|
|
320
|
+
decoded = decodeURIComponent(candidate);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
decoded = candidate;
|
|
324
|
+
}
|
|
325
|
+
if (decoded === candidate)
|
|
326
|
+
break;
|
|
327
|
+
candidate = decoded;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
/** Normalize path for consistent boundary comparisons. */
|
|
332
|
+
function normalizeForBoundaryCheck(inputPath) {
|
|
333
|
+
const resolved = path.normalize(path.resolve(inputPath));
|
|
334
|
+
const root = path.parse(resolved).root;
|
|
335
|
+
const trimmed = resolved.length > root.length
|
|
336
|
+
? resolved.replace(/[\\/]+$/g, '')
|
|
337
|
+
: resolved;
|
|
338
|
+
return process.platform === 'win32' ? trimmed.toLowerCase() : trimmed;
|
|
339
|
+
}
|
|
305
340
|
/** Check whether `childPath` is equal to or under `parentPath`. */
|
|
306
341
|
function isUnderOrEqual(childPath, parentPath) {
|
|
307
|
-
|
|
342
|
+
const normalizedChild = normalizeForBoundaryCheck(childPath);
|
|
343
|
+
const normalizedParent = normalizeForBoundaryCheck(parentPath);
|
|
344
|
+
if (normalizedChild === normalizedParent)
|
|
308
345
|
return true;
|
|
309
|
-
|
|
346
|
+
const relative = path.relative(normalizedParent, normalizedChild);
|
|
347
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
310
348
|
}
|
|
311
349
|
/** Validate workDir to prevent path traversal attacks (Issue #435).
|
|
312
350
|
* 1. Reject raw strings containing ".." before any normalization.
|
|
@@ -318,9 +356,8 @@ function isUnderOrEqual(childPath, parentPath) {
|
|
|
318
356
|
export async function validateWorkDir(workDir, allowedWorkDirs = []) {
|
|
319
357
|
if (typeof workDir !== 'string')
|
|
320
358
|
return { error: 'workDir must be a string', code: 'INVALID_WORKDIR' };
|
|
321
|
-
// Step 1: Reject path traversal in
|
|
322
|
-
|
|
323
|
-
if (workDir.includes('..')) {
|
|
359
|
+
// Step 1: Reject path traversal in raw/mixed/decoded forms before resolution.
|
|
360
|
+
if (containsTraversalSegment(workDir)) {
|
|
324
361
|
return { error: 'workDir must not contain path traversal components (..)', code: 'INVALID_WORKDIR' };
|
|
325
362
|
}
|
|
326
363
|
// Step 2: Resolve to absolute path and follow symlinks.
|
|
@@ -333,9 +370,17 @@ export async function validateWorkDir(workDir, allowedWorkDirs = []) {
|
|
|
333
370
|
return { error: `workDir does not exist: ${resolved}`, code: 'INVALID_WORKDIR' };
|
|
334
371
|
}
|
|
335
372
|
// Step 3: Directory allowlist check.
|
|
336
|
-
const
|
|
337
|
-
? allowedWorkDirs.map((
|
|
338
|
-
: getDefaultSafeDirs();
|
|
373
|
+
const safeDirCandidates = allowedWorkDirs.length > 0
|
|
374
|
+
? allowedWorkDirs.map((dir) => path.resolve(dir))
|
|
375
|
+
: getDefaultSafeDirs().map((dir) => path.resolve(dir));
|
|
376
|
+
const safeDirs = await Promise.all(safeDirCandidates.map(async (dir) => {
|
|
377
|
+
try {
|
|
378
|
+
return await fs.realpath(dir);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return dir;
|
|
382
|
+
}
|
|
383
|
+
}));
|
|
339
384
|
const allowed = safeDirs.some((dir) => isUnderOrEqual(realPath, dir));
|
|
340
385
|
if (!allowed) {
|
|
341
386
|
return { error: 'workDir is not in the allowed directories list', code: 'INVALID_WORKDIR' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-bridge",
|
|
3
|
-
"version": "2.12.
|
|
3
|
+
"version": "2.12.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -53,6 +53,9 @@
|
|
|
53
53
|
"fastify": "^5.8.2",
|
|
54
54
|
"zod": "^4.3.6"
|
|
55
55
|
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"zod": "^4.3.6"
|
|
58
|
+
},
|
|
56
59
|
"repository": {
|
|
57
60
|
"type": "git",
|
|
58
61
|
"url": "https://github.com/OneStepAt4time/aegis.git"
|