ai-agent-session-center 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +484 -429
- package/docs/3D/ADAPTATION_GUIDE.md +592 -0
- package/docs/3D/index.html +754 -0
- package/docs/AGENT_TEAM_TASKS.md +716 -0
- package/docs/CYBERDROME_V2_SPEC.md +531 -0
- package/docs/ERROR_185_ANALYSIS.md +263 -0
- package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
- package/docs/SESSION_DETAIL_FEATURES.md +98 -0
- package/docs/_3d_multimedia_features.md +1080 -0
- package/docs/_frontend_features.md +1057 -0
- package/docs/_server_features.md +1077 -0
- package/docs/session-duplication-fixes.md +271 -0
- package/docs/session-terminal-linkage.md +412 -0
- package/package.json +63 -5
- package/public/apple-touch-icon.svg +21 -0
- package/public/css/dashboard.css +0 -161
- package/public/css/detail-panel.css +25 -0
- package/public/css/layout.css +18 -1
- package/public/css/modals.css +0 -26
- package/public/css/settings.css +0 -150
- package/public/css/terminal.css +34 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +6 -26
- package/public/js/alarmManager.js +0 -21
- package/public/js/app.js +21 -7
- package/public/js/detailPanel.js +63 -64
- package/public/js/historyPanel.js +61 -55
- package/public/js/quickActions.js +132 -48
- package/public/js/sessionCard.js +5 -20
- package/public/js/sessionControls.js +8 -0
- package/public/js/settingsManager.js +0 -142
- package/server/apiRouter.js +60 -15
- package/server/apiRouter.ts +774 -0
- package/server/approvalDetector.ts +94 -0
- package/server/authManager.ts +144 -0
- package/server/autoIdleManager.ts +110 -0
- package/server/config.ts +121 -0
- package/server/constants.ts +150 -0
- package/server/db.ts +475 -0
- package/server/hookInstaller.d.ts +3 -0
- package/server/hookProcessor.ts +108 -0
- package/server/hookRouter.ts +18 -0
- package/server/hookStats.ts +116 -0
- package/server/index.js +15 -1
- package/server/index.ts +230 -0
- package/server/logger.ts +75 -0
- package/server/mqReader.ts +349 -0
- package/server/portManager.ts +55 -0
- package/server/processMonitor.ts +239 -0
- package/server/serverConfig.ts +29 -0
- package/server/sessionMatcher.js +17 -6
- package/server/sessionMatcher.ts +403 -0
- package/server/sessionStore.js +109 -3
- package/server/sessionStore.ts +1145 -0
- package/server/sshManager.js +167 -24
- package/server/sshManager.ts +671 -0
- package/server/teamManager.ts +289 -0
- package/server/wsManager.ts +200 -0
package/server/sessionMatcher.js
CHANGED
|
@@ -166,7 +166,20 @@ export function matchSession(hookData, sessions, pendingResume, pidToSession, pr
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
if (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
|
|
360
|
+
// No matching dashboard terminal — ignore external sessions (VS Code, iTerm, etc.)
|
|
348
361
|
const detectedSource = detectHookSource(hookData);
|
|
349
|
-
log.
|
|
350
|
-
|
|
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
|
+
}
|
package/server/sessionStore.js
CHANGED
|
@@ -122,7 +122,9 @@ export function saveSnapshot(mqOffset) {
|
|
|
122
122
|
}
|
|
123
123
|
const sessionsObj = {};
|
|
124
124
|
for (const [id, session] of sessions) {
|
|
125
|
-
|
|
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
|
-
|
|
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;
|