aegis-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +404 -0
  3. package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
  4. package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
  5. package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/api-contracts.d.ts +229 -0
  8. package/dist/api-contracts.js +7 -0
  9. package/dist/api-contracts.typecheck.d.ts +14 -0
  10. package/dist/api-contracts.typecheck.js +1 -0
  11. package/dist/api-error-envelope.d.ts +15 -0
  12. package/dist/api-error-envelope.js +80 -0
  13. package/dist/auth.d.ts +87 -0
  14. package/dist/auth.js +276 -0
  15. package/dist/channels/index.d.ts +8 -0
  16. package/dist/channels/index.js +8 -0
  17. package/dist/channels/manager.d.ts +47 -0
  18. package/dist/channels/manager.js +115 -0
  19. package/dist/channels/telegram-style.d.ts +118 -0
  20. package/dist/channels/telegram-style.js +202 -0
  21. package/dist/channels/telegram.d.ts +91 -0
  22. package/dist/channels/telegram.js +1518 -0
  23. package/dist/channels/types.d.ts +77 -0
  24. package/dist/channels/types.js +8 -0
  25. package/dist/channels/webhook.d.ts +60 -0
  26. package/dist/channels/webhook.js +216 -0
  27. package/dist/cli.d.ts +8 -0
  28. package/dist/cli.js +252 -0
  29. package/dist/config.d.ts +90 -0
  30. package/dist/config.js +214 -0
  31. package/dist/consensus.d.ts +16 -0
  32. package/dist/consensus.js +19 -0
  33. package/dist/continuation-pointer.d.ts +11 -0
  34. package/dist/continuation-pointer.js +65 -0
  35. package/dist/diagnostics.d.ts +27 -0
  36. package/dist/diagnostics.js +95 -0
  37. package/dist/error-categories.d.ts +39 -0
  38. package/dist/error-categories.js +73 -0
  39. package/dist/events.d.ts +133 -0
  40. package/dist/events.js +389 -0
  41. package/dist/fault-injection.d.ts +29 -0
  42. package/dist/fault-injection.js +115 -0
  43. package/dist/file-utils.d.ts +2 -0
  44. package/dist/file-utils.js +37 -0
  45. package/dist/handshake.d.ts +60 -0
  46. package/dist/handshake.js +124 -0
  47. package/dist/hook-settings.d.ts +80 -0
  48. package/dist/hook-settings.js +272 -0
  49. package/dist/hook.d.ts +19 -0
  50. package/dist/hook.js +231 -0
  51. package/dist/hooks.d.ts +32 -0
  52. package/dist/hooks.js +364 -0
  53. package/dist/jsonl-watcher.d.ts +59 -0
  54. package/dist/jsonl-watcher.js +166 -0
  55. package/dist/logger.d.ts +35 -0
  56. package/dist/logger.js +65 -0
  57. package/dist/mcp-server.d.ts +123 -0
  58. package/dist/mcp-server.js +869 -0
  59. package/dist/memory-bridge.d.ts +27 -0
  60. package/dist/memory-bridge.js +137 -0
  61. package/dist/memory-routes.d.ts +3 -0
  62. package/dist/memory-routes.js +100 -0
  63. package/dist/metrics.d.ts +126 -0
  64. package/dist/metrics.js +286 -0
  65. package/dist/model-router.d.ts +53 -0
  66. package/dist/model-router.js +150 -0
  67. package/dist/monitor.d.ts +103 -0
  68. package/dist/monitor.js +820 -0
  69. package/dist/path-utils.d.ts +11 -0
  70. package/dist/path-utils.js +21 -0
  71. package/dist/permission-evaluator.d.ts +10 -0
  72. package/dist/permission-evaluator.js +48 -0
  73. package/dist/permission-guard.d.ts +51 -0
  74. package/dist/permission-guard.js +196 -0
  75. package/dist/permission-request-manager.d.ts +12 -0
  76. package/dist/permission-request-manager.js +36 -0
  77. package/dist/permission-routes.d.ts +7 -0
  78. package/dist/permission-routes.js +28 -0
  79. package/dist/pipeline.d.ts +97 -0
  80. package/dist/pipeline.js +291 -0
  81. package/dist/process-utils.d.ts +4 -0
  82. package/dist/process-utils.js +73 -0
  83. package/dist/question-manager.d.ts +54 -0
  84. package/dist/question-manager.js +80 -0
  85. package/dist/retry.d.ts +11 -0
  86. package/dist/retry.js +34 -0
  87. package/dist/safe-json.d.ts +12 -0
  88. package/dist/safe-json.js +22 -0
  89. package/dist/screenshot.d.ts +28 -0
  90. package/dist/screenshot.js +60 -0
  91. package/dist/server.d.ts +10 -0
  92. package/dist/server.js +1973 -0
  93. package/dist/session-cleanup.d.ts +18 -0
  94. package/dist/session-cleanup.js +11 -0
  95. package/dist/session.d.ts +379 -0
  96. package/dist/session.js +1568 -0
  97. package/dist/shutdown-utils.d.ts +5 -0
  98. package/dist/shutdown-utils.js +24 -0
  99. package/dist/signal-cleanup-helper.d.ts +48 -0
  100. package/dist/signal-cleanup-helper.js +117 -0
  101. package/dist/sse-limiter.d.ts +47 -0
  102. package/dist/sse-limiter.js +61 -0
  103. package/dist/sse-writer.d.ts +31 -0
  104. package/dist/sse-writer.js +94 -0
  105. package/dist/ssrf.d.ts +102 -0
  106. package/dist/ssrf.js +267 -0
  107. package/dist/startup.d.ts +6 -0
  108. package/dist/startup.js +162 -0
  109. package/dist/suppress.d.ts +33 -0
  110. package/dist/suppress.js +79 -0
  111. package/dist/swarm-monitor.d.ts +117 -0
  112. package/dist/swarm-monitor.js +300 -0
  113. package/dist/template-store.d.ts +45 -0
  114. package/dist/template-store.js +142 -0
  115. package/dist/terminal-parser.d.ts +16 -0
  116. package/dist/terminal-parser.js +346 -0
  117. package/dist/tmux-capture-cache.d.ts +18 -0
  118. package/dist/tmux-capture-cache.js +34 -0
  119. package/dist/tmux.d.ts +183 -0
  120. package/dist/tmux.js +906 -0
  121. package/dist/tool-registry.d.ts +40 -0
  122. package/dist/tool-registry.js +83 -0
  123. package/dist/transcript.d.ts +63 -0
  124. package/dist/transcript.js +284 -0
  125. package/dist/utils/circular-buffer.d.ts +11 -0
  126. package/dist/utils/circular-buffer.js +37 -0
  127. package/dist/utils/redact-headers.d.ts +13 -0
  128. package/dist/utils/redact-headers.js +54 -0
  129. package/dist/validation.d.ts +406 -0
  130. package/dist/validation.js +415 -0
  131. package/dist/verification.d.ts +2 -0
  132. package/dist/verification.js +72 -0
  133. package/dist/worktree-lookup.d.ts +24 -0
  134. package/dist/worktree-lookup.js +71 -0
  135. package/dist/ws-terminal.d.ts +32 -0
  136. package/dist/ws-terminal.js +348 -0
  137. package/package.json +83 -0
