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.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
package/dist/session.js
ADDED
|
@@ -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
|
+
}
|