ai-agent-session-center 2.0.2 → 2.0.3
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/README.md +484 -429
- package/docs/3D/ADAPTATION_GUIDE.md +592 -0
- package/docs/3D/index.html +754 -0
- package/docs/AGENT_TEAM_TASKS.md +716 -0
- package/docs/CYBERDROME_V2_SPEC.md +531 -0
- package/docs/ERROR_185_ANALYSIS.md +263 -0
- package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
- package/docs/SESSION_DETAIL_FEATURES.md +98 -0
- package/docs/_3d_multimedia_features.md +1080 -0
- package/docs/_frontend_features.md +1057 -0
- package/docs/_server_features.md +1077 -0
- package/docs/session-duplication-fixes.md +271 -0
- package/docs/session-terminal-linkage.md +412 -0
- package/package.json +63 -5
- package/public/apple-touch-icon.svg +21 -0
- package/public/css/dashboard.css +0 -161
- package/public/css/detail-panel.css +25 -0
- package/public/css/layout.css +18 -1
- package/public/css/modals.css +0 -26
- package/public/css/settings.css +0 -150
- package/public/css/terminal.css +34 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +6 -26
- package/public/js/alarmManager.js +0 -21
- package/public/js/app.js +21 -7
- package/public/js/detailPanel.js +63 -64
- package/public/js/historyPanel.js +61 -55
- package/public/js/quickActions.js +132 -48
- package/public/js/sessionCard.js +5 -20
- package/public/js/sessionControls.js +8 -0
- package/public/js/settingsManager.js +0 -142
- package/server/apiRouter.js +60 -15
- package/server/apiRouter.ts +774 -0
- package/server/approvalDetector.ts +94 -0
- package/server/authManager.ts +144 -0
- package/server/autoIdleManager.ts +110 -0
- package/server/config.ts +121 -0
- package/server/constants.ts +150 -0
- package/server/db.ts +475 -0
- package/server/hookInstaller.d.ts +3 -0
- package/server/hookProcessor.ts +108 -0
- package/server/hookRouter.ts +18 -0
- package/server/hookStats.ts +116 -0
- package/server/index.js +15 -1
- package/server/index.ts +230 -0
- package/server/logger.ts +75 -0
- package/server/mqReader.ts +349 -0
- package/server/portManager.ts +55 -0
- package/server/processMonitor.ts +239 -0
- package/server/serverConfig.ts +29 -0
- package/server/sessionMatcher.js +17 -6
- package/server/sessionMatcher.ts +403 -0
- package/server/sessionStore.js +109 -3
- package/server/sessionStore.ts +1145 -0
- package/server/sshManager.js +167 -24
- package/server/sshManager.ts +671 -0
- package/server/teamManager.ts +289 -0
- package/server/wsManager.ts +200 -0
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
// sessionStore.ts — In-memory session state machine (coordinator)
|
|
2
|
+
// Delegates to sub-modules: sessionMatcher, approvalDetector, teamManager, processMonitor, autoIdleManager
|
|
3
|
+
//
|
|
4
|
+
// Session State Machine:
|
|
5
|
+
// SessionStart -> idle (Idle animation)
|
|
6
|
+
// UserPromptSubmit -> prompting (Wave + Walking)
|
|
7
|
+
// PreToolUse -> working (Running)
|
|
8
|
+
// PostToolUse -> working (stays)
|
|
9
|
+
// PermissionRequest -> approval (Waiting)
|
|
10
|
+
// Stop -> waiting (ThumbsUp/Dance + Waiting)
|
|
11
|
+
// SessionEnd -> ended (Death, removed after 10s)
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import log from './logger.js';
|
|
16
|
+
import { getWaitingLabel } from './config.js';
|
|
17
|
+
import {
|
|
18
|
+
EVENT_TYPES, SESSION_STATUS, ANIMATION_STATE, EMOTE, WS_TYPES,
|
|
19
|
+
} from './constants.js';
|
|
20
|
+
|
|
21
|
+
// Sub-module imports
|
|
22
|
+
import { matchSession, detectHookSource } from './sessionMatcher.js';
|
|
23
|
+
import { startApprovalTimer, clearApprovalTimer, hasChildProcesses } from './approvalDetector.js';
|
|
24
|
+
import {
|
|
25
|
+
findPendingSubagentMatch, handleTeamMemberEnd, addPendingSubagent,
|
|
26
|
+
linkByParentSessionId,
|
|
27
|
+
getTeam, getAllTeams, getTeamForSession, getTeamIdForSession,
|
|
28
|
+
} from './teamManager.js';
|
|
29
|
+
import { startMonitoring, stopMonitoring, findClaudeProcess as _findClaudeProcess } from './processMonitor.js';
|
|
30
|
+
import { startAutoIdle, stopAutoIdle, startPendingResumeCleanup, stopPendingResumeCleanup } from './autoIdleManager.js';
|
|
31
|
+
import {
|
|
32
|
+
upsertSession as dbUpsertSession,
|
|
33
|
+
updateSessionTitle as dbUpdateTitle,
|
|
34
|
+
updateSessionLabel as dbUpdateLabel,
|
|
35
|
+
updateSessionSummary as dbUpdateSummary,
|
|
36
|
+
updateSessionArchived as dbUpdateArchived,
|
|
37
|
+
migrateSessionId as dbMigrateSessionId,
|
|
38
|
+
} from './db.js';
|
|
39
|
+
import type { Session, HandleEventResult, BufferedEvent, PendingResume, SessionEvent } from '../src/types/session.js';
|
|
40
|
+
import type { HookPayload } from '../src/types/hook.js';
|
|
41
|
+
import type { TerminalConfig } from '../src/types/terminal.js';
|
|
42
|
+
import type { TeamSerialized } from '../src/types/team.js';
|
|
43
|
+
|
|
44
|
+
const sessions = new Map<string, Session>();
|
|
45
|
+
const projectSessionCounters = new Map<string, number>();
|
|
46
|
+
/** pid -> sessionId — ensures each PID is only assigned to one session */
|
|
47
|
+
const pidToSession = new Map<number, string>();
|
|
48
|
+
/** terminalId -> pending resume info */
|
|
49
|
+
const pendingResume = new Map<string, PendingResume>();
|
|
50
|
+
|
|
51
|
+
// Serialization cache for getAllSessions() — invalidated on any session change
|
|
52
|
+
let sessionsCacheDirty = true;
|
|
53
|
+
let sessionsCache: Record<string, Session> | null = null;
|
|
54
|
+
|
|
55
|
+
function invalidateSessionsCache(): void {
|
|
56
|
+
sessionsCacheDirty = true;
|
|
57
|
+
sessionsCache = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Event ring buffer for reconnect replay
|
|
61
|
+
const EVENT_BUFFER_MAX = 500;
|
|
62
|
+
let eventSeq = 0;
|
|
63
|
+
const eventBuffer: BufferedEvent[] = [];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Push an event to the ring buffer for WebSocket reconnect replay.
|
|
67
|
+
*/
|
|
68
|
+
export function pushEvent(type: string, data: unknown): number {
|
|
69
|
+
eventSeq++;
|
|
70
|
+
eventBuffer.push({ seq: eventSeq, type, data, timestamp: Date.now() });
|
|
71
|
+
if (eventBuffer.length > EVENT_BUFFER_MAX) eventBuffer.shift();
|
|
72
|
+
return eventSeq;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getEventsSince(sinceSeq: number): BufferedEvent[] {
|
|
76
|
+
return eventBuffer.filter(e => e.seq > sinceSeq);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getEventSeq(): number {
|
|
80
|
+
return eventSeq;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- Snapshot persistence ----
|
|
84
|
+
const SNAPSHOT_DIR = process.platform === 'win32'
|
|
85
|
+
? join(process.env.TEMP || process.env.TMP || 'C:\\Temp', 'claude-session-center')
|
|
86
|
+
: '/tmp/claude-session-center';
|
|
87
|
+
const SNAPSHOT_FILE = join(SNAPSHOT_DIR, 'sessions-snapshot.json');
|
|
88
|
+
const SNAPSHOT_INTERVAL_MS = 10_000; // Save every 10s
|
|
89
|
+
let snapshotTimer: ReturnType<typeof setInterval> | null = null;
|
|
90
|
+
let lastSnapshotMqOffset = 0; // Stores the MQ byte offset at snapshot time
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if a PID is still alive.
|
|
94
|
+
*/
|
|
95
|
+
function isPidAlive(pid: number): boolean {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Save current sessions to a snapshot file for persistence across restarts.
|
|
106
|
+
* Writes atomically (tmp file + rename).
|
|
107
|
+
*/
|
|
108
|
+
export function saveSnapshot(mqOffset?: number): void {
|
|
109
|
+
try {
|
|
110
|
+
if (typeof mqOffset === 'number') {
|
|
111
|
+
lastSnapshotMqOffset = mqOffset;
|
|
112
|
+
}
|
|
113
|
+
const sessionsObj: Record<string, Session> = {};
|
|
114
|
+
for (const [id, session] of sessions) {
|
|
115
|
+
// Always key by sessionId to prevent Map key / sessionId divergence
|
|
116
|
+
const key = session.sessionId || id;
|
|
117
|
+
sessionsObj[key] = { ...session };
|
|
118
|
+
}
|
|
119
|
+
const countersObj: Record<string, number> = {};
|
|
120
|
+
for (const [name, count] of projectSessionCounters) {
|
|
121
|
+
countersObj[name] = count;
|
|
122
|
+
}
|
|
123
|
+
const pidObj: Record<string, string> = {};
|
|
124
|
+
for (const [pid, sid] of pidToSession) pidObj[String(pid)] = sid;
|
|
125
|
+
const pendingResumeObj: Record<string, PendingResume> = {};
|
|
126
|
+
for (const [termId, info] of pendingResume) {
|
|
127
|
+
pendingResumeObj[termId] = info;
|
|
128
|
+
}
|
|
129
|
+
const snapshot = {
|
|
130
|
+
version: 1,
|
|
131
|
+
savedAt: Date.now(),
|
|
132
|
+
eventSeq,
|
|
133
|
+
mqOffset: lastSnapshotMqOffset,
|
|
134
|
+
sessions: sessionsObj,
|
|
135
|
+
projectSessionCounters: countersObj,
|
|
136
|
+
pidToSession: pidObj,
|
|
137
|
+
pendingResume: pendingResumeObj,
|
|
138
|
+
};
|
|
139
|
+
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
140
|
+
const tmpFile = SNAPSHOT_FILE + '.tmp';
|
|
141
|
+
writeFileSync(tmpFile, JSON.stringify(snapshot));
|
|
142
|
+
renameSync(tmpFile, SNAPSHOT_FILE);
|
|
143
|
+
log.debug('session', `Snapshot saved: ${Object.keys(sessionsObj).length} sessions`);
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
+
log.warn('session', `Snapshot save failed: ${msg}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Load sessions from a snapshot file. Checks PID liveness and marks dead sessions as ended.
|
|
152
|
+
*/
|
|
153
|
+
export function loadSnapshot(): { mqOffset: number } | null {
|
|
154
|
+
if (!existsSync(SNAPSHOT_FILE)) {
|
|
155
|
+
log.info('session', 'No snapshot file found — starting fresh');
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const raw = readFileSync(SNAPSHOT_FILE, 'utf8');
|
|
160
|
+
const snapshot = JSON.parse(raw);
|
|
161
|
+
if (!snapshot || snapshot.version !== 1 || !snapshot.sessions) {
|
|
162
|
+
log.warn('session', 'Snapshot file has invalid format — starting fresh');
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let restored = 0;
|
|
167
|
+
let ended = 0;
|
|
168
|
+
for (const [id, session] of Object.entries(snapshot.sessions) as [string, Session][]) {
|
|
169
|
+
// Skip sessions that were already ended
|
|
170
|
+
if (session.status === SESSION_STATUS.ENDED) {
|
|
171
|
+
// Still restore ended SSH sessions (historical)
|
|
172
|
+
if (session.source === 'ssh' && session.isHistorical) {
|
|
173
|
+
sessions.set(id, session);
|
|
174
|
+
restored++;
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Check PID liveness for active sessions
|
|
179
|
+
if (session.cachedPid) {
|
|
180
|
+
if (isPidAlive(session.cachedPid)) {
|
|
181
|
+
if (session.source === 'ssh') {
|
|
182
|
+
// SSH sessions: terminal is ALWAYS dead after server restart (PTY was
|
|
183
|
+
// owned by the old node process). The Claude process is an orphan —
|
|
184
|
+
// alive but unreachable. Kill it and mark as ended.
|
|
185
|
+
try { process.kill(session.cachedPid, 'SIGTERM'); } catch { /* ignore */ }
|
|
186
|
+
session.status = SESSION_STATUS.ENDED;
|
|
187
|
+
session.animationState = ANIMATION_STATE.DEATH;
|
|
188
|
+
session.endedAt = Date.now();
|
|
189
|
+
session.events = session.events || [];
|
|
190
|
+
session.events.push({
|
|
191
|
+
type: 'ServerRestart',
|
|
192
|
+
detail: 'Killed orphaned SSH process — terminal died with old server',
|
|
193
|
+
timestamp: Date.now(),
|
|
194
|
+
});
|
|
195
|
+
session.isHistorical = true;
|
|
196
|
+
session.lastTerminalId = session.terminalId;
|
|
197
|
+
session.terminalId = null;
|
|
198
|
+
sessions.set(id, session);
|
|
199
|
+
ended++;
|
|
200
|
+
} else {
|
|
201
|
+
// Non-SSH (VS Code, iTerm, etc.): process can legitimately survive
|
|
202
|
+
// server restart since the terminal is external
|
|
203
|
+
sessions.set(id, session);
|
|
204
|
+
pidToSession.set(session.cachedPid, id);
|
|
205
|
+
restored++;
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
// Process died while server was down — mark as ended
|
|
209
|
+
session.status = SESSION_STATUS.ENDED;
|
|
210
|
+
session.animationState = ANIMATION_STATE.DEATH;
|
|
211
|
+
session.endedAt = Date.now();
|
|
212
|
+
session.events = session.events || [];
|
|
213
|
+
session.events.push({
|
|
214
|
+
type: 'ServerRestart',
|
|
215
|
+
detail: 'Process ended while server was down',
|
|
216
|
+
timestamp: Date.now(),
|
|
217
|
+
});
|
|
218
|
+
if (session.source === 'ssh') {
|
|
219
|
+
session.isHistorical = true;
|
|
220
|
+
session.lastTerminalId = session.terminalId;
|
|
221
|
+
session.terminalId = null;
|
|
222
|
+
}
|
|
223
|
+
// Keep all dead-PID sessions so they can be auto-linked
|
|
224
|
+
// when `claude --resume` sends a SessionStart with a new session_id
|
|
225
|
+
sessions.set(id, session);
|
|
226
|
+
ended++;
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// No PID cached — restore as-is, processMonitor will handle it
|
|
230
|
+
sessions.set(id, session);
|
|
231
|
+
restored++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Post-restoration cleanup: handle stale terminal references and zombie sessions.
|
|
236
|
+
// After a server restart, ALL PTY terminals are dead (children of the old node process).
|
|
237
|
+
// sshManager.terminals Map is always empty on fresh start.
|
|
238
|
+
let sshCleaned = 0;
|
|
239
|
+
const nonSshCleanupIds: string[] = [];
|
|
240
|
+
for (const [id, session] of sessions) {
|
|
241
|
+
// SSH sessions: clear stale terminalId + handle zombies
|
|
242
|
+
if (session.source === 'ssh') {
|
|
243
|
+
// Clear stale terminalId on ALL SSH sessions — terminals never survive restart
|
|
244
|
+
if (session.terminalId) {
|
|
245
|
+
if (!session.lastTerminalId) {
|
|
246
|
+
session.lastTerminalId = session.terminalId;
|
|
247
|
+
}
|
|
248
|
+
session.terminalId = null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// SSH sessions without cachedPid in non-ended status are zombies — mark as ended
|
|
252
|
+
if (!session.cachedPid && session.status !== SESSION_STATUS.ENDED) {
|
|
253
|
+
session.status = SESSION_STATUS.ENDED;
|
|
254
|
+
session.animationState = ANIMATION_STATE.DEATH;
|
|
255
|
+
session.endedAt = Date.now();
|
|
256
|
+
session.events = session.events || [];
|
|
257
|
+
session.events.push({
|
|
258
|
+
type: 'ServerRestart',
|
|
259
|
+
detail: 'SSH session ended — no cached PID and terminal lost on restart',
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
});
|
|
262
|
+
session.isHistorical = true;
|
|
263
|
+
sshCleaned++;
|
|
264
|
+
ended++;
|
|
265
|
+
}
|
|
266
|
+
} else if (session.status === SESSION_STATUS.ENDED) {
|
|
267
|
+
// Non-SSH ended sessions: schedule cleanup after 5 min (gives Priority 0.5 time to match)
|
|
268
|
+
nonSshCleanupIds.push(id);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (sshCleaned > 0) {
|
|
272
|
+
log.info('session', `Post-restart cleanup: ${sshCleaned} SSH sessions marked ended (no PID, dead terminal)`);
|
|
273
|
+
}
|
|
274
|
+
// Defer non-SSH cleanup — keep for 30 min so Priority 0.5 auto-linking and
|
|
275
|
+
// manual Resume/Reconnect have time to match. Mark them with ServerRestart
|
|
276
|
+
// so they're eligible for auto-linking.
|
|
277
|
+
if (nonSshCleanupIds.length > 0) {
|
|
278
|
+
for (const id of nonSshCleanupIds) {
|
|
279
|
+
const s = sessions.get(id);
|
|
280
|
+
if (s && !s.events?.some(e => e.type === 'ServerRestart')) {
|
|
281
|
+
s.events = s.events || [];
|
|
282
|
+
s.events.push({
|
|
283
|
+
type: 'ServerRestart',
|
|
284
|
+
detail: 'Process ended while server was down',
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
for (const id of nonSshCleanupIds) {
|
|
291
|
+
if (sessions.has(id) && sessions.get(id)!.status === SESSION_STATUS.ENDED) {
|
|
292
|
+
sessions.delete(id);
|
|
293
|
+
invalidateSessionsCache();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
log.info('session', `Deferred cleanup: removed ${nonSshCleanupIds.length} stale non-SSH sessions`);
|
|
297
|
+
}, 30 * 60 * 1000);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Restore project session counters
|
|
301
|
+
if (snapshot.projectSessionCounters) {
|
|
302
|
+
for (const [name, count] of Object.entries(snapshot.projectSessionCounters) as [string, number][]) {
|
|
303
|
+
projectSessionCounters.set(name, count);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Restore pidToSession map (supplements per-session PID caching above)
|
|
308
|
+
if (snapshot.pidToSession) {
|
|
309
|
+
for (const [pid, sid] of Object.entries(snapshot.pidToSession) as [string, string][]) {
|
|
310
|
+
const numPid = Number(pid);
|
|
311
|
+
// Only restore if the session exists and the PID is still alive
|
|
312
|
+
if (sessions.has(sid) && isPidAlive(numPid)) {
|
|
313
|
+
pidToSession.set(numPid, sid);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Restore pendingResume entries — these survive Ctrl+C so Priority 0
|
|
319
|
+
// can disambiguate "which session was being resumed" on next start.
|
|
320
|
+
// Terminal IDs are stale (PTYs died), but the path-based fallback in
|
|
321
|
+
// Priority 0 only needs oldSessionId + projectPath to match.
|
|
322
|
+
let pendingResumeRestored = 0;
|
|
323
|
+
if (snapshot.pendingResume) {
|
|
324
|
+
for (const [termId, info] of Object.entries(snapshot.pendingResume) as [string, PendingResume][]) {
|
|
325
|
+
// Only restore if the referenced session still exists in the map
|
|
326
|
+
if (info.oldSessionId && sessions.has(info.oldSessionId)) {
|
|
327
|
+
pendingResume.set(termId, {
|
|
328
|
+
...info,
|
|
329
|
+
// Refresh timestamp so autoIdleManager's 2-minute cleanup
|
|
330
|
+
// doesn't immediately garbage-collect restored entries
|
|
331
|
+
timestamp: Date.now(),
|
|
332
|
+
});
|
|
333
|
+
pendingResumeRestored++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Restore eventSeq
|
|
339
|
+
if (snapshot.eventSeq) {
|
|
340
|
+
eventSeq = snapshot.eventSeq;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Repair any Map key / sessionId mismatches (defensive — prevents duplicate cards)
|
|
344
|
+
let keyRepairs = 0;
|
|
345
|
+
const repairList: Array<{ oldKey: string; newKey: string; session: Session }> = [];
|
|
346
|
+
for (const [id, session] of sessions) {
|
|
347
|
+
if (session.sessionId && id !== session.sessionId) {
|
|
348
|
+
repairList.push({ oldKey: id, newKey: session.sessionId, session });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const { oldKey, newKey, session } of repairList) {
|
|
352
|
+
sessions.delete(oldKey);
|
|
353
|
+
// If the correct key already exists, keep the newer session
|
|
354
|
+
if (sessions.has(newKey)) {
|
|
355
|
+
const existing = sessions.get(newKey)!;
|
|
356
|
+
if ((session.lastActivityAt || 0) > (existing.lastActivityAt || 0)) {
|
|
357
|
+
sessions.set(newKey, session);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
sessions.set(newKey, session);
|
|
361
|
+
}
|
|
362
|
+
keyRepairs++;
|
|
363
|
+
}
|
|
364
|
+
if (keyRepairs > 0) {
|
|
365
|
+
log.warn('session', `Repaired ${keyRepairs} Map key/sessionId mismatches in snapshot`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Deduplicate: remove sessions with identical projectPath+source that are ended
|
|
369
|
+
// and whose sessionId differs (stale leftover from interrupted re-key)
|
|
370
|
+
const seenPaths = new Map<string, string[]>(); // projectPath -> [sessionId, ...]
|
|
371
|
+
const dupsToRemove: string[] = [];
|
|
372
|
+
for (const [id, session] of sessions) {
|
|
373
|
+
if (session.status !== SESSION_STATUS.ENDED || !session.projectPath) continue;
|
|
374
|
+
const key = `${session.projectPath}|${session.source}`;
|
|
375
|
+
if (!seenPaths.has(key)) {
|
|
376
|
+
seenPaths.set(key, [id]);
|
|
377
|
+
} else {
|
|
378
|
+
seenPaths.get(key)!.push(id);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const [, ids] of seenPaths) {
|
|
382
|
+
if (ids.length <= 1) continue;
|
|
383
|
+
// Keep the one with the most recent activity, remove the rest
|
|
384
|
+
const sorted = ids
|
|
385
|
+
.map(id => ({ id, lastActivity: sessions.get(id)!.lastActivityAt || 0 }))
|
|
386
|
+
.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
387
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
388
|
+
dupsToRemove.push(sorted[i].id);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (dupsToRemove.length > 0) {
|
|
392
|
+
for (const id of dupsToRemove) {
|
|
393
|
+
sessions.delete(id);
|
|
394
|
+
}
|
|
395
|
+
log.info('session', `Removed ${dupsToRemove.length} duplicate ended sessions (same path+source) during snapshot load`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
invalidateSessionsCache();
|
|
399
|
+
log.info('session', `Snapshot loaded: ${restored} sessions restored, ${ended} ended (dead PID), ${pidToSession.size} PIDs tracked, ${pendingResumeRestored} pendingResume entries`);
|
|
400
|
+
|
|
401
|
+
return { mqOffset: snapshot.mqOffset || 0 };
|
|
402
|
+
} catch (err: unknown) {
|
|
403
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
404
|
+
log.warn('session', `Snapshot load failed: ${msg} — starting fresh`);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Start periodic snapshot saving.
|
|
411
|
+
*/
|
|
412
|
+
export function startPeriodicSave(getMqOffset?: () => number): void {
|
|
413
|
+
if (snapshotTimer) return;
|
|
414
|
+
snapshotTimer = setInterval(() => {
|
|
415
|
+
const offset = getMqOffset ? getMqOffset() : lastSnapshotMqOffset;
|
|
416
|
+
saveSnapshot(offset);
|
|
417
|
+
}, SNAPSHOT_INTERVAL_MS);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Stop periodic snapshot saving. */
|
|
421
|
+
export function stopPeriodicSave(): void {
|
|
422
|
+
if (snapshotTimer) {
|
|
423
|
+
clearInterval(snapshotTimer);
|
|
424
|
+
snapshotTimer = null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Extract a short title from the first prompt (first sentence or first ~60 chars)
|
|
429
|
+
function makeShortTitle(prompt: string): string {
|
|
430
|
+
if (!prompt) return '';
|
|
431
|
+
// Strip leading whitespace and common prefixes
|
|
432
|
+
let text = prompt.trim().replace(/^(please|can you|could you|help me|i want to|i need to)\s+/i, '');
|
|
433
|
+
if (!text) return '';
|
|
434
|
+
// Take first sentence (up to . ! ? or newline)
|
|
435
|
+
const match = text.match(/^[^\n.!?]{1,60}/);
|
|
436
|
+
if (match) text = match[0].trim();
|
|
437
|
+
// Capitalize first letter
|
|
438
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Summarize tool input for the tool log detail panel
|
|
442
|
+
function summarizeToolInput(toolInput: Record<string, unknown> | undefined, toolName: string): string {
|
|
443
|
+
if (!toolInput) return '';
|
|
444
|
+
switch (toolName) {
|
|
445
|
+
case 'Read': return (toolInput.file_path as string) || '';
|
|
446
|
+
case 'Write': return (toolInput.file_path as string) || '';
|
|
447
|
+
case 'Edit': return (toolInput.file_path as string) || '';
|
|
448
|
+
case 'Bash': return ((toolInput.command as string) || '').substring(0, 120);
|
|
449
|
+
case 'Grep': return `${(toolInput.pattern as string) || ''} in ${(toolInput.path as string) || 'cwd'}`;
|
|
450
|
+
case 'Glob': return (toolInput.pattern as string) || '';
|
|
451
|
+
case 'WebFetch': return (toolInput.url as string) || '';
|
|
452
|
+
case 'Task': return (toolInput.description as string) || '';
|
|
453
|
+
default: return JSON.stringify(toolInput).substring(0, 100);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Async broadcast helper — lazy imports wsManager to avoid circular deps
|
|
458
|
+
async function broadcastAsync(data: unknown): Promise<void> {
|
|
459
|
+
const { broadcast } = await import('./wsManager.js');
|
|
460
|
+
broadcast(data as { type: string; [key: string]: unknown });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Debounced broadcast — batches rapid state changes within 50ms window
|
|
464
|
+
const BROADCAST_DEBOUNCE_MS = 50;
|
|
465
|
+
let pendingBroadcasts: Array<{ type: string; session?: Session; [key: string]: unknown }> = [];
|
|
466
|
+
let broadcastDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
467
|
+
|
|
468
|
+
async function debouncedBroadcast(data: { type: string; session?: Session; [key: string]: unknown }): Promise<void> {
|
|
469
|
+
pendingBroadcasts.push(data);
|
|
470
|
+
if (broadcastDebounceTimer) return;
|
|
471
|
+
broadcastDebounceTimer = setTimeout(async () => {
|
|
472
|
+
const batch = pendingBroadcasts;
|
|
473
|
+
pendingBroadcasts = [];
|
|
474
|
+
broadcastDebounceTimer = null;
|
|
475
|
+
// Deduplicate: for session_update, keep only the latest per sessionId
|
|
476
|
+
const seen = new Map<string, typeof batch[number]>();
|
|
477
|
+
for (const item of batch) {
|
|
478
|
+
if (item.type === WS_TYPES.SESSION_UPDATE && item.session?.sessionId) {
|
|
479
|
+
seen.set(item.session.sessionId, item);
|
|
480
|
+
} else {
|
|
481
|
+
// Non-session updates get a unique key to ensure they're sent
|
|
482
|
+
seen.set(`${item.type}_${Date.now()}_${Math.random()}`, item);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const item of seen.values()) {
|
|
486
|
+
await broadcastAsync(item);
|
|
487
|
+
}
|
|
488
|
+
}, BROADCAST_DEBOUNCE_MS);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Broadcast helper for approval timer
|
|
492
|
+
async function broadcastSessionUpdate(session: Session): Promise<void> {
|
|
493
|
+
await debouncedBroadcast({ type: WS_TYPES.SESSION_UPDATE, session: { ...session } });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Process an incoming hook event, updating the session state machine.
|
|
498
|
+
*/
|
|
499
|
+
export function handleEvent(hookData: HookPayload): HandleEventResult | null {
|
|
500
|
+
const { session_id, hook_event_name, cwd } = hookData;
|
|
501
|
+
if (!session_id) return null;
|
|
502
|
+
|
|
503
|
+
if (hookData.claude_pid) {
|
|
504
|
+
const env = [
|
|
505
|
+
`pid=${hookData.claude_pid}`,
|
|
506
|
+
hookData.tty_path ? `tty=${hookData.tty_path}` : null,
|
|
507
|
+
hookData.term_program ? `term=${hookData.term_program}` : null,
|
|
508
|
+
hookData.tab_id ? `tab=${hookData.tab_id}` : null,
|
|
509
|
+
hookData.vscode_pid ? `vscode_pid=${hookData.vscode_pid}` : null,
|
|
510
|
+
hookData.tmux ? `tmux=${hookData.tmux.pane}` : null,
|
|
511
|
+
hookData.window_id ? `x11win=${hookData.window_id}` : null,
|
|
512
|
+
].filter(Boolean).join(' ');
|
|
513
|
+
log.info('session', `event=${hook_event_name} session=${session_id?.slice(0,8)} ${env}`);
|
|
514
|
+
} else {
|
|
515
|
+
log.info('session', `event=${hook_event_name} session=${session_id?.slice(0,8)} cwd=${cwd || 'none'}`);
|
|
516
|
+
}
|
|
517
|
+
log.debugJson('session', 'Full hook data', hookData);
|
|
518
|
+
|
|
519
|
+
// Match or create session (delegated to sessionMatcher)
|
|
520
|
+
const session = matchSession(hookData, sessions, pendingResume, pidToSession, projectSessionCounters);
|
|
521
|
+
|
|
522
|
+
// Auto-revive sessions that were marked ended by ServerRestart but whose Claude process survived.
|
|
523
|
+
// This happens when Claude runs in tmux/screen and keeps sending hooks after server restart.
|
|
524
|
+
const REVIVABLE_EVENTS: Set<string> = new Set([
|
|
525
|
+
EVENT_TYPES.SESSION_START, EVENT_TYPES.USER_PROMPT_SUBMIT,
|
|
526
|
+
EVENT_TYPES.PRE_TOOL_USE, EVENT_TYPES.POST_TOOL_USE,
|
|
527
|
+
EVENT_TYPES.PERMISSION_REQUEST, EVENT_TYPES.STOP,
|
|
528
|
+
]);
|
|
529
|
+
if (session.status === SESSION_STATUS.ENDED
|
|
530
|
+
&& session.events?.some(e => e.type === 'ServerRestart')
|
|
531
|
+
&& REVIVABLE_EVENTS.has(hook_event_name)) {
|
|
532
|
+
session.endedAt = null;
|
|
533
|
+
session.isHistorical = false;
|
|
534
|
+
session.events.push({
|
|
535
|
+
type: 'AutoRevived',
|
|
536
|
+
detail: `Session auto-revived on ${hook_event_name} — process survived server restart`,
|
|
537
|
+
timestamp: Date.now(),
|
|
538
|
+
});
|
|
539
|
+
log.info('session', `AUTO-REVIVE: session ${session_id?.slice(0,8)} revived on ${hook_event_name}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
invalidateSessionsCache();
|
|
543
|
+
session.lastActivityAt = Date.now();
|
|
544
|
+
const eventEntry: SessionEvent = {
|
|
545
|
+
type: hook_event_name,
|
|
546
|
+
timestamp: Date.now(),
|
|
547
|
+
detail: ''
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
switch (hook_event_name) {
|
|
551
|
+
case EVENT_TYPES.SESSION_START: {
|
|
552
|
+
session.status = SESSION_STATUS.IDLE;
|
|
553
|
+
session.animationState = ANIMATION_STATE.IDLE;
|
|
554
|
+
session.model = hookData.model || session.model;
|
|
555
|
+
if ('transcript_path' in hookData && hookData.transcript_path) session.transcriptPath = hookData.transcript_path;
|
|
556
|
+
if ('permission_mode' in hookData && hookData.permission_mode) session.permissionMode = hookData.permission_mode;
|
|
557
|
+
|
|
558
|
+
// Update projectPath from the hook's actual cwd — ONLY for SSH sessions.
|
|
559
|
+
// For remote SSH, createTerminalSession resolves '~' to LOCAL homedir
|
|
560
|
+
// (e.g. /Users/kason) but the hook reports the REMOTE cwd (e.g. /home/user/project).
|
|
561
|
+
// Without this correction, Priority 0 path matching fails on resume/reconnect.
|
|
562
|
+
// Display-only sessions (VS Code, Terminal, iTerm, etc.) already have the
|
|
563
|
+
// correct path from createDefaultSession — don't touch them to avoid
|
|
564
|
+
// overwriting their source-derived projectName.
|
|
565
|
+
if (cwd && cwd !== session.projectPath && session.source === 'ssh') {
|
|
566
|
+
const oldPath = session.projectPath;
|
|
567
|
+
session.projectPath = cwd;
|
|
568
|
+
session.projectName = cwd.split('/').filter(Boolean).pop() || session.projectName;
|
|
569
|
+
// Preserve source — NEVER overwrite (VS Code, Terminal, ssh, etc.)
|
|
570
|
+
log.info('session', `Updated SSH projectPath: ${oldPath} → ${cwd} (from hook cwd)`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
eventEntry.detail = `Session started (${('source' in hookData ? hookData.source : undefined) || 'startup'})`;
|
|
574
|
+
log.debug('session', `SessionStart: ${session_id?.slice(0,8)} project=${session.projectName} model=${session.model}`);
|
|
575
|
+
|
|
576
|
+
// Priority 0: Direct link via CLAUDE_CODE_PARENT_SESSION_ID env var
|
|
577
|
+
let teamResult: { teamId: string; team: TeamSerialized } | null = null;
|
|
578
|
+
if (hookData.parent_session_id) {
|
|
579
|
+
teamResult = linkByParentSessionId(
|
|
580
|
+
session_id,
|
|
581
|
+
hookData.parent_session_id,
|
|
582
|
+
hookData.agent_type || 'unknown',
|
|
583
|
+
hookData.agent_name || null,
|
|
584
|
+
hookData.team_name || null,
|
|
585
|
+
sessions
|
|
586
|
+
);
|
|
587
|
+
if (teamResult) {
|
|
588
|
+
eventEntry.detail += ` [Team: ${teamResult.teamId} via env]`;
|
|
589
|
+
log.debug('session', `Subagent linked to team ${teamResult.teamId} via parent_session_id`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Fallback: path-based pending subagent matching (backward compatible)
|
|
594
|
+
if (!teamResult) {
|
|
595
|
+
teamResult = findPendingSubagentMatch(session_id, session.projectPath, sessions);
|
|
596
|
+
if (teamResult) {
|
|
597
|
+
eventEntry.detail += ` [Team: ${teamResult.teamId}]`;
|
|
598
|
+
log.debug('session', `Subagent matched to team ${teamResult.teamId}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
case EVENT_TYPES.USER_PROMPT_SUBMIT:
|
|
605
|
+
session.status = SESSION_STATUS.PROMPTING;
|
|
606
|
+
session.animationState = ANIMATION_STATE.WALKING;
|
|
607
|
+
session.emote = EMOTE.WAVE;
|
|
608
|
+
session.currentPrompt = ('prompt' in hookData ? hookData.prompt : undefined) || '';
|
|
609
|
+
session.promptHistory.push({
|
|
610
|
+
text: ('prompt' in hookData ? hookData.prompt : undefined) || '',
|
|
611
|
+
timestamp: Date.now()
|
|
612
|
+
});
|
|
613
|
+
// Keep last 50 prompts
|
|
614
|
+
if (session.promptHistory.length > 50) session.promptHistory.shift();
|
|
615
|
+
eventEntry.detail = (('prompt' in hookData ? hookData.prompt : undefined) || '').substring(0, 80);
|
|
616
|
+
|
|
617
|
+
// Auto-generate title from project name + label + counter + short prompt summary
|
|
618
|
+
if (!session.title) {
|
|
619
|
+
const counter = projectSessionCounters.get(session.projectName) || 1;
|
|
620
|
+
const labelPart = session.label ? ` ${session.label}` : '';
|
|
621
|
+
const shortPrompt = makeShortTitle(('prompt' in hookData ? hookData.prompt : undefined) || '');
|
|
622
|
+
session.title = shortPrompt
|
|
623
|
+
? `${session.projectName}${labelPart} #${counter} — ${shortPrompt}`
|
|
624
|
+
: `${session.projectName}${labelPart} — Session #${counter}`;
|
|
625
|
+
}
|
|
626
|
+
break;
|
|
627
|
+
|
|
628
|
+
case EVENT_TYPES.PRE_TOOL_USE: {
|
|
629
|
+
session.status = SESSION_STATUS.WORKING;
|
|
630
|
+
session.animationState = ANIMATION_STATE.RUNNING;
|
|
631
|
+
const toolName = ('tool_name' in hookData ? hookData.tool_name : undefined) || 'Unknown';
|
|
632
|
+
session.toolUsage[toolName] = (session.toolUsage[toolName] || 0) + 1;
|
|
633
|
+
session.totalToolCalls++;
|
|
634
|
+
// Store detailed tool log entry for the detail panel
|
|
635
|
+
const toolInputSummary = summarizeToolInput(
|
|
636
|
+
('tool_input' in hookData ? hookData.tool_input : undefined) as Record<string, unknown> | undefined,
|
|
637
|
+
toolName
|
|
638
|
+
);
|
|
639
|
+
session.toolLog.push({
|
|
640
|
+
tool: toolName,
|
|
641
|
+
input: toolInputSummary,
|
|
642
|
+
timestamp: Date.now()
|
|
643
|
+
});
|
|
644
|
+
if (session.toolLog.length > 200) session.toolLog.shift();
|
|
645
|
+
eventEntry.detail = `${toolName}`;
|
|
646
|
+
|
|
647
|
+
// Approval/input detection via timer (delegated to approvalDetector)
|
|
648
|
+
startApprovalTimer(session_id, session, toolName, toolInputSummary, broadcastSessionUpdate);
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
case EVENT_TYPES.POST_TOOL_USE:
|
|
653
|
+
// Tool completed — cancel approval timer, stay working
|
|
654
|
+
clearApprovalTimer(session_id, session);
|
|
655
|
+
session.status = SESSION_STATUS.WORKING;
|
|
656
|
+
eventEntry.detail = `${('tool_name' in hookData ? hookData.tool_name : undefined) || 'Tool'} completed`;
|
|
657
|
+
break;
|
|
658
|
+
|
|
659
|
+
case EVENT_TYPES.STOP: {
|
|
660
|
+
// Clear any pending tool approval timer
|
|
661
|
+
clearApprovalTimer(session_id, session);
|
|
662
|
+
|
|
663
|
+
const wasHeavyWork = session.totalToolCalls > 10 &&
|
|
664
|
+
session.status === SESSION_STATUS.WORKING;
|
|
665
|
+
// Session finished its turn — waiting for user's next prompt
|
|
666
|
+
session.status = SESSION_STATUS.WAITING;
|
|
667
|
+
if (wasHeavyWork) {
|
|
668
|
+
session.animationState = ANIMATION_STATE.DANCE;
|
|
669
|
+
session.emote = null;
|
|
670
|
+
} else {
|
|
671
|
+
session.animationState = ANIMATION_STATE.WAITING;
|
|
672
|
+
session.emote = EMOTE.THUMBS_UP;
|
|
673
|
+
}
|
|
674
|
+
eventEntry.detail = wasHeavyWork ? 'Heavy work done — ready for input' : 'Ready for your input';
|
|
675
|
+
|
|
676
|
+
// Store response if present — try multiple possible field names
|
|
677
|
+
const responseText = ('response' in hookData ? hookData.response : undefined)
|
|
678
|
+
|| ('message' in hookData ? hookData.message : undefined)
|
|
679
|
+
|| ('stop_reason_str' in hookData ? hookData.stop_reason_str : undefined)
|
|
680
|
+
|| '';
|
|
681
|
+
if (responseText) {
|
|
682
|
+
const excerpt = responseText.substring(0, 2000);
|
|
683
|
+
session.responseLog.push({ text: excerpt, timestamp: Date.now() });
|
|
684
|
+
if (session.responseLog.length > 50) session.responseLog.shift();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Reset tool counter for next turn
|
|
688
|
+
session.totalToolCalls = 0;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
case EVENT_TYPES.SUBAGENT_START:
|
|
693
|
+
session.subagentCount++;
|
|
694
|
+
session.emote = EMOTE.JUMP;
|
|
695
|
+
eventEntry.detail = `Subagent spawned (${hookData.agent_type || 'unknown'}${hookData.agent_name ? ' ' + hookData.agent_name : ''}${hookData.agent_id ? ' #' + hookData.agent_id.slice(0, 8) : ''})`;
|
|
696
|
+
// Store agent name on session if available from enriched hook
|
|
697
|
+
if (hookData.agent_name) {
|
|
698
|
+
session.lastSubagentName = hookData.agent_name;
|
|
699
|
+
}
|
|
700
|
+
// Track pending subagent for team auto-detection (delegated to teamManager)
|
|
701
|
+
addPendingSubagent(session_id, session.projectPath, hookData.agent_type, hookData.agent_id);
|
|
702
|
+
break;
|
|
703
|
+
|
|
704
|
+
case EVENT_TYPES.SUBAGENT_STOP:
|
|
705
|
+
session.subagentCount = Math.max(0, session.subagentCount - 1);
|
|
706
|
+
eventEntry.detail = `Subagent finished`;
|
|
707
|
+
break;
|
|
708
|
+
|
|
709
|
+
case EVENT_TYPES.PERMISSION_REQUEST: {
|
|
710
|
+
// Real signal that user approval is needed — replaces timeout-based heuristic
|
|
711
|
+
clearApprovalTimer(session_id, session);
|
|
712
|
+
const permTool = ('tool_name' in hookData ? hookData.tool_name : undefined) || session.pendingTool || 'Unknown';
|
|
713
|
+
session.status = SESSION_STATUS.APPROVAL;
|
|
714
|
+
session.animationState = ANIMATION_STATE.WAITING;
|
|
715
|
+
session.waitingDetail = ('tool_input' in hookData && hookData.tool_input)
|
|
716
|
+
? `Approve ${permTool}: ${summarizeToolInput(hookData.tool_input as Record<string, unknown>, permTool)}`
|
|
717
|
+
: `Approve ${permTool}`;
|
|
718
|
+
session.permissionMode = ('permission_mode' in hookData ? hookData.permission_mode : undefined) || null;
|
|
719
|
+
eventEntry.detail = `Permission request: ${permTool}`;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
case EVENT_TYPES.POST_TOOL_USE_FAILURE: {
|
|
724
|
+
// Tool call failed — cancel approval timer, mark the failure in tool log
|
|
725
|
+
clearApprovalTimer(session_id, session);
|
|
726
|
+
session.status = SESSION_STATUS.WORKING;
|
|
727
|
+
const failedTool = ('tool_name' in hookData ? hookData.tool_name : undefined) || 'Tool';
|
|
728
|
+
// Mark last tool log entry as failed if it matches
|
|
729
|
+
if (session.toolLog.length > 0) {
|
|
730
|
+
const lastEntry = session.toolLog[session.toolLog.length - 1];
|
|
731
|
+
if (lastEntry.tool === failedTool && !lastEntry.failed) {
|
|
732
|
+
lastEntry.failed = true;
|
|
733
|
+
lastEntry.error = ('error' in hookData ? hookData.error : undefined)
|
|
734
|
+
|| ('message' in hookData ? hookData.message : undefined)
|
|
735
|
+
|| 'Failed';
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const errorMsg = ('error' in hookData ? hookData.error : undefined);
|
|
739
|
+
eventEntry.detail = `${failedTool} failed${errorMsg ? ': ' + errorMsg.substring(0, 80) : ''}`;
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
case EVENT_TYPES.TEAMMATE_IDLE:
|
|
744
|
+
eventEntry.detail = `Teammate idle: ${hookData.agent_name || hookData.agent_id || 'unknown'}`;
|
|
745
|
+
break;
|
|
746
|
+
|
|
747
|
+
case EVENT_TYPES.TASK_COMPLETED:
|
|
748
|
+
eventEntry.detail = `Task completed: ${('task_description' in hookData ? hookData.task_description : undefined) || ('task_id' in hookData ? hookData.task_id : undefined) || 'unknown'}`;
|
|
749
|
+
session.emote = EMOTE.THUMBS_UP;
|
|
750
|
+
break;
|
|
751
|
+
|
|
752
|
+
case EVENT_TYPES.PRE_COMPACT:
|
|
753
|
+
eventEntry.detail = 'Context compaction starting';
|
|
754
|
+
break;
|
|
755
|
+
|
|
756
|
+
case EVENT_TYPES.NOTIFICATION:
|
|
757
|
+
eventEntry.detail = ('message' in hookData ? hookData.message : undefined)
|
|
758
|
+
|| ('title' in hookData ? hookData.title : undefined)
|
|
759
|
+
|| 'Notification';
|
|
760
|
+
break;
|
|
761
|
+
|
|
762
|
+
case EVENT_TYPES.SESSION_END:
|
|
763
|
+
session.status = SESSION_STATUS.ENDED;
|
|
764
|
+
session.animationState = ANIMATION_STATE.DEATH;
|
|
765
|
+
session.endedAt = Date.now();
|
|
766
|
+
eventEntry.detail = `Session ended (${('reason' in hookData ? hookData.reason : undefined) || 'unknown'})`;
|
|
767
|
+
|
|
768
|
+
// Release PID cache for this session
|
|
769
|
+
if (session.cachedPid) {
|
|
770
|
+
log.debug('session', `releasing pid=${session.cachedPid} from session=${session_id?.slice(0,8)}`);
|
|
771
|
+
pidToSession.delete(session.cachedPid);
|
|
772
|
+
session.cachedPid = null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Team cleanup (delegated to teamManager)
|
|
776
|
+
handleTeamMemberEnd(session_id, sessions);
|
|
777
|
+
|
|
778
|
+
// SSH sessions: keep in memory as historical (disconnected), preserve terminal ref for resume
|
|
779
|
+
if (session.source === 'ssh') {
|
|
780
|
+
session.isHistorical = true;
|
|
781
|
+
session.lastTerminalId = session.terminalId;
|
|
782
|
+
session.terminalId = null;
|
|
783
|
+
} else {
|
|
784
|
+
setTimeout(() => sessions.delete(session_id), 10000);
|
|
785
|
+
}
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Keep last 50 events
|
|
790
|
+
session.events.push(eventEntry);
|
|
791
|
+
if (session.events.length > 50) session.events.shift();
|
|
792
|
+
|
|
793
|
+
// Persist to SQLite on key state transitions
|
|
794
|
+
const DB_PERSIST_EVENTS: Set<string> = new Set([
|
|
795
|
+
EVENT_TYPES.SESSION_START, EVENT_TYPES.USER_PROMPT_SUBMIT,
|
|
796
|
+
EVENT_TYPES.STOP, EVENT_TYPES.SESSION_END,
|
|
797
|
+
]);
|
|
798
|
+
if (DB_PERSIST_EVENTS.has(hook_event_name)) {
|
|
799
|
+
dbUpsertSession(session);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const result: HandleEventResult = { session: { ...session } };
|
|
803
|
+
// Migrate DB records when session is re-keyed (e.g., after claude --resume)
|
|
804
|
+
if (session.replacesId) {
|
|
805
|
+
dbMigrateSessionId(session.replacesId, session_id);
|
|
806
|
+
}
|
|
807
|
+
// Clean up one-time re-key flag
|
|
808
|
+
delete session.replacesId;
|
|
809
|
+
// Include team info if session belongs to a team
|
|
810
|
+
const teamId = getTeamIdForSession(session_id);
|
|
811
|
+
if (teamId) {
|
|
812
|
+
const teamData = getTeam(teamId);
|
|
813
|
+
if (teamData) result.team = teamData;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Push to ring buffer for reconnect replay
|
|
817
|
+
pushEvent(WS_TYPES.SESSION_UPDATE, result);
|
|
818
|
+
|
|
819
|
+
return result;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export function getAllSessions(): Record<string, Session> {
|
|
823
|
+
if (!sessionsCacheDirty && sessionsCache) {
|
|
824
|
+
return sessionsCache;
|
|
825
|
+
}
|
|
826
|
+
const result: Record<string, Session> = {};
|
|
827
|
+
for (const [id, session] of sessions) {
|
|
828
|
+
// Defensive: always key by session.sessionId to prevent key mismatch bugs.
|
|
829
|
+
// If Map key diverged from sessionId (e.g., after re-key edge case), fix it.
|
|
830
|
+
const key = session.sessionId || id;
|
|
831
|
+
if (id !== key) {
|
|
832
|
+
log.warn('session', `Key mismatch: Map key=${id?.slice(0,8)} vs sessionId=${key?.slice(0,8)} — using sessionId`);
|
|
833
|
+
}
|
|
834
|
+
result[key] = { ...session };
|
|
835
|
+
}
|
|
836
|
+
sessionsCache = result;
|
|
837
|
+
sessionsCacheDirty = false;
|
|
838
|
+
return result;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function getSession(sessionId: string): Session | null {
|
|
842
|
+
const s = sessions.get(sessionId);
|
|
843
|
+
return s ? { ...s } : null;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Create a session card immediately when SSH terminal connects (before hooks arrive).
|
|
848
|
+
*/
|
|
849
|
+
export async function createTerminalSession(terminalId: string, config: TerminalConfig): Promise<Session> {
|
|
850
|
+
const workDir = config.workingDir
|
|
851
|
+
? (config.workingDir.startsWith('~') ? config.workingDir.replace(/^~/, homedir()) : config.workingDir)
|
|
852
|
+
: homedir();
|
|
853
|
+
const projectName = workDir === homedir() ? 'Home' : workDir.split('/').filter(Boolean).pop() || 'SSH Session';
|
|
854
|
+
// Build default title: projectName + label + counter
|
|
855
|
+
let defaultTitle = `${config.host || 'localhost'}:${workDir}`;
|
|
856
|
+
if (!config.sessionTitle && config.label) {
|
|
857
|
+
const counter = (projectSessionCounters.get(projectName) || 0) + 1;
|
|
858
|
+
projectSessionCounters.set(projectName, counter);
|
|
859
|
+
defaultTitle = `${projectName} ${config.label} #${counter}`;
|
|
860
|
+
}
|
|
861
|
+
const session: Session = {
|
|
862
|
+
sessionId: terminalId,
|
|
863
|
+
projectPath: workDir,
|
|
864
|
+
projectName,
|
|
865
|
+
label: config.label || '',
|
|
866
|
+
title: config.sessionTitle || defaultTitle,
|
|
867
|
+
status: SESSION_STATUS.CONNECTING as Session['status'],
|
|
868
|
+
animationState: ANIMATION_STATE.WALKING,
|
|
869
|
+
emote: EMOTE.WAVE,
|
|
870
|
+
startedAt: Date.now(),
|
|
871
|
+
lastActivityAt: Date.now(),
|
|
872
|
+
endedAt: null,
|
|
873
|
+
currentPrompt: '',
|
|
874
|
+
promptHistory: [],
|
|
875
|
+
toolUsage: {},
|
|
876
|
+
totalToolCalls: 0,
|
|
877
|
+
model: '',
|
|
878
|
+
subagentCount: 0,
|
|
879
|
+
toolLog: [],
|
|
880
|
+
responseLog: [],
|
|
881
|
+
events: [{ type: 'TerminalCreated', detail: `SSH → ${config.host || 'localhost'}`, timestamp: Date.now() }],
|
|
882
|
+
archived: 0,
|
|
883
|
+
source: 'ssh',
|
|
884
|
+
pendingTool: null,
|
|
885
|
+
waitingDetail: null,
|
|
886
|
+
cachedPid: null,
|
|
887
|
+
queueCount: 0,
|
|
888
|
+
terminalId,
|
|
889
|
+
sshHost: config.host || 'localhost',
|
|
890
|
+
sshCommand: config.command || 'claude',
|
|
891
|
+
sshConfig: {
|
|
892
|
+
host: config.host || 'localhost',
|
|
893
|
+
port: config.port || 22,
|
|
894
|
+
username: config.username,
|
|
895
|
+
authMethod: config.authMethod || 'key',
|
|
896
|
+
privateKeyPath: config.privateKeyPath,
|
|
897
|
+
workingDir: config.workingDir || '~',
|
|
898
|
+
command: config.command || 'claude',
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
sessions.set(terminalId, session);
|
|
902
|
+
invalidateSessionsCache();
|
|
903
|
+
dbUpsertSession(session);
|
|
904
|
+
|
|
905
|
+
log.info('session', `Created terminal session ${terminalId} → ${config.host}:${workDir}`);
|
|
906
|
+
|
|
907
|
+
await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...session } });
|
|
908
|
+
|
|
909
|
+
// Non-Claude CLIs (codex, gemini, etc.) don't send hooks — auto-transition to idle
|
|
910
|
+
const command = config.command || 'claude';
|
|
911
|
+
if (!command.startsWith('claude')) {
|
|
912
|
+
setTimeout(async () => {
|
|
913
|
+
const s = sessions.get(terminalId);
|
|
914
|
+
if (s && s.status === (SESSION_STATUS.CONNECTING as string)) {
|
|
915
|
+
s.status = SESSION_STATUS.IDLE;
|
|
916
|
+
s.animationState = ANIMATION_STATE.IDLE;
|
|
917
|
+
s.emote = null;
|
|
918
|
+
s.model = command; // Show command name as model
|
|
919
|
+
await broadcastAsync({ type: WS_TYPES.SESSION_UPDATE, session: { ...s } });
|
|
920
|
+
log.info('session', `Auto-transitioned non-Claude session ${terminalId} to idle (${command})`);
|
|
921
|
+
}
|
|
922
|
+
}, 3000);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return session;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function linkTerminalToSession(sessionId: string, terminalId: string): Session | null {
|
|
929
|
+
const session = sessions.get(sessionId);
|
|
930
|
+
if (!session) return null;
|
|
931
|
+
session.terminalId = terminalId;
|
|
932
|
+
invalidateSessionsCache();
|
|
933
|
+
return { ...session };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function updateQueueCount(sessionId: string, count: number): Session | null {
|
|
937
|
+
const session = sessions.get(sessionId);
|
|
938
|
+
if (!session) return null;
|
|
939
|
+
session.queueCount = count || 0;
|
|
940
|
+
invalidateSessionsCache();
|
|
941
|
+
return { ...session };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export function killSession(sessionId: string): Session | null {
|
|
945
|
+
const session = sessions.get(sessionId);
|
|
946
|
+
if (!session) return null;
|
|
947
|
+
invalidateSessionsCache();
|
|
948
|
+
session.status = SESSION_STATUS.ENDED;
|
|
949
|
+
session.animationState = ANIMATION_STATE.DEATH;
|
|
950
|
+
session.archived = 1;
|
|
951
|
+
session.lastActivityAt = Date.now();
|
|
952
|
+
session.endedAt = Date.now();
|
|
953
|
+
// SSH sessions: keep in memory as historical (disconnected), preserve terminal ref for resume
|
|
954
|
+
if (session.source === 'ssh') {
|
|
955
|
+
session.isHistorical = true;
|
|
956
|
+
session.lastTerminalId = session.terminalId;
|
|
957
|
+
session.terminalId = null;
|
|
958
|
+
} else {
|
|
959
|
+
setTimeout(() => sessions.delete(sessionId), 10000);
|
|
960
|
+
}
|
|
961
|
+
return { ...session };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export function deleteSessionFromMemory(sessionId: string): boolean {
|
|
965
|
+
const session = sessions.get(sessionId);
|
|
966
|
+
if (!session) return false;
|
|
967
|
+
// Release PID cache
|
|
968
|
+
if (session.cachedPid) {
|
|
969
|
+
pidToSession.delete(session.cachedPid);
|
|
970
|
+
}
|
|
971
|
+
// Team cleanup
|
|
972
|
+
handleTeamMemberEnd(sessionId, sessions);
|
|
973
|
+
sessions.delete(sessionId);
|
|
974
|
+
invalidateSessionsCache();
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function setSessionTitle(sessionId: string, title: string): Session | null {
|
|
979
|
+
const session = sessions.get(sessionId);
|
|
980
|
+
if (session) { session.title = title; invalidateSessionsCache(); dbUpdateTitle(sessionId, title); }
|
|
981
|
+
return session ? { ...session } : null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function setSessionLabel(sessionId: string, label: string): Session | null {
|
|
985
|
+
const session = sessions.get(sessionId);
|
|
986
|
+
if (session) { session.label = label; invalidateSessionsCache(); dbUpdateLabel(sessionId, label); }
|
|
987
|
+
return session ? { ...session } : null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function setSummary(sessionId: string, summary: string): Session | null {
|
|
991
|
+
const session = sessions.get(sessionId);
|
|
992
|
+
if (session) { session.summary = summary; invalidateSessionsCache(); dbUpdateSummary(sessionId, summary); }
|
|
993
|
+
return session ? { ...session } : null;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function setSessionAccentColor(sessionId: string, color: string): void {
|
|
997
|
+
const session = sessions.get(sessionId);
|
|
998
|
+
if (session) { session.accentColor = color; invalidateSessionsCache(); }
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export function setSessionCharacterModel(sessionId: string, model: string): Session | null {
|
|
1002
|
+
const session = sessions.get(sessionId);
|
|
1003
|
+
if (session) { session.characterModel = model; invalidateSessionsCache(); }
|
|
1004
|
+
return session ? { ...session } : null;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export function archiveSession(sessionId: string, archived: boolean | number): Session | null {
|
|
1008
|
+
const session = sessions.get(sessionId);
|
|
1009
|
+
if (session) { session.archived = archived ? 1 : 0; invalidateSessionsCache(); dbUpdateArchived(sessionId, archived); }
|
|
1010
|
+
return session ? { ...session } : null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Resume a disconnected SSH session — sends claude --resume with --continue fallback.
|
|
1015
|
+
*/
|
|
1016
|
+
export function resumeSession(sessionId: string): { error: string } | { ok: true; terminalId: string; session: Session } {
|
|
1017
|
+
const session = sessions.get(sessionId);
|
|
1018
|
+
if (!session) return { error: 'Session not found' };
|
|
1019
|
+
if (!session.lastTerminalId) return { error: 'No terminal associated with this session' };
|
|
1020
|
+
|
|
1021
|
+
// Archive current session data into previousSessions array
|
|
1022
|
+
if (!session.previousSessions) session.previousSessions = [];
|
|
1023
|
+
session.previousSessions.push({
|
|
1024
|
+
sessionId: session.sessionId,
|
|
1025
|
+
startedAt: session.startedAt,
|
|
1026
|
+
endedAt: session.endedAt,
|
|
1027
|
+
promptHistory: [...session.promptHistory],
|
|
1028
|
+
toolLog: [...(session.toolLog || [])],
|
|
1029
|
+
responseLog: [...(session.responseLog || [])],
|
|
1030
|
+
events: [...session.events],
|
|
1031
|
+
toolUsage: { ...session.toolUsage },
|
|
1032
|
+
totalToolCalls: session.totalToolCalls,
|
|
1033
|
+
});
|
|
1034
|
+
// Cap to prevent unbounded growth (each entry can hold hundreds of log items)
|
|
1035
|
+
if (session.previousSessions.length > 5) session.previousSessions.shift();
|
|
1036
|
+
|
|
1037
|
+
// Register pending resume
|
|
1038
|
+
pendingResume.set(session.lastTerminalId, {
|
|
1039
|
+
oldSessionId: sessionId,
|
|
1040
|
+
timestamp: Date.now(),
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// Restore terminal link and transition to connecting
|
|
1044
|
+
session.terminalId = session.lastTerminalId;
|
|
1045
|
+
session.status = SESSION_STATUS.CONNECTING as Session['status'];
|
|
1046
|
+
session.animationState = ANIMATION_STATE.WALKING;
|
|
1047
|
+
session.emote = EMOTE.WAVE;
|
|
1048
|
+
session.isHistorical = false;
|
|
1049
|
+
session.lastActivityAt = Date.now();
|
|
1050
|
+
invalidateSessionsCache();
|
|
1051
|
+
|
|
1052
|
+
session.events.push({
|
|
1053
|
+
type: 'ResumeRequested',
|
|
1054
|
+
timestamp: Date.now(),
|
|
1055
|
+
detail: 'Resume requested by user',
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
log.info('session', `RESUME: session ${sessionId?.slice(0,8)} → connecting (terminal=${session.lastTerminalId?.slice(0,8)})`);
|
|
1059
|
+
|
|
1060
|
+
return { ok: true, terminalId: session.lastTerminalId, session: { ...session } };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Reconnect an ended SSH session to a newly created terminal.
|
|
1065
|
+
* Used when the original terminal died (server restart) and a new one was created.
|
|
1066
|
+
* Updates the REAL session in the Map and registers pendingResume for hook matching.
|
|
1067
|
+
*/
|
|
1068
|
+
export function reconnectSessionTerminal(sessionId: string, newTerminalId: string): { error: string } | { ok: true; session: Session } {
|
|
1069
|
+
const session = sessions.get(sessionId);
|
|
1070
|
+
if (!session) return { error: 'Session not found' };
|
|
1071
|
+
|
|
1072
|
+
// Archive current session data (same as resumeSession)
|
|
1073
|
+
if (!session.previousSessions) session.previousSessions = [];
|
|
1074
|
+
session.previousSessions.push({
|
|
1075
|
+
sessionId: session.sessionId,
|
|
1076
|
+
startedAt: session.startedAt,
|
|
1077
|
+
endedAt: session.endedAt,
|
|
1078
|
+
promptHistory: [...session.promptHistory],
|
|
1079
|
+
toolLog: [...(session.toolLog || [])],
|
|
1080
|
+
responseLog: [...(session.responseLog || [])],
|
|
1081
|
+
events: [...session.events],
|
|
1082
|
+
toolUsage: { ...session.toolUsage },
|
|
1083
|
+
totalToolCalls: session.totalToolCalls,
|
|
1084
|
+
});
|
|
1085
|
+
if (session.previousSessions.length > 5) session.previousSessions.shift();
|
|
1086
|
+
|
|
1087
|
+
// Register pending resume so session matching can link new Claude hooks
|
|
1088
|
+
pendingResume.set(newTerminalId, {
|
|
1089
|
+
oldSessionId: sessionId,
|
|
1090
|
+
timestamp: Date.now(),
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// Update the REAL session (not a copy)
|
|
1094
|
+
session.terminalId = newTerminalId;
|
|
1095
|
+
session.lastTerminalId = newTerminalId;
|
|
1096
|
+
session.status = SESSION_STATUS.CONNECTING as Session['status'];
|
|
1097
|
+
session.animationState = ANIMATION_STATE.WALKING;
|
|
1098
|
+
session.emote = EMOTE.WAVE;
|
|
1099
|
+
session.isHistorical = false;
|
|
1100
|
+
session.endedAt = null;
|
|
1101
|
+
session.lastActivityAt = Date.now();
|
|
1102
|
+
session.events.push({
|
|
1103
|
+
type: 'ResumeNewTerminal',
|
|
1104
|
+
timestamp: Date.now(),
|
|
1105
|
+
detail: `New terminal ${newTerminalId?.slice(0, 8)} for claude --resume || --continue`,
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
invalidateSessionsCache();
|
|
1109
|
+
log.info('session', `RECONNECT: session ${sessionId?.slice(0, 8)} → new terminal ${newTerminalId?.slice(0, 8)}`);
|
|
1110
|
+
|
|
1111
|
+
return { ok: true, session: { ...session } };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function detectSessionSource(sessionId: string): string {
|
|
1115
|
+
const session = sessions.get(sessionId);
|
|
1116
|
+
if (!session) return 'unknown';
|
|
1117
|
+
return session.source || 'ssh';
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Wrapper for findClaudeProcess that passes internal state
|
|
1121
|
+
export function findClaudeProcess(sessionId: string, projectPath: string): number | null {
|
|
1122
|
+
return _findClaudeProcess(sessionId, projectPath, sessions, pidToSession);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ---- Start background monitors ----
|
|
1126
|
+
|
|
1127
|
+
// Auto-idle transitions
|
|
1128
|
+
startAutoIdle(sessions);
|
|
1129
|
+
|
|
1130
|
+
// Process liveness monitoring
|
|
1131
|
+
startMonitoring(
|
|
1132
|
+
sessions,
|
|
1133
|
+
pidToSession,
|
|
1134
|
+
clearApprovalTimer,
|
|
1135
|
+
(sid: string) => handleTeamMemberEnd(sid, sessions),
|
|
1136
|
+
broadcastAsync
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
// Clean up stale pendingResume entries
|
|
1140
|
+
startPendingResumeCleanup(pendingResume, sessions, broadcastAsync);
|
|
1141
|
+
|
|
1142
|
+
// ---- Re-exports from sub-modules for backward compatibility ----
|
|
1143
|
+
// External files (apiRouter, wsManager, hookProcessor, index) should not need to change their imports
|
|
1144
|
+
export { getAllTeams, getTeam, getTeamForSession } from './teamManager.js';
|
|
1145
|
+
export { hasChildProcesses } from './approvalDetector.js';
|