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/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.startSessionIdDiscovery(id);
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.startSessionIdDiscovery(id);
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.startSessionIdDiscovery(id);
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.startSessionIdDiscovery(id);
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 BOTH discovery methods in parallel:
589
- // 1. Hook-based: fast, relies on SessionStart hook writing session_map.json
590
- // 2. Filesystem-based: slower, scans for new .jsonl files works when hooks fail
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
- if (!(await this.tmux.windowExists(session.windowId)))
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
- try {
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
- // Clear polling timers (both regular and filesystem discovery variants)
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
- /** Try to discover the CC session ID and JSONL path. */
1393
- startSessionIdDiscovery(id) {
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
- clearInterval(interval);
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
- clearInterval(interval);
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
- catch { /* ignore */ }
1420
- }, 2000);
1472
+ }, 2_000);
1421
1473
  this.pollTimers.set(id, interval);
1422
- // P3 fix: Stop after 5 minutes if not found, log timeout
1423
- // #835: Track the timeout so cleanupSession can cancel it
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
- if (timer) {
1429
- clearInterval(timer);
1430
- this.pollTimers.delete(id);
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', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}');
96
- const existing = (rawWindows ?? '').split('\n').filter(Boolean).map(line => {
97
- const [windowId, windowName, cwd, paneCommand] = line.split('\t');
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', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}');
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(line => {
143
- const [windowId, windowName, cwd, paneCommand] = line.split('\t');
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', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}');
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. */
@@ -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
- bash_approval: "bash_approval";
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().
@@ -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
- if (childPath === parentPath)
342
+ const normalizedChild = normalizeForBoundaryCheck(childPath);
343
+ const normalizedParent = normalizeForBoundaryCheck(parentPath);
344
+ if (normalizedChild === normalizedParent)
308
345
  return true;
309
- return childPath.startsWith(parentPath + path.sep);
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 the raw string BEFORE any normalization.
322
- // path.normalize() would resolve ".." components, making the check useless.
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 safeDirs = allowedWorkDirs.length > 0
337
- ? allowedWorkDirs.map((d) => path.resolve(d))
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.0",
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"