ai-agent-session-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,1049 @@
1
+ // sessionStore.js — In-memory session state machine (no database)
2
+ import { execSync } from 'child_process';
3
+ import { homedir } from 'os';
4
+ import log from './logger.js';
5
+ import { getToolTimeout, getToolCategory, getWaitingStatus, getWaitingLabel, AUTO_IDLE_TIMEOUTS, PROCESS_CHECK_INTERVAL } from './config.js';
6
+ import { tryLinkByWorkDir, getTerminalForSession, getTerminalByPtyChild } from './sshManager.js';
7
+
8
+ const sessions = new Map();
9
+ const projectSessionCounters = new Map();
10
+ const pendingToolTimers = new Map(); // session_id -> timeout for tool approval detection
11
+ const pidToSession = new Map(); // pid -> sessionId — ensures each PID is only assigned to one session
12
+
13
+ // Team mode structures
14
+ const teams = new Map(); // teamId -> { teamId, parentSessionId, childSessionIds: Set, teamName, createdAt }
15
+ const sessionToTeam = new Map(); // sessionId -> teamId
16
+ const pendingSubagents = []; // { parentSessionId, parentCwd, agentType, timestamp }
17
+
18
+ // Event ring buffer for reconnect replay
19
+ const EVENT_BUFFER_MAX = 500;
20
+ let eventSeq = 0;
21
+ const eventBuffer = []; // { seq, type, data, timestamp }
22
+
23
+ export function pushEvent(type, data) {
24
+ eventSeq++;
25
+ eventBuffer.push({ seq: eventSeq, type, data, timestamp: Date.now() });
26
+ if (eventBuffer.length > EVENT_BUFFER_MAX) eventBuffer.shift();
27
+ return eventSeq;
28
+ }
29
+
30
+ export function getEventsSince(sinceSeq) {
31
+ return eventBuffer.filter(e => e.seq > sinceSeq);
32
+ }
33
+
34
+ export function getEventSeq() {
35
+ return eventSeq;
36
+ }
37
+
38
+ export function loadActiveSessions() {
39
+ // No-op: no database to load from. Sessions are populated from hooks at runtime.
40
+ // Browser IndexedDB is the persistence layer — it loads history on page open.
41
+ }
42
+
43
+ // Load active sessions at module init
44
+ loadActiveSessions();
45
+
46
+ export function handleEvent(hookData) {
47
+ const { session_id, hook_event_name, cwd } = hookData;
48
+ if (!session_id) return null;
49
+
50
+ if (hookData.claude_pid) {
51
+ const env = [
52
+ `pid=${hookData.claude_pid}`,
53
+ hookData.tty_path ? `tty=${hookData.tty_path}` : null,
54
+ hookData.term_program ? `term=${hookData.term_program}` : null,
55
+ hookData.tab_id ? `tab=${hookData.tab_id}` : null,
56
+ hookData.vscode_pid ? `vscode_pid=${hookData.vscode_pid}` : null,
57
+ hookData.tmux ? `tmux=${hookData.tmux.pane}` : null,
58
+ hookData.window_id ? `x11win=${hookData.window_id}` : null,
59
+ ].filter(Boolean).join(' ');
60
+ log.info('session', `event=${hook_event_name} session=${session_id?.slice(0,8)} ${env}`);
61
+ } else {
62
+ log.info('session', `event=${hook_event_name} session=${session_id?.slice(0,8)} cwd=${cwd || 'none'}`);
63
+ }
64
+ log.debugJson('session', 'Full hook data', hookData);
65
+
66
+ let session = sessions.get(session_id);
67
+
68
+ // Cache all process/tab info from hook's enriched environment data
69
+ if (hookData.claude_pid) {
70
+ const pid = Number(hookData.claude_pid);
71
+ if (pid > 0 && session && session.cachedPid !== pid) {
72
+ if (session.cachedPid) pidToSession.delete(session.cachedPid);
73
+ session.cachedPid = pid;
74
+ pidToSession.set(pid, session_id);
75
+ console.log(`[hook] CACHED pid=${pid} → session=${session_id?.slice(0,8)}`);
76
+ }
77
+ }
78
+
79
+ // Create session if new — ONLY when linked to an SSH terminal
80
+ if (!session) {
81
+ // Priority 1: Direct match via AGENT_MANAGER_TERMINAL_ID (injected into pty env)
82
+ if (hookData.agent_terminal_id) {
83
+ const preSession = sessions.get(hookData.agent_terminal_id);
84
+ if (preSession && preSession.terminalId) {
85
+ sessions.delete(hookData.agent_terminal_id);
86
+ preSession.sessionId = session_id;
87
+ preSession.replacesId = hookData.agent_terminal_id;
88
+ session = preSession;
89
+ sessions.set(session_id, session);
90
+ log.info('session', `Re-keyed terminal session ${hookData.agent_terminal_id} → ${session_id?.slice(0,8)} (via terminal ID)`);
91
+ }
92
+ }
93
+
94
+ // Priority 2: Match via pending workDir link
95
+ if (!session) {
96
+ const linkedTerminalId = tryLinkByWorkDir(cwd || '', session_id);
97
+ if (linkedTerminalId) {
98
+ // Check if there's a pre-created terminal session with this terminalId
99
+ const preSession = sessions.get(linkedTerminalId);
100
+ if (preSession) {
101
+ sessions.delete(linkedTerminalId);
102
+ preSession.sessionId = session_id;
103
+ preSession.replacesId = linkedTerminalId;
104
+ session = preSession;
105
+ sessions.set(session_id, session);
106
+ log.info('session', `Re-keyed terminal session ${linkedTerminalId} → ${session_id?.slice(0,8)} (via workDir link)`);
107
+ } else {
108
+ console.log(`[hook] NEW SSH SESSION ${session_id?.slice(0,8)} — terminal=${linkedTerminalId}`);
109
+ session = {
110
+ sessionId: session_id,
111
+ projectPath: cwd || '',
112
+ projectName: cwd ? cwd.split('/').filter(Boolean).pop() : 'Unknown',
113
+ title: '',
114
+ status: 'idle',
115
+ animationState: 'Idle',
116
+ emote: null,
117
+ startedAt: Date.now(),
118
+ lastActivityAt: Date.now(),
119
+ currentPrompt: '',
120
+ promptHistory: [],
121
+ toolUsage: {},
122
+ totalToolCalls: 0,
123
+ model: hookData.model || '',
124
+ subagentCount: 0,
125
+ toolLog: [],
126
+ responseLog: [],
127
+ events: [],
128
+ archived: 0,
129
+ source: 'ssh',
130
+ pendingTool: null,
131
+ waitingDetail: null,
132
+ cachedPid: null,
133
+ queueCount: 0,
134
+ terminalId: linkedTerminalId
135
+ };
136
+ sessions.set(session_id, session);
137
+ }
138
+ } else {
139
+ // Priority 3: Scan pre-created sessions by normalized path
140
+ let found = false;
141
+ for (const [key, s] of sessions) {
142
+ if (s.terminalId && s.status === 'connecting' && s.projectPath) {
143
+ const normalizedSessionPath = s.projectPath.replace(/\/$/, '');
144
+ const normalizedCwd = (cwd || '').replace(/\/$/, '');
145
+ if (normalizedSessionPath === normalizedCwd || s.projectPath === cwd) {
146
+ sessions.delete(key);
147
+ s.sessionId = session_id;
148
+ s.replacesId = key;
149
+ session = s;
150
+ sessions.set(session_id, session);
151
+ log.info('session', `Re-keyed terminal session ${key} → ${session_id?.slice(0,8)} (via path scan)`);
152
+ found = true;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ // Priority 4: PID-based fallback — check if Claude's parent is a known pty
158
+ if (!found && hookData.claude_pid) {
159
+ const pidTerminalId = getTerminalByPtyChild(Number(hookData.claude_pid));
160
+ if (pidTerminalId) {
161
+ const preSession = sessions.get(pidTerminalId);
162
+ if (preSession && preSession.terminalId) {
163
+ sessions.delete(pidTerminalId);
164
+ preSession.sessionId = session_id;
165
+ preSession.replacesId = pidTerminalId;
166
+ session = preSession;
167
+ sessions.set(session_id, session);
168
+ log.info('session', `Re-keyed terminal session ${pidTerminalId} → ${session_id?.slice(0,8)} (via PID fallback)`);
169
+ found = true;
170
+ }
171
+ }
172
+ }
173
+ if (!found) {
174
+ // No SSH terminal match — create a display-only card with detected source
175
+ const detectedSource = detectHookSource(hookData);
176
+ log.info('session', `Creating display-only session ${session_id?.slice(0,8)} source=${detectedSource} cwd=${cwd}`);
177
+ session = {
178
+ sessionId: session_id,
179
+ projectPath: cwd || '',
180
+ projectName: cwd ? cwd.split('/').filter(Boolean).pop() : 'Unknown',
181
+ title: '',
182
+ status: 'idle',
183
+ animationState: 'Idle',
184
+ emote: null,
185
+ startedAt: Date.now(),
186
+ lastActivityAt: Date.now(),
187
+ currentPrompt: '',
188
+ promptHistory: [],
189
+ toolUsage: {},
190
+ totalToolCalls: 0,
191
+ model: hookData.model || '',
192
+ subagentCount: 0,
193
+ toolLog: [],
194
+ responseLog: [],
195
+ events: [],
196
+ archived: 0,
197
+ source: detectedSource,
198
+ pendingTool: null,
199
+ waitingDetail: null,
200
+ cachedPid: null,
201
+ queueCount: 0,
202
+ terminalId: null,
203
+ };
204
+ sessions.set(session_id, session);
205
+ }
206
+ }
207
+ }
208
+
209
+ // Cache PID from hook
210
+ const pid = hookData.claude_pid ? Number(hookData.claude_pid) : null;
211
+ if (pid && pid > 0) {
212
+ session.cachedPid = pid;
213
+ pidToSession.set(pid, session_id);
214
+ console.log(`[hook] CACHED pid=${pid} → session=${session_id?.slice(0,8)} (new session)`);
215
+ }
216
+
217
+ // Increment per-project session counter
218
+ const projectKey = session.projectName;
219
+ const count = (projectSessionCounters.get(projectKey) || 0) + 1;
220
+ projectSessionCounters.set(projectKey, count);
221
+ }
222
+
223
+ session.lastActivityAt = Date.now();
224
+ const eventEntry = {
225
+ type: hook_event_name,
226
+ timestamp: Date.now(),
227
+ detail: ''
228
+ };
229
+
230
+ switch (hook_event_name) {
231
+ case 'SessionStart': {
232
+ session.status = 'idle';
233
+ session.animationState = 'Idle';
234
+ session.model = hookData.model || session.model;
235
+ if (hookData.transcript_path) session.transcriptPath = hookData.transcript_path;
236
+ if (hookData.permission_mode) session.permissionMode = hookData.permission_mode;
237
+ eventEntry.detail = `Session started (${hookData.source || 'startup'})`;
238
+ log.debug('session', `SessionStart: ${session_id?.slice(0,8)} project=${session.projectName} model=${session.model}`);
239
+ // Try to match this new session as a subagent child
240
+ const teamResult = findPendingSubagentMatch(session_id, session.projectPath);
241
+ if (teamResult) {
242
+ eventEntry.detail += ` [Team: ${teamResult.teamId}]`;
243
+ log.debug('session', `Subagent matched to team ${teamResult.teamId}`);
244
+ }
245
+ break;
246
+ }
247
+
248
+ case 'UserPromptSubmit':
249
+ session.status = 'prompting';
250
+ session.animationState = 'Walking';
251
+ session.emote = 'Wave';
252
+ session.currentPrompt = hookData.prompt || '';
253
+ session.promptHistory.push({
254
+ text: hookData.prompt || '',
255
+ timestamp: Date.now()
256
+ });
257
+ // Keep last 50 prompts
258
+ if (session.promptHistory.length > 50) session.promptHistory.shift();
259
+ eventEntry.detail = (hookData.prompt || '').substring(0, 80);
260
+
261
+ // Auto-generate title from project name + label + counter + short prompt summary
262
+ if (!session.title) {
263
+ const counter = projectSessionCounters.get(session.projectName) || 1;
264
+ const labelPart = session.label ? ` ${session.label}` : '';
265
+ const shortPrompt = makeShortTitle(hookData.prompt || '');
266
+ session.title = shortPrompt
267
+ ? `${session.projectName}${labelPart} #${counter} — ${shortPrompt}`
268
+ : `${session.projectName}${labelPart} — Session #${counter}`;
269
+ }
270
+ break;
271
+
272
+ case 'PreToolUse': {
273
+ session.status = 'working';
274
+ session.animationState = 'Running';
275
+ const toolName = hookData.tool_name || 'Unknown';
276
+ session.toolUsage[toolName] = (session.toolUsage[toolName] || 0) + 1;
277
+ session.totalToolCalls++;
278
+ // Store detailed tool log entry for the detail panel
279
+ const toolInputSummary = summarizeToolInput(hookData.tool_input, toolName);
280
+ session.toolLog.push({
281
+ tool: toolName,
282
+ input: toolInputSummary,
283
+ timestamp: Date.now()
284
+ });
285
+ if (session.toolLog.length > 200) session.toolLog.shift();
286
+ eventEntry.detail = `${toolName}`;
287
+
288
+ // Approval/input detection: if PostToolUse doesn't arrive within the
289
+ // timeout, the tool is likely pending user interaction.
290
+ // Tool timeouts and categories are defined in config.js.
291
+ clearTimeout(pendingToolTimers.get(session_id));
292
+ const approvalTimeout = getToolTimeout(toolName);
293
+ if (approvalTimeout > 0) {
294
+ session.pendingTool = toolName;
295
+ session.pendingToolDetail = toolInputSummary;
296
+ const timer = setTimeout(async () => {
297
+ pendingToolTimers.delete(session_id);
298
+ if (session.status === 'working' && session.pendingTool) {
299
+ const category = getToolCategory(session.pendingTool);
300
+ if (category === 'slow' && session.cachedPid && hasChildProcesses(session.cachedPid)) {
301
+ return; // Command is running, not waiting for approval
302
+ }
303
+
304
+ const waitingStatus = getWaitingStatus(session.pendingTool) || 'approval';
305
+ session.status = waitingStatus;
306
+ session.animationState = 'Waiting';
307
+ session.waitingDetail = getWaitingLabel(session.pendingTool, session.pendingToolDetail);
308
+ try {
309
+ const { broadcast } = await import('./wsManager.js');
310
+ broadcast({ type: 'session_update', session: { ...session } });
311
+ } catch(e) {}
312
+ }
313
+ }, approvalTimeout);
314
+ pendingToolTimers.set(session_id, timer);
315
+ } else {
316
+ session.pendingTool = null;
317
+ session.pendingToolDetail = null;
318
+ }
319
+ break;
320
+ }
321
+
322
+ case 'PostToolUse':
323
+ // Tool completed — cancel approval timer, stay working
324
+ clearTimeout(pendingToolTimers.get(session_id));
325
+ pendingToolTimers.delete(session_id);
326
+ session.pendingTool = null;
327
+ session.pendingToolDetail = null;
328
+ session.waitingDetail = null;
329
+ session.status = 'working';
330
+ eventEntry.detail = `${hookData.tool_name || 'Tool'} completed`;
331
+ break;
332
+
333
+ case 'Stop': {
334
+ // Clear any pending tool approval timer
335
+ clearTimeout(pendingToolTimers.get(session_id));
336
+ pendingToolTimers.delete(session_id);
337
+ session.pendingTool = null;
338
+ session.pendingToolDetail = null;
339
+ session.waitingDetail = null;
340
+
341
+ const wasHeavyWork = session.totalToolCalls > 10 &&
342
+ session.status === 'working';
343
+ // Session finished its turn — waiting for user's next prompt
344
+ session.status = 'waiting';
345
+ if (wasHeavyWork) {
346
+ session.animationState = 'Dance';
347
+ session.emote = null;
348
+ } else {
349
+ session.animationState = 'Waiting';
350
+ session.emote = 'ThumbsUp';
351
+ }
352
+ eventEntry.detail = wasHeavyWork ? 'Heavy work done — ready for input' : 'Ready for your input';
353
+
354
+ // Store response if present — try multiple possible field names
355
+ const responseText = hookData.response || hookData.message || hookData.stop_reason_str || '';
356
+ if (responseText) {
357
+ const excerpt = responseText.substring(0, 2000);
358
+ session.responseLog.push({ text: excerpt, timestamp: Date.now() });
359
+ if (session.responseLog.length > 50) session.responseLog.shift();
360
+ }
361
+
362
+ // Reset tool counter for next turn
363
+ session.totalToolCalls = 0;
364
+ break;
365
+ }
366
+
367
+ case 'SubagentStart':
368
+ session.subagentCount++;
369
+ session.emote = 'Jump';
370
+ eventEntry.detail = `Subagent spawned (${hookData.agent_type || 'unknown'}${hookData.agent_id ? ' #' + hookData.agent_id.slice(0, 8) : ''})`;
371
+ // Track pending subagent for team auto-detection
372
+ pendingSubagents.push({
373
+ parentSessionId: session_id,
374
+ parentCwd: session.projectPath,
375
+ agentType: hookData.agent_type || 'unknown',
376
+ agentId: hookData.agent_id || null,
377
+ timestamp: Date.now()
378
+ });
379
+ // Prune stale entries (>30s old)
380
+ const now_sub = Date.now();
381
+ while (pendingSubagents.length > 0 && now_sub - pendingSubagents[0].timestamp > 30000) {
382
+ pendingSubagents.shift();
383
+ }
384
+ break;
385
+
386
+ case 'SubagentStop':
387
+ session.subagentCount = Math.max(0, session.subagentCount - 1);
388
+ eventEntry.detail = `Subagent finished`;
389
+ break;
390
+
391
+ case 'PermissionRequest': {
392
+ // Real signal that user approval is needed — replaces timeout-based heuristic
393
+ clearTimeout(pendingToolTimers.get(session_id));
394
+ pendingToolTimers.delete(session_id);
395
+ const permTool = hookData.tool_name || session.pendingTool || 'Unknown';
396
+ session.status = 'approval';
397
+ session.animationState = 'Waiting';
398
+ session.waitingDetail = hookData.tool_input
399
+ ? `Approve ${permTool}: ${summarizeToolInput(hookData.tool_input, permTool)}`
400
+ : `Approve ${permTool}`;
401
+ session.permissionMode = hookData.permission_mode || null;
402
+ eventEntry.detail = `Permission request: ${permTool}`;
403
+ break;
404
+ }
405
+
406
+ case 'PostToolUseFailure': {
407
+ // Tool call failed — cancel approval timer, mark the failure in tool log
408
+ clearTimeout(pendingToolTimers.get(session_id));
409
+ pendingToolTimers.delete(session_id);
410
+ session.pendingTool = null;
411
+ session.pendingToolDetail = null;
412
+ session.waitingDetail = null;
413
+ session.status = 'working';
414
+ const failedTool = hookData.tool_name || 'Tool';
415
+ // Mark last tool log entry as failed if it matches
416
+ if (session.toolLog.length > 0) {
417
+ const lastEntry = session.toolLog[session.toolLog.length - 1];
418
+ if (lastEntry.tool === failedTool && !lastEntry.failed) {
419
+ lastEntry.failed = true;
420
+ lastEntry.error = hookData.error || hookData.message || 'Failed';
421
+ }
422
+ }
423
+ eventEntry.detail = `${failedTool} failed${hookData.error ? ': ' + hookData.error.substring(0, 80) : ''}`;
424
+ break;
425
+ }
426
+
427
+ case 'TeammateIdle':
428
+ eventEntry.detail = `Teammate idle: ${hookData.agent_name || hookData.agent_id || 'unknown'}`;
429
+ break;
430
+
431
+ case 'TaskCompleted':
432
+ eventEntry.detail = `Task completed: ${hookData.task_description || hookData.task_id || 'unknown'}`;
433
+ session.emote = 'ThumbsUp';
434
+ break;
435
+
436
+ case 'PreCompact':
437
+ eventEntry.detail = 'Context compaction starting';
438
+ break;
439
+
440
+ case 'Notification':
441
+ eventEntry.detail = hookData.message || hookData.title || 'Notification';
442
+ break;
443
+
444
+ case 'SessionEnd':
445
+ session.status = 'ended';
446
+ session.animationState = 'Death';
447
+ session.endedAt = Date.now();
448
+ eventEntry.detail = `Session ended (${hookData.reason || 'unknown'})`;
449
+
450
+ // Release PID cache for this session
451
+ if (session.cachedPid) {
452
+ console.log(`[findProcess] releasing pid=${session.cachedPid} from session=${session_id?.slice(0,8)}`);
453
+ pidToSession.delete(session.cachedPid);
454
+ session.cachedPid = null;
455
+ }
456
+
457
+ // Team cleanup: remove from team, clean up if empty
458
+ handleTeamMemberEnd(session_id);
459
+
460
+ // SSH sessions: keep in memory as historical (disconnected), clear terminal link
461
+ if (session.source === 'ssh') {
462
+ session.isHistorical = true;
463
+ session.terminalId = null;
464
+ } else {
465
+ setTimeout(() => sessions.delete(session_id), 10000);
466
+ }
467
+ break;
468
+ }
469
+
470
+ // Keep last 50 events
471
+ session.events.push(eventEntry);
472
+ if (session.events.length > 50) session.events.shift();
473
+
474
+ const result = { session: { ...session } };
475
+ // Clean up one-time re-key flag
476
+ delete session.replacesId;
477
+ // Include team info if session belongs to a team
478
+ const teamId = sessionToTeam.get(session_id);
479
+ if (teamId) {
480
+ result.team = serializeTeam(teams.get(teamId));
481
+ }
482
+
483
+ // Push to ring buffer for reconnect replay
484
+ pushEvent('session_update', result);
485
+
486
+ return result;
487
+ }
488
+
489
+ export function getAllSessions() {
490
+ const result = {};
491
+ for (const [id, session] of sessions) {
492
+ result[id] = { ...session };
493
+ }
494
+ return result;
495
+ }
496
+
497
+ // Extract a short title from the first prompt (first sentence or first ~60 chars)
498
+ function makeShortTitle(prompt) {
499
+ if (!prompt) return '';
500
+ // Strip leading whitespace and common prefixes
501
+ let text = prompt.trim().replace(/^(please|can you|could you|help me|i want to|i need to)\s+/i, '');
502
+ if (!text) return '';
503
+ // Take first sentence (up to . ! ? or newline)
504
+ const match = text.match(/^[^\n.!?]{1,60}/);
505
+ if (match) text = match[0].trim();
506
+ // Capitalize first letter
507
+ return text.charAt(0).toUpperCase() + text.slice(1);
508
+ }
509
+
510
+ // Summarize tool input for the tool log detail panel
511
+ function summarizeToolInput(toolInput, toolName) {
512
+ if (!toolInput) return '';
513
+ switch (toolName) {
514
+ case 'Read': return toolInput.file_path || '';
515
+ case 'Write': return toolInput.file_path || '';
516
+ case 'Edit': return toolInput.file_path || '';
517
+ case 'Bash': return (toolInput.command || '').substring(0, 120);
518
+ case 'Grep': return `${toolInput.pattern || ''} in ${toolInput.path || 'cwd'}`;
519
+ case 'Glob': return toolInput.pattern || '';
520
+ case 'WebFetch': return toolInput.url || '';
521
+ case 'Task': return toolInput.description || '';
522
+ default: return JSON.stringify(toolInput).substring(0, 100);
523
+ }
524
+ }
525
+
526
+ // ---- Team Mode Functions ----
527
+
528
+ function findPendingSubagentMatch(childSessionId, childCwd) {
529
+ const now = Date.now();
530
+ // Clean stale entries (>10s old)
531
+ while (pendingSubagents.length > 0 && now - pendingSubagents[0].timestamp > 10000) {
532
+ pendingSubagents.shift();
533
+ }
534
+ if (!childCwd || pendingSubagents.length === 0) return null;
535
+
536
+ // Match by cwd — exact match or parent/child path relationship
537
+ for (let i = pendingSubagents.length - 1; i >= 0; i--) {
538
+ const pending = pendingSubagents[i];
539
+ if (pending.parentSessionId === childSessionId) continue; // skip self
540
+ const parentCwd = pending.parentCwd;
541
+ if (parentCwd && (childCwd === parentCwd || childCwd.startsWith(parentCwd + '/') || parentCwd.startsWith(childCwd + '/'))) {
542
+ // Found match — consume it
543
+ pendingSubagents.splice(i, 1);
544
+ return linkSessionToTeam(pending.parentSessionId, childSessionId, pending.agentType);
545
+ }
546
+ }
547
+ return null;
548
+ }
549
+
550
+ function linkSessionToTeam(parentId, childId, agentType) {
551
+ const teamId = `team-${parentId}`;
552
+ let team = teams.get(teamId);
553
+
554
+ if (!team) {
555
+ team = {
556
+ teamId,
557
+ parentSessionId: parentId,
558
+ childSessionIds: new Set(),
559
+ teamName: null,
560
+ createdAt: Date.now()
561
+ };
562
+ teams.set(teamId, team);
563
+
564
+ // Set team name from parent's project name
565
+ const parentSession = sessions.get(parentId);
566
+ if (parentSession) {
567
+ team.teamName = `${parentSession.projectName} Team`;
568
+ parentSession.teamId = teamId;
569
+ parentSession.teamRole = 'leader';
570
+ sessionToTeam.set(parentId, teamId);
571
+ }
572
+ }
573
+
574
+ // Link child
575
+ team.childSessionIds.add(childId);
576
+ const childSession = sessions.get(childId);
577
+ if (childSession) {
578
+ childSession.teamId = teamId;
579
+ childSession.teamRole = 'member';
580
+ childSession.agentType = agentType;
581
+ }
582
+ sessionToTeam.set(childId, teamId);
583
+
584
+ console.log(`[sessionStore] Linked session ${childId} to team ${teamId} as ${agentType}`);
585
+ return { teamId, team: serializeTeam(team) };
586
+ }
587
+
588
+ function handleTeamMemberEnd(sessionId) {
589
+ const teamId = sessionToTeam.get(sessionId);
590
+ if (!teamId) return null;
591
+
592
+ const team = teams.get(teamId);
593
+ if (!team) return null;
594
+
595
+ team.childSessionIds.delete(sessionId);
596
+ sessionToTeam.delete(sessionId);
597
+
598
+ // If parent ended and all children ended, clean up the team
599
+ if (sessionId === team.parentSessionId) {
600
+ const allChildrenEnded = [...team.childSessionIds].every(cid => {
601
+ const s = sessions.get(cid);
602
+ return !s || s.status === 'ended';
603
+ });
604
+ if (allChildrenEnded) {
605
+ // Clean up team after a delay
606
+ setTimeout(() => {
607
+ teams.delete(teamId);
608
+ sessionToTeam.delete(team.parentSessionId);
609
+ for (const cid of team.childSessionIds) {
610
+ sessionToTeam.delete(cid);
611
+ }
612
+ }, 15000);
613
+ }
614
+ }
615
+
616
+ return { teamId, team: serializeTeam(team) };
617
+ }
618
+
619
+ function serializeTeam(team) {
620
+ if (!team) return null;
621
+ return {
622
+ teamId: team.teamId,
623
+ parentSessionId: team.parentSessionId,
624
+ childSessionIds: [...team.childSessionIds],
625
+ teamName: team.teamName,
626
+ createdAt: team.createdAt
627
+ };
628
+ }
629
+
630
+ export function getTeam(teamId) {
631
+ const team = teams.get(teamId);
632
+ return team ? serializeTeam(team) : null;
633
+ }
634
+
635
+ export function getAllTeams() {
636
+ const result = {};
637
+ for (const [id, team] of teams) {
638
+ result[id] = serializeTeam(team);
639
+ }
640
+ return result;
641
+ }
642
+
643
+ export function getTeamForSession(sessionId) {
644
+ const teamId = sessionToTeam.get(sessionId);
645
+ if (!teamId) return null;
646
+ return getTeam(teamId);
647
+ }
648
+
649
+ export function getSession(sessionId) {
650
+ const s = sessions.get(sessionId);
651
+ return s ? { ...s } : null;
652
+ }
653
+
654
+ // Create a session card immediately when SSH terminal connects (before hooks arrive)
655
+ export async function createTerminalSession(terminalId, config) {
656
+ const workDir = config.workingDir
657
+ ? (config.workingDir.startsWith('~') ? config.workingDir.replace(/^~/, homedir()) : config.workingDir)
658
+ : homedir();
659
+ const projectName = workDir === homedir() ? 'Home' : workDir.split('/').filter(Boolean).pop() || 'SSH Session';
660
+ // Build default title: projectName + label + counter
661
+ let defaultTitle = `${config.host || 'localhost'}:${workDir}`;
662
+ if (!config.sessionTitle && config.label) {
663
+ const counter = (projectSessionCounters.get(projectName) || 0) + 1;
664
+ projectSessionCounters.set(projectName, counter);
665
+ defaultTitle = `${projectName} ${config.label} #${counter}`;
666
+ }
667
+ const session = {
668
+ sessionId: terminalId,
669
+ projectPath: workDir,
670
+ projectName,
671
+ label: config.label || '',
672
+ title: config.sessionTitle || defaultTitle,
673
+ status: 'connecting',
674
+ animationState: 'Walking',
675
+ emote: 'Wave',
676
+ startedAt: Date.now(),
677
+ lastActivityAt: Date.now(),
678
+ currentPrompt: '',
679
+ promptHistory: [],
680
+ toolUsage: {},
681
+ totalToolCalls: 0,
682
+ model: '',
683
+ subagentCount: 0,
684
+ toolLog: [],
685
+ responseLog: [],
686
+ events: [{ type: 'TerminalCreated', detail: `SSH → ${config.host || 'localhost'}`, timestamp: Date.now() }],
687
+ archived: 0,
688
+ source: 'ssh',
689
+ pendingTool: null,
690
+ waitingDetail: null,
691
+ cachedPid: null,
692
+ queueCount: 0,
693
+ terminalId,
694
+ sshHost: config.host || 'localhost',
695
+ sshCommand: config.command || 'claude',
696
+ };
697
+ sessions.set(terminalId, session);
698
+
699
+ log.info('session', `Created terminal session ${terminalId} → ${config.host}:${workDir}`);
700
+
701
+ const { broadcast } = await import('./wsManager.js');
702
+ broadcast({ type: 'session_update', session: { ...session } });
703
+
704
+ // Non-Claude CLIs (codex, gemini, etc.) don't send hooks — auto-transition to idle
705
+ const command = config.command || 'claude';
706
+ if (!command.startsWith('claude')) {
707
+ setTimeout(async () => {
708
+ const s = sessions.get(terminalId);
709
+ if (s && s.status === 'connecting') {
710
+ s.status = 'idle';
711
+ s.animationState = 'Idle';
712
+ s.emote = null;
713
+ s.model = command; // Show command name as model
714
+ const { broadcast: bc } = await import('./wsManager.js');
715
+ bc({ type: 'session_update', session: { ...s } });
716
+ log.info('session', `Auto-transitioned non-Claude session ${terminalId} to idle (${command})`);
717
+ }
718
+ }, 3000);
719
+ }
720
+
721
+ return session;
722
+ }
723
+
724
+ export function linkTerminalToSession(sessionId, terminalId) {
725
+ const session = sessions.get(sessionId);
726
+ if (!session) return null;
727
+ session.terminalId = terminalId;
728
+ return { ...session };
729
+ }
730
+
731
+ export function updateQueueCount(sessionId, count) {
732
+ const session = sessions.get(sessionId);
733
+ if (!session) return null;
734
+ session.queueCount = count || 0;
735
+ return { ...session };
736
+ }
737
+
738
+ export function killSession(sessionId) {
739
+ const session = sessions.get(sessionId);
740
+ if (!session) return null;
741
+ session.status = 'ended';
742
+ session.animationState = 'Death';
743
+ session.archived = 1;
744
+ session.lastActivityAt = Date.now();
745
+ session.endedAt = Date.now();
746
+ // SSH sessions: keep in memory as historical (disconnected)
747
+ if (session.source === 'ssh') {
748
+ session.isHistorical = true;
749
+ session.terminalId = null;
750
+ } else {
751
+ setTimeout(() => sessions.delete(sessionId), 10000);
752
+ }
753
+ return { ...session };
754
+ }
755
+
756
+ export function deleteSessionFromMemory(sessionId) {
757
+ const session = sessions.get(sessionId);
758
+ if (!session) return false;
759
+ // Release PID cache
760
+ if (session.cachedPid) {
761
+ pidToSession.delete(session.cachedPid);
762
+ }
763
+ // Team cleanup
764
+ handleTeamMemberEnd(sessionId);
765
+ sessions.delete(sessionId);
766
+ return true;
767
+ }
768
+
769
+ export function setSessionTitle(sessionId, title) {
770
+ const session = sessions.get(sessionId);
771
+ if (session) session.title = title;
772
+ return session ? { ...session } : null;
773
+ }
774
+
775
+ export function setSessionLabel(sessionId, label) {
776
+ const session = sessions.get(sessionId);
777
+ if (session) session.label = label;
778
+ return session ? { ...session } : null;
779
+ }
780
+
781
+ export function setSummary(sessionId, summary) {
782
+ const session = sessions.get(sessionId);
783
+ if (session) session.summary = summary;
784
+ return session ? { ...session } : null;
785
+ }
786
+
787
+ export function setSessionAccentColor(sessionId, color) {
788
+ const session = sessions.get(sessionId);
789
+ if (session) session.accentColor = color;
790
+ }
791
+
792
+ export function setSessionCharacterModel(sessionId, model) {
793
+ const session = sessions.get(sessionId);
794
+ if (session) session.characterModel = model;
795
+ return session ? { ...session } : null;
796
+ }
797
+
798
+ export function archiveSession(sessionId, archived) {
799
+ const session = sessions.get(sessionId);
800
+ if (session) session.archived = archived ? 1 : 0;
801
+ return session ? { ...session } : null;
802
+ }
803
+
804
+ // Auto-idle: mark sessions as idle if no activity for a while
805
+ // Timeouts are defined in config.js (AUTO_IDLE_TIMEOUTS)
806
+ setInterval(() => {
807
+ const now = Date.now();
808
+ for (const [id, session] of sessions) {
809
+ if (session.status === 'ended' || session.status === 'idle') continue;
810
+ const elapsed = now - session.lastActivityAt;
811
+
812
+ if (session.status === 'approval' && elapsed > AUTO_IDLE_TIMEOUTS.approval) {
813
+ session.status = 'idle';
814
+ session.animationState = 'Idle';
815
+ session.emote = null;
816
+ session.pendingTool = null;
817
+ session.pendingToolDetail = null;
818
+ session.waitingDetail = null;
819
+ } else if (session.status === 'input' && elapsed > AUTO_IDLE_TIMEOUTS.input) {
820
+ session.status = 'idle';
821
+ session.animationState = 'Idle';
822
+ session.emote = null;
823
+ session.pendingTool = null;
824
+ session.pendingToolDetail = null;
825
+ session.waitingDetail = null;
826
+ } else if (session.status === 'prompting' && elapsed > AUTO_IDLE_TIMEOUTS.prompting) {
827
+ session.status = 'waiting';
828
+ session.animationState = 'Waiting';
829
+ session.emote = null;
830
+ } else if (session.status === 'waiting' && elapsed > AUTO_IDLE_TIMEOUTS.waiting) {
831
+ session.status = 'idle';
832
+ session.animationState = 'Idle';
833
+ session.emote = null;
834
+ } else if (session.status !== 'waiting' && session.status !== 'prompting'
835
+ && session.status !== 'approval' && session.status !== 'input'
836
+ && elapsed > AUTO_IDLE_TIMEOUTS.working) {
837
+ session.status = 'idle';
838
+ session.animationState = 'Idle';
839
+ session.emote = null;
840
+ }
841
+ }
842
+ }, 10000);
843
+
844
+ // ---- Process Liveness Monitor ----
845
+ setInterval(async () => {
846
+ for (const [id, session] of sessions) {
847
+ if (session.status === 'ended') continue;
848
+ if (!session.cachedPid) continue;
849
+
850
+ // Skip sessions with active terminal — the PTY is the source of truth
851
+ if (session.terminalId && getTerminalForSession(id)) continue;
852
+
853
+ try {
854
+ process.kill(session.cachedPid, 0); // signal 0 = liveness check, doesn't kill
855
+ } catch {
856
+ // Process is dead — auto-end this session
857
+ console.log(`[processMonitor] pid=${session.cachedPid} is dead → ending session=${id.slice(0,8)}`);
858
+
859
+ session.status = 'ended';
860
+ session.animationState = 'Death';
861
+ session.lastActivityAt = Date.now();
862
+ session.endedAt = Date.now();
863
+
864
+ session.events.push({
865
+ type: 'SessionEnd',
866
+ timestamp: Date.now(),
867
+ detail: 'Session ended (process exited)'
868
+ });
869
+ if (session.events.length > 50) session.events.shift();
870
+
871
+ // Release PID cache
872
+ pidToSession.delete(session.cachedPid);
873
+ session.cachedPid = null;
874
+
875
+ // Clear any pending tool timer
876
+ clearTimeout(pendingToolTimers.get(id));
877
+ pendingToolTimers.delete(id);
878
+ session.pendingTool = null;
879
+ session.pendingToolDetail = null;
880
+ session.waitingDetail = null;
881
+
882
+ // Team cleanup
883
+ handleTeamMemberEnd(id);
884
+
885
+ // Broadcast to connected browsers
886
+ try {
887
+ const { broadcast } = await import('./wsManager.js');
888
+ broadcast({ type: 'session_update', session: { ...session } });
889
+ } catch(e) {}
890
+
891
+ // SSH sessions: keep in memory as historical (disconnected)
892
+ if (session.source === 'ssh') {
893
+ session.isHistorical = true;
894
+ session.terminalId = null;
895
+ } else {
896
+ setTimeout(() => sessions.delete(id), 10000);
897
+ }
898
+ }
899
+ }
900
+ }, PROCESS_CHECK_INTERVAL);
901
+
902
+ // Detect where a hook-only session originated from environment variables
903
+ function detectHookSource(hookData) {
904
+ if (hookData.vscode_pid) return 'vscode';
905
+ const tp = (hookData.term_program || '').toLowerCase();
906
+ if (tp.includes('vscode') || tp.includes('code')) return 'vscode';
907
+ if (tp.includes('jetbrains') || tp.includes('intellij') || tp.includes('idea') || tp.includes('webstorm') || tp.includes('pycharm') || tp.includes('goland') || tp.includes('clion') || tp.includes('phpstorm') || tp.includes('rider') || tp.includes('rubymine') || tp.includes('datagrip')) return 'jetbrains';
908
+ if (tp.includes('iterm')) return 'iterm';
909
+ if (tp.includes('warp')) return 'warp';
910
+ if (tp.includes('kitty')) return 'kitty';
911
+ if (tp.includes('ghostty') || hookData.is_ghostty) return 'ghostty';
912
+ if (tp.includes('alacritty')) return 'alacritty';
913
+ if (tp.includes('wezterm') || hookData.wezterm_pane) return 'wezterm';
914
+ if (tp.includes('hyper')) return 'hyper';
915
+ if (tp.includes('apple_terminal') || tp === 'apple_terminal') return 'terminal';
916
+ if (hookData.tmux) return 'tmux';
917
+ if (tp) return tp;
918
+ return 'terminal';
919
+ }
920
+
921
+ export function detectSessionSource(sessionId) {
922
+ const session = sessions.get(sessionId);
923
+ if (!session) return 'unknown';
924
+ return session.source || 'ssh';
925
+ }
926
+
927
+ export function findClaudeProcess(sessionId, projectPath) {
928
+ const session = sessionId ? sessions.get(sessionId) : null;
929
+ if (session?.cachedPid) {
930
+ try {
931
+ execSync(`kill -0 ${session.cachedPid} 2>/dev/null`, { timeout: 1000 });
932
+ console.log(`[findProcess] session=${sessionId?.slice(0,8)} → cached pid=${session.cachedPid}`);
933
+ return session.cachedPid;
934
+ } catch {
935
+ console.log(`[findProcess] session=${sessionId?.slice(0,8)} cached pid=${session.cachedPid} is dead, re-scanning`);
936
+ pidToSession.delete(session.cachedPid);
937
+ session.cachedPid = null;
938
+ }
939
+ }
940
+
941
+ const myPid = process.pid;
942
+ console.log(`[findProcess] ── session=${sessionId?.slice(0,8)} projectPath=${projectPath}`);
943
+
944
+ const claimedPids = new Set();
945
+ for (const [pid, sid] of pidToSession) {
946
+ if (sid !== sessionId) claimedPids.add(pid);
947
+ }
948
+ if (claimedPids.size > 0) {
949
+ console.log(`[findProcess] PIDs claimed by other sessions: [${[...claimedPids].join(', ')}]`);
950
+ }
951
+
952
+ try {
953
+ if (process.platform === 'win32') {
954
+ if (!projectPath) return null;
955
+ const psScript = `
956
+ $procs = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*claude*' -and $_.ProcessId -ne ${myPid} }
957
+ foreach ($p in $procs) {
958
+ try {
959
+ $proc = Get-Process -Id $p.ProcessId -ErrorAction Stop
960
+ if ($proc.Path) {
961
+ $cwd = (Get-Process -Id $p.ProcessId).Path | Split-Path
962
+ }
963
+ } catch {}
964
+ }
965
+ if ($procs.Count -gt 0) { $procs[0].ProcessId }
966
+ `;
967
+ const out = execSync(
968
+ `powershell -NoProfile -Command "${psScript.replace(/"/g, '\\"')}"`,
969
+ { encoding: 'utf-8', timeout: 5000 }
970
+ );
971
+ const pid = parseInt(out.trim(), 10);
972
+ if (pid > 0) cachePid(pid, sessionId, session);
973
+ return pid > 0 ? pid : null;
974
+ } else {
975
+ const pidsOut = execSync(`pgrep -f claude 2>/dev/null || true`, { encoding: 'utf-8', timeout: 5000 });
976
+ const pids = pidsOut.trim().split('\n')
977
+ .map(p => parseInt(p.trim(), 10))
978
+ .filter(p => p > 0 && p !== myPid);
979
+
980
+ console.log(`[findProcess] pgrep found ${pids.length} claude pids: [${pids.join(', ')}]`);
981
+
982
+ if (pids.length === 0) return null;
983
+
984
+ if (projectPath) {
985
+ for (const pid of pids) {
986
+ if (claimedPids.has(pid)) {
987
+ console.log(`[findProcess] pid=${pid} SKIP (claimed by session ${pidToSession.get(pid)?.slice(0,8)})`);
988
+ continue;
989
+ }
990
+ try {
991
+ let cwd;
992
+ if (process.platform === 'darwin') {
993
+ const out = execSync(`lsof -a -d cwd -Fn -p ${pid} 2>/dev/null | grep '^n'`, { encoding: 'utf-8', timeout: 3000 });
994
+ cwd = out.trim().replace(/^n/, '');
995
+ } else {
996
+ cwd = execSync(`readlink /proc/${pid}/cwd 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 }).trim();
997
+ }
998
+ const match = cwd === projectPath;
999
+ console.log(`[findProcess] pid=${pid} cwd="${cwd}" ${match ? '✓ MATCH' : '✗ no match'}`);
1000
+ if (match) {
1001
+ cachePid(pid, sessionId, session);
1002
+ return pid;
1003
+ }
1004
+ } catch(e) {
1005
+ console.log(`[findProcess] pid=${pid} cwd lookup failed: ${e.message?.split('\n')[0]}`);
1006
+ continue;
1007
+ }
1008
+ }
1009
+ console.log(`[findProcess] no cwd match found, trying tty fallback`);
1010
+ }
1011
+
1012
+ for (const pid of pids) {
1013
+ if (claimedPids.has(pid)) continue;
1014
+ try {
1015
+ const tty = execSync(`ps -o tty= -p ${pid}`, { encoding: 'utf-8', timeout: 3000 }).trim();
1016
+ console.log(`[findProcess] fallback pid=${pid} tty=${tty || 'NONE'}`);
1017
+ if (tty && tty !== '??' && tty !== '?') {
1018
+ console.log(`[findProcess] FALLBACK returning pid=${pid} (first unclaimed with tty)`);
1019
+ cachePid(pid, sessionId, session);
1020
+ return pid;
1021
+ }
1022
+ } catch(e) { continue; }
1023
+ }
1024
+
1025
+ const unclaimed = pids.find(p => !claimedPids.has(p));
1026
+ console.log(`[findProcess] last resort returning pid=${unclaimed || 'null'}`);
1027
+ if (unclaimed) cachePid(unclaimed, sessionId, session);
1028
+ return unclaimed || null;
1029
+ }
1030
+ } catch(e) {
1031
+ console.log(`[findProcess] ERROR: ${e.message}`);
1032
+ }
1033
+ return null;
1034
+ }
1035
+
1036
+ function hasChildProcesses(pid) {
1037
+ try {
1038
+ const out = execSync(`pgrep -P ${pid} 2>/dev/null`, { encoding: 'utf-8', timeout: 2000 });
1039
+ return out.trim().length > 0;
1040
+ } catch {
1041
+ return false;
1042
+ }
1043
+ }
1044
+
1045
+ function cachePid(pid, sessionId, session) {
1046
+ pidToSession.set(pid, sessionId);
1047
+ if (session) session.cachedPid = pid;
1048
+ console.log(`[findProcess] CACHED pid=${pid} → session=${sessionId?.slice(0,8)}`);
1049
+ }