aegis-bridge 2.2.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
  4. package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
  5. package/dashboard/dist/index.html +14 -0
  6. package/dist/auth.d.ts +76 -0
  7. package/dist/auth.js +219 -0
  8. package/dist/channels/index.d.ts +8 -0
  9. package/dist/channels/index.js +9 -0
  10. package/dist/channels/manager.d.ts +39 -0
  11. package/dist/channels/manager.js +101 -0
  12. package/dist/channels/telegram-style.d.ts +118 -0
  13. package/dist/channels/telegram-style.js +203 -0
  14. package/dist/channels/telegram.d.ts +76 -0
  15. package/dist/channels/telegram.js +1396 -0
  16. package/dist/channels/types.d.ts +77 -0
  17. package/dist/channels/types.js +9 -0
  18. package/dist/channels/webhook.d.ts +58 -0
  19. package/dist/channels/webhook.js +162 -0
  20. package/dist/cli.d.ts +8 -0
  21. package/dist/cli.js +223 -0
  22. package/dist/config.d.ts +60 -0
  23. package/dist/config.js +188 -0
  24. package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
  25. package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
  26. package/dist/dashboard/index.html +14 -0
  27. package/dist/events.d.ts +86 -0
  28. package/dist/events.js +258 -0
  29. package/dist/hook-settings.d.ts +67 -0
  30. package/dist/hook-settings.js +138 -0
  31. package/dist/hook.d.ts +18 -0
  32. package/dist/hook.js +199 -0
  33. package/dist/hooks.d.ts +32 -0
  34. package/dist/hooks.js +279 -0
  35. package/dist/jsonl-watcher.d.ts +57 -0
  36. package/dist/jsonl-watcher.js +159 -0
  37. package/dist/mcp-server.d.ts +60 -0
  38. package/dist/mcp-server.js +788 -0
  39. package/dist/metrics.d.ts +104 -0
  40. package/dist/metrics.js +226 -0
  41. package/dist/monitor.d.ts +84 -0
  42. package/dist/monitor.js +553 -0
  43. package/dist/permission-guard.d.ts +51 -0
  44. package/dist/permission-guard.js +197 -0
  45. package/dist/pipeline.d.ts +84 -0
  46. package/dist/pipeline.js +218 -0
  47. package/dist/screenshot.d.ts +26 -0
  48. package/dist/screenshot.js +57 -0
  49. package/dist/server.d.ts +10 -0
  50. package/dist/server.js +1577 -0
  51. package/dist/session.d.ts +297 -0
  52. package/dist/session.js +1275 -0
  53. package/dist/sse-limiter.d.ts +47 -0
  54. package/dist/sse-limiter.js +62 -0
  55. package/dist/sse-writer.d.ts +31 -0
  56. package/dist/sse-writer.js +95 -0
  57. package/dist/ssrf.d.ts +57 -0
  58. package/dist/ssrf.js +169 -0
  59. package/dist/swarm-monitor.d.ts +114 -0
  60. package/dist/swarm-monitor.js +267 -0
  61. package/dist/terminal-parser.d.ts +16 -0
  62. package/dist/terminal-parser.js +343 -0
  63. package/dist/tmux.d.ts +161 -0
  64. package/dist/tmux.js +725 -0
  65. package/dist/transcript.d.ts +47 -0
  66. package/dist/transcript.js +244 -0
  67. package/dist/validation.d.ts +222 -0
  68. package/dist/validation.js +268 -0
  69. package/dist/ws-terminal.d.ts +32 -0
  70. package/dist/ws-terminal.js +297 -0
  71. package/package.json +71 -0
