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
@@ -166,7 +166,20 @@ export function matchSession(hookData, sessions, pendingResume, pidToSession, pr
166
166
  }
167
167
  }
168
168
 
169
- if (session) return session;
169
+ if (session) {
170
+ // When `claude --resume` reuses the same session_id, the session is found
171
+ // by direct Map lookup and we return early. But pendingResume/pendingLinks
172
+ // entries (registered by reconnectSessionTerminal / createTerminal) are left
173
+ // dangling. Clean them up here to prevent stale matches for future hooks.
174
+ if (hook_event_name === EVENT_TYPES.SESSION_START && session.terminalId) {
175
+ if (pendingResume.has(session.terminalId)) {
176
+ pendingResume.delete(session.terminalId);
177
+ log.debug('session', `Cleaned stale pendingResume for terminal=${session.terminalId?.slice(0,8)} (session found by direct ID)`);
178
+ }
179
+ consumePendingLink(session.projectPath);
180
+ }
181
+ return session;
182
+ }
170
183
 
171
184
  // Session not found — try matching strategies
172
185
 
@@ -344,12 +357,10 @@ export function matchSession(hookData, sessions, pendingResume, pidToSession, pr
344
357
  }
345
358
  }
346
359
  if (!found) {
347
- // No SSH terminal match create a display-only card with detected source
360
+ // No matching dashboard terminal — ignore external sessions (VS Code, iTerm, etc.)
348
361
  const detectedSource = detectHookSource(hookData);
349
- log.info('session', `Creating display-only session ${session_id?.slice(0,8)} source=${detectedSource} cwd=${cwd}`);
350
- const inheritedConfig = findSshConfig(sessions, hookData.agent_terminal_id, cwd);
351
- session = createDefaultSession(session_id, cwd, hookData, inheritedConfig ? 'ssh' : detectedSource, hookData.agent_terminal_id || null, inheritedConfig);
352
- sessions.set(session_id, session);
362
+ log.debug('session', `Ignoring external session ${session_id?.slice(0,8)} source=${detectedSource} cwd=${cwd}`);
363
+ return null;
353
364
  }
354
365
  }
355
366
  }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * @module sessionMatcher
