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.
Files changed (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. 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';