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.
- package/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- 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
|
+
}
|