3
+ * 5-priority session matching system that maps incoming hook events to existing sessions.
4
+ * Priorities: pendingResume > agent_terminal_id > workDir link > path scan > PID fallback.
5
+ * Also detects hook source (terminal type) from environment variables.
6
+ */
7
+ import { tryLinkByWorkDir, getTerminalByPtyChild, consumePendingLink } from './sshManager.js';
8
+ import { EVENT_TYPES, SESSION_STATUS, ANIMATION_STATE } from './constants.js';
9
+ import log from './logger.js';
10
+ import type { Session, SessionSource, SshConfig, PendingResume } from '../src/types/session.js';
11
+ import type { HookPayloadBase } from '../src/types/hook.js';
12
+
13
+ /**
14
+ * Detect where a hook-only session originated from environment variables.
15
+ */
16
+ export function detectHookSource(hookData: HookPayloadBase): SessionSource | string {
17
+ if (hookData.vscode_pid) return 'vscode';
18
+ const tp = (hookData.term_program || '').toLowerCase();
19
+ if (tp.includes('vscode') || tp.includes('code')) return 'vscode';
20
+ 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';
21
+ if (tp.includes('iterm')) return 'iterm';
22
+ if (tp.includes('warp')) return 'warp';
23
+ if (tp.includes('kitty')) return 'kitty';
24
+ if (tp.includes('ghostty') || hookData.is_ghostty) return 'ghostty';
25
+ if (tp.includes('alacritty')) return 'alacritty';
26
+ if (tp.includes('wezterm') || hookData.wezterm_pane) return 'wezterm';
27
+ if (tp.includes('hyper')) return 'hyper';
28
+ if (tp.includes('apple_terminal') || tp === 'apple_terminal') return 'terminal';
29
+ if (hookData.tmux) return 'tmux';
30
+ if (tp) return tp;
31
+ return 'terminal';
32
+ }
33
+
34
+ /**
35
+ * Re-key a resumed session: transfer from old sessionId to new, reset state for fresh session.
36
+ * Note: previousSessions is intentionally preserved (not reset) to maintain history chain.
37
+ */
38
+ export function reKeyResumedSession(
39
+ sessions: Map<string, Session>,
40
+ oldSession: Session,
41
+ newSessionId: string,
42
+ oldSessionId: string,
43
+ pidToSession?: Map<number, string>,
44
+ ): Session {
45
+ sessions.delete(oldSessionId);
46
+
47
+ // Clear stale PID mapping — next hook will re-cache with the new session ID
48
+ if (pidToSession && oldSession.cachedPid) {
49
+ pidToSession.delete(oldSession.cachedPid);
50
+ oldSession.cachedPid = null;
51
+ }
52
+
53
+ oldSession.replacesId = oldSessionId;
54
+ oldSession.sessionId = newSessionId;
55
+ oldSession.status = SESSION_STATUS.IDLE;
56
+ oldSession.animationState = ANIMATION_STATE.IDLE;
57
+ oldSession.emote = null;
58
+ oldSession.startedAt = Date.now();
59
+ oldSession.endedAt = null;
60
+ oldSession.isHistorical = false;
61
+ oldSession.currentPrompt = '';
62
+ oldSession.totalToolCalls = 0;
63
+ oldSession.toolUsage = {};
64
+ oldSession.promptHistory = [];
65
+ oldSession.toolLog = [];
66
+ oldSession.responseLog = [];
67
+ oldSession.events = [{ type: 'SessionResumed', timestamp: Date.now(), detail: `Resumed from ${oldSessionId?.slice(0, 8)}` }];
68
+ sessions.set(newSessionId, oldSession);
69
+ return oldSession;
70
+ }
71
+
72
+ /**
73
+ * Create a default session object.
74
+ */
75
+ function createDefaultSession(
76
+ session_id: string,
77
+ cwd: string | undefined,
78
+ hookData: HookPayloadBase,
79
+ source: string,
80
+ terminalId: string | null,
81
+ sshConfig?: SshConfig | null,
82
+ ): Session {
83
+ const session: Session = {
84
+ sessionId: session_id,
85
+ projectPath: cwd || '',
86
+ projectName: cwd ? cwd.split('/').filter(Boolean).pop() || 'Unknown' : 'Unknown',
87
+ title: '',
88
+ status: SESSION_STATUS.IDLE,
89
+ animationState: ANIMATION_STATE.IDLE,
90
+ emote: null,
91
+ startedAt: Date.now(),
92
+ lastActivityAt: Date.now(),
93
+ endedAt: null,
94
+ currentPrompt: '',
95
+ promptHistory: [],
96
+ toolUsage: {},
97
+ totalToolCalls: 0,
98
+ model: hookData.model || '',
99
+ subagentCount: 0,
100
+ toolLog: [],
101
+ responseLog: [],
102
+ events: [],
103
+ archived: 0,
104
+ source,
105
+ pendingTool: null,
106
+ waitingDetail: null,
107
+ cachedPid: null,
108
+ queueCount: 0,
109
+ terminalId: terminalId || null,
110
+ };
111
+ if (sshConfig) session.sshConfig = { ...sshConfig };
112
+ return session;
113
+ }
114
+
115
+ /**
116
+ * Find the sshConfig from any existing session that shares the given terminalId or projectPath.
117
+ * Used to propagate SSH config when creating new sessions in already-known SSH terminals.
118
+ */
119
+ function findSshConfig(
120
+ sessions: Map<string, Session>,
121
+ terminalId: string | undefined,
122
+ cwd: string | undefined,
123
+ ): SshConfig | null {
124
+ // Prefer exact terminal match first
125
+ if (terminalId) {
126
+ for (const s of sessions.values()) {
127
+ if (s.sshConfig && (s.terminalId === terminalId || s.lastTerminalId === terminalId)) {
128
+ return s.sshConfig;
129
+ }
130
+ }
131
+ }
132
+ // Fallback: match by projectPath + source=ssh
133
+ if (cwd) {
134
+ const normalizedCwd = cwd.replace(/\/$/, '');
135
+ for (const s of sessions.values()) {
136
+ if (s.sshConfig && s.source === 'ssh' && s.projectPath?.replace(/\/$/, '') === normalizedCwd) {
137
+ return s.sshConfig;
138
+ }
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Match an incoming hook event to an existing session, or create a new one.
146
+ * Implements a 5-priority fallback system:
147
+ * Priority 0: pendingResume + terminal ID / workDir matching
148
+ * Priority 1: agent_terminal_id matching
149
+ * Priority 2: tryLinkByWorkDir matching
150
+ * Priority 3: scan pre-created sessions by path
151
+ * Priority 4: PID parent check
152
+ */
153
+ export function matchSession(
154
+ hookData: HookPayloadBase,
155
+ sessions: Map<string, Session>,
156
+ pendingResume: Map<string, PendingResume>,
157
+ pidToSession: Map<number, string>,
158
+ projectSessionCounters: Map<string, number>,
159
+ ): Session {
160
+ const { session_id, hook_event_name, cwd } = hookData;
161
+ let session = sessions.get(session_id);
162
+
163
+ // Cache all process/tab info from hook's enriched environment data
164
+ if (hookData.claude_pid) {
165
+ const pid = Number(hookData.claude_pid);
166
+ if (pid > 0 && session && session.cachedPid !== pid) {
167
+ if (session.cachedPid) pidToSession.delete(session.cachedPid);
168
+ session.cachedPid = pid;
169
+ pidToSession.set(pid, session_id);
170
+ log.debug('session', `CACHED pid=${pid} -> session=${session_id?.slice(0, 8)}`);
171
+ }
172
+ }
173
+
174
+ if (session) {
175
+ // When `claude --resume` reuses the same session_id, the session is found
176
+ // by direct Map lookup and we return early. But pendingResume/pendingLinks
177
+ // entries (registered by reconnectSessionTerminal / createTerminal) are left
178
+ // dangling. Clean them up here to prevent stale matches for future hooks.
179
+ if (hook_event_name === EVENT_TYPES.SESSION_START && session.terminalId) {
180
+ if (pendingResume.has(session.terminalId)) {
181
+ pendingResume.delete(session.terminalId);
182
+ log.debug('session', `Cleaned stale pendingResume for terminal=${session.terminalId?.slice(0, 8)} (session found by direct ID)`);
183
+ }
184
+ consumePendingLink(session.projectPath);
185
+ }
186
+ return session;
187
+ }
188
+
189
+ // Session not found — try matching strategies
190
+
191
+ // Priority 0: Check if this new session matches a pending resume request
192
+ if (hook_event_name === EVENT_TYPES.SESSION_START) {
193
+ const termId = hookData.agent_terminal_id;
194
+ // Match by agent_terminal_id
195
+ if (termId && pendingResume.has(termId)) {
196
+ const pending = pendingResume.get(termId)!;
197
+ pendingResume.delete(termId);
198
+ const oldSession = sessions.get(pending.oldSessionId);
199
+ if (oldSession) {
200
+ session = reKeyResumedSession(sessions, oldSession, session_id, pending.oldSessionId, pidToSession);
201
+ log.info('session', `RESUME: Re-keyed session ${pending.oldSessionId?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (via pending resume + terminal ID)`);
202
+ }
203
+ }
204
+ // Fallback: match by projectPath — only if exactly one pendingResume matches.
205
+ // When multiple pending resumes share the same projectPath, path alone is
206
+ // ambiguous and we'd link the wrong session.
207
+ if (!session) {
208
+ const pathMatches: Array<{ pTermId: string; pending: PendingResume; oldSession: Session }> = [];
209
+ const normalizedCwd = (cwd || '').replace(/\/$/, '');
210
+ for (const [pTermId, pending] of pendingResume) {
211
+ const oldSession = sessions.get(pending.oldSessionId);
212
+ if (oldSession && oldSession.projectPath) {
213
+ const normalizedSessionPath = oldSession.projectPath.replace(/\/$/, '');
214
+ if (normalizedSessionPath === normalizedCwd) {
215
+ pathMatches.push({ pTermId, pending, oldSession });
216
+ }
217
+ }
218
+ }
219
+ if (pathMatches.length === 1) {
220
+ const { pTermId, pending, oldSession } = pathMatches[0];
221
+ pendingResume.delete(pTermId);
222
+ session = reKeyResumedSession(sessions, oldSession, session_id, pending.oldSessionId, pidToSession);
223
+ log.info('session', `RESUME: Re-keyed session ${pending.oldSessionId?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (via pending resume + workDir match, 1 candidate)`);
224
+ } else if (pathMatches.length > 1) {
225
+ log.info('session', `SKIP RESUME path match: ${pathMatches.length} pending resumes for cwd=${normalizedCwd} — ambiguous`);
226
+ }
227
+ }
228
+ // Clean up stale pendingLinks from sshManager.createTerminal() so they
229
+ // don't create a duplicate session at Priority 2
230
+ if (session && session.projectPath) {
231
+ consumePendingLink(session.projectPath);
232
+ }
233
+ }
234
+
235
+ // Priority 0.5: Auto-link to snapshot-restored ended session by projectPath.
236
+ // After server restart, sessions with dead PIDs are marked ended with a
237
+ // 'ServerRestart' event. When `claude --resume` sends a SessionStart with
238
+ // a NEW session_id in the same directory, re-key the old card instead of
239
+ // creating a duplicate.
240
+ // IMPORTANT: Only auto-link when there is exactly ONE candidate. When
241
+ // multiple sessions share the same projectPath, path alone is ambiguous
242
+ // and we'd link the wrong card. In that case, skip and let a new card
243
+ // be created — the user can manually resume/reconnect.
244
+ if (!session && hook_event_name === EVENT_TYPES.SESSION_START && cwd) {
245
+ const normalizedCwd = cwd.replace(/\/$/, '');
246
+ const candidates: Array<{ oldId: string; s: Session; endedAt: number }> = [];
247
+ for (const [oldId, s] of sessions) {
248
+ if (s.projectPath?.replace(/\/$/, '') !== normalizedCwd) continue;
249
+ // Match 1: Ended sessions with ServerRestart event (standard post-restart case)
250
+ if (s.status === SESSION_STATUS.ENDED
251
+ && s.events?.some(e => e.type === 'ServerRestart')
252
+ && (Date.now() - (s.endedAt || 0)) < 30 * 60 * 1000) {
253
+ candidates.push({ oldId, s, endedAt: s.endedAt || 0 });
254
+ }
255
+ // Match 2: Safety net — zombie SSH sessions that slipped through cleanup
256
+ // (non-ended, source=ssh, no terminalId, stale for >60s)
257
+ if (s.source === 'ssh'
258
+ && s.status !== SESSION_STATUS.ENDED
259
+ && !s.terminalId
260
+ && s.lastActivityAt && (Date.now() - s.lastActivityAt) > 60_000) {
261
+ candidates.push({ oldId, s, endedAt: s.lastActivityAt || 0 });
262
+ }
263
+ }
264
+ if (candidates.length === 1) {
265
+ const match = candidates[0];
266
+ session = reKeyResumedSession(sessions, match.s, session_id, match.oldId, pidToSession);
267
+ log.info('session', `AUTO-RESUME: Re-keyed ${match.oldId?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (snapshot restore + projectPath match, 1 candidate)`);
268
+ } else if (candidates.length > 1) {
269
+ log.info('session', `SKIP AUTO-RESUME: ${candidates.length} candidates for cwd=${normalizedCwd} — ambiguous, creating new card`);
270
+ }
271
+ }
272
+
273
+ // Priority 1: Direct match via AGENT_MANAGER_TERMINAL_ID (injected into pty env)
274
+ if (!session && hookData.agent_terminal_id) {
275
+ const preSession = sessions.get(hookData.agent_terminal_id);
276
+ if (preSession && preSession.terminalId) {
277
+ sessions.delete(hookData.agent_terminal_id);
278
+ preSession.sessionId = session_id;
279
+ preSession.replacesId = hookData.agent_terminal_id;
280
+ session = preSession;
281
+ sessions.set(session_id, session);
282
+ log.info('session', `Re-keyed terminal session ${hookData.agent_terminal_id} -> ${session_id?.slice(0, 8)} (via terminal ID)`);
283
+ }
284
+ }
285
+
286
+ // Priority 2: Match via pending workDir link
287
+ if (!session) {
288
+ const linkedTerminalId = tryLinkByWorkDir(cwd || '', session_id);
289
+ if (linkedTerminalId) {
290
+ // Check if there's a pre-created terminal session with this terminalId as its key
291
+ let preSession = sessions.get(linkedTerminalId);
292
+ if (preSession) {
293
+ sessions.delete(linkedTerminalId);
294
+ preSession.sessionId = session_id;
295
+ preSession.replacesId = linkedTerminalId;
296
+ session = preSession;
297
+ sessions.set(session_id, session);
298
+ log.info('session', `Re-keyed terminal session ${linkedTerminalId} -> ${session_id?.slice(0, 8)} (via workDir link)`);
299
+ }
300
+ // Fallback: scan for a session that owns this terminal (resume case —
301
+ // reconnectSessionTerminal keeps the session under its old ID, not the terminal ID)
302
+ if (!session) {
303
+ for (const [key, s] of sessions) {
304
+ if (s.terminalId === linkedTerminalId) {
305
+ sessions.delete(key);
306
+ s.sessionId = session_id;
307
+ s.replacesId = key;
308
+ session = s;
309
+ sessions.set(session_id, session);
310
+ log.info('session', `Re-keyed resumed session ${key?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (via workDir link + terminalId scan)`);
311
+ break;
312
+ }
313
+ }
314
+ }
315
+ if (!session) {
316
+ log.info('session', `NEW SSH SESSION ${session_id?.slice(0, 8)} — terminal=${linkedTerminalId}`);
317
+ const inheritedConfig = findSshConfig(sessions, linkedTerminalId, cwd);
318
+ session = createDefaultSession(session_id, cwd, hookData, 'ssh', linkedTerminalId, inheritedConfig);
319
+ sessions.set(session_id, session);
320
+ }
321
+ } else {
322
+ // Priority 3: Scan pre-created sessions by normalized path.
323
+ // Only match when exactly one CONNECTING session shares the path —
324
+ // multiple CONNECTING sessions in the same dir means ambiguity.
325
+ let found = false;
326
+ const normalizedCwd = (cwd || '').replace(/\/$/, '');
327
+ const connectingMatches: Array<{ key: string; s: Session }> = [];
328
+ for (const [key, s] of sessions) {
329
+ if (s.terminalId && s.status === SESSION_STATUS.CONNECTING && s.projectPath) {
330
+ const normalizedSessionPath = s.projectPath.replace(/\/$/, '');
331
+ if (normalizedSessionPath === normalizedCwd || s.projectPath === cwd) {
332
+ connectingMatches.push({ key, s });
333
+ }
334
+ }
335
+ }
336
+ if (connectingMatches.length === 1) {
337
+ const { key, s } = connectingMatches[0];
338
+ sessions.delete(key);
339
+ s.sessionId = session_id;
340
+ s.replacesId = key;
341
+ session = s;
342
+ sessions.set(session_id, session);
343
+ log.info('session', `Re-keyed terminal session ${key} -> ${session_id?.slice(0, 8)} (via path scan, 1 candidate)`);
344
+ found = true;
345
+ } else if (connectingMatches.length > 1) {
346
+ log.info('session', `SKIP path scan: ${connectingMatches.length} CONNECTING sessions for cwd=${normalizedCwd} — ambiguous`);
347
+ }
348
+ // Priority 4: PID-based fallback — check if Claude's parent is a known pty
349
+ if (!found && hookData.claude_pid) {
350
+ const pidTerminalId = getTerminalByPtyChild(Number(hookData.claude_pid));
351
+ if (pidTerminalId) {
352
+ const preSession = sessions.get(pidTerminalId);
353
+ if (preSession && preSession.terminalId) {
354
+ sessions.delete(pidTerminalId);
355
+ preSession.sessionId = session_id;
356
+ preSession.replacesId = pidTerminalId;
357
+ session = preSession;
358
+ sessions.set(session_id, session);
359
+ log.info('session', `Re-keyed terminal session ${pidTerminalId} -> ${session_id?.slice(0, 8)} (via PID fallback)`);
360
+ found = true;
361
+ }
362
+ }
363
+ }
364
+ if (!found) {
365
+ // No SSH terminal match — create a display-only card with detected source
366
+ const detectedSource = detectHookSource(hookData);
367
+ log.info('session', `Creating display-only session ${session_id?.slice(0, 8)} source=${detectedSource} cwd=${cwd}`);
368
+ const inheritedConfig = findSshConfig(sessions, hookData.agent_terminal_id, cwd);
369
+ session = createDefaultSession(session_id, cwd, hookData, inheritedConfig ? 'ssh' : detectedSource, hookData.agent_terminal_id || null, inheritedConfig);
370
+ sessions.set(session_id, session);
371
+ }
372
+ }
373
+ }
374
+
375
+ // Cache PID from hook
376
+ const pid = hookData.claude_pid ? Number(hookData.claude_pid) : null;
377
+ if (pid && pid > 0) {
378
+ session!.cachedPid = pid;
379
+ pidToSession.set(pid, session_id);
380
+ log.debug('session', `CACHED pid=${pid} -> session=${session_id?.slice(0, 8)} (new session)`);
381
+ }
382
+
383
+ // Store team-related fields from enriched hook data
384
+ if (hookData.agent_name && !session!.agentName) {
385
+ session!.agentName = hookData.agent_name;
386
+ }
387
+ if (hookData.agent_type && !session!.agentType) {
388
+ session!.agentType = hookData.agent_type;
389
+ }
390
+ if (hookData.team_name && !session!.teamName) {
391
+ session!.teamName = hookData.team_name;
392
+ }
393
+ if (hookData.agent_color && !session!.agentColor) {
394
+ session!.agentColor = hookData.agent_color;
395
+ }
396
+
397
+ // Increment per-project session counter
398
+ const projectKey = session!.projectName;
399
+ const count = (projectSessionCounters.get(projectKey) || 0) + 1;
400
+ projectSessionCounters.set(projectKey, count);
401
+
402
+ return session!;
403
+ }
@@ -122,7 +122,9 @@ export function saveSnapshot(mqOffset) {
122
122
  }
123
123
  const sessionsObj = {};
124
124
  for (const [id, session] of sessions) {
125
- sessionsObj[id] = { ...session };
125
+ // Always key by sessionId to prevent Map key / sessionId divergence
126
+ const key = session.sessionId || id;
127
+ sessionsObj[key] = { ...session };
126
128
  }
127
129
  const countersObj = {};
128
130
  for (const [name, count] of projectSessionCounters) {
@@ -130,6 +132,10 @@ export function saveSnapshot(mqOffset) {
130
132
  }
131
133
  const pidObj = {};
132
134
  for (const [pid, sid] of pidToSession) pidObj[pid] = sid;
135
+ const pendingResumeObj = {};
136
+ for (const [termId, info] of pendingResume) {
137
+ pendingResumeObj[termId] = info;
138
+ }
133
139
  const snapshot = {
134
140
  version: 1,
135
141
  savedAt: Date.now(),
@@ -138,6 +144,7 @@ export function saveSnapshot(mqOffset) {
138
144
  sessions: sessionsObj,
139
145
  projectSessionCounters: countersObj,
140
146
  pidToSession: pidObj,
147
+ pendingResume: pendingResumeObj,
141
148
  };
142
149
  mkdirSync(SNAPSHOT_DIR, { recursive: true });
143
150
  const tmpFile = SNAPSHOT_FILE + '.tmp';
@@ -318,13 +325,88 @@ export function loadSnapshot() {
318
325
  }
319
326
  }
320
327
 
328
+ // Restore pendingResume entries — these survive Ctrl+C so Priority 0
329
+ // can disambiguate "which session was being resumed" on next start.
330
+ // Terminal IDs are stale (PTYs died), but the path-based fallback in
331
+ // Priority 0 only needs oldSessionId + projectPath to match.
332
+ let pendingResumeRestored = 0;
333
+ if (snapshot.pendingResume) {
334
+ for (const [termId, info] of Object.entries(snapshot.pendingResume)) {
335
+ // Only restore if the referenced session still exists in the map
336
+ if (info.oldSessionId && sessions.has(info.oldSessionId)) {
337
+ pendingResume.set(termId, {
338
+ ...info,
339
+ // Refresh timestamp so autoIdleManager's 2-minute cleanup
340
+ // doesn't immediately garbage-collect restored entries
341
+ timestamp: Date.now(),
342
+ });
343
+ pendingResumeRestored++;
344
+ }
345
+ }
346
+ }
347
+
321
348
  // Restore eventSeq
322
349
  if (snapshot.eventSeq) {
323
350
  eventSeq = snapshot.eventSeq;
324
351
  }
325
352
 
353
+ // Repair any Map key / sessionId mismatches (defensive — prevents duplicate cards)
354
+ let keyRepairs = 0;
355
+ const repairList = [];
356
+ for (const [id, session] of sessions) {
357
+ if (session.sessionId && id !== session.sessionId) {
358
+ repairList.push({ oldKey: id, newKey: session.sessionId, session });
359
+ }
360
+ }
361
+ for (const { oldKey, newKey, session } of repairList) {
362
+ sessions.delete(oldKey);
363
+ // If the correct key already exists, keep the newer session
364
+ if (sessions.has(newKey)) {
365
+ const existing = sessions.get(newKey);
366
+ if ((session.lastActivityAt || 0) > (existing.lastActivityAt || 0)) {
367
+ sessions.set(newKey, session);
368
+ }
369
+ } else {
370
+ sessions.set(newKey, session);
371
+ }
372
+ keyRepairs++;
373
+ }
374
+ if (keyRepairs > 0) {
375
+ log.warn('session', `Repaired ${keyRepairs} Map key/sessionId mismatches in snapshot`);
376
+ }
377
+
378
+ // Deduplicate: remove sessions with identical projectPath+source that are ended
379
+ // and whose sessionId differs (stale leftover from interrupted re-key)
380
+ const seenPaths = new Map(); // projectPath -> [sessionId, ...]
381
+ const dupsToRemove = [];
382
+ for (const [id, session] of sessions) {
383
+ if (session.status !== SESSION_STATUS.ENDED || !session.projectPath) continue;
384
+ const key = `${session.projectPath}|${session.source}`;
385
+ if (!seenPaths.has(key)) {
386
+ seenPaths.set(key, [id]);
387
+ } else {
388
+ seenPaths.get(key).push(id);
389
+ }
390
+ }
391
+ for (const [, ids] of seenPaths) {
392
+ if (ids.length <= 1) continue;
393
+ // Keep the one with the most recent activity, remove the rest
394
+ const sorted = ids
395
+ .map(id => ({ id, lastActivity: sessions.get(id).lastActivityAt || 0 }))
396
+ .sort((a, b) => b.lastActivity - a.lastActivity);
397
+ for (let i = 1; i < sorted.length; i++) {
398
+ dupsToRemove.push(sorted[i].id);
399
+ }
400
+ }
401
+ if (dupsToRemove.length > 0) {
402
+ for (const id of dupsToRemove) {
403
+ sessions.delete(id);
404
+ }
405
+ log.info('session', `Removed ${dupsToRemove.length} duplicate ended sessions (same path+source) during snapshot load`);
406
+ }
407
+
326
408
  invalidateSessionsCache();
327
- log.info('session', `Snapshot loaded: ${restored} sessions restored, ${ended} ended (dead PID), ${pidToSession.size} PIDs tracked`);
409
+ log.info('session', `Snapshot loaded: ${restored} sessions restored, ${ended} ended (dead PID), ${pidToSession.size} PIDs tracked, ${pendingResumeRestored} pendingResume entries`);
328
410
 
329
411
  return { mqOffset: snapshot.mqOffset || 0 };
330
412
  } catch (err) {
@@ -447,7 +529,9 @@ export function handleEvent(hookData) {
447
529
  log.debugJson('session', 'Full hook data', hookData);
448
530
 
449
531
  // Match or create session (delegated to sessionMatcher)
532
+ // Returns null for external sessions (VS Code, iTerm, etc.) that have no dashboard terminal
450
533
  const session = matchSession(hookData, sessions, pendingResume, pidToSession, projectSessionCounters);
534
+ if (!session) return;
451
535
 
452
536
  // Auto-revive sessions that were marked ended by ServerRestart but whose Claude process survived.
453
537
  // This happens when Claude runs in tmux/screen and keeps sending hooks after server restart.
@@ -484,6 +568,22 @@ export function handleEvent(hookData) {
484
568
  session.model = hookData.model || session.model;
485
569
  if (hookData.transcript_path) session.transcriptPath = hookData.transcript_path;
486
570
  if (hookData.permission_mode) session.permissionMode = hookData.permission_mode;
571
+
572
+ // Update projectPath from the hook's actual cwd — ONLY for SSH sessions.
573
+ // For remote SSH, createTerminalSession resolves '~' to LOCAL homedir
574
+ // (e.g. /Users/kason) but the hook reports the REMOTE cwd (e.g. /home/user/project).
575
+ // Without this correction, Priority 0 path matching fails on resume/reconnect.
576
+ // Display-only sessions (VS Code, Terminal, iTerm, etc.) already have the
577
+ // correct path from createDefaultSession — don't touch them to avoid
578
+ // overwriting their source-derived projectName.
579
+ if (cwd && cwd !== session.projectPath && session.source === 'ssh') {
580
+ const oldPath = session.projectPath;
581
+ session.projectPath = cwd;
582
+ session.projectName = cwd.split('/').filter(Boolean).pop() || session.projectName;
583
+ // Preserve source — NEVER overwrite (VS Code, Terminal, ssh, etc.)
584
+ log.info('session', `Updated SSH projectPath: ${oldPath} → ${cwd} (from hook cwd)`);
585
+ }
586
+
487
587
  eventEntry.detail = `Session started (${hookData.source || 'startup'})`;
488
588
  log.debug('session', `SessionStart: ${session_id?.slice(0,8)} project=${session.projectName} model=${session.model}`);
489
589
 
@@ -728,7 +828,13 @@ export function getAllSessions() {
728
828
  }
729
829
  const result = {};
730
830
  for (const [id, session] of sessions) {
731
- result[id] = { ...session };
831
+ // Defensive: always key by session.sessionId to prevent key mismatch bugs.
832
+ // If Map key diverged from sessionId (e.g., after re-key edge case), fix it.
833
+ const key = session.sessionId || id;
834
+ if (id !== key) {
835
+ log.warn('session', `Key mismatch: Map key=${id?.slice(0,8)} vs sessionId=${key?.slice(0,8)} — using sessionId`);
836
+ }
837
+ result[key] = { ...session };
732
838
  }
733
839
  sessionsCache = result;
734
840
  sessionsCacheDirty = false;