@@ -0,0 +1,1568 @@
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 { randomBytes } from 'node:crypto';
8
+ import { readFile, writeFile, rename, mkdir, stat, readdir } from 'node:fs/promises';
9
+ import { existsSync, unlinkSync, readdirSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import { findSessionFile, readNewEntries } from './transcript.js';
13
+ import { findSessionFileWithFanout } from './worktree-lookup.js';
14
+ import { detectUIState, extractInteractiveContent, parseStatusLine } from './terminal-parser.js';
15
+ import { computeStallThreshold } from './config.js';
16
+ import { neutralizeBypassPermissions, restoreSettings, cleanOrphanedBackup } from './permission-guard.js';
17
+ import { persistedStateSchema } from './validation.js';
18
+ import { loadContinuationPointers } from './continuation-pointer.js';
19
+ import { writeHookSettingsFile, cleanupHookSettingsFile, cleanupStaleSessionHooks } from './hook-settings.js';
20
+ import { PermissionRequestManager } from './permission-request-manager.js';
21
+ import { QuestionManager } from './question-manager.js';
22
+ import { Mutex } from 'async-mutex';
23
+ import { maybeInjectFault } from './fault-injection.js';
24
+ import { computeProjectHash } from './path-utils.js';
25
+ /** Convert parsed JSON arrays to Sets for activeSubagents (#668). */
26
+ function hydrateSessions(raw) {
27
+ const sessions = {};
28
+ for (const [id, s] of Object.entries(raw)) {
29
+ const { activeSubagents, ...rest } = s;
30
+ sessions[id] = {
31
+ ...rest,
32
+ activeSubagents: activeSubagents ? new Set(activeSubagents) : undefined,
33
+ };
34
+ }
35
+ return sessions;
36
+ }
37
+ /**
38
+ * Detect whether CC is showing numbered permission options (e.g. "1. Yes, 2. No")
39
+ * vs a simple y/N prompt. Returns the approval method to use.
40
+ *
41
+ * CC's permission UI uses indented numbered lines with "Esc to cancel" nearby.
42
+ * We look for the pattern " <N>. <option>" where N is 1-3, which distinguishes
43
+ * permission options from regular numbered lists in output.
44
+ */
45
+ export function detectApprovalMethod(paneText) {
46
+ // Match CC's permission option format: indented " 1. Yes" lines.
47
+ // Issue #843: Tightened to require "Esc to cancel" nearby (within 300 chars)
48
+ // to avoid false positives on regular indented numbered lists in output.
49
+ const numberedOptionPattern = /^\s{2}[1-3]\.\s/m;
50
+ if (numberedOptionPattern.test(paneText) && /Esc to cancel/i.test(paneText)) {
51
+ return 'numbered';
52
+ }
53
+ return 'yes';
54
+ }
55
+ /**
56
+ * Coordinates session lifecycle, persistence, transcript discovery, and
57
+ * interactive approval/question flows for all managed Claude Code sessions.
58
+ */
59
+ export class SessionManager {
60
+ tmux;
61
+ config;
62
+ state = { sessions: {} };
63
+ stateFile;
64
+ sessionMapFile;
65
+ pollTimers = new Map();
66
+ /** Next filesystem-scan time (ms epoch) for each discovery poller. */
67
+ discoveryNextFilesystemScanAt = new Map();
68
+ /** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
69
+ discoveryTimeouts = new Map();
70
+ saveQueue = Promise.resolve(); // #218: serialize concurrent saves
71
+ saveDebounceTimer = null;
72
+ static SAVE_DEBOUNCE_MS = 5_000; // #357: debounce offset-only saves
73
+ permissionRequests = new PermissionRequestManager();
74
+ questions = new QuestionManager();
75
+ // #357: Cache of all parsed JSONL entries per session to avoid re-reading from offset 0
76
+ // #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
77
+ static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
78
+ parsedEntriesCache = new Map();
79
+ // Issue #657: Cached session list to avoid allocating a new array per call
80
+ sessionsListCache = null;
81
+ // Issue #840/#880: Explicit mutex to prevent TOCTOU races in session acquisition.
82
+ sessionAcquireMutex = new Mutex();
83
+ constructor(tmux, config) {
84
+ this.tmux = tmux;
85
+ this.config = config;
86
+ this.stateFile = join(config.stateDir, 'state.json');
87
+ this.sessionMapFile = join(config.stateDir, 'session_map.json');
88
+ }
89
+ /**
90
+ * Issue #884: Worktree-aware session file lookup.
91
+ * When `worktreeAwareContinuation` is enabled, fans out to sibling worktree
92
+ * project dirs; otherwise falls back to the existing single-directory search.
93
+ */
94
+ findSessionFileMaybeWorktree(sessionId) {
95
+ if (this.config.worktreeAwareContinuation && this.config.worktreeSiblingDirs.length > 0) {
96
+ return findSessionFileWithFanout(sessionId, this.config.claudeProjectsDir, this.config.worktreeSiblingDirs);
97
+ }
98
+ return findSessionFile(sessionId, this.config.claudeProjectsDir);
99
+ }
100
+ /** Validate that parsed data looks like a valid SessionState. */
101
+ isValidState(data) {
102
+ if (typeof data !== 'object' || data === null)
103
+ return false;
104
+ const obj = data;
105
+ if (typeof obj.sessions !== 'object' || obj.sessions === null)
106
+ return false;
107
+ const sessions = obj.sessions;
108
+ for (const val of Object.values(sessions)) {
109
+ if (typeof val !== 'object' || val === null)
110
+ return false;
111
+ const s = val;
112
+ if (typeof s.id !== 'string' || typeof s.windowId !== 'string')
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ /** Clean up stale .tmp files left by crashed writes. */
118
+ cleanTmpFiles(dir) {
119
+ try {
120
+ for (const entry of readdirSync(dir)) {
121
+ if (entry.endsWith('.tmp')) {
122
+ const fullPath = join(dir, entry);
123
+ try {
124
+ unlinkSync(fullPath);
125
+ }
126
+ catch { /* best effort */ }
127
+ console.log(`Cleaned stale tmp file: ${entry}`);
128
+ }
129
+ }
130
+ }
131
+ catch { /* dir may not exist yet */ }
132
+ }
133
+ /** Load state from disk. */
134
+ async load() {
135
+ const dir = dirname(this.stateFile);
136
+ if (!existsSync(dir)) {
137
+ await mkdir(dir, { recursive: true });
138
+ }
139
+ // Clean stale .tmp files from crashed writes
140
+ this.cleanTmpFiles(dir);
141
+ if (existsSync(this.stateFile)) {
142
+ try {
143
+ const raw = await readFile(this.stateFile, 'utf-8');
144
+ const parsed = persistedStateSchema.safeParse(JSON.parse(raw));
145
+ if (parsed.success && this.isValidState({ sessions: parsed.data })) {
146
+ this.state = { sessions: hydrateSessions(parsed.data) };
147
+ }
148
+ else {
149
+ console.warn('State file failed validation, attempting backup restore');
150
+ // Try loading from backup before resetting
151
+ const backupFile = `${this.stateFile}.bak`;
152
+ if (existsSync(backupFile)) {
153
+ try {
154
+ const backupRaw = await readFile(backupFile, 'utf-8');
155
+ const backupParsed = persistedStateSchema.safeParse(JSON.parse(backupRaw));
156
+ if (backupParsed.success && this.isValidState({ sessions: backupParsed.data })) {
157
+ this.state = { sessions: hydrateSessions(backupParsed.data) };
158
+ console.log('Restored state from backup');
159
+ }
160
+ else {
161
+ this.state = { sessions: {} };
162
+ }
163
+ }
164
+ catch { /* backup state file corrupted — start empty */
165
+ this.state = { sessions: {} };
166
+ }
167
+ }
168
+ else {
169
+ this.state = { sessions: {} };
170
+ }
171
+ }
172
+ }
173
+ catch { /* state file corrupted — start empty */
174
+ this.state = { sessions: {} };
175
+ }
176
+ }
177
+ // Create backup of successfully loaded state
178
+ try {
179
+ await writeFile(`${this.stateFile}.bak`, JSON.stringify(this.state, null, 2));
180
+ }
181
+ catch { /* non-critical */ }
182
+ // Issue #657: Invalidate sessions list cache after loading state
183
+ this.invalidateSessionsListCache();
184
+ // Reconcile: verify tmux windows still exist, clean up dead sessions
185
+ await this.reconcile();
186
+ }
187
+ /** Reconcile state with actual tmux windows. Remove dead sessions, restart discovery for live ones.
188
+ * Issue #397: Also handles re-attach by window name when windowId is stale after tmux restart. */
189
+ async reconcile() {
190
+ const windows = await this.tmux.listWindows();
191
+ const windowIds = new Set(windows.map(w => w.windowId));
192
+ const windowByName = new Map();
193
+ for (const w of windows)
194
+ windowByName.set(w.windowName, w);
195
+ let changed = false;
196
+ for (const [id, session] of Object.entries(this.state.sessions)) {
197
+ const windowIdAlive = windowIds.has(session.windowId);
198
+ const windowNameAlive = windowByName.has(session.windowName);
199
+ if (!windowIdAlive && !windowNameAlive) {
200
+ console.log(`Reconcile: session ${session.windowName} (${id.slice(0, 8)}) — tmux window gone, removing`);
201
+ // Restore patched settings before removing dead session
202
+ if (session.settingsPatched) {
203
+ await cleanOrphanedBackup(session.workDir);
204
+ }
205
+ delete this.state.sessions[id];
206
+ this.invalidateSessionsListCache();
207
+ changed = true;
208
+ }
209
+ else if (!windowIdAlive && windowNameAlive) {
210
+ // Issue #397: Window exists with same name but different ID (tmux restarted).
211
+ // Re-attach by updating the windowId to the new one.
212
+ const win = windowByName.get(session.windowName);
213
+ const oldWindowId = session.windowId;
214
+ session.windowId = win.windowId;
215
+ console.log(`Reconcile: session ${session.windowName} re-attached: ${oldWindowId} → ${win.windowId}`);
216
+ // Restart discovery if needed
217
+ if (!session.claudeSessionId || !session.jsonlPath) {
218
+ this.startDiscoveryPolling(id, session.workDir);
219
+ }
220
+ changed = true;
221
+ }
222
+ else {
223
+ // Session is alive — restart discovery if needed
224
+ if (!session.claudeSessionId || !session.jsonlPath) {
225
+ console.log(`Reconcile: session ${session.windowName} — restarting JSONL discovery`);
226
+ this.startDiscoveryPolling(id, session.workDir);
227
+ }
228
+ else {
229
+ console.log(`Reconcile: session ${session.windowName} — alive, JSONL ready`);
230
+ }
231
+ }
232
+ }
233
+ // P0 fix: On startup, purge session_map entries that don't correspond to active sessions.
234
+ const finalWindowIds = new Set(Object.values(this.state.sessions).map(s => s.windowId));
235
+ const finalWindowNames = new Set(Object.values(this.state.sessions).map(s => s.windowName));
236
+ await this.purgeStaleSessionMapEntries(finalWindowIds, finalWindowNames);
237
+ // Issue #35: Adopt orphaned tmux windows (cc-* prefix) not in state
238
+ const knownWindowIds = new Set(Object.values(this.state.sessions).map(s => s.windowId));
239
+ const knownWindowNames = new Set(Object.values(this.state.sessions).map(s => s.windowName));
240
+ for (const win of windows) {
241
+ if (knownWindowIds.has(win.windowId) || knownWindowNames.has(win.windowName))
242
+ continue;
243
+ // Only adopt windows that look like Aegis-created sessions (cc-* prefix or _bridge_ prefix)
244
+ if (!win.windowName.startsWith('cc-') && !win.windowName.startsWith('_bridge_'))
245
+ continue;
246
+ const id = crypto.randomUUID();
247
+ const session = {
248
+ id,
249
+ windowId: win.windowId,
250
+ windowName: win.windowName,
251
+ workDir: win.cwd || homedir(),
252
+ byteOffset: 0,
253
+ monitorOffset: 0,
254
+ status: 'unknown',
255
+ createdAt: Date.now(),
256
+ lastActivity: Date.now(),
257
+ stallThresholdMs: SessionManager.DEFAULT_STALL_THRESHOLD_MS,
258
+ permissionStallMs: SessionManager.DEFAULT_PERMISSION_STALL_MS,
259
+ permissionMode: 'default',
260
+ };
261
+ this.state.sessions[id] = session;
262
+ this.invalidateSessionsListCache();
263
+ console.log(`Reconcile: adopted orphaned window ${win.windowName} (${win.windowId}) as ${id.slice(0, 8)}`);
264
+ this.startDiscoveryPolling(id, session.workDir);
265
+ changed = true;
266
+ }
267
+ if (changed) {
268
+ await this.save();
269
+ }
270
+ }
271
+ /** Issue #397: Reconcile after tmux server crash recovery.
272
+ * Called when the monitor detects tmux server came back after a crash.
273
+ * Returns counts for observability. */
274
+ async reconcileTmuxCrash() {
275
+ console.log('Reconcile: tmux crash recovery — checking all sessions');
276
+ const windows = await this.tmux.listWindows();
277
+ const windowIds = new Set(windows.map(w => w.windowId));
278
+ const windowByName = new Map();
279
+ for (const w of windows)
280
+ windowByName.set(w.windowName, w);
281
+ let recovered = 0;
282
+ let orphaned = 0;
283
+ let changed = false;
284
+ for (const [id, session] of Object.entries(this.state.sessions)) {
285
+ const windowIdAlive = windowIds.has(session.windowId);
286
+ const windowNameAlive = windowByName.has(session.windowName);
287
+ if (windowIdAlive) {
288
+ // Window ID still matches — session survived the crash
289
+ continue;
290
+ }
291
+ if (windowNameAlive) {
292
+ // Window exists by name but ID changed — re-attach
293
+ const win = windowByName.get(session.windowName);
294
+ const oldWindowId = session.windowId;
295
+ session.windowId = win.windowId;
296
+ session.status = 'unknown';
297
+ session.lastActivity = Date.now();
298
+ console.log(`Reconcile (crash): session ${session.windowName} re-attached: ${oldWindowId} → ${win.windowId}`);
299
+ // Restart discovery in case the session state is stale
300
+ if (!session.claudeSessionId || !session.jsonlPath) {
301
+ this.startDiscoveryPolling(id, session.workDir);
302
+ }
303
+ recovered++;
304
+ changed = true;
305
+ }
306
+ else {
307
+ // Window gone entirely — session is orphaned
308
+ console.log(`Reconcile (crash): session ${session.windowName} (${id.slice(0, 8)}) — window gone, marking orphaned`);
309
+ session.status = 'unknown';
310
+ session.lastDeadAt = Date.now();
311
+ orphaned++;
312
+ changed = true;
313
+ }
314
+ }
315
+ if (changed) {
316
+ await this.save();
317
+ }
318
+ return { recovered, orphaned };
319
+ }
320
+ /** Save state to disk atomically (write to temp, then rename).
321
+ * #218: Uses a write queue to serialize concurrent saves and prevent corruption. */
322
+ async save() {
323
+ this.saveQueue = this.saveQueue.then(() => this.doSave()).catch(e => console.error('State save error:', e));
324
+ await this.saveQueue;
325
+ }
326
+ /** #357: Debounced save — skips immediate save for offset-only changes.
327
+ * Coalesces rapid successive reads into a single disk write. */
328
+ debouncedSave() {
329
+ if (this.saveDebounceTimer !== null)
330
+ clearTimeout(this.saveDebounceTimer);
331
+ this.saveDebounceTimer = setTimeout(() => {
332
+ this.saveDebounceTimer = null;
333
+ void this.save();
334
+ }, SessionManager.SAVE_DEBOUNCE_MS);
335
+ }
336
+ async doSave() {
337
+ const dir = dirname(this.stateFile);
338
+ if (!existsSync(dir)) {
339
+ await mkdir(dir, { recursive: true });
340
+ }
341
+ const tmpFile = `${this.stateFile}.tmp`;
342
+ // #357: Use replacer to serialize Set<string> as arrays
343
+ await writeFile(tmpFile, JSON.stringify(this.state, (_, value) => {
344
+ if (value instanceof Set)
345
+ return [...value];
346
+ return value;
347
+ }, 2));
348
+ await rename(tmpFile, this.stateFile);
349
+ }
350
+ /** Default stall threshold: 2 min (Issue #392: 1.5x CC's 90s default, configurable via CLAUDE_STREAM_IDLE_TIMEOUT_MS). */
351
+ static DEFAULT_STALL_THRESHOLD_MS = computeStallThreshold();
352
+ static DEFAULT_PERMISSION_STALL_MS = 5 * 60 * 1000;
353
+ /** Create a new CC session. */
354
+ /** Default timeout for waiting CC to become ready (60s for cold starts). */
355
+ static DEFAULT_PROMPT_TIMEOUT_MS = 60_000;
356
+ /** Max retries if CC doesn't become ready in time. */
357
+ static DEFAULT_PROMPT_MAX_RETRIES = 2;
358
+ /**
359
+ * Wait for CC to show its idle prompt in the tmux pane, then send the initial prompt.
360
+ * Uses exponential backoff on retry: first attempt waits timeoutMs, subsequent attempts
361
+ * wait 1.5x the previous timeout.
362
+ *
363
+ * Returns delivery result. Logs warnings on each retry for observability.
364
+ */
365
+ async sendInitialPrompt(sessionId, prompt, timeoutMs, maxRetries) {
366
+ const session = this.getSession(sessionId);
367
+ if (!session)
368
+ return { delivered: false, attempts: 0 };
369
+ const effectiveTimeout = timeoutMs ?? SessionManager.DEFAULT_PROMPT_TIMEOUT_MS;
370
+ const effectiveMaxRetries = maxRetries ?? SessionManager.DEFAULT_PROMPT_MAX_RETRIES;
371
+ for (let attempt = 1; attempt <= effectiveMaxRetries + 1; attempt++) {
372
+ const attemptTimeout = attempt === 1
373
+ ? effectiveTimeout
374
+ : Math.min(effectiveTimeout * Math.pow(1.5, attempt - 1), 120_000); // cap at 2min per retry
375
+ const result = await this.waitForReadyAndSend(sessionId, prompt, attemptTimeout);
376
+ if (result.delivered) {
377
+ if (attempt > 1) {
378
+ console.log(`sendInitialPrompt: delivered on attempt ${attempt}/${effectiveMaxRetries + 1}`);
379
+ }
380
+ return result;
381
+ }
382
+ // If this was the last attempt, return failure
383
+ if (attempt > effectiveMaxRetries) {
384
+ console.error(`sendInitialPrompt: FAILED after ${attempt} attempts for session ${sessionId.slice(0, 8)}`);
385
+ return result;
386
+ }
387
+ // Log retry
388
+ console.warn(`sendInitialPrompt: CC not ready after ${attemptTimeout}ms, retry ${attempt}/${effectiveMaxRetries}`);
389
+ }
390
+ return { delivered: false, attempts: effectiveMaxRetries + 1 };
391
+ }
392
+ /** Wait for CC idle prompt, then send. Single attempt. */
393
+ async waitForReadyAndSend(sessionId, prompt, timeoutMs) {
394
+ const session = this.getSession(sessionId);
395
+ if (!session)
396
+ return { delivered: false, attempts: 0 };
397
+ // #363: Exponential backoff from 500ms → 2000ms to reduce tmux CLI calls.
398
+ // Instead of ~120 fixed-interval polls, we get ~8-10 polls per session.
399
+ const MIN_POLL_MS = 500;
400
+ const MAX_POLL_MS = 2_000;
401
+ let pollInterval = MIN_POLL_MS;
402
+ const start = Date.now();
403
+ while (Date.now() - start < timeoutMs) {
404
+ // Use capturePaneDirect to bypass the serialize queue.
405
+ // At session creation, no other code is writing to this pane,
406
+ // so queue serialization is unnecessary and adds latency.
407
+ const paneText = await this.tmux.capturePaneDirect(session.windowId);
408
+ // Issue #561: Use detectUIState for robust readiness detection.
409
+ // Requires both ❯ prompt AND chrome separators (─────) to confirm idle.
410
+ // Naive includes('❯') matched splash/startup output, causing premature sends.
411
+ if (paneText && detectUIState(paneText) === 'idle') {
412
+ const result = await this.sendMessageDirect(sessionId, prompt);
413
+ if (!result.delivered)
414
+ return result;
415
+ // Issue #561: Post-send verification. Wait for CC to transition to a
416
+ // recognized active state. If CC stays in idle/unknown, the prompt was
417
+ // swallowed — report as undelivered so the retry loop can re-attempt.
418
+ const verified = await this.verifyPromptAccepted(session.windowId);
419
+ return verified
420
+ ? result
421
+ : { delivered: false, attempts: result.attempts };
422
+ }
423
+ await new Promise(r => setTimeout(r, pollInterval));
424
+ pollInterval = Math.min(pollInterval * 2, MAX_POLL_MS);
425
+ }
426
+ return { delivered: false, attempts: 0 };
427
+ }
428
+ /**
429
+ * Issue #561: After sending an initial prompt, verify CC actually accepted it
430
+ * by polling for a state transition away from idle/unknown.
431
+ * Returns true if CC transitions to a recognized active state within the timeout.
432
+ */
433
+ async verifyPromptAccepted(windowId) {
434
+ const VERIFY_TIMEOUT_MS = 5_000;
435
+ const VERIFY_POLL_MS = 500;
436
+ const verifyStart = Date.now();
437
+ while (Date.now() - verifyStart < VERIFY_TIMEOUT_MS) {
438
+ const paneText = await this.tmux.capturePaneDirect(windowId);
439
+ const state = detectUIState(paneText);
440
+ // Active states mean CC received and is processing the prompt.
441
+ // waiting_for_input = CC accepted prompt, awaiting follow-up (no chrome yet).
442
+ if (state === 'working' || state === 'permission_prompt' ||
443
+ state === 'bash_approval' || state === 'plan_mode' ||
444
+ state === 'ask_question' || state === 'compacting' ||
445
+ state === 'context_warning' || state === 'waiting_for_input') {
446
+ return true;
447
+ }
448
+ // idle or unknown — keep polling
449
+ await new Promise(r => setTimeout(r, VERIFY_POLL_MS));
450
+ }
451
+ console.warn(`verifyPromptAccepted: CC did not transition from idle/unknown within ${VERIFY_TIMEOUT_MS}ms`);
452
+ return false;
453
+ }
454
+ async createSession(opts) {
455
+ const id = crypto.randomUUID();
456
+ const windowName = opts.name || `cc-${id.slice(0, 8)}`;
457
+ // Merge defaultSessionEnv (from config) with per-session env (per-session wins)
458
+ // Security: validate env var names to prevent injection attacks
459
+ const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
460
+ // Issue #630: Expanded blocklist with additional dangerous env vars
461
+ const DANGEROUS_ENV_VARS = new Set([
462
+ // Dynamic loader / library injection
463
+ 'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
464
+ 'DYLD_INSERT_LIBRARIES', 'IFS', 'SHELL', 'ENV', 'BASH_ENV',
465
+ // Language-specific library paths
466
+ 'PYTHONPATH', 'PERL5LIB', 'RUBYLIB', 'CLASSPATH',
467
+ 'NODE_PATH', 'PYTHONHOME', 'PYTHONSTARTUP',
468
+ // Issue #630: Shell command injection vectors
469
+ 'PROMPT_COMMAND', 'GIT_SSH_COMMAND', 'EDITOR', 'VISUAL',
470
+ 'SUDO_ASKPASS', 'GIT_EXEC_PATH', 'NODE_ENV',
471
+ // Issue #630: Token/credential leakage
472
+ 'GITHUB_TOKEN', 'NPM_TOKEN', 'GITLAB_TOKEN',
473
+ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN',
474
+ 'AZURE_CLIENT_SECRET', 'GOOGLE_APPLICATION_CREDENTIALS',
475
+ 'DOCKER_TOKEN', 'HEROKU_API_KEY',
476
+ ]);
477
+ // Issue #630: Dangerous env var prefixes (prefix match)
478
+ const DANGEROUS_ENV_PREFIXES = [
479
+ 'npm_config_', // npm configuration override
480
+ 'BASH_FUNC_', // bash function export
481
+ 'SSH_', // SSH keys/agent config (SSH_AUTH_SOCK, SSH_PRIVATE_KEY, etc.)
482
+ 'GITHUB_', // GitHub tokens/keys (except GITHUB_PATH which is benign)
483
+ 'GITLAB_', // GitLab tokens
484
+ 'AWS_', // AWS credentials
485
+ 'AZURE_', // Azure credentials
486
+ 'TF_', // Terraform tokens
487
+ 'CI_', // CI tokens (CI_JOB_TOKEN, CI_BUILD_TOKEN, etc.)
488
+ 'DOCKER_', // Docker registry tokens
489
+ ];
490
+ const mergedEnv = {};
491
+ const allEnv = { ...this.config.defaultSessionEnv, ...opts.env };
492
+ for (const [key, value] of Object.entries(allEnv)) {
493
+ if (!ENV_NAME_RE.test(key)) {
494
+ throw new Error(`Invalid env var name: "${key}" — must match /^[A-Z_][A-Z0-9_]*$/`);
495
+ }
496
+ if (DANGEROUS_ENV_VARS.has(key)) {
497
+ throw new Error(`Forbidden env var: "${key}" — cannot override dangerous environment variables`);
498
+ }
499
+ // Issue #630: Check dangerous prefixes
500
+ if (DANGEROUS_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) {
501
+ throw new Error(`Forbidden env var: "${key}" — cannot override dangerous environment variable prefix "${DANGEROUS_ENV_PREFIXES.find(p => key.startsWith(p))}"`);
502
+ }
503
+ mergedEnv[key] = value;
504
+ }
505
+ const hasEnv = Object.keys(mergedEnv).length > 0;
506
+ // Permission guard: if permissionMode is "default", neutralize any project-level
507
+ // settings.local.json that has bypassPermissions. The CLI flag --permission-mode
508
+ // should be authoritative, but CC lets project settings override it.
509
+ // We back up the file, patch it, and restore on session cleanup.
510
+ const effectivePermissionMode = opts.permissionMode
511
+ ?? (opts.autoApprove === true ? 'bypassPermissions' : opts.autoApprove === false ? 'default' : undefined)
512
+ ?? this.config.defaultPermissionMode
513
+ ?? 'default';
514
+ let settingsPatched = false;
515
+ if (effectivePermissionMode !== 'bypassPermissions') {
516
+ settingsPatched = await neutralizeBypassPermissions(opts.workDir, effectivePermissionMode);
517
+ }
518
+ // Issue #629: Generate per-session HMAC secret for hook URL authentication.
519
+ const hookSecret = randomBytes(32).toString('hex');
520
+ // Issue #169 Phase 2: Generate HTTP hook settings for this session.
521
+ // Writes a temp file with hooks pointing to Aegis's hook receiver.
522
+ // Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
523
+ // This prevents CC from loading dead hook URLs on restart.
524
+ try {
525
+ const activeIds = new Set(this.listSessions().map(s => s.id));
526
+ if (activeIds.size > 0) {
527
+ await cleanupStaleSessionHooks(opts.workDir, activeIds);
528
+ }
529
+ }
530
+ catch (e) {
531
+ console.warn(`Hook cleanup: failed to clean stale hooks: ${e.message}`);
532
+ }
533
+ let hookSettingsFile;
534
+ try {
535
+ const baseUrl = `http://${this.config.host}:${this.config.port}`;
536
+ hookSettingsFile = await writeHookSettingsFile(baseUrl, id, hookSecret, opts.workDir);
537
+ }
538
+ catch (e) {
539
+ console.error(`Hook settings: failed to generate settings file: ${e.message}`);
540
+ // Non-fatal: hooks won't work for this session, but CC still launches
541
+ }
542
+ const { windowId, windowName: finalName, freshSessionId } = await this.tmux.createWindow({
543
+ workDir: opts.workDir,
544
+ windowName,
545
+ resumeSessionId: opts.resumeSessionId,
546
+ claudeCommand: opts.claudeCommand,
547
+ env: hasEnv ? mergedEnv : undefined,
548
+ permissionMode: effectivePermissionMode,
549
+ settingsFile: hookSettingsFile,
550
+ });
551
+ const session = {
552
+ id,
553
+ windowId,
554
+ windowName: finalName,
555
+ workDir: opts.workDir,
556
+ // If we know the CC session ID upfront (from --session-id), set it immediately.
557
+ // This eliminates the discovery delay and prevents stale ID assignment entirely.
558
+ claudeSessionId: freshSessionId || undefined,
559
+ byteOffset: 0,
560
+ monitorOffset: 0,
561
+ status: 'unknown',
562
+ createdAt: Date.now(),
563
+ lastActivity: Date.now(),
564
+ stallThresholdMs: opts.stallThresholdMs || SessionManager.DEFAULT_STALL_THRESHOLD_MS,
565
+ permissionStallMs: opts.permissionStallMs || SessionManager.DEFAULT_PERMISSION_STALL_MS,
566
+ permissionMode: effectivePermissionMode,
567
+ settingsPatched,
568
+ hookSettingsFile,
569
+ hookSecret,
570
+ prd: opts.prd,
571
+ };
572
+ this.state.sessions[id] = session;
573
+ this.invalidateSessionsListCache();
574
+ await this.save();
575
+ // Issue #702: Register child with parent
576
+ if (opts.parentId) {
577
+ const parent = this.state.sessions[opts.parentId];
578
+ if (parent) {
579
+ if (!parent.children)
580
+ parent.children = [];
581
+ parent.children.push(id);
582
+ await this.save();
583
+ }
584
+ }
585
+ // Issue #353: Fetch CC process PID for swarm parent matching.
586
+ // Fire-and-forget — PID is not needed synchronously.
587
+ // Issue #574: Add .catch() to prevent unhandled rejection if tmux fails mid-lookup.
588
+ void this.tmux.listPanePid(windowId).then(pid => {
589
+ if (pid !== null) {
590
+ session.ccPid = pid;
591
+ void this.save().catch(e => console.error(`Session: failed to save PID for ${id}:`, e));
592
+ }
593
+ }).catch(e => console.error(`Session: failed to list pane PID for ${id}:`, e));
594
+ // Start coordinated discovery polling:
595
+ // - Hook/session_map sync: fast path
596
+ // - Filesystem scan fallback: works when hooks fail or are skipped (Issue #16)
597
+ // Field bug (Zeus 2026-03-22): hooks may not fire even without --bare
598
+ this.startDiscoveryPolling(id, opts.workDir);
599
+ // P0 fix: Clean stale entries from session_map.json for BOTH window name AND id.
600
+ // After archiving old .jsonl files, stale session_map entries would point
601
+ // to moved files, causing discovery to pick up ghost session IDs.
602
+ // Also cleans stale windowId entries that could collide after restart.
603
+ await this.cleanSessionMapForWindow(finalName, windowId);
604
+ return session;
605
+ }
606
+ /** Get a session by ID. */
607
+ getSession(id) {
608
+ return this.state.sessions[id] || null;
609
+ }
610
+ /** Issue #169 Phase 3: Update session status from a hook event.
611
+ * Returns the previous status for change detection.
612
+ * Issue #87: Also records hook latency timestamps. */
613
+ updateStatusFromHook(id, hookEvent, hookTimestamp) {
614
+ const session = this.state.sessions[id];
615
+ if (!session)
616
+ return null;
617
+ const prevStatus = session.status;
618
+ const now = Date.now();
619
+ // Map hook events to UI states
620
+ switch (hookEvent) {
621
+ case 'Stop':
622
+ case 'TaskCompleted':
623
+ case 'SessionEnd':
624
+ case 'TeammateIdle':
625
+ break;
626
+ case 'PreToolUse':
627
+ case 'PostToolUse':
628
+ case 'SubagentStart':
629
+ case 'UserPromptSubmit':
630
+ session.status = 'working';
631
+ break;
632
+ case 'PermissionRequest':
633
+ session.status = 'permission_prompt';
634
+ break;
635
+ case 'StopFailure':
636
+ case 'PostToolUseFailure':
637
+ session.status = 'error';
638
+ break;
639
+ case 'Notification':
640
+ case 'PreCompact':
641
+ case 'PostCompact':
642
+ case 'SubagentStop':
643
+ // Informational events — no status change
644
+ break;
645
+ default:
646
+ // Unknown hook events: no status change
647
+ break;
648
+ }
649
+ session.lastHookAt = now;
650
+ session.lastActivity = now;
651
+ // Issue #87: Record hook receive timestamp for latency calculation
652
+ session.lastHookReceivedAt = now;
653
+ if (hookTimestamp) {
654
+ // Issue #828: Clamp future timestamps to prevent clock skew corruption.
655
+ // If the client's clock is ahead of ours, store our timestamp instead.
656
+ if (hookTimestamp > now) {
657
+ console.warn(`updateStatusFromHook: clamping future hookTimestamp ` +
658
+ `(${hookTimestamp} > ${now}) for session ${id.slice(0, 8)}`);
659
+ session.lastHookEventAt = now;
660
+ }
661
+ else {
662
+ session.lastHookEventAt = hookTimestamp;
663
+ }
664
+ }
665
+ // Issue #87: Track permission prompt timestamp
666
+ if (hookEvent === 'PermissionRequest') {
667
+ session.permissionPromptAt = now;
668
+ }
669
+ return prevStatus;
670
+ }
671
+ /** Issue #812: Detect if CC is waiting for user input by analyzing the JSONL transcript.
672
+ * Returns true if the last assistant message has text content only (no tool_use). */
673
+ async detectWaitingForInput(id) {
674
+ const session = this.state.sessions[id];
675
+ if (!session?.jsonlPath)
676
+ return false;
677
+ try {
678
+ const { raw } = await readNewEntries(session.jsonlPath, 0);
679
+ // Walk backwards to find the last assistant JSONL entry
680
+ for (let i = raw.length - 1; i >= 0; i--) {
681
+ const entry = raw[i];
682
+ if (entry.type !== 'assistant' || !entry.message)
683
+ continue;
684
+ const content = entry.message.content;
685
+ if (typeof content === 'string')
686
+ return true; // text-only message
687
+ if (!Array.isArray(content))
688
+ return false;
689
+ // Check if any content block is a tool_use
690
+ const hasToolUse = content.some((block) => block.type === 'tool_use');
691
+ return !hasToolUse;
692
+ }
693
+ }
694
+ catch {
695
+ // If we can't read the transcript, don't override status
696
+ }
697
+ return false;
698
+ }
699
+ /** Issue #88: Add an active subagent to a session. */
700
+ addSubagent(id, name) {
701
+ const session = this.state.sessions[id];
702
+ if (!session)
703
+ return;
704
+ if (!session.activeSubagents)
705
+ session.activeSubagents = new Set();
706
+ session.activeSubagents.add(name);
707
+ }
708
+ /** Issue #88: Remove an active subagent from a session. */
709
+ removeSubagent(id, name) {
710
+ const session = this.state.sessions[id];
711
+ if (!session || !session.activeSubagents)
712
+ return;
713
+ session.activeSubagents.delete(name);
714
+ }
715
+ /** Issue #89 L25: Update the model field on a session from hook payload. */
716
+ updateSessionModel(id, model) {
717
+ const session = this.state.sessions[id];
718
+ if (!session)
719
+ return;
720
+ session.model = model;
721
+ }
722
+ /** Issue #87: Get latency metrics for a session. */
723
+ getLatencyMetrics(id) {
724
+ const session = this.state.sessions[id];
725
+ if (!session)
726
+ return null;
727
+ // hook_latency_ms: time from CC sending hook to Aegis receiving it
728
+ // Calculated from the difference between our receive time and the hook's timestamp
729
+ let hookLatency = null;
730
+ if (session.lastHookReceivedAt && session.lastHookEventAt) {
731
+ hookLatency = session.lastHookReceivedAt - session.lastHookEventAt;
732
+ // Guard against negative values (clock skew)
733
+ if (hookLatency < 0)
734
+ hookLatency = null;
735
+ }
736
+ // state_change_detection_ms: time from CC state change to Aegis detection
737
+ // Approximated as hook_latency_ms since the hook IS the state change signal
738
+ let stateChangeDetection = hookLatency;
739
+ // permission_response_ms: time from permission prompt to user action
740
+ let permissionResponse = null;
741
+ if (session.permissionPromptAt && session.permissionRespondedAt) {
742
+ permissionResponse = session.permissionRespondedAt - session.permissionPromptAt;
743
+ }
744
+ return {
745
+ hook_latency_ms: hookLatency,
746
+ state_change_detection_ms: stateChangeDetection,
747
+ permission_response_ms: permissionResponse,
748
+ };
749
+ }
750
+ /** Check if a session's tmux window still exists and has a live process.
751
+ * Issue #69: A window can exist with a crashed/zombie CC process (zombie window).
752
+ * After checking window exists, also verify the pane PID is alive.
753
+ * Issue #390: Check stored ccPid first for immediate crash detection.
754
+ * When CC crashes (SIGKILL, OOM), the shell prompt returns in the pane,
755
+ * so the current pane PID is the shell (alive). Checking ccPid catches
756
+ * the crash within seconds instead of waiting for the 5-min stall timer. */
757
+ async isWindowAlive(id) {
758
+ const session = this.state.sessions[id];
759
+ if (!session)
760
+ return false;
761
+ try {
762
+ // Issue #390: Fast crash detection via stored CC PID
763
+ const windowHealth = await this.tmux.getWindowHealth(session.windowId);
764
+ if (!windowHealth.windowExists)
765
+ return false;
766
+ // Issue #1040: When CC exits (normal or crash), it becomes a zombie.
767
+ // isPidAlive returns false for zombies — we cannot distinguish normal exit from crash.
768
+ // paneDead + grace period handles both: keep alive briefly, then mark dead.
769
+ if (windowHealth.paneDead) {
770
+ const msSinceActivity = Date.now() - (session.lastActivity || session.createdAt);
771
+ const GRACE_PERIOD_MS = 15000; // 15 seconds — enough for CC to finish and write results
772
+ return msSinceActivity < GRACE_PERIOD_MS;
773
+ }
774
+ // Pane not dead — verify pane process is alive (for non-CC processes like shells)
775
+ const panePid = await this.tmux.listPanePid(session.windowId);
776
+ if (panePid !== null && !this.tmux.isPidAlive(panePid))
777
+ return false;
778
+ return true;
779
+ }
780
+ catch { /* tmux query failed — treat as not alive */
781
+ return false;
782
+ }
783
+ }
784
+ /** Issue #657: Invalidate the sessions list cache. Call on any mutation. */
785
+ invalidateSessionsListCache() {
786
+ this.sessionsListCache = null;
787
+ }
788
+ /** List all sessions. */
789
+ listSessions() {
790
+ if (!this.sessionsListCache) {
791
+ this.sessionsListCache = Object.values(this.state.sessions);
792
+ }
793
+ return this.sessionsListCache;
794
+ }
795
+ /** Issue #607: Find an idle session for the given workDir.
796
+ * Returns the most recently active idle session, or null if none found.
797
+ * Used to resume existing sessions instead of creating duplicates.
798
+ * Issue #636: Verifies tmux window is still alive before returning.
799
+ * Issue #840/#880: Atomically acquires the session under a mutex to prevent TOCTOU race. */
800
+ async findIdleSessionByWorkDir(workDir) {
801
+ return this.sessionAcquireMutex.runExclusive(async () => {
802
+ await maybeInjectFault('session.findIdleSessionByWorkDir.start');
803
+ const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
804
+ if (candidates.length === 0)
805
+ return null;
806
+ // Return the most recently active session
807
+ candidates.sort((a, b) => b.lastActivity - a.lastActivity);
808
+ // Issue #636: verify tmux window exists before returning
809
+ for (const candidate of candidates) {
810
+ await maybeInjectFault('session.findIdleSessionByWorkDir.windowExists');
811
+ if (await this.tmux.windowExists(candidate.windowId)) {
812
+ // Issue #840: Mark session as acquired immediately to prevent
813
+ // concurrent callers from grabbing the same session
814
+ candidate.status = 'acquired';
815
+ return candidate;
816
+ }
817
+ }
818
+ return null;
819
+ });
820
+ }
821
+ /** Release a session claim after the reuse path completes (success or failure). */
822
+ releaseSessionClaim(id) {
823
+ const session = this.state.sessions[id];
824
+ if (session) {
825
+ session.status = 'idle';
826
+ }
827
+ }
828
+ /** Get health info for a session.
829
+ * Issue #2: Returns comprehensive health status for orchestrators.
830
+ */
831
+ async getHealth(id) {
832
+ const session = this.state.sessions[id];
833
+ if (!session)
834
+ throw new Error(`Session ${id} not found`);
835
+ const now = Date.now();
836
+ const windowHealth = await this.tmux.getWindowHealth(session.windowId);
837
+ // Get terminal state
838
+ let status = 'unknown';
839
+ // Issue #69: Also check if the pane PID is alive (zombie window detection)
840
+ let processAlive = true;
841
+ const paneExited = !!windowHealth.paneDead;
842
+ if (windowHealth.windowExists) {
843
+ if (paneExited) {
844
+ processAlive = false;
845
+ }
846
+ else {
847
+ try {
848
+ const panePid = await this.tmux.listPanePid(session.windowId);
849
+ if (panePid !== null) {
850
+ processAlive = this.tmux.isPidAlive(panePid);
851
+ }
852
+ }
853
+ catch { /* cannot list pane PID — assume dead */
854
+ processAlive = false;
855
+ }
856
+ }
857
+ }
858
+ if (windowHealth.windowExists && processAlive) {
859
+ try {
860
+ const paneText = await this.tmux.capturePane(session.windowId);
861
+ status = detectUIState(paneText);
862
+ session.status = status;
863
+ }
864
+ catch { /* pane capture failed — default to unknown */
865
+ status = 'unknown';
866
+ }
867
+ }
868
+ const hasTranscript = !!(session.claudeSessionId && session.jsonlPath);
869
+ const lastActivityAgo = now - session.lastActivity;
870
+ const sessionAge = now - session.createdAt;
871
+ // Determine if session is alive
872
+ // Alive = window exists AND process alive AND (Claude running OR recently active)
873
+ const recentlyActive = lastActivityAgo < 5 * 60 * 1000; // 5 minutes
874
+ const alive = windowHealth.windowExists && processAlive && (windowHealth.claudeRunning || recentlyActive);
875
+ // Human-readable detail
876
+ let details;
877
+ if (!windowHealth.windowExists) {
878
+ details = 'Tmux window does not exist — session is dead';
879
+ }
880
+ else if (paneExited) {
881
+ details = 'Tmux pane has exited — session is dead';
882
+ }
883
+ else if (!processAlive) {
884
+ details = 'Tmux window exists but pane process is dead — session is dead (zombie window)';
885
+ }
886
+ else if (!windowHealth.claudeRunning && !recentlyActive) {
887
+ details = `Claude not running (pane: ${windowHealth.paneCommand}), no activity for ${Math.round(lastActivityAgo / 60000)}min`;
888
+ }
889
+ else if (status === 'idle') {
890
+ details = 'Claude is idle, awaiting input';
891
+ }
892
+ else if (status === 'working') {
893
+ details = 'Claude is actively working';
894
+ }
895
+ else if (status === 'permission_prompt' || status === 'bash_approval') {
896
+ details = `Claude is waiting for permission approval. POST /v1/sessions/${session.id}/approve to approve, or /v1/sessions/${session.id}/reject to reject.`;
897
+ }
898
+ else {
899
+ details = `Status: ${status}, pane: ${windowHealth.paneCommand}`;
900
+ }
901
+ // Issue #20: Action hints for interactive states
902
+ const actionHints = (status === 'permission_prompt' || status === 'bash_approval')
903
+ ? {
904
+ approve: { method: 'POST', url: `/v1/sessions/${session.id}/approve`, description: 'Approve the pending permission' },
905
+ reject: { method: 'POST', url: `/v1/sessions/${session.id}/reject`, description: 'Reject the pending permission' },
906
+ }
907
+ : undefined;
908
+ return {
909
+ alive,
910
+ windowExists: windowHealth.windowExists,
911
+ claudeRunning: windowHealth.claudeRunning,
912
+ paneCommand: windowHealth.paneCommand,
913
+ status,
914
+ hasTranscript,
915
+ lastActivity: session.lastActivity,
916
+ lastActivityAgo,
917
+ sessionAge,
918
+ details,
919
+ actionHints,
920
+ };
921
+ }
922
+ /** Send a message to a session with delivery verification.
923
+ * Issue #1: Uses capture-pane to verify the prompt was delivered.
924
+ * Returns delivery status for API response.
925
+ */
926
+ async sendMessage(id, text) {
927
+ const session = this.state.sessions[id];
928
+ if (!session)
929
+ throw new Error(`Session ${id} not found`);
930
+ const result = await this.tmux.sendKeysVerified(session.windowId, text);
931
+ if (result.delivered) {
932
+ session.lastActivity = Date.now();
933
+ try {
934
+ await this.save();
935
+ }
936
+ catch {
937
+ // Message was delivered — don't let a save failure mask the success
938
+ }
939
+ }
940
+ return result;
941
+ }
942
+ /** Send message bypassing the tmux serialize queue.
943
+ * Used by sendInitialPrompt for critical-path prompt delivery.
944
+ *
945
+ * Issue #285: Changed from sendKeysDirect (unverified) to sendKeysVerified
946
+ * with 3 retry attempts. tmux send-keys can silently fail even at session
947
+ * creation time, causing ~20% prompt delivery failure rate.
948
+ *
949
+ * We still bypass the serialize queue (using capturePaneDirect in verifyDelivery)
950
+ * but now verify actual delivery to CC.
951
+ */
952
+ async sendMessageDirect(id, text) {
953
+ const session = this.state.sessions[id];
954
+ if (!session)
955
+ throw new Error(`Session ${id} not found`);
956
+ // Issue #285: Use verified sending with retry for reliability
957
+ const result = await this.tmux.sendKeysVerified(session.windowId, text, 3);
958
+ if (result.delivered) {
959
+ session.lastActivity = Date.now();
960
+ try {
961
+ await this.save();
962
+ }
963
+ catch {
964
+ // Message was delivered — don't let a save failure mask the success
965
+ }
966
+ }
967
+ return result;
968
+ }
969
+ /** Record that a permission prompt was detected for this session. */
970
+ recordPermissionPrompt(id) {
971
+ const session = this.state.sessions[id];
972
+ if (!session)
973
+ return;
974
+ session.permissionPromptAt = Date.now();
975
+ }
976
+ /** Approve a permission prompt. Resolves pending hook permission first, falls back to tmux send-keys. */
977
+ async approve(id) {
978
+ const session = this.state.sessions[id];
979
+ if (!session)
980
+ throw new Error(`Session ${id} not found`);
981
+ // Issue #284: Resolve pending hook-based permission first
982
+ if (this.permissionRequests.resolvePendingPermission(id, 'allow')) {
983
+ session.lastActivity = Date.now();
984
+ if (session.permissionPromptAt) {
985
+ session.permissionRespondedAt = Date.now();
986
+ }
987
+ return;
988
+ }
989
+ // Fallback: tmux send-keys
990
+ const paneText = await this.tmux.capturePane(session.windowId);
991
+ const method = detectApprovalMethod(paneText);
992
+ await this.tmux.sendKeys(session.windowId, method === 'numbered' ? '1' : 'y', true);
993
+ session.lastActivity = Date.now();
994
+ if (session.permissionPromptAt) {
995
+ session.permissionRespondedAt = Date.now();
996
+ }
997
+ }
998
+ /** Reject a permission prompt. Resolves pending hook permission first, falls back to tmux send-keys. */
999
+ async reject(id) {
1000
+ const session = this.state.sessions[id];
1001
+ if (!session)
1002
+ throw new Error(`Session ${id} not found`);
1003
+ // Issue #284: Resolve pending hook-based permission first
1004
+ if (this.permissionRequests.resolvePendingPermission(id, 'deny')) {
1005
+ session.lastActivity = Date.now();
1006
+ if (session.permissionPromptAt) {
1007
+ session.permissionRespondedAt = Date.now();
1008
+ }
1009
+ return;
1010
+ }
1011
+ // Fallback: tmux send-keys
1012
+ await this.tmux.sendKeys(session.windowId, 'n', true);
1013
+ session.lastActivity = Date.now();
1014
+ if (session.permissionPromptAt) {
1015
+ session.permissionRespondedAt = Date.now();
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Issue #284: Store a pending permission request and return a promise that
1020
+ * resolves when the client approves/rejects via the API.
1021
+ *
1022
+ * @param sessionId - Aegis session ID
1023
+ * @param timeoutMs - Timeout before auto-rejecting (default 10_000ms, matching CC's hook timeout)
1024
+ * @param toolName - Optional tool name from the hook payload
1025
+ * @param prompt - Optional permission prompt text
1026
+ * @returns Promise that resolves with the client's decision
1027
+ */
1028
+ waitForPermissionDecision(sessionId, timeoutMs = 10_000, toolName, prompt) {
1029
+ return this.permissionRequests.waitForPermissionDecision(sessionId, timeoutMs, toolName, prompt);
1030
+ }
1031
+ /** Check if a session has a pending permission request. */
1032
+ hasPendingPermission(sessionId) {
1033
+ return this.permissionRequests.hasPendingPermission(sessionId);
1034
+ }
1035
+ /** Get info about a pending permission (for API responses). */
1036
+ getPendingPermissionInfo(sessionId) {
1037
+ return this.permissionRequests.getPendingPermissionInfo(sessionId);
1038
+ }
1039
+ /** Clean up any pending permission for a session (e.g. on session delete). */
1040
+ cleanupPendingPermission(sessionId) {
1041
+ this.permissionRequests.cleanupPendingPermission(sessionId);
1042
+ }
1043
+ /**
1044
+ * Issue #336: Store a pending AskUserQuestion and return a promise that
1045
+ * resolves when the external client provides an answer via POST /answer.
1046
+ */
1047
+ waitForAnswer(sessionId, toolUseId, question, timeoutMs = 30_000) {
1048
+ return this.questions.waitForAnswer(sessionId, toolUseId, question, timeoutMs);
1049
+ }
1050
+ /** Issue #336: Submit an answer to a pending question. Returns true if resolved. */
1051
+ submitAnswer(sessionId, questionId, answer) {
1052
+ return this.questions.submitAnswer(sessionId, questionId, answer);
1053
+ }
1054
+ /** Issue #336: Check if a session has a pending question. */
1055
+ hasPendingQuestion(sessionId) {
1056
+ return this.questions.hasPendingQuestion(sessionId);
1057
+ }
1058
+ /** Issue #336: Get info about a pending question. */
1059
+ getPendingQuestionInfo(sessionId) {
1060
+ return this.questions.getPendingQuestionInfo(sessionId);
1061
+ }
1062
+ /** Issue #336: Clean up any pending question for a session. */
1063
+ cleanupPendingQuestion(sessionId) {
1064
+ this.questions.cleanupPendingQuestion(sessionId);
1065
+ }
1066
+ /** Send Escape key. */
1067
+ async escape(id) {
1068
+ const session = this.state.sessions[id];
1069
+ if (!session)
1070
+ throw new Error(`Session ${id} not found`);
1071
+ await this.tmux.sendSpecialKey(session.windowId, 'Escape');
1072
+ }
1073
+ /** Send Ctrl+C. */
1074
+ async interrupt(id) {
1075
+ const session = this.state.sessions[id];
1076
+ if (!session)
1077
+ throw new Error(`Session ${id} not found`);
1078
+ await this.tmux.sendSpecialKey(session.windowId, 'C-c');
1079
+ }
1080
+ /** Read new messages from a session. */
1081
+ async readMessages(id) {
1082
+ const session = this.state.sessions[id];
1083
+ if (!session)
1084
+ throw new Error(`Session ${id} not found`);
1085
+ // Detect UI state from terminal
1086
+ const paneText = await this.tmux.capturePane(session.windowId);
1087
+ const status = detectUIState(paneText);
1088
+ const statusText = parseStatusLine(paneText);
1089
+ const interactive = extractInteractiveContent(paneText);
1090
+ session.status = status;
1091
+ session.lastActivity = Date.now();
1092
+ // Try to find JSONL if we don't have it yet (Issue #884: worktree-aware)
1093
+ if (!session.jsonlPath && session.claudeSessionId) {
1094
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1095
+ if (path) {
1096
+ session.jsonlPath = path;
1097
+ session.byteOffset = 0;
1098
+ }
1099
+ }
1100
+ // Read JSONL if we have the file path
1101
+ let messages = [];
1102
+ if (session.jsonlPath && existsSync(session.jsonlPath)) {
1103
+ try {
1104
+ const result = await readNewEntries(session.jsonlPath, session.byteOffset);
1105
+ messages = result.entries;
1106
+ session.byteOffset = result.newOffset;
1107
+ }
1108
+ catch {
1109
+ // File may not exist yet
1110
+ }
1111
+ }
1112
+ // #357: Debounce saves on GET reads — offsets change frequently but disk
1113
+ // writes are expensive. Full save still happens on create/kill/reconcile.
1114
+ this.debouncedSave();
1115
+ return {
1116
+ messages,
1117
+ status,
1118
+ statusText,
1119
+ interactiveContent: interactive?.content || null,
1120
+ };
1121
+ }
1122
+ /** Read new messages for the monitor (separate offset from API reads). */
1123
+ async readMessagesForMonitor(id) {
1124
+ const session = this.state.sessions[id];
1125
+ if (!session)
1126
+ throw new Error(`Session ${id} not found`);
1127
+ // Detect UI state from terminal
1128
+ const paneText = await this.tmux.capturePane(session.windowId);
1129
+ const status = detectUIState(paneText);
1130
+ const statusText = parseStatusLine(paneText);
1131
+ const interactive = extractInteractiveContent(paneText);
1132
+ session.status = status;
1133
+ // Try to find JSONL if we don't have it yet (Issue #884: worktree-aware)
1134
+ if (!session.jsonlPath && session.claudeSessionId) {
1135
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1136
+ if (path) {
1137
+ session.jsonlPath = path;
1138
+ session.monitorOffset = 0;
1139
+ }
1140
+ }
1141
+ // Read JSONL using monitor offset
1142
+ let messages = [];
1143
+ if (session.jsonlPath && existsSync(session.jsonlPath)) {
1144
+ try {
1145
+ const result = await readNewEntries(session.jsonlPath, session.monitorOffset);
1146
+ messages = result.entries;
1147
+ session.monitorOffset = result.newOffset;
1148
+ }
1149
+ catch {
1150
+ // File may not exist yet
1151
+ }
1152
+ }
1153
+ return {
1154
+ messages,
1155
+ status,
1156
+ statusText,
1157
+ interactiveContent: interactive?.content || null,
1158
+ };
1159
+ }
1160
+ /** #357: Get all parsed entries for a session, using a cache to avoid full reparse.
1161
+ * Reads only the delta from the last cached offset. */
1162
+ async getCachedEntries(session) {
1163
+ if (!session.jsonlPath || !existsSync(session.jsonlPath))
1164
+ return [];
1165
+ const cached = this.parsedEntriesCache.get(session.id);
1166
+ try {
1167
+ const fromOffset = cached ? cached.offset : 0;
1168
+ const result = await readNewEntries(session.jsonlPath, fromOffset);
1169
+ if (cached) {
1170
+ // #832: Detect JSONL truncation — newOffset resets to 0 when file is rewritten.
1171
+ // readNewEntries returns empty entries + newOffset:0 on truncation.
1172
+ // Discard stale cached entries and rebuild from scratch.
1173
+ if (fromOffset > 0 && result.newOffset === 0 && result.entries.length === 0) {
1174
+ const freshResult = await readNewEntries(session.jsonlPath, 0);
1175
+ this.parsedEntriesCache.set(session.id, { entries: [...freshResult.entries], offset: freshResult.newOffset });
1176
+ return freshResult.entries;
1177
+ }
1178
+ cached.entries.push(...result.entries);
1179
+ cached.offset = result.newOffset;
1180
+ // #424: Evict oldest entries when cache exceeds per-session cap
1181
+ if (cached.entries.length > SessionManager.MAX_CACHE_ENTRIES_PER_SESSION) {
1182
+ cached.entries.splice(0, cached.entries.length - SessionManager.MAX_CACHE_ENTRIES_PER_SESSION);
1183
+ }
1184
+ return cached.entries;
1185
+ }
1186
+ // First read — cache it
1187
+ this.parsedEntriesCache.set(session.id, { entries: [...result.entries], offset: result.newOffset });
1188
+ return result.entries;
1189
+ }
1190
+ catch { /* JSONL read failed — return cached entries or empty */
1191
+ return cached ? [...cached.entries] : [];
1192
+ }
1193
+ }
1194
+ /** Issue #35: Get a condensed summary of a session's transcript. */
1195
+ async getSummary(id, maxMessages = 20) {
1196
+ const session = this.state.sessions[id];
1197
+ if (!session)
1198
+ throw new Error(`Session ${id} not found`);
1199
+ // #357: Use cached entries instead of re-reading from offset 0
1200
+ const allMessages = await this.getCachedEntries(session);
1201
+ // Take last N messages
1202
+ const recent = allMessages.slice(-maxMessages).map(m => ({
1203
+ role: m.role,
1204
+ contentType: m.contentType,
1205
+ text: m.text.slice(0, 500), // Truncate long messages
1206
+ }));
1207
+ return {
1208
+ sessionId: session.id,
1209
+ windowName: session.windowName,
1210
+ status: session.status,
1211
+ totalMessages: allMessages.length,
1212
+ messages: recent,
1213
+ createdAt: session.createdAt,
1214
+ lastActivity: session.lastActivity,
1215
+ permissionMode: session.permissionMode,
1216
+ prd: session.prd,
1217
+ };
1218
+ }
1219
+ /** Paginated transcript read — does NOT advance the session's byteOffset. */
1220
+ async readTranscript(id, page = 1, limit = 50, roleFilter) {
1221
+ const session = this.state.sessions[id];
1222
+ if (!session)
1223
+ throw new Error(`Session ${id} not found`);
1224
+ // Discover JSONL path if not yet known (Issue #884: worktree-aware)
1225
+ if (!session.jsonlPath && session.claudeSessionId) {
1226
+ const path = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1227
+ if (path) {
1228
+ session.jsonlPath = path;
1229
+ session.byteOffset = 0;
1230
+ }
1231
+ }
1232
+ let allEntries = [];
1233
+ // #357: Use cached entries instead of re-reading from offset 0
1234
+ allEntries = await this.getCachedEntries(session);
1235
+ if (roleFilter) {
1236
+ allEntries = allEntries.filter(e => e.role === roleFilter);
1237
+ }
1238
+ const total = allEntries.length;
1239
+ const start = (page - 1) * limit;
1240
+ const messages = allEntries.slice(start, start + limit);
1241
+ const hasMore = start + messages.length < total;
1242
+ return {
1243
+ messages,
1244
+ total,
1245
+ page,
1246
+ limit,
1247
+ hasMore,
1248
+ };
1249
+ }
1250
+ /**
1251
+ * Cursor-based transcript read — stable under concurrent appends.
1252
+ *
1253
+ * Uses 1-based sequential entry indices as cursors.
1254
+ * - `beforeId`: exclusive upper bound (fetch entries with index < beforeId).
1255
+ * If omitted, fetch the newest `limit` entries.
1256
+ * - `limit`: max entries to return (capped at 200).
1257
+ * - Returns entries in ascending order (oldest first) within the window.
1258
+ */
1259
+ async readTranscriptCursor(id, beforeId, limit = 50, roleFilter) {
1260
+ const session = this.state.sessions[id];
1261
+ if (!session)
1262
+ throw new Error(`Session ${id} not found`);
1263
+ // Discover JSONL path if not yet known
1264
+ if (!session.jsonlPath && session.claudeSessionId) {
1265
+ const path = await findSessionFile(session.claudeSessionId, this.config.claudeProjectsDir);
1266
+ if (path) {
1267
+ session.jsonlPath = path;
1268
+ session.byteOffset = 0;
1269
+ }
1270
+ }
1271
+ let allEntries = await this.getCachedEntries(session);
1272
+ if (roleFilter) {
1273
+ allEntries = allEntries.filter(e => e.role === roleFilter);
1274
+ }
1275
+ const total = allEntries.length;
1276
+ const clampedLimit = Math.min(200, Math.max(1, limit));
1277
+ // Determine exclusive upper index (0-based)
1278
+ const upperExclusive = beforeId !== undefined
1279
+ ? Math.min(beforeId - 1, total) // beforeId is 1-based
1280
+ : total;
1281
+ const lowerInclusive = Math.max(0, upperExclusive - clampedLimit);
1282
+ const slice = allEntries.slice(lowerInclusive, upperExclusive);
1283
+ const messages = slice.map((entry, i) => ({
1284
+ ...entry,
1285
+ _cursor_id: lowerInclusive + i + 1, // 1-based stable index
1286
+ }));
1287
+ return {
1288
+ messages,
1289
+ has_more: lowerInclusive > 0,
1290
+ oldest_id: messages.length > 0 ? messages[0]._cursor_id : null,
1291
+ newest_id: messages.length > 0 ? messages[messages.length - 1]._cursor_id : null,
1292
+ };
1293
+ }
1294
+ /** #405: Clean up all tracking maps for a session to prevent memory leaks. */
1295
+ cleanupSession(id) {
1296
+ this.stopDiscoveryPolling(id);
1297
+ this.cleanupPendingPermission(id);
1298
+ this.cleanupPendingQuestion(id);
1299
+ this.parsedEntriesCache.delete(id);
1300
+ }
1301
+ /** Kill a session. */
1302
+ async killSession(id) {
1303
+ const session = this.state.sessions[id];
1304
+ if (!session)
1305
+ return;
1306
+ await this.tmux.killWindow(session.windowId);
1307
+ // Permission guard: restore original settings.local.json if we patched it
1308
+ if (session.settingsPatched) {
1309
+ await restoreSettings(session.workDir);
1310
+ }
1311
+ // Issue #169 Phase 2: Clean up temp hook settings file
1312
+ if (session.hookSettingsFile) {
1313
+ await cleanupHookSettingsFile(session.hookSettingsFile);
1314
+ }
1315
+ // #405: Clean up all tracking maps (pollTimers, pendingPermissions, pendingQuestions, parsedEntriesCache)
1316
+ this.cleanupSession(id);
1317
+ delete this.state.sessions[id];
1318
+ this.invalidateSessionsListCache();
1319
+ // #357: Cancel any pending debounced save before doing an immediate save
1320
+ if (this.saveDebounceTimer !== null) {
1321
+ clearTimeout(this.saveDebounceTimer);
1322
+ this.saveDebounceTimer = null;
1323
+ }
1324
+ await this.save();
1325
+ }
1326
+ /** Remove stale entries from session_map.json for a given window.
1327
+ * P0 fix: After aegis service restarts, old session_map entries with stale windowIds
1328
+ * can survive and cause new sessions to inherit context from old sessions.
1329
+ * We must clean by BOTH windowName AND windowId to prevent collisions.
1330
+ *
1331
+ * After archiving old .jsonl files, old hook entries would cause discovery
1332
+ * to map the new session to a ghost claudeSessionId whose file no longer exists.
1333
+ */
1334
+ async cleanSessionMapForWindow(windowName, windowId) {
1335
+ if (!existsSync(this.sessionMapFile))
1336
+ return;
1337
+ try {
1338
+ const mapData = await loadContinuationPointers(this.sessionMapFile, this.config.continuationPointerTtlMs);
1339
+ let changed = false;
1340
+ for (const [key, info] of Object.entries(mapData)) {
1341
+ // Clean by window_name (original behavior)
1342
+ if (info.window_name === windowName) {
1343
+ delete mapData[key];
1344
+ changed = true;
1345
+ continue;
1346
+ }
1347
+ // P0 fix: Also clean entries where key ends with :windowId
1348
+ // This prevents stale windowId collisions after restart
1349
+ if (windowId && key.endsWith(':' + windowId)) {
1350
+ delete mapData[key];
1351
+ changed = true;
1352
+ }
1353
+ }
1354
+ if (changed) {
1355
+ const tmpFile = `${this.sessionMapFile}.tmp`;
1356
+ await writeFile(tmpFile, JSON.stringify(mapData, null, 2));
1357
+ await rename(tmpFile, this.sessionMapFile);
1358
+ }
1359
+ }
1360
+ catch { /* ignore parse/write errors */ }
1361
+ }
1362
+ /** P0 fix: Purge session_map entries that don't correspond to active aegis sessions.
1363
+ * After aegis restarts, old session_map entries with stale windowIds can survive
1364
+ * and cause new sessions to inherit context from old sessions.
1365
+ */
1366
+ async purgeStaleSessionMapEntries(activeWindowIds, activeWindowNames) {
1367
+ if (!existsSync(this.sessionMapFile))
1368
+ return;
1369
+ try {
1370
+ const mapData = await loadContinuationPointers(this.sessionMapFile, this.config.continuationPointerTtlMs);
1371
+ let changed = false;
1372
+ const activeNamesLower = new Set([...activeWindowNames].map(n => n.toLowerCase()));
1373
+ for (const [key, info] of Object.entries(mapData)) {
1374
+ // Extract windowId from key (format: "sessionName:windowId")
1375
+ const keyWindowId = key.includes(':') ? key.split(':').pop() : null;
1376
+ const windowName = (info.window_name || '').toLowerCase();
1377
+ // Keep entry only if it matches an active window
1378
+ const windowIdActive = keyWindowId && activeWindowIds.has(keyWindowId);
1379
+ const windowNameActive = activeNamesLower.has(windowName);
1380
+ if (!windowIdActive && !windowNameActive) {
1381
+ console.log(`Reconcile: purging stale session_map entry: ${key}`);
1382
+ delete mapData[key];
1383
+ changed = true;
1384
+ }
1385
+ }
1386
+ if (changed) {
1387
+ const tmpFile = `${this.sessionMapFile}.tmp`;
1388
+ await writeFile(tmpFile, JSON.stringify(mapData, null, 2));
1389
+ await rename(tmpFile, this.sessionMapFile);
1390
+ }
1391
+ }
1392
+ catch { /* ignore parse/write errors */ }
1393
+ }
1394
+ /** Stop and remove the coordinated discovery poller/timer for a session. */
1395
+ stopDiscoveryPolling(id) {
1396
+ const timer = this.pollTimers.get(id);
1397
+ if (timer) {
1398
+ clearInterval(timer);
1399
+ this.pollTimers.delete(id);
1400
+ }
1401
+ const timeout = this.discoveryTimeouts.get(id);
1402
+ if (timeout) {
1403
+ clearTimeout(timeout);
1404
+ this.discoveryTimeouts.delete(id);
1405
+ }
1406
+ this.discoveryNextFilesystemScanAt.delete(id);
1407
+ }
1408
+ /** Attempt filesystem-based discovery for a single session poll tick. */
1409
+ async maybeDiscoverFromFilesystem(session, workDir) {
1410
+ const projectHash = computeProjectHash(workDir);
1411
+ const projectDir = join(this.config.claudeProjectsDir, projectHash);
1412
+ if (!existsSync(projectDir))
1413
+ return false;
1414
+ const files = await readdir(projectDir);
1415
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('.'));
1416
+ for (const file of jsonlFiles) {
1417
+ const filePath = join(projectDir, file);
1418
+ const fileStat = await stat(filePath);
1419
+ // Only consider files created after the session.
1420
+ if (fileStat.mtimeMs < session.createdAt)
1421
+ continue;
1422
+ // Extract session ID from filename (filename = sessionId.jsonl).
1423
+ const sessionId = file.replace('.jsonl', '');
1424
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sessionId))
1425
+ continue;
1426
+ session.claudeSessionId = sessionId;
1427
+ session.jsonlPath = filePath;
1428
+ session.byteOffset = 0;
1429
+ console.log(`Discovery (filesystem): session ${session.windowName} mapped to ${sessionId.slice(0, 8)}...`);
1430
+ await this.save();
1431
+ return true;
1432
+ }
1433
+ return false;
1434
+ }
1435
+ /**
1436
+ * Coordinated discovery poller for a session.
1437
+ *
1438
+ * Consolidates hook/session_map sync and filesystem fallback into a single
1439
+ * interval loop per session, reducing duplicate independent pollers.
1440
+ */
1441
+ startDiscoveryPolling(id, workDir) {
1442
+ // If a poller already exists, replace it to ensure only one active poller/session.
1443
+ this.stopDiscoveryPolling(id);
1444
+ const interval = setInterval(async () => {
1445
+ const session = this.state.sessions[id];
1446
+ if (!session) {
1447
+ this.stopDiscoveryPolling(id);
1448
+ return;
1449
+ }
1450
+ // Stop when we have both session ID and JSONL path.
1451
+ if (session.claudeSessionId && session.jsonlPath) {
1452
+ this.stopDiscoveryPolling(id);
1453
+ return;
1454
+ }
1455
+ try {
1456
+ await this.syncSessionMap();
1457
+ // If we have claudeSessionId but no jsonlPath, try finding it (Issue #884: worktree-aware).
1458
+ if (session.claudeSessionId && !session.jsonlPath) {
1459
+ const jsonlPath = await this.findSessionFileMaybeWorktree(session.claudeSessionId);
1460
+ if (jsonlPath) {
1461
+ session.jsonlPath = jsonlPath;
1462
+ session.byteOffset = 0;
1463
+ await this.save();
1464
+ }
1465
+ }
1466
+ // Filesystem fallback scan cadence (originally every 3s).
1467
+ const now = Date.now();
1468
+ const nextFsScanAt = this.discoveryNextFilesystemScanAt.get(id) ?? 0;
1469
+ if (now >= nextFsScanAt && (!session.claudeSessionId || !session.jsonlPath)) {
1470
+ this.discoveryNextFilesystemScanAt.set(id, now + 3_000);
1471
+ await this.maybeDiscoverFromFilesystem(session, workDir);
1472
+ }
1473
+ if (session.claudeSessionId && session.jsonlPath) {
1474
+ this.stopDiscoveryPolling(id);
1475
+ }
1476
+ }
1477
+ catch {
1478
+ // best-effort polling; ignore transient errors
1479
+ }
1480
+ }, 2_000);
1481
+ this.pollTimers.set(id, interval);
1482
+ this.discoveryNextFilesystemScanAt.set(id, Date.now());
1483
+ // P3 fix: Stop after 5 minutes if not found, log timeout.
1484
+ // #835: Track the timeout so cleanupSession can cancel it.
1485
+ const discoveryTimeout = setTimeout(() => {
1486
+ const session = this.state.sessions[id];
1487
+ this.stopDiscoveryPolling(id);
1488
+ if (session && !session.claudeSessionId) {
1489
+ console.log(`Discovery: session ${session.windowName} — timed out after 5min, no session_id found`);
1490
+ }
1491
+ }, 5 * 60 * 1000);
1492
+ this.discoveryTimeouts.set(id, discoveryTimeout);
1493
+ }
1494
+ /** Sync CC session IDs from the hook-written session_map.json. */
1495
+ async syncSessionMap() {
1496
+ if (!existsSync(this.sessionMapFile))
1497
+ return;
1498
+ try {
1499
+ const mapData = await loadContinuationPointers(this.sessionMapFile, this.config.continuationPointerTtlMs);
1500
+ for (const session of Object.values(this.state.sessions)) {
1501
+ if (session.claudeSessionId)
1502
+ continue;
1503
+ // Find matching entry by window ID (exact match to avoid @1 matching @10, @11, etc.)
1504
+ for (const [key, info] of Object.entries(mapData)) {
1505
+ // P0 fix: Match by exact windowId suffix (e.g., "aegis:@5"), not substring
1506
+ // This prevents @5 from matching @15, @50, etc.
1507
+ const keyWindowId = key.includes(':') ? key.split(':').pop() : null;
1508
+ const matchesWindowId = keyWindowId === session.windowId;
1509
+ const matchesWindowName = info.window_name === session.windowName;
1510
+ if (matchesWindowId || matchesWindowName) {
1511
+ // GUARD 1: Timestamp — reject session_map entries written before this session was created.
1512
+ // After service restarts, old entries survive with stale windowIds that collide
1513
+ // with newly assigned tmux window IDs (tmux reuses @N identifiers).
1514
+ // Issue #6: Zeus D51 got claudeSessionId from D18/D19/D20 due to this.
1515
+ const writtenAt = info.written_at || 0;
1516
+ if (writtenAt > 0 && writtenAt < session.createdAt) {
1517
+ console.log(`Discovery: session ${session.windowName} — rejecting stale entry ` +
1518
+ `(written_at ${new Date(writtenAt).toISOString()} < createdAt ${new Date(session.createdAt).toISOString()})`);
1519
+ continue;
1520
+ }
1521
+ // Use transcript_path from hook if available (M3: eliminates filesystem scan)
1522
+ // Falls back to findSessionFile for backward compat with old hook versions
1523
+ let jsonlPath = null;
1524
+ if (info.transcript_path && existsSync(info.transcript_path)) {
1525
+ jsonlPath = info.transcript_path;
1526
+ }
1527
+ else {
1528
+ jsonlPath = await findSessionFile(info.session_id, this.config.claudeProjectsDir);
1529
+ }
1530
+ // GUARD 2: Reject paths in _archived/ directory — these are stale sessions
1531
+ if (jsonlPath && (jsonlPath.includes('/_archived/') || jsonlPath.includes('\\_archived\\'))) {
1532
+ console.log(`Discovery: session ${session.windowName} — rejecting archived path: ${jsonlPath}`);
1533
+ continue;
1534
+ }
1535
+ if (!jsonlPath) {
1536
+ // No JSONL file found — mapping is stale or CC hasn't written it yet.
1537
+ // Don't break — there may be a fresher entry. Continue searching.
1538
+ continue;
1539
+ }
1540
+ // GUARD 3: JSONL mtime — reject if file was last modified before session creation.
1541
+ // Catches cases where session_map has no written_at (old hook without timestamp)
1542
+ // but the JSONL is clearly from a previous session.
1543
+ try {
1544
+ const fileStat = await stat(jsonlPath);
1545
+ if (fileStat.mtimeMs < session.createdAt) {
1546
+ console.log(`Discovery: session ${session.windowName} — rejecting stale JSONL ` +
1547
+ `(mtime ${new Date(fileStat.mtimeMs).toISOString()} < createdAt ${new Date(session.createdAt).toISOString()})`);
1548
+ continue;
1549
+ }
1550
+ }
1551
+ catch {
1552
+ // stat failed — file removed between find and stat
1553
+ continue;
1554
+ }
1555
+ session.claudeSessionId = info.session_id;
1556
+ session.jsonlPath = jsonlPath;
1557
+ session.byteOffset = 0;
1558
+ console.log(`Discovery: session ${session.windowName} mapped to ` +
1559
+ `${info.session_id.slice(0, 8)}... (verified: timestamp + mtime)`);
1560
+ break;
1561
+ }
1562
+ }
1563
+ }
1564
+ await this.save();
1565
+ }
1566
+ catch { /* ignore parse errors */ }
1567
+ }
1568
+ }