@@ -0,0 +1,1275 @@
1
+ /**
2
+ * session.ts — Session state manager.
3
+ *
4
+ * Manages the lifecycle of CC sessions running in tmux windows.
5
+ * Tracks: session ID, window ID, byte offset for JSONL reading, status.
6
+ */
7
+ import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises';
8
+ import { existsSync, unlinkSync, readdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { findSessionFile, readNewEntries } from './transcript.js';
12
+ import { detectUIState, extractInteractiveContent, parseStatusLine } from './terminal-parser.js';
13
+ import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
14
+ import { persistedStateSchema, sessionMapSchema } from './validation.js';
15
+ import { writeHookSettingsFile, cleanupHookSettingsFile } from './hook-settings.js';
16
+ /**
17
+ * Detect whether CC is showing numbered permission options (e.g. "1. Yes, 2. No")
18
+ * vs a simple y/N prompt. Returns the approval method to use.
19
+ *
20
+ * CC's permission UI uses indented numbered lines with "Esc to cancel" nearby.
21
+ * We look for the pattern " <N>. <option>" where N is 1-3, which distinguishes
22
+ * permission options from regular numbered lists in output.
23
+ */
24
+ export function detectApprovalMethod(paneText) {
25
+ // Match CC's permission option format: indented " 1. Yes" lines
26
+ // The indentation + short number range distinguishes from output numbered lists
27
+ const numberedOptionPattern = /^\s{2}[1-3]\.\s/m;
28
+ if (numberedOptionPattern.test(paneText)) {
29
+ return 'numbered';
30
+ }
31
+ return 'yes';
32
+ }
33
+ export class SessionManager {
34
+ tmux;
35
+ config;
36
+ state = { sessions: {} };
37
+ stateFile;
38
+ sessionMapFile;
39
+ pollTimers = new Map();
40
+ saveQueue = Promise.resolve(); // #218: serialize concurrent saves
41
+ saveDebounceTimer = null;
42
+ static SAVE_DEBOUNCE_MS = 5_000; // #357: debounce offset-only saves
43
+ pendingPermissions = new Map();
44
+ pendingQuestions = new Map();
45
+ // #357: Cache of all parsed JSONL entries per session to avoid re-reading from offset 0
46
+ parsedEntriesCache = new Map();
47
+ constructor(tmux, config) {
48
+ this.tmux = tmux;
49
+ this.config = config;
50
+ this.stateFile = join(config.stateDir, 'state.json');
51
+ this.sessionMapFile = join(config.stateDir, 'session_map.json');
52
+ }
53
+ /** Validate that parsed data looks like a valid SessionState. */
54
+ isValidState(data) {
55
+ if (typeof data !== 'object' || data === null)
56
+ return false;
57
+ const obj = data;
58
+ if (typeof obj.sessions !== 'object' || obj.sessions === null)
59
+ return false;
60
+ const sessions = obj.sessions;
61
+ for (const val of Object.values(sessions)) {
62
+ if (typeof val !== 'object' || val === null)
63
+ return false;
64
+ const s = val;
65
+ if (typeof s.id !== 'string' || typeof s.windowId !== 'string')
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+ /** Clean up stale .tmp files left by crashed writes. */
71
+ cleanTmpFiles(dir) {
72
+ try {
73
+ for (const entry of readdirSync(dir)) {
74
+ if (entry.endsWith('.tmp')) {
75
+ const fullPath = join(dir, entry);
76
+ try {
77
+ unlinkSync(fullPath);
78
+ }
79
+ catch { /* best effort */ }
80
+ console.log(`Cleaned stale tmp file: ${entry}`);
81
+ }
82
+ }
83
+ }
84
+ catch { /* dir may not exist yet */ }
85
+ }
86
+ /** Load state from disk. */
87
+ async load() {
88
+ const dir = dirname(this.stateFile);
89
+ if (!existsSync(dir)) {
90
+ await mkdir(dir, { recursive: true });
91
+ }
92
+ // Clean stale .tmp files from crashed writes
93
+ this.cleanTmpFiles(dir);
94
+ if (existsSync(this.stateFile)) {
95
+ try {
96
+ const raw = await readFile(this.stateFile, 'utf-8');
97
+ const parsed = persistedStateSchema.safeParse(JSON.parse(raw));
98
+ if (parsed.success && this.isValidState({ sessions: parsed.data })) {
99
+ this.state = { sessions: parsed.data };
100
+ }
101
+ else {
102
+ console.warn('State file failed validation, attempting backup restore');
103
+ // Try loading from backup before resetting
104
+ const backupFile = `${this.stateFile}.bak`;
105
+ if (existsSync(backupFile)) {
106
+ try {
107
+ const backupRaw = await readFile(backupFile, 'utf-8');
108
+ const backupParsed = persistedStateSchema.safeParse(JSON.parse(backupRaw));
109
+ if (backupParsed.success && this.isValidState({ sessions: backupParsed.data })) {
110
+ this.state = { sessions: backupParsed.data };
111
+ console.log('Restored state from backup');
112
+ }
113
+ else {
114
+ this.state = { sessions: {} };
115
+ }
116
+ }
117
+ catch { /* backup state file corrupted — start empty */
118
+ this.state = { sessions: {} };
119
+ }
120
+ }
121
+ else {
122
+ this.state = { sessions: {} };
123
+ }
124
+ }
125
+ }
126
+ catch { /* state file corrupted — start empty */
127
+ this.state = { sessions: {} };
128
+ }
129
+ }
130
+ // #357: Convert deserialized activeSubagents arrays to Sets
131
+ for (const session of Object.values(this.state.sessions)) {
132
+ if (Array.isArray(session.activeSubagents)) {
133
+ session.activeSubagents = new Set(session.activeSubagents);
134
+ }
135
+ }
136
+ // Create backup of successfully loaded state
137
+ try {
138
+ await writeFile(`${this.stateFile}.bak`, JSON.stringify(this.state, null, 2));
139
+ }
140
+ catch { /* non-critical */ }
141
+ // Reconcile: verify tmux windows still exist, clean up dead sessions
142
+ await this.reconcile();
143
+ }
144
+ /** Reconcile state with actual tmux windows. Remove dead sessions, restart discovery for live ones. */
145
+ async reconcile() {
146
+ const windows = await this.tmux.listWindows();
147
+ const windowIds = new Set(windows.map(w => w.windowId));
148
+ const windowNames = new Set(windows.map(w => w.windowName));
149
+ let changed = false;
150
+ for (const [id, session] of Object.entries(this.state.sessions)) {
151
+ const alive = windowIds.has(session.windowId) || windowNames.has(session.windowName);
152
+ if (!alive) {
153
+ console.log(`Reconcile: session ${session.windowName} (${id.slice(0, 8)}) — tmux window gone, removing`);
154
+ // Restore patched settings before removing dead session
155
+ if (session.settingsPatched) {
156
+ await cleanOrphanedBackup(session.workDir);
157
+ }
158
+ delete this.state.sessions[id];
159
+ changed = true;
160
+ }
161
+ else {
162
+ // Session is alive — restart discovery if needed
163
+ if (!session.claudeSessionId || !session.jsonlPath) {
164
+ console.log(`Reconcile: session ${session.windowName} — restarting JSONL discovery`);
165
+ this.startSessionIdDiscovery(id);
166
+ }
167
+ else {
168
+ console.log(`Reconcile: session ${session.windowName} — alive, JSONL ready`);
169
+ }
170
+ }
171
+ }
172
+ // P0 fix: On startup, purge session_map entries that don't correspond to active sessions.
173
+ await this.purgeStaleSessionMapEntries(windowIds, windowNames);
174
+ // Issue #35: Adopt orphaned tmux windows (cc-* prefix) not in state
175
+ const knownWindowIds = new Set(Object.values(this.state.sessions).map(s => s.windowId));
176
+ const knownWindowNames = new Set(Object.values(this.state.sessions).map(s => s.windowName));
177
+ for (const win of windows) {
178
+ if (knownWindowIds.has(win.windowId) || knownWindowNames.has(win.windowName))
179
+ continue;
180
+ // Only adopt windows that look like Aegis-created sessions (cc-* prefix or _bridge_ prefix)
181
+ if (!win.windowName.startsWith('cc-') && !win.windowName.startsWith('_bridge_'))
182
+ continue;
183
+ const id = crypto.randomUUID();
184
+ const session = {
185
+ id,
186
+ windowId: win.windowId,
187
+ windowName: win.windowName,
188
+ workDir: win.cwd || homedir(),
189
+ byteOffset: 0,
190
+ monitorOffset: 0,
191
+ status: 'unknown',
192
+ createdAt: Date.now(),
193
+ lastActivity: Date.now(),
194
+ stallThresholdMs: SessionManager.DEFAULT_STALL_THRESHOLD_MS,
195
+ permissionStallMs: SessionManager.DEFAULT_PERMISSION_STALL_MS,
196
+ permissionMode: 'default',
197
+ };
198
+ this.state.sessions[id] = session;
199
+ console.log(`Reconcile: adopted orphaned window ${win.windowName} (${win.windowId}) as ${id.slice(0, 8)}`);
200
+ this.startSessionIdDiscovery(id);
201
+ this.startFilesystemDiscovery(id, session.workDir);
202
+ changed = true;
203
+ }
204
+ if (changed) {
205
+ await this.save();
206
+ }
207
+ }
208
+ /** Save state to disk atomically (write to temp, then rename).
209
+ * #218: Uses a write queue to serialize concurrent saves and prevent corruption. */
210
+ async save() {
211
+ this.saveQueue = this.saveQueue.then(() => this.doSave()).catch(e => console.error('State save error:', e));
212
+ await this.saveQueue;
213
+ }
214
+ /** #357: Debounced save — skips immediate save for offset-only changes.
215
+ * Coalesces rapid successive reads into a single disk write. */
216
+ debouncedSave() {
217
+ if (this.saveDebounceTimer !== null)
218
+ clearTimeout(this.saveDebounceTimer);
219
+ this.saveDebounceTimer = setTimeout(() => {
220
+ this.saveDebounceTimer = null;
221
+ void this.save();
222
+ }, SessionManager.SAVE_DEBOUNCE_MS);
223
+ }
224
+ async doSave() {
225
+ const dir = dirname(this.stateFile);
226
+ if (!existsSync(dir)) {
227
+ await mkdir(dir, { recursive: true });
228
+ }
229
+ const tmpFile = `${this.stateFile}.tmp`;
230
+ // #357: Use replacer to serialize Set<string> as arrays
231
+ await writeFile(tmpFile, JSON.stringify(this.state, (_, value) => {
232
+ if (value instanceof Set)
233
+ return [...value];
234
+ return value;
235
+ }, 2));
236
+ await rename(tmpFile, this.stateFile);
237
+ }
238
+ /** Default stall threshold: 5 minutes (Issue #4: reduced from 60 min). */
239
+ static DEFAULT_STALL_THRESHOLD_MS = 5 * 60 * 1000;
240
+ static DEFAULT_PERMISSION_STALL_MS = 5 * 60 * 1000;
241
+ /** Create a new CC session. */
242
+ /** Default timeout for waiting CC to become ready (60s for cold starts). */
243
+ static DEFAULT_PROMPT_TIMEOUT_MS = 60_000;
244
+ /** Max retries if CC doesn't become ready in time. */
245
+ static DEFAULT_PROMPT_MAX_RETRIES = 2;
246
+ /**
247
+ * Wait for CC to show its idle prompt in the tmux pane, then send the initial prompt.
248
+ * Uses exponential backoff on retry: first attempt waits timeoutMs, subsequent attempts
249
+ * wait 1.5x the previous timeout.
250
+ *
251
+ * Returns delivery result. Logs warnings on each retry for observability.
252
+ */
253
+ async sendInitialPrompt(sessionId, prompt, timeoutMs, maxRetries) {
254
+ const session = this.getSession(sessionId);
255
+ if (!session)
256
+ return { delivered: false, attempts: 0 };
257
+ const effectiveTimeout = timeoutMs ?? SessionManager.DEFAULT_PROMPT_TIMEOUT_MS;
258
+ const effectiveMaxRetries = maxRetries ?? SessionManager.DEFAULT_PROMPT_MAX_RETRIES;
259
+ for (let attempt = 1; attempt <= effectiveMaxRetries + 1; attempt++) {
260
+ const attemptTimeout = attempt === 1
261
+ ? effectiveTimeout
262
+ : Math.min(effectiveTimeout * Math.pow(1.5, attempt - 1), 120_000); // cap at 2min per retry
263
+ const result = await this.waitForReadyAndSend(sessionId, prompt, attemptTimeout);
264
+ if (result.delivered) {
265
+ if (attempt > 1) {
266
+ console.log(`sendInitialPrompt: delivered on attempt ${attempt}/${effectiveMaxRetries + 1}`);
267
+ }
268
+ return result;
269
+ }
270
+ // If this was the last attempt, return failure
271
+ if (attempt > effectiveMaxRetries) {
272
+ console.error(`sendInitialPrompt: FAILED after ${attempt} attempts for session ${sessionId.slice(0, 8)}`);
273
+ return result;
274
+ }
275
+ // Log retry
276
+ console.warn(`sendInitialPrompt: CC not ready after ${attemptTimeout}ms, retry ${attempt}/${effectiveMaxRetries}`);
277
+ }
278
+ return { delivered: false, attempts: effectiveMaxRetries + 1 };
279
+ }
280
+ /** Wait for CC idle prompt, then send. Single attempt. */
281
+ async waitForReadyAndSend(sessionId, prompt, timeoutMs) {
282
+ const session = this.getSession(sessionId);
283
+ if (!session)
284
+ return { delivered: false, attempts: 0 };
285
+ // #363: Exponential backoff from 500ms → 2000ms to reduce tmux CLI calls.
286
+ // Instead of ~120 fixed-interval polls, we get ~8-10 polls per session.
287
+ const MIN_POLL_MS = 500;
288
+ const MAX_POLL_MS = 2_000;
289
+ let pollInterval = MIN_POLL_MS;
290
+ const start = Date.now();
291
+ while (Date.now() - start < timeoutMs) {
292
+ // Use capturePaneDirect to bypass the serialize queue.
293
+ // At session creation, no other code is writing to this pane,
294
+ // so queue serialization is unnecessary and adds latency.
295
+ const paneText = await this.tmux.capturePaneDirect(session.windowId);
296
+ // CC shows ❯ (U+276F) when ready for input. Avoid checking for plain >
297
+ // which appears frequently in tool output, diffs, and prompts.
298
+ if (paneText && paneText.includes('❯')) {
299
+ return this.sendMessageDirect(sessionId, prompt);
300
+ }
301
+ await new Promise(r => setTimeout(r, pollInterval));
302
+ pollInterval = Math.min(pollInterval * 2, MAX_POLL_MS);
303
+ }
304
+ return { delivered: false, attempts: 0 };
305
+ }
306
+ async createSession(opts) {
307
+ const id = crypto.randomUUID();
308
+ const windowName = opts.name || `cc-${id.slice(0, 8)}`;
309
+ // Merge defaultSessionEnv (from config) with per-session env (per-session wins)
310
+ // Security: validate env var names to prevent injection attacks
311
+ const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
312
+ const DANGEROUS_ENV_VARS = new Set([
313
+ 'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
314
+ 'DYLD_INSERT_LIBRARIES', 'IFS', 'SHELL', 'ENV', 'BASH_ENV',
315
+ 'PYTHONPATH', 'PERL5LIB', 'RUBYLIB', 'CLASSPATH',
316
+ 'NODE_PATH', 'PYTHONHOME', 'PYTHONSTARTUP',
317
+ ]);
318
+ const mergedEnv = {};
319
+ const allEnv = { ...this.config.defaultSessionEnv, ...opts.env };
320
+ for (const [key, value] of Object.entries(allEnv)) {
321
+ if (!ENV_NAME_RE.test(key)) {
322
+ throw new Error(`Invalid env var name: "${key}" — must match /^[A-Z_][A-Z0-9_]*$/`);
323
+ }
324
+ if (DANGEROUS_ENV_VARS.has(key)) {
325
+ throw new Error(`Forbidden env var: "${key}" — cannot override dangerous environment variables`);
326
+ }
327
+ mergedEnv[key] = value;
328
+ }
329
+ const hasEnv = Object.keys(mergedEnv).length > 0;
330
+ // Permission guard: if permissionMode is "default", neutralize any project-level
331
+ // settings.local.json that has bypassPermissions. The CLI flag --permission-mode
332
+ // should be authoritative, but CC lets project settings override it.
333
+ // We back up the file, patch it, and restore on session cleanup.
334
+ const effectivePermissionMode = opts.permissionMode
335
+ ?? (opts.autoApprove === true ? 'bypassPermissions' : opts.autoApprove === false ? 'default' : undefined)
336
+ ?? this.config.defaultPermissionMode
337
+ ?? 'bypassPermissions';
338
+ let settingsPatched = false;
339
+ if (effectivePermissionMode !== 'bypassPermissions') {
340
+ settingsPatched = await neutralizeBypassPermissions(opts.workDir, effectivePermissionMode);
341
+ }
342
+ // Issue #169 Phase 2: Generate HTTP hook settings for this session.
343
+ // Writes a temp file with hooks pointing to Aegis's hook receiver.
344
+ let hookSettingsFile;
345
+ try {
346
+ const baseUrl = `http://${this.config.host}:${this.config.port}`;
347
+ hookSettingsFile = await writeHookSettingsFile(baseUrl, id, opts.workDir);
348
+ }
349
+ catch (e) {
350
+ console.error(`Hook settings: failed to generate settings file: ${e.message}`);
351
+ // Non-fatal: hooks won't work for this session, but CC still launches
352
+ }
353
+ const { windowId, windowName: finalName, freshSessionId } = await this.tmux.createWindow({
354
+ workDir: opts.workDir,
355
+ windowName,
356
+ resumeSessionId: opts.resumeSessionId,
357
+ claudeCommand: opts.claudeCommand,
358
+ env: hasEnv ? mergedEnv : undefined,
359
+ permissionMode: effectivePermissionMode,
360
+ settingsFile: hookSettingsFile,
361
+ });
362
+ const session = {
363
+ id,
364
+ windowId,
365
+ windowName: finalName,
366
+ workDir: opts.workDir,
367
+ // If we know the CC session ID upfront (from --session-id), set it immediately.
368
+ // This eliminates the discovery delay and prevents stale ID assignment entirely.
369
+ claudeSessionId: freshSessionId || undefined,
370
+ byteOffset: 0,
371
+ monitorOffset: 0,
372
+ status: 'unknown',
373
+ createdAt: Date.now(),
374
+ lastActivity: Date.now(),
375
+ stallThresholdMs: opts.stallThresholdMs || SessionManager.DEFAULT_STALL_THRESHOLD_MS,
376
+ permissionStallMs: opts.permissionStallMs || SessionManager.DEFAULT_PERMISSION_STALL_MS,
377
+ permissionMode: effectivePermissionMode,
378
+ settingsPatched,
379
+ hookSettingsFile,
380
+ };
381
+ this.state.sessions[id] = session;
382
+ await this.save();
383
+ // Issue #353: Fetch CC process PID for swarm parent matching.
384
+ // Fire-and-forget — PID is not needed synchronously.
385
+ void this.tmux.listPanePid(windowId).then(pid => {
386
+ if (pid !== null) {
387
+ session.ccPid = pid;
388
+ void this.save();
389
+ }
390
+ });
391
+ // Start BOTH discovery methods in parallel:
392
+ // 1. Hook-based: fast, relies on SessionStart hook writing session_map.json
393
+ // 2. Filesystem-based: slower, scans for new .jsonl files — works when hooks fail
394
+ // Issue #16: --bare flag skips hooks entirely
395
+ // Field bug (Zeus 2026-03-22): hooks may not fire even without --bare
396
+ //
397
+ // If we already have the claudeSessionId (from --session-id), filesystem discovery
398
+ // will just find the JSONL path. Hook discovery may still run but won't override.
399
+ this.startFilesystemDiscovery(id, opts.workDir);
400
+ // P0 fix: Clean stale entries from session_map.json for BOTH window name AND id.
401
+ // After archiving old .jsonl files, stale session_map entries would point
402
+ // to moved files, causing discovery to pick up ghost session IDs.
403
+ // Also cleans stale windowId entries that could collide after restart.
404
+ await this.cleanSessionMapForWindow(finalName, windowId);
405
+ // Start watching for the CC session ID via hook
406
+ this.startSessionIdDiscovery(id);
407
+ return session;
408
+ }
409
+ /** Get a session by ID. */
410
+ getSession(id) {
411
+ return this.state.sessions[id] || null;
412
+ }
413
+ /** Issue #169 Phase 3: Update session status from a hook event.
414
+ * Returns the previous status for change detection.
415
+ * Issue #87: Also records hook latency timestamps. */
416
+ updateStatusFromHook(id, hookEvent, hookTimestamp) {
417
+ const session = this.state.sessions[id];
418
+ if (!session)
419
+ return null;
420
+ const prevStatus = session.status;
421
+ const now = Date.now();
422
+ // Map hook events to UI states
423
+ switch (hookEvent) {
424
+ case 'Stop':
425
+ case 'TaskCompleted':
426
+ case 'SessionEnd':
427
+ case 'TeammateIdle':
428
+ session.status = 'idle';
429
+ break;
430
+ case 'PreToolUse':
431
+ case 'PostToolUse':
432
+ case 'SubagentStart':
433
+ case 'UserPromptSubmit':
434
+ session.status = 'working';
435
+ break;
436
+ case 'PermissionRequest':
437
+ session.status = 'permission_prompt';
438
+ break;
439
+ case 'StopFailure':
440
+ case 'PostToolUseFailure':
441
+ session.status = 'error';
442
+ break;
443
+ case 'Notification':
444
+ case 'PreCompact':
445
+ case 'PostCompact':
446
+ case 'SubagentStop':
447
+ // Informational events — no status change
448
+ break;
449
+ default:
450
+ // Unknown hook events: no status change
451
+ break;
452
+ }
453
+ session.lastHookAt = now;
454
+ session.lastActivity = now;
455
+ // Issue #87: Record hook receive timestamp for latency calculation
456
+ session.lastHookReceivedAt = now;
457
+ if (hookTimestamp) {
458
+ session.lastHookEventAt = hookTimestamp;
459
+ }
460
+ // Issue #87: Track permission prompt timestamp
461
+ if (hookEvent === 'PermissionRequest') {
462
+ session.permissionPromptAt = now;
463
+ }
464
+ return prevStatus;
465
+ }
466
+ /** Issue #88: Add an active subagent to a session. */
467
+ addSubagent(id, name) {
468
+ const session = this.state.sessions[id];
469
+ if (!session)
470
+ return;
471
+ if (!session.activeSubagents)
472
+ session.activeSubagents = new Set();
473
+ session.activeSubagents.add(name);
474
+ }
475
+ /** Issue #88: Remove an active subagent from a session. */
476
+ removeSubagent(id, name) {
477
+ const session = this.state.sessions[id];
478
+ if (!session || !session.activeSubagents)
479
+ return;
480
+ session.activeSubagents.delete(name);
481
+ }
482
+ /** Issue #89 L25: Update the model field on a session from hook payload. */
483
+ updateSessionModel(id, model) {
484
+ const session = this.state.sessions[id];
485
+ if (!session)
486
+ return;
487
+ session.model = model;
488
+ }
489
+ /** Issue #87: Get latency metrics for a session. */
490
+ getLatencyMetrics(id) {
491
+ const session = this.state.sessions[id];
492
+ if (!session)
493
+ return null;
494
+ // hook_latency_ms: time from CC sending hook to Aegis receiving it
495
+ // Calculated from the difference between our receive time and the hook's timestamp
496
+ let hookLatency = null;
497
+ if (session.lastHookReceivedAt && session.lastHookEventAt) {
498
+ hookLatency = session.lastHookReceivedAt - session.lastHookEventAt;
499
+ // Guard against negative values (clock skew)
500
+ if (hookLatency < 0)
501
+ hookLatency = null;
502
+ }
503
+ // state_change_detection_ms: time from CC state change to Aegis detection
504
+ // Approximated as hook_latency_ms since the hook IS the state change signal
505
+ let stateChangeDetection = hookLatency;
506
+ // permission_response_ms: time from permission prompt to user action
507
+ let permissionResponse = null;
508
+ if (session.permissionPromptAt && session.permissionRespondedAt) {
509
+ permissionResponse = session.permissionRespondedAt - session.permissionPromptAt;
510
+ }
511
+ return {
512
+ hook_latency_ms: hookLatency,
513
+ state_change_detection_ms: stateChangeDetection,
514
+ permission_response_ms: permissionResponse,
515
+ };
516
+ }
517
+ /** Check if a session's tmux window still exists and has a live process.
518
+ * Issue #69: A window can exist with a crashed/zombie CC process (zombie window).
519
+ * After checking window exists, also verify the pane PID is alive.
520
+ * Issue #390: Check stored ccPid first for immediate crash detection.
521
+ * When CC crashes (SIGKILL, OOM), the shell prompt returns in the pane,
522
+ * so the current pane PID is the shell (alive). Checking ccPid catches
523
+ * the crash within seconds instead of waiting for the 5-min stall timer. */
524
+ async isWindowAlive(id) {
525
+ const session = this.state.sessions[id];
526
+ if (!session)
527
+ return false;
528
+ try {
529
+ // Issue #390: Fast crash detection via stored CC PID
530
+ if (session.ccPid && !this.tmux.isPidAlive(session.ccPid))
531
+ return false;
532
+ if (!(await this.tmux.windowExists(session.windowId)))
533
+ return false;
534
+ // Verify the process inside the pane is still alive
535
+ const panePid = await this.tmux.listPanePid(session.windowId);
536
+ if (panePid !== null && !this.tmux.isPidAlive(panePid))
537
+ return false;
538
+ return true;
539
+ }
540
+ catch { /* tmux query failed — treat as not alive */
541
+ return false;
542
+ }
543
+ }
544
+ /** List all sessions. */
545
+ listSessions() {
546
+ return Object.values(this.state.sessions);
547
+ }
548
+ /** Get health info for a session.
549
+ * Issue #2: Returns comprehensive health status for orchestrators.
550
+ */
551
+ async getHealth(id) {
552
+ const session = this.state.sessions[id];
553
+ if (!session)
554
+ throw new Error(`Session ${id} not found`);
555
+ const now = Date.now();
556
+ const windowHealth = await this.tmux.getWindowHealth(session.windowId);
557
+ // Get terminal state
558
+ let status = 'unknown';
559
+ // Issue #69: Also check if the pane PID is alive (zombie window detection)
560
+ let processAlive = true;
561
+ if (windowHealth.windowExists) {
562
+ try {
563
+ const panePid = await this.tmux.listPanePid(session.windowId);
564
+ if (panePid !== null) {
565
+ processAlive = this.tmux.isPidAlive(panePid);
566
+ }
567
+ }
568
+ catch { /* cannot list pane PID — assume dead */
569
+ processAlive = false;
570
+ }
571
+ }
572
+ if (windowHealth.windowExists && processAlive) {
573
+ try {
574
+ const paneText = await this.tmux.capturePane(session.windowId);
575
+ status = detectUIState(paneText);
576
+ session.status = status;
577
+ }
578
+ catch { /* pane capture failed — default to unknown */
579
+ status = 'unknown';
580
+ }
581
+ }
582
+ const hasTranscript = !!(session.claudeSessionId && session.jsonlPath);
583
+ const lastActivityAgo = now - session.lastActivity;
584
+ const sessionAge = now - session.createdAt;
585
+ // Determine if session is alive
586
+ // Alive = window exists AND process alive AND (Claude running OR recently active)
587
+ const recentlyActive = lastActivityAgo < 5 * 60 * 1000; // 5 minutes
588
+ const alive = windowHealth.windowExists && processAlive && (windowHealth.claudeRunning || recentlyActive);
589
+ // Human-readable detail
590
+ let details;
591
+ if (!windowHealth.windowExists) {
592
+ details = 'Tmux window does not exist — session is dead';
593
+ }
594
+ else if (!processAlive) {
595
+ details = 'Tmux window exists but pane process is dead — session is dead (zombie window)';
596
+ }
597
+ else if (!windowHealth.claudeRunning && !recentlyActive) {
598
+ details = `Claude not running (pane: ${windowHealth.paneCommand}), no activity for ${Math.round(lastActivityAgo / 60000)}min`;
599
+ }
600
+ else if (status === 'idle') {
601
+ details = 'Claude is idle, awaiting input';
602
+ }
603
+ else if (status === 'working') {
604
+ details = 'Claude is actively working';
605
+ }
606
+ else if (status === 'permission_prompt' || status === 'bash_approval') {
607
+ details = `Claude is waiting for permission approval. POST /v1/sessions/${session.id}/approve to approve, or /v1/sessions/${session.id}/reject to reject.`;
608
+ }
609
+ else {
610
+ details = `Status: ${status}, pane: ${windowHealth.paneCommand}`;
611
+ }
612
+ // Issue #20: Action hints for interactive states
613
+ const actionHints = (status === 'permission_prompt' || status === 'bash_approval')
614
+ ? {
615
+ approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
616
+ reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
617
+ }
618
+ : undefined;
619
+ return {
620
+ alive,
621
+ windowExists: windowHealth.windowExists,
622
+ claudeRunning: windowHealth.claudeRunning,
623
+ paneCommand: windowHealth.paneCommand,
624
+ status,
625
+ hasTranscript,
626
+ lastActivity: session.lastActivity,
627
+ lastActivityAgo,
628
+ sessionAge,
629
+ details,
630
+ actionHints,
631
+ };
632
+ }
633
+ /** Send a message to a session with delivery verification.
634
+ * Issue #1: Uses capture-pane to verify the prompt was delivered.
635
+ * Returns delivery status for API response.
636
+ */
637
+ async sendMessage(id, text) {
638
+ const session = this.state.sessions[id];
639
+ if (!session)
640
+ throw new Error(`Session ${id} not found`);
641
+ const result = await this.tmux.sendKeysVerified(session.windowId, text);
642
+ session.lastActivity = Date.now();
643
+ await this.save();
644
+ return result;
645
+ }
646
+ /** Send message bypassing the tmux serialize queue.
647
+ * Used by sendInitialPrompt for critical-path prompt delivery.
648
+ *
649
+ * Issue #285: Changed from sendKeysDirect (unverified) to sendKeysVerified
650
+ * with 3 retry attempts. tmux send-keys can silently fail even at session
651
+ * creation time, causing ~20% prompt delivery failure rate.
652
+ *
653
+ * We still bypass the serialize queue (using capturePaneDirect in verifyDelivery)
654
+ * but now verify actual delivery to CC.
655
+ */
656
+ async sendMessageDirect(id, text) {
657
+ const session = this.state.sessions[id];
658
+ if (!session)
659
+ throw new Error(`Session ${id} not found`);
660
+ // Issue #285: Use verified sending with retry for reliability
661
+ const result = await this.tmux.sendKeysVerified(session.windowId, text, 3);
662
+ session.lastActivity = Date.now();
663
+ await this.save();
664
+ return result;
665
+ }
666
+ /** Record that a permission prompt was detected for this session. */
667
+ recordPermissionPrompt(id) {
668
+ const session = this.state.sessions[id];
669
+ if (!session)
670
+ return;
671
+ session.permissionPromptAt = Date.now();
672
+ }
673
+ /** Approve a permission prompt. Resolves pending hook permission first, falls back to tmux send-keys. */
674
+ async approve(id) {
675
+ const session = this.state.sessions[id];
676
+ if (!session)
677
+ throw new Error(`Session ${id} not found`);
678
+ // Issue #284: Resolve pending hook-based permission first
679
+ if (this.resolvePendingPermission(id, 'allow')) {
680
+ session.lastActivity = Date.now();
681
+ if (session.permissionPromptAt) {
682
+ session.permissionRespondedAt = Date.now();
683
+ }
684
+ return;
685
+ }
686
+ // Fallback: tmux send-keys
687
+ const paneText = await this.tmux.capturePane(session.windowId);
688
+ const method = detectApprovalMethod(paneText);
689
+ await this.tmux.sendKeys(session.windowId, method === 'numbered' ? '1' : 'y', true);
690
+ session.lastActivity = Date.now();
691
+ if (session.permissionPromptAt) {
692
+ session.permissionRespondedAt = Date.now();
693
+ }
694
+ }
695
+ /** Reject a permission prompt. Resolves pending hook permission first, falls back to tmux send-keys. */
696
+ async reject(id) {
697
+ const session = this.state.sessions[id];
698
+ if (!session)
699
+ throw new Error(`Session ${id} not found`);
700
+ // Issue #284: Resolve pending hook-based permission first
701
+ if (this.resolvePendingPermission(id, 'deny')) {
702
+ session.lastActivity = Date.now();
703
+ if (session.permissionPromptAt) {
704
+ session.permissionRespondedAt = Date.now();
705
+ }
706
+ return;
707
+ }
708
+ // Fallback: tmux send-keys
709
+ await this.tmux.sendKeys(session.windowId, 'n', true);
710
+ session.lastActivity = Date.now();
711
+ if (session.permissionPromptAt) {
712
+ session.permissionRespondedAt = Date.now();
713
+ }
714
+ }
715
+ /**
716
+ * Issue #284: Store a pending permission request and return a promise that
717
+ * resolves when the client approves/rejects via the API.
718
+ *
719
+ * @param sessionId - Aegis session ID
720
+ * @param timeoutMs - Timeout before auto-rejecting (default 10_000ms, matching CC's hook timeout)
721
+ * @param toolName - Optional tool name from the hook payload
722
+ * @param prompt - Optional permission prompt text
723
+ * @returns Promise that resolves with the client's decision
724
+ */
725
+ waitForPermissionDecision(sessionId, timeoutMs = 10_000, toolName, prompt) {
726
+ return new Promise((resolve) => {
727
+ const timer = setTimeout(() => {
728
+ this.pendingPermissions.delete(sessionId);
729
+ console.log(`Hooks: PermissionRequest timeout for session ${sessionId} — auto-rejecting`);
730
+ resolve('deny');
731
+ }, timeoutMs);
732
+ this.pendingPermissions.set(sessionId, { resolve, timer, toolName, prompt });
733
+ });
734
+ }
735
+ /** Check if a session has a pending permission request. */
736
+ hasPendingPermission(sessionId) {
737
+ return this.pendingPermissions.has(sessionId);
738
+ }
739
+ /** Get info about a pending permission (for API responses). */
740
+ getPendingPermissionInfo(sessionId) {
741
+ const pending = this.pendingPermissions.get(sessionId);
742
+ return pending ? { toolName: pending.toolName, prompt: pending.prompt } : null;
743
+ }
744
+ /**
745
+ * Resolve a pending permission. Returns true if there was a pending permission to resolve.
746
+ */
747
+ resolvePendingPermission(sessionId, decision) {
748
+ const pending = this.pendingPermissions.get(sessionId);
749
+ if (!pending)
750
+ return false;
751
+ clearTimeout(pending.timer);
752
+ this.pendingPermissions.delete(sessionId);
753
+ pending.resolve(decision);
754
+ return true;
755
+ }
756
+ /** Clean up any pending permission for a session (e.g. on session delete). */
757
+ cleanupPendingPermission(sessionId) {
758
+ const pending = this.pendingPermissions.get(sessionId);
759
+ if (pending) {
760
+ clearTimeout(pending.timer);
761
+ this.pendingPermissions.delete(sessionId);
762
+ }
763
+ }
764
+ /**
765
+ * Issue #336: Store a pending AskUserQuestion and return a promise that
766
+ * resolves when the external client provides an answer via POST /answer.
767
+ */
768
+ waitForAnswer(sessionId, toolUseId, question, timeoutMs = 30_000) {
769
+ return new Promise((resolve) => {
770
+ const timer = setTimeout(() => {
771
+ this.pendingQuestions.delete(sessionId);
772
+ console.log(`Hooks: AskUserQuestion timeout for session ${sessionId} — allowing without answer`);
773
+ resolve(null);
774
+ }, timeoutMs);
775
+ this.pendingQuestions.set(sessionId, { resolve, timer, toolUseId, question });
776
+ });
777
+ }
778
+ /** Issue #336: Submit an answer to a pending question. Returns true if resolved. */
779
+ submitAnswer(sessionId, questionId, answer) {
780
+ const pending = this.pendingQuestions.get(sessionId);
781
+ if (!pending)
782
+ return false;
783
+ if (pending.toolUseId !== questionId)
784
+ return false;
785
+ clearTimeout(pending.timer);
786
+ this.pendingQuestions.delete(sessionId);
787
+ pending.resolve(answer);
788
+ return true;
789
+ }
790
+ /** Issue #336: Check if a session has a pending question. */
791
+ hasPendingQuestion(sessionId) {
792
+ return this.pendingQuestions.has(sessionId);
793
+ }
794
+ /** Issue #336: Get info about a pending question. */
795
+ getPendingQuestionInfo(sessionId) {
796
+ const pending = this.pendingQuestions.get(sessionId);
797
+ return pending ? { toolUseId: pending.toolUseId, question: pending.question } : null;
798
+ }
799
+ /** Issue #336: Clean up any pending question for a session. */
800
+ cleanupPendingQuestion(sessionId) {
801
+ const pending = this.pendingQuestions.get(sessionId);
802
+ if (pending) {
803
+ clearTimeout(pending.timer);
804
+ this.pendingQuestions.delete(sessionId);
805
+ }
806
+ }
807
+ /** Send Escape key. */
808
+ async escape(id) {
809
+ const session = this.state.sessions[id];
810
+ if (!session)
811
+ throw new Error(`Session ${id} not found`);
812
+ await this.tmux.sendSpecialKey(session.windowId, 'Escape');
813
+ }
814
+ /** Send Ctrl+C. */
815
+ async interrupt(id) {
816
+ const session = this.state.sessions[id];
817
+ if (!session)
818
+ throw new Error(`Session ${id} not found`);
819
+ await this.tmux.sendSpecialKey(session.windowId, 'C-c');
820
+ }
821
+ /** Read new messages from a session. */
822
+ async readMessages(id) {
823
+ const session = this.state.sessions[id];
824
+ if (!session)
825
+ throw new Error(`Session ${id} not found`);
826
+ // Detect UI state from terminal
827
+ const paneText = await this.tmux.capturePane(session.windowId);
828
+ const status = detectUIState(paneText);
829
+ const statusText = parseStatusLine(paneText);
830
+ const interactive = extractInteractiveContent(paneText);
831
+ session.status = status;
832
+ session.lastActivity = Date.now();
833
+ // Try to find JSONL if we don't have it yet
834
+ if (!session.jsonlPath && session.claudeSessionId) {
835
+ const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
836
+ if (path) {
837
+ session.jsonlPath = path;
838
+ session.byteOffset = 0;
839
+ }
840
+ }
841
+ // Read JSONL if we have the file path
842
+ let messages = [];
843
+ if (session.jsonlPath && existsSync(session.jsonlPath)) {
844
+ try {
845
+ const result = await readNewEntries(session.jsonlPath, session.byteOffset);
846
+ messages = result.entries;
847
+ session.byteOffset = result.newOffset;
848
+ }
849
+ catch {
850
+ // File may not exist yet
851
+ }
852
+ }
853
+ // #357: Debounce saves on GET reads — offsets change frequently but disk
854
+ // writes are expensive. Full save still happens on create/kill/reconcile.
855
+ this.debouncedSave();
856
+ return {
857
+ messages,
858
+ status,
859
+ statusText,
860
+ interactiveContent: interactive?.content || null,
861
+ };
862
+ }
863
+ /** Read new messages for the monitor (separate offset from API reads). */
864
+ async readMessagesForMonitor(id) {
865
+ const session = this.state.sessions[id];
866
+ if (!session)
867
+ throw new Error(`Session ${id} not found`);
868
+ // Detect UI state from terminal
869
+ const paneText = await this.tmux.capturePane(session.windowId);
870
+ const status = detectUIState(paneText);
871
+ const statusText = parseStatusLine(paneText);
872
+ const interactive = extractInteractiveContent(paneText);
873
+ session.status = status;
874
+ // Try to find JSONL if we don't have it yet
875
+ if (!session.jsonlPath && session.claudeSessionId) {
876
+ const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
877
+ if (path) {
878
+ session.jsonlPath = path;
879
+ session.monitorOffset = 0;
880
+ }
881
+ }
882
+ // Read JSONL using monitor offset
883
+ let messages = [];
884
+ if (session.jsonlPath && existsSync(session.jsonlPath)) {
885
+ try {
886
+ const result = await readNewEntries(session.jsonlPath, session.monitorOffset);
887
+ messages = result.entries;
888
+ session.monitorOffset = result.newOffset;
889
+ }
890
+ catch {
891
+ // File may not exist yet
892
+ }
893
+ }
894
+ return {
895
+ messages,
896
+ status,
897
+ statusText,
898
+ interactiveContent: interactive?.content || null,
899
+ };
900
+ }
901
+ /** #357: Get all parsed entries for a session, using a cache to avoid full reparse.
902
+ * Reads only the delta from the last cached offset. */
903
+ async getCachedEntries(session) {
904
+ if (!session.jsonlPath || !existsSync(session.jsonlPath))
905
+ return [];
906
+ const cached = this.parsedEntriesCache.get(session.id);
907
+ try {
908
+ const fromOffset = cached ? cached.offset : 0;
909
+ const result = await readNewEntries(session.jsonlPath, fromOffset);
910
+ if (cached) {
911
+ cached.entries.push(...result.entries);
912
+ cached.offset = result.newOffset;
913
+ return cached.entries;
914
+ }
915
+ // First read — cache it
916
+ this.parsedEntriesCache.set(session.id, { entries: [...result.entries], offset: result.newOffset });
917
+ return result.entries;
918
+ }
919
+ catch { /* JSONL read failed — return cached entries or empty */
920
+ return cached ? [...cached.entries] : [];
921
+ }
922
+ }
923
+ /** Issue #35: Get a condensed summary of a session's transcript. */
924
+ async getSummary(id, maxMessages = 20) {
925
+ const session = this.state.sessions[id];
926
+ if (!session)
927
+ throw new Error(`Session ${id} not found`);
928
+ // #357: Use cached entries instead of re-reading from offset 0
929
+ const allMessages = await this.getCachedEntries(session);
930
+ // Take last N messages
931
+ const recent = allMessages.slice(-maxMessages).map(m => ({
932
+ role: m.role,
933
+ contentType: m.contentType,
934
+ text: m.text.slice(0, 500), // Truncate long messages
935
+ }));
936
+ return {
937
+ sessionId: session.id,
938
+ windowName: session.windowName,
939
+ status: session.status,
940
+ totalMessages: allMessages.length,
941
+ messages: recent,
942
+ createdAt: session.createdAt,
943
+ lastActivity: session.lastActivity,
944
+ permissionMode: session.permissionMode,
945
+ };
946
+ }
947
+ /** Paginated transcript read — does NOT advance the session's byteOffset. */
948
+ async readTranscript(id, page = 1, limit = 50, roleFilter) {
949
+ const session = this.state.sessions[id];
950
+ if (!session)
951
+ throw new Error(`Session ${id} not found`);
952
+ // Discover JSONL path if not yet known
953
+ if (!session.jsonlPath && session.claudeSessionId) {
954
+ const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
955
+ if (path) {
956
+ session.jsonlPath = path;
957
+ session.byteOffset = 0;
958
+ }
959
+ }
960
+ let allEntries = [];
961
+ // #357: Use cached entries instead of re-reading from offset 0
962
+ allEntries = await this.getCachedEntries(session);
963
+ if (roleFilter) {
964
+ allEntries = allEntries.filter(e => e.role === roleFilter);
965
+ }
966
+ const total = allEntries.length;
967
+ const start = (page - 1) * limit;
968
+ const messages = allEntries.slice(start, start + limit);
969
+ const hasMore = start + messages.length < total;
970
+ return {
971
+ messages,
972
+ total,
973
+ page,
974
+ limit,
975
+ hasMore,
976
+ };
977
+ }
978
+ /** #405: Clean up all tracking maps for a session to prevent memory leaks. */
979
+ cleanupSession(id) {
980
+ // Clear polling timers (both regular and filesystem discovery variants)
981
+ for (const key of [id, `fs-${id}`]) {
982
+ const timer = this.pollTimers.get(key);
983
+ if (timer) {
984
+ clearInterval(timer);
985
+ this.pollTimers.delete(key);
986
+ }
987
+ }
988
+ this.cleanupPendingPermission(id);
989
+ this.cleanupPendingQuestion(id);
990
+ this.parsedEntriesCache.delete(id);
991
+ }
992
+ /** Kill a session. */
993
+ async killSession(id) {
994
+ const session = this.state.sessions[id];
995
+ if (!session)
996
+ return;
997
+ await this.tmux.killWindow(session.windowId);
998
+ // Permission guard: restore original settings.local.json if we patched it
999
+ if (session.settingsPatched) {
1000
+ await restoreSettings(session.workDir);
1001
+ }
1002
+ // Issue #169 Phase 2: Clean up temp hook settings file
1003
+ if (session.hookSettingsFile) {
1004
+ await cleanupHookSettingsFile(session.hookSettingsFile);
1005
+ }
1006
+ // #405: Clean up all tracking maps (pollTimers, pendingPermissions, pendingQuestions, parsedEntriesCache)
1007
+ this.cleanupSession(id);
1008
+ delete this.state.sessions[id];
1009
+ // #357: Cancel any pending debounced save before doing an immediate save
1010
+ if (this.saveDebounceTimer !== null) {
1011
+ clearTimeout(this.saveDebounceTimer);
1012
+ this.saveDebounceTimer = null;
1013
+ }
1014
+ await this.save();
1015
+ }
1016
+ /** Remove stale entries from session_map.json for a given window.
1017
+ * P0 fix: After aegis service restarts, old session_map entries with stale windowIds
1018
+ * can survive and cause new sessions to inherit context from old sessions.
1019
+ * We must clean by BOTH windowName AND windowId to prevent collisions.
1020
+ *
1021
+ * After archiving old .jsonl files, old hook entries would cause discovery
1022
+ * to map the new session to a ghost claudeSessionId whose file no longer exists.
1023
+ */
1024
+ async cleanSessionMapForWindow(windowName, windowId) {
1025
+ if (!existsSync(this.sessionMapFile))
1026
+ return;
1027
+ try {
1028
+ const raw = await readFile(this.sessionMapFile, 'utf-8');
1029
+ const parsed = sessionMapSchema.safeParse(JSON.parse(raw));
1030
+ if (!parsed.success) {
1031
+ console.warn('session_map.json failed validation in cleanSessionMapForWindow');
1032
+ return;
1033
+ }
1034
+ const mapData = parsed.data;
1035
+ let changed = false;
1036
+ for (const [key, info] of Object.entries(mapData)) {
1037
+ // Clean by window_name (original behavior)
1038
+ if (info.window_name === windowName) {
1039
+ delete mapData[key];
1040
+ changed = true;
1041
+ continue;
1042
+ }
1043
+ // P0 fix: Also clean entries where key ends with :windowId
1044
+ // This prevents stale windowId collisions after restart
1045
+ if (windowId && key.endsWith(':' + windowId)) {
1046
+ delete mapData[key];
1047
+ changed = true;
1048
+ }
1049
+ }
1050
+ if (changed) {
1051
+ const tmpFile = `${this.sessionMapFile}.tmp`;
1052
+ await writeFile(tmpFile, JSON.stringify(mapData, null, 2));
1053
+ await rename(tmpFile, this.sessionMapFile);
1054
+ }
1055
+ }
1056
+ catch { /* ignore parse/write errors */ }
1057
+ }
1058
+ /** P0 fix: Purge session_map entries that don't correspond to active aegis sessions.
1059
+ * After aegis restarts, old session_map entries with stale windowIds can survive
1060
+ * and cause new sessions to inherit context from old sessions.
1061
+ */
1062
+ async purgeStaleSessionMapEntries(activeWindowIds, activeWindowNames) {
1063
+ if (!existsSync(this.sessionMapFile))
1064
+ return;
1065
+ try {
1066
+ const raw = await readFile(this.sessionMapFile, 'utf-8');
1067
+ const parsed = sessionMapSchema.safeParse(JSON.parse(raw));
1068
+ if (!parsed.success) {
1069
+ console.warn('session_map.json failed validation in purgeStaleSessionMapEntries');
1070
+ return;
1071
+ }
1072
+ const mapData = parsed.data;
1073
+ let changed = false;
1074
+ const activeNamesLower = new Set([...activeWindowNames].map(n => n.toLowerCase()));
1075
+ for (const [key, info] of Object.entries(mapData)) {
1076
+ // Extract windowId from key (format: "sessionName:windowId")
1077
+ const keyWindowId = key.includes(':') ? key.split(':').pop() : null;
1078
+ const windowName = (info.window_name || '').toLowerCase();
1079
+ // Keep entry only if it matches an active window
1080
+ const windowIdActive = keyWindowId && activeWindowIds.has(keyWindowId);
1081
+ const windowNameActive = activeNamesLower.has(windowName);
1082
+ if (!windowIdActive && !windowNameActive) {
1083
+ console.log(`Reconcile: purging stale session_map entry: ${key}`);
1084
+ delete mapData[key];
1085
+ changed = true;
1086
+ }
1087
+ }
1088
+ if (changed) {
1089
+ const tmpFile = `${this.sessionMapFile}.tmp`;
1090
+ await writeFile(tmpFile, JSON.stringify(mapData, null, 2));
1091
+ await rename(tmpFile, this.sessionMapFile);
1092
+ }
1093
+ }
1094
+ catch { /* ignore parse/write errors */ }
1095
+ }
1096
+ /** Try to discover the CC session ID and JSONL path. */
1097
+ startSessionIdDiscovery(id) {
1098
+ const interval = setInterval(async () => {
1099
+ const session = this.state.sessions[id];
1100
+ if (!session) {
1101
+ clearInterval(interval);
1102
+ this.pollTimers.delete(id);
1103
+ return;
1104
+ }
1105
+ // Stop when we have both session ID and JSONL path
1106
+ if (session.claudeSessionId && session.jsonlPath) {
1107
+ clearInterval(interval);
1108
+ this.pollTimers.delete(id);
1109
+ return;
1110
+ }
1111
+ try {
1112
+ await this.syncSessionMap();
1113
+ // If we have claudeSessionId but no jsonlPath, try finding it
1114
+ if (session.claudeSessionId && !session.jsonlPath) {
1115
+ const jsonlPath = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1116
+ if (jsonlPath) {
1117
+ session.jsonlPath = jsonlPath;
1118
+ session.byteOffset = 0;
1119
+ await this.save();
1120
+ }
1121
+ }
1122
+ }
1123
+ catch { /* ignore */ }
1124
+ }, 2000);
1125
+ this.pollTimers.set(id, interval);
1126
+ // P3 fix: Stop after 5 minutes if not found, log timeout
1127
+ setTimeout(() => {
1128
+ const timer = this.pollTimers.get(id);
1129
+ const session = this.state.sessions[id];
1130
+ if (timer) {
1131
+ clearInterval(timer);
1132
+ this.pollTimers.delete(id);
1133
+ // P3 fix: Log when discovery times out
1134
+ if (session && !session.claudeSessionId) {
1135
+ console.log(`Discovery: session ${session.windowName} — timed out after 5min, no session_id found`);
1136
+ }
1137
+ }
1138
+ }, 5 * 60 * 1000);
1139
+ }
1140
+ /** Issue #16: Filesystem-based discovery for --bare mode (no hooks).
1141
+ * Scans the Claude projects directory for new .jsonl files created after the session.
1142
+ */
1143
+ startFilesystemDiscovery(id, workDir) {
1144
+ const projectHash = '-' + workDir.replace(/^\//, '').replace(/\//g, '-');
1145
+ const projectDir = join(this.config.claudeProjectsDir, projectHash);
1146
+ const interval = setInterval(async () => {
1147
+ const session = this.state.sessions[id];
1148
+ if (!session) {
1149
+ clearInterval(interval);
1150
+ this.pollTimers.delete(`fs-${id}`);
1151
+ return;
1152
+ }
1153
+ if (session.claudeSessionId && session.jsonlPath) {
1154
+ clearInterval(interval);
1155
+ this.pollTimers.delete(`fs-${id}`);
1156
+ return;
1157
+ }
1158
+ try {
1159
+ if (!existsSync(projectDir))
1160
+ return;
1161
+ const { readdir } = await import('node:fs/promises');
1162
+ const files = await readdir(projectDir);
1163
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('.'));
1164
+ for (const file of jsonlFiles) {
1165
+ const filePath = join(projectDir, file);
1166
+ const fileStat = await stat(filePath);
1167
+ // Only consider files created after the session
1168
+ if (fileStat.mtimeMs < session.createdAt)
1169
+ continue;
1170
+ // Extract session ID from filename (filename = sessionId.jsonl)
1171
+ const sessionId = file.replace('.jsonl', '');
1172
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId))
1173
+ continue;
1174
+ session.claudeSessionId = sessionId;
1175
+ session.jsonlPath = filePath;
1176
+ session.byteOffset = 0;
1177
+ console.log(`Discovery (filesystem): session ${session.windowName} mapped to ${sessionId.slice(0, 8)}...`);
1178
+ await this.save();
1179
+ break;
1180
+ }
1181
+ }
1182
+ catch { /* ignore */ }
1183
+ }, 3000);
1184
+ this.pollTimers.set(`fs-${id}`, interval);
1185
+ // Timeout after 5 minutes
1186
+ setTimeout(() => {
1187
+ const timer = this.pollTimers.get(`fs-${id}`);
1188
+ if (timer) {
1189
+ clearInterval(timer);
1190
+ this.pollTimers.delete(`fs-${id}`);
1191
+ }
1192
+ }, 5 * 60 * 1000);
1193
+ }
1194
+ /** Sync CC session IDs from the hook-written session_map.json. */
1195
+ async syncSessionMap() {
1196
+ if (!existsSync(this.sessionMapFile))
1197
+ return;
1198
+ try {
1199
+ const mapRaw = await readFile(this.sessionMapFile, 'utf-8');
1200
+ const mapParsed = sessionMapSchema.safeParse(JSON.parse(mapRaw));
1201
+ if (!mapParsed.success) {
1202
+ console.warn('session_map.json failed validation in syncSessionMap');
1203
+ return;
1204
+ }
1205
+ const mapData = mapParsed.data;
1206
+ for (const session of Object.values(this.state.sessions)) {
1207
+ if (session.claudeSessionId)
1208
+ continue;
1209
+ // Find matching entry by window ID (exact match to avoid @1 matching @10, @11, etc.)
1210
+ for (const [key, info] of Object.entries(mapData)) {
1211
+ // P0 fix: Match by exact windowId suffix (e.g., "aegis:@5"), not substring
1212
+ // This prevents @5 from matching @15, @50, etc.
1213
+ const keyWindowId = key.includes(':') ? key.split(':').pop() : null;
1214
+ const matchesWindowId = keyWindowId === session.windowId;
1215
+ const matchesWindowName = info.window_name === session.windowName;
1216
+ if (matchesWindowId || matchesWindowName) {
1217
+ // GUARD 1: Timestamp — reject session_map entries written before this session was created.
1218
+ // After service restarts, old entries survive with stale windowIds that collide
1219
+ // with newly assigned tmux window IDs (tmux reuses @N identifiers).
1220
+ // Issue #6: Zeus D51 got claudeSessionId from D18/D19/D20 due to this.
1221
+ const writtenAt = info.written_at || 0;
1222
+ if (writtenAt > 0 && writtenAt < session.createdAt) {
1223
+ console.log(`Discovery: session ${session.windowName} — rejecting stale entry ` +
1224
+ `(written_at ${new Date(writtenAt).toISOString()} < createdAt ${new Date(session.createdAt).toISOString()})`);
1225
+ continue;
1226
+ }
1227
+ // Use transcript_path from hook if available (M3: eliminates filesystem scan)
1228
+ // Falls back to findSessionFile for backward compat with old hook versions
1229
+ let jsonlPath = null;
1230
+ if (info.transcript_path && existsSync(info.transcript_path)) {
1231
+ jsonlPath = info.transcript_path;
1232
+ }
1233
+ else {
1234
+ jsonlPath = await findSessionFile(info.session_id, this.config.claudeProjectsDir);
1235
+ }
1236
+ // GUARD 2: Reject paths in _archived/ directory — these are stale sessions
1237
+ if (jsonlPath && (jsonlPath.includes('/_archived/') || jsonlPath.includes('\\_archived\\'))) {
1238
+ console.log(`Discovery: session ${session.windowName} — rejecting archived path: ${jsonlPath}`);
1239
+ continue;
1240
+ }
1241
+ if (!jsonlPath) {
1242
+ // No JSONL file found — mapping is stale or CC hasn't written it yet.
1243
+ // Don't break — there may be a fresher entry. Continue searching.
1244
+ continue;
1245
+ }
1246
+ // GUARD 3: JSONL mtime — reject if file was last modified before session creation.
1247
+ // Catches cases where session_map has no written_at (old hook without timestamp)
1248
+ // but the JSONL is clearly from a previous session.
1249
+ try {
1250
+ const fileStat = await stat(jsonlPath);
1251
+ if (fileStat.mtimeMs < session.createdAt) {
1252
+ console.log(`Discovery: session ${session.windowName} — rejecting stale JSONL ` +
1253
+ `(mtime ${new Date(fileStat.mtimeMs).toISOString()} < createdAt ${new Date(session.createdAt).toISOString()})`);
1254
+ continue;
1255
+ }
1256
+ }
1257
+ catch {
1258
+ // stat failed — file removed between find and stat
1259
+ continue;
1260
+ }
1261
+ session.claudeSessionId = info.session_id;
1262
+ session.jsonlPath = jsonlPath;
1263
+ session.byteOffset = 0;
1264
+ console.log(`Discovery: session ${session.windowName} mapped to ` +
1265
+ `${info.session_id.slice(0, 8)}... (verified: timestamp + mtime)`);
1266
+ break;
1267
+ }
1268
+ }
1269
+ }
1270
+ await this.save();
1271
+ }
1272
+ catch { /* ignore parse errors */ }
1273
+ }
1274
+ }
1275
+ //# sourceMappingURL=session.js.map