claudehq 1.0.1 → 1.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/bin/cli.js +2 -2
- package/lib/core/claude-events.js +308 -0
- package/lib/core/config.js +103 -0
- package/lib/core/event-bus.js +145 -0
- package/lib/core/sse.js +96 -0
- package/lib/core/watchers.js +127 -0
- package/lib/data/conversation.js +195 -0
- package/lib/data/orchestration.js +941 -0
- package/lib/data/plans.js +247 -0
- package/lib/data/tasks.js +263 -0
- package/lib/data/todos.js +205 -0
- package/lib/index.js +481 -0
- package/lib/orchestration/executor.js +635 -0
- package/lib/routes/api.js +1010 -0
- package/lib/sessions/manager.js +870 -0
- package/package.json +10 -4
- package/{lib/server.js → public/index.html} +1984 -2644
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager - Tracks and manages Claude Code sessions
|
|
3
|
+
*
|
|
4
|
+
* Handles session lifecycle, status tracking, health monitoring,
|
|
5
|
+
* permission detection, and tmux integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { execFile } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
EVENTS_DIR,
|
|
15
|
+
CUSTOM_NAMES_FILE,
|
|
16
|
+
HIDDEN_SESSIONS_FILE,
|
|
17
|
+
SESSION_STATUS,
|
|
18
|
+
HEALTH_CHECK_INTERVAL,
|
|
19
|
+
WORKING_TIMEOUT
|
|
20
|
+
} = require('../core/config');
|
|
21
|
+
const { eventBus, EventTypes } = require('../core/event-bus');
|
|
22
|
+
const { sseClients, broadcastUpdate } = require('../core/sse');
|
|
23
|
+
|
|
24
|
+
// File paths
|
|
25
|
+
const SESSIONS_FILE = path.join(EVENTS_DIR, 'sessions.json');
|
|
26
|
+
|
|
27
|
+
// In-memory session storage
|
|
28
|
+
const managedSessions = new Map();
|
|
29
|
+
const claudeToManagedMap = new Map(); // Maps Claude session IDs to managed session IDs
|
|
30
|
+
let sessionCounter = 0;
|
|
31
|
+
|
|
32
|
+
// Permission tracking
|
|
33
|
+
const pendingPermissions = new Map(); // sessionId -> { tool, detectedAt }
|
|
34
|
+
|
|
35
|
+
// Callbacks for metadata enrichment (set by data modules)
|
|
36
|
+
let getSessionMetadataCallback = null;
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Utility Functions
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
function shortId() {
|
|
43
|
+
return crypto.randomUUID().slice(0, 8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateTmuxSession(name) {
|
|
47
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
48
|
+
throw new Error('Invalid tmux session name');
|
|
49
|
+
}
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Session Storage
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
function loadManagedSessions() {
|
|
58
|
+
if (!fs.existsSync(SESSIONS_FILE)) {
|
|
59
|
+
console.log(' No saved sessions file found');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const content = fs.readFileSync(SESSIONS_FILE, 'utf-8');
|
|
65
|
+
const data = JSON.parse(content);
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(data.sessions)) {
|
|
68
|
+
for (const session of data.sessions) {
|
|
69
|
+
session.status = SESSION_STATUS.OFFLINE;
|
|
70
|
+
session.currentTool = undefined;
|
|
71
|
+
managedSessions.set(session.id, session);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(data.claudeToManagedMap)) {
|
|
76
|
+
for (const [claudeId, managedId] of data.claudeToManagedMap) {
|
|
77
|
+
claudeToManagedMap.set(claudeId, managedId);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof data.sessionCounter === 'number') {
|
|
82
|
+
sessionCounter = data.sessionCounter;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(` Loaded ${managedSessions.size} managed sessions from: ${SESSIONS_FILE}`);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error('Error loading managed sessions:', e.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function saveManagedSessions() {
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.existsSync(EVENTS_DIR)) {
|
|
94
|
+
fs.mkdirSync(EVENTS_DIR, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const data = {
|
|
98
|
+
sessions: Array.from(managedSessions.values()),
|
|
99
|
+
claudeToManagedMap: Array.from(claudeToManagedMap.entries()),
|
|
100
|
+
sessionCounter
|
|
101
|
+
};
|
|
102
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error('Failed to save managed sessions:', e.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// Custom Names
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
function loadCustomNames() {
|
|
113
|
+
try {
|
|
114
|
+
if (fs.existsSync(CUSTOM_NAMES_FILE)) {
|
|
115
|
+
return JSON.parse(fs.readFileSync(CUSTOM_NAMES_FILE, 'utf-8'));
|
|
116
|
+
}
|
|
117
|
+
} catch (e) { /* ignore */ }
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function saveCustomNames(names) {
|
|
122
|
+
try {
|
|
123
|
+
const dir = path.dirname(CUSTOM_NAMES_FILE);
|
|
124
|
+
if (!fs.existsSync(dir)) {
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
fs.writeFileSync(CUSTOM_NAMES_FILE, JSON.stringify(names, null, 2));
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error('Failed to save custom names:', e.message);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// Hidden Sessions
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
function loadHiddenSessions() {
|
|
138
|
+
try {
|
|
139
|
+
if (fs.existsSync(HIDDEN_SESSIONS_FILE)) {
|
|
140
|
+
return JSON.parse(fs.readFileSync(HIDDEN_SESSIONS_FILE, 'utf-8'));
|
|
141
|
+
}
|
|
142
|
+
} catch (e) { /* ignore */ }
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveHiddenSessions(hiddenIds) {
|
|
147
|
+
try {
|
|
148
|
+
const dir = path.dirname(HIDDEN_SESSIONS_FILE);
|
|
149
|
+
if (!fs.existsSync(dir)) {
|
|
150
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
fs.writeFileSync(HIDDEN_SESSIONS_FILE, JSON.stringify(hiddenIds, null, 2));
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.error('Failed to save hidden sessions:', e.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hideSession(sessionId) {
|
|
159
|
+
const hidden = loadHiddenSessions();
|
|
160
|
+
if (!hidden.includes(sessionId)) {
|
|
161
|
+
hidden.push(sessionId);
|
|
162
|
+
saveHiddenSessions(hidden);
|
|
163
|
+
}
|
|
164
|
+
return { success: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function unhideSession(sessionId) {
|
|
168
|
+
let hidden = loadHiddenSessions();
|
|
169
|
+
hidden = hidden.filter(id => id !== sessionId);
|
|
170
|
+
saveHiddenSessions(hidden);
|
|
171
|
+
return { success: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isSessionHidden(sessionId, claudeSessionId) {
|
|
175
|
+
const hidden = loadHiddenSessions();
|
|
176
|
+
return hidden.includes(sessionId) || (claudeSessionId && hidden.includes(claudeSessionId));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// Permanent Delete
|
|
181
|
+
// =============================================================================
|
|
182
|
+
|
|
183
|
+
function permanentDeleteSession(sessionId) {
|
|
184
|
+
let session = managedSessions.get(sessionId);
|
|
185
|
+
let managedId = sessionId;
|
|
186
|
+
|
|
187
|
+
if (!session) {
|
|
188
|
+
for (const [id, s] of managedSessions) {
|
|
189
|
+
if (s.claudeSessionId === sessionId) {
|
|
190
|
+
session = s;
|
|
191
|
+
managedId = id;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!session) {
|
|
198
|
+
return { success: false, error: 'Session not found' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const claudeSessionId = session.claudeSessionId;
|
|
202
|
+
|
|
203
|
+
managedSessions.delete(managedId);
|
|
204
|
+
if (claudeSessionId) {
|
|
205
|
+
claudeToManagedMap.delete(claudeSessionId);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let hidden = loadHiddenSessions();
|
|
209
|
+
hidden = hidden.filter(id => id !== managedId && id !== claudeSessionId);
|
|
210
|
+
saveHiddenSessions(hidden);
|
|
211
|
+
|
|
212
|
+
const names = loadCustomNames();
|
|
213
|
+
delete names[managedId];
|
|
214
|
+
if (claudeSessionId) {
|
|
215
|
+
delete names[claudeSessionId];
|
|
216
|
+
}
|
|
217
|
+
saveCustomNames(names);
|
|
218
|
+
|
|
219
|
+
saveManagedSessions();
|
|
220
|
+
broadcastManagedSessions();
|
|
221
|
+
|
|
222
|
+
console.log(` Permanently deleted session: ${session.name} (${managedId})`);
|
|
223
|
+
return { success: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// Session Retrieval
|
|
228
|
+
// =============================================================================
|
|
229
|
+
|
|
230
|
+
function setMetadataCallback(callback) {
|
|
231
|
+
getSessionMetadataCallback = callback;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getManagedSessions(includeHidden = false) {
|
|
235
|
+
const customNames = loadCustomNames();
|
|
236
|
+
const hiddenSessions = loadHiddenSessions();
|
|
237
|
+
|
|
238
|
+
return Array.from(managedSessions.values())
|
|
239
|
+
.filter(session => {
|
|
240
|
+
if (includeHidden) return true;
|
|
241
|
+
return !hiddenSessions.includes(session.id) &&
|
|
242
|
+
!hiddenSessions.includes(session.claudeSessionId);
|
|
243
|
+
})
|
|
244
|
+
.map(session => {
|
|
245
|
+
let firstPrompt = null;
|
|
246
|
+
if (session.claudeSessionId && getSessionMetadataCallback) {
|
|
247
|
+
const metadata = getSessionMetadataCallback(session.claudeSessionId);
|
|
248
|
+
firstPrompt = metadata.firstPrompt;
|
|
249
|
+
}
|
|
250
|
+
const customName = customNames[session.id] || customNames[session.claudeSessionId] || null;
|
|
251
|
+
return {
|
|
252
|
+
...session,
|
|
253
|
+
name: customName || session.name,
|
|
254
|
+
firstPrompt,
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getManagedSession(id) {
|
|
260
|
+
return managedSessions.get(id);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function findManagedSessionByClaudeId(claudeSessionId) {
|
|
264
|
+
const managedId = claudeToManagedMap.get(claudeSessionId);
|
|
265
|
+
if (managedId) {
|
|
266
|
+
return managedSessions.get(managedId);
|
|
267
|
+
}
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function findManagedSessionByCwd(cwd) {
|
|
272
|
+
if (!cwd) return undefined;
|
|
273
|
+
for (const session of managedSessions.values()) {
|
|
274
|
+
if (session.cwd === cwd) {
|
|
275
|
+
return session;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// =============================================================================
|
|
282
|
+
// Session Discovery & Updates
|
|
283
|
+
// =============================================================================
|
|
284
|
+
|
|
285
|
+
function linkClaudeSession(claudeSessionId, managedSessionId) {
|
|
286
|
+
claudeToManagedMap.set(claudeSessionId, managedSessionId);
|
|
287
|
+
const session = managedSessions.get(managedSessionId);
|
|
288
|
+
if (session) {
|
|
289
|
+
session.claudeSessionId = claudeSessionId;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function discoverSession(event) {
|
|
294
|
+
const id = crypto.randomUUID();
|
|
295
|
+
const projectName = event.cwd ? path.basename(event.cwd) : 'Unknown Project';
|
|
296
|
+
|
|
297
|
+
const session = {
|
|
298
|
+
id,
|
|
299
|
+
name: projectName,
|
|
300
|
+
tmuxSession: null,
|
|
301
|
+
status: SESSION_STATUS.WORKING,
|
|
302
|
+
claudeSessionId: event.sessionId,
|
|
303
|
+
createdAt: Date.now(),
|
|
304
|
+
lastActivity: Date.now(),
|
|
305
|
+
cwd: event.cwd || null,
|
|
306
|
+
currentTool: event.tool || null,
|
|
307
|
+
discovered: true
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
managedSessions.set(id, session);
|
|
311
|
+
linkClaudeSession(event.sessionId, id);
|
|
312
|
+
|
|
313
|
+
console.log(` Discovered Claude session: ${session.name} (${event.sessionId.slice(0, 8)}) in ${event.cwd || 'unknown directory'}`);
|
|
314
|
+
|
|
315
|
+
broadcastManagedSessions();
|
|
316
|
+
saveManagedSessions();
|
|
317
|
+
|
|
318
|
+
return session;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function updateSessionFromEvent(event) {
|
|
322
|
+
if (!event.sessionId) return;
|
|
323
|
+
|
|
324
|
+
const hiddenIds = loadHiddenSessions();
|
|
325
|
+
if (hiddenIds.includes(event.sessionId)) {
|
|
326
|
+
console.log(` Auto-unhiding session ${event.sessionId.substring(0, 8)}... (received activity)`);
|
|
327
|
+
unhideSession(event.sessionId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let managedSession = findManagedSessionByClaudeId(event.sessionId);
|
|
331
|
+
|
|
332
|
+
if (!managedSession) {
|
|
333
|
+
managedSession = discoverSession(event);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const prevStatus = managedSession.status;
|
|
337
|
+
managedSession.lastActivity = Date.now();
|
|
338
|
+
if (event.cwd) managedSession.cwd = event.cwd;
|
|
339
|
+
|
|
340
|
+
switch (event.type) {
|
|
341
|
+
case 'pre_tool_use':
|
|
342
|
+
managedSession.status = SESSION_STATUS.WORKING;
|
|
343
|
+
managedSession.currentTool = event.tool;
|
|
344
|
+
break;
|
|
345
|
+
|
|
346
|
+
case 'post_tool_use':
|
|
347
|
+
managedSession.currentTool = undefined;
|
|
348
|
+
break;
|
|
349
|
+
|
|
350
|
+
case 'user_prompt_submit':
|
|
351
|
+
managedSession.status = SESSION_STATUS.WORKING;
|
|
352
|
+
managedSession.currentTool = undefined;
|
|
353
|
+
break;
|
|
354
|
+
|
|
355
|
+
case 'stop':
|
|
356
|
+
case 'session_end':
|
|
357
|
+
managedSession.status = SESSION_STATUS.IDLE;
|
|
358
|
+
managedSession.currentTool = undefined;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (prevStatus !== managedSession.status) {
|
|
363
|
+
broadcastManagedSessions();
|
|
364
|
+
saveManagedSessions();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// =============================================================================
|
|
369
|
+
// Session Rename
|
|
370
|
+
// =============================================================================
|
|
371
|
+
|
|
372
|
+
function renameSession(sessionId, newName) {
|
|
373
|
+
const names = loadCustomNames();
|
|
374
|
+
const previousName = names[sessionId];
|
|
375
|
+
|
|
376
|
+
if (newName && newName.trim()) {
|
|
377
|
+
names[sessionId] = newName.trim();
|
|
378
|
+
} else {
|
|
379
|
+
delete names[sessionId];
|
|
380
|
+
}
|
|
381
|
+
saveCustomNames(names);
|
|
382
|
+
|
|
383
|
+
let managedSession = managedSessions.get(sessionId);
|
|
384
|
+
if (!managedSession) {
|
|
385
|
+
for (const [id, session] of managedSessions) {
|
|
386
|
+
if (session.claudeSessionId === sessionId) {
|
|
387
|
+
managedSession = session;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (managedSession) {
|
|
393
|
+
managedSession.name = newName?.trim() || managedSession.name;
|
|
394
|
+
saveManagedSessions();
|
|
395
|
+
broadcastManagedSessions();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
eventBus.emit(EventTypes.SESSION_RENAMED, { sessionId, newName: newName?.trim(), previousName });
|
|
399
|
+
return { success: true };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// =============================================================================
|
|
403
|
+
// Session CRUD Operations
|
|
404
|
+
// =============================================================================
|
|
405
|
+
|
|
406
|
+
async function createManagedSession(options = {}) {
|
|
407
|
+
const id = crypto.randomUUID();
|
|
408
|
+
sessionCounter++;
|
|
409
|
+
|
|
410
|
+
const name = options.name || `Claude ${sessionCounter}`;
|
|
411
|
+
const tmuxSessionName = `tasks-board-${shortId()}`;
|
|
412
|
+
const cwd = options.cwd || process.cwd();
|
|
413
|
+
|
|
414
|
+
if (!fs.existsSync(cwd)) {
|
|
415
|
+
throw new Error(`Directory does not exist: ${cwd}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const flags = options.flags || {};
|
|
419
|
+
const claudeArgs = [];
|
|
420
|
+
|
|
421
|
+
if (flags.continue !== false) {
|
|
422
|
+
claudeArgs.push('-c');
|
|
423
|
+
}
|
|
424
|
+
if (flags.skipPermissions !== false) {
|
|
425
|
+
claudeArgs.push('--dangerously-skip-permissions');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const claudeCmd = claudeArgs.length > 0 ? `claude ${claudeArgs.join(' ')}` : 'claude';
|
|
429
|
+
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
execFile('tmux', [
|
|
432
|
+
'new-session', '-d', '-s', tmuxSessionName, '-c', cwd, claudeCmd
|
|
433
|
+
], (error) => {
|
|
434
|
+
if (error) {
|
|
435
|
+
console.error(`Failed to spawn session: ${error.message}`);
|
|
436
|
+
reject(new Error(`Failed to spawn session: ${error.message}`));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const session = {
|
|
441
|
+
id,
|
|
442
|
+
name,
|
|
443
|
+
tmuxSession: tmuxSessionName,
|
|
444
|
+
status: SESSION_STATUS.IDLE,
|
|
445
|
+
claudeSessionId: null,
|
|
446
|
+
createdAt: Date.now(),
|
|
447
|
+
lastActivity: Date.now(),
|
|
448
|
+
cwd,
|
|
449
|
+
currentTool: null
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
managedSessions.set(id, session);
|
|
453
|
+
console.log(` Created managed session: ${name} (${id.slice(0, 8)}) -> tmux:${tmuxSessionName}`);
|
|
454
|
+
|
|
455
|
+
broadcastManagedSessions();
|
|
456
|
+
saveManagedSessions();
|
|
457
|
+
|
|
458
|
+
resolve(session);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function updateManagedSession(id, updates) {
|
|
464
|
+
const session = managedSessions.get(id);
|
|
465
|
+
if (!session) return null;
|
|
466
|
+
|
|
467
|
+
if (updates.name) {
|
|
468
|
+
session.name = updates.name;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
broadcastManagedSessions();
|
|
472
|
+
saveManagedSessions();
|
|
473
|
+
|
|
474
|
+
return session;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function deleteManagedSession(id) {
|
|
478
|
+
const session = managedSessions.get(id);
|
|
479
|
+
if (!session) return false;
|
|
480
|
+
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
if (session.tmuxSession) {
|
|
483
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSession], (error) => {
|
|
484
|
+
if (error) {
|
|
485
|
+
console.log(` Note: tmux session ${session.tmuxSession} may already be dead`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
managedSessions.delete(id);
|
|
489
|
+
if (session.claudeSessionId) {
|
|
490
|
+
claudeToManagedMap.delete(session.claudeSessionId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
console.log(` Deleted managed session: ${session.name} (${id.slice(0, 8)})`);
|
|
494
|
+
broadcastManagedSessions();
|
|
495
|
+
saveManagedSessions();
|
|
496
|
+
|
|
497
|
+
resolve(true);
|
|
498
|
+
});
|
|
499
|
+
} else {
|
|
500
|
+
managedSessions.delete(id);
|
|
501
|
+
if (session.claudeSessionId) {
|
|
502
|
+
claudeToManagedMap.delete(session.claudeSessionId);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
broadcastManagedSessions();
|
|
506
|
+
saveManagedSessions();
|
|
507
|
+
resolve(true);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function restartManagedSession(id) {
|
|
513
|
+
const session = managedSessions.get(id);
|
|
514
|
+
if (!session) return null;
|
|
515
|
+
|
|
516
|
+
const cwd = session.cwd || process.cwd();
|
|
517
|
+
|
|
518
|
+
return new Promise((resolve, reject) => {
|
|
519
|
+
if (session.tmuxSession) {
|
|
520
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSession], () => {
|
|
521
|
+
spawnNewTmux();
|
|
522
|
+
});
|
|
523
|
+
} else {
|
|
524
|
+
spawnNewTmux();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function spawnNewTmux() {
|
|
528
|
+
const tmuxSessionName = `tasks-board-${shortId()}`;
|
|
529
|
+
const claudeCmd = 'claude -c --dangerously-skip-permissions';
|
|
530
|
+
|
|
531
|
+
execFile('tmux', [
|
|
532
|
+
'new-session', '-d', '-s', tmuxSessionName, '-c', cwd, claudeCmd
|
|
533
|
+
], (error) => {
|
|
534
|
+
if (error) {
|
|
535
|
+
reject(new Error(`Failed to restart session: ${error.message}`));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
session.tmuxSession = tmuxSessionName;
|
|
540
|
+
session.status = SESSION_STATUS.IDLE;
|
|
541
|
+
session.currentTool = null;
|
|
542
|
+
session.lastActivity = Date.now();
|
|
543
|
+
|
|
544
|
+
console.log(` Restarted managed session: ${session.name} -> tmux:${tmuxSessionName}`);
|
|
545
|
+
broadcastManagedSessions();
|
|
546
|
+
saveManagedSessions();
|
|
547
|
+
|
|
548
|
+
resolve(session);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// =============================================================================
|
|
555
|
+
// Tmux Interaction
|
|
556
|
+
// =============================================================================
|
|
557
|
+
|
|
558
|
+
async function sendToTmux(tmuxSession, text) {
|
|
559
|
+
return new Promise((resolve, reject) => {
|
|
560
|
+
const tempFile = `/tmp/claude-tasks-prompt-${Date.now()}.txt`;
|
|
561
|
+
fs.writeFileSync(tempFile, text);
|
|
562
|
+
|
|
563
|
+
execFile('tmux', ['load-buffer', tempFile], (err) => {
|
|
564
|
+
if (err) {
|
|
565
|
+
fs.unlinkSync(tempFile);
|
|
566
|
+
return reject(new Error(`tmux load-buffer failed: ${err.message}`));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
execFile('tmux', ['paste-buffer', '-t', tmuxSession], (err2) => {
|
|
570
|
+
fs.unlinkSync(tempFile);
|
|
571
|
+
|
|
572
|
+
if (err2) {
|
|
573
|
+
return reject(new Error(`tmux paste-buffer failed: ${err2.message}`));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
setTimeout(() => {
|
|
577
|
+
execFile('tmux', ['send-keys', '-t', tmuxSession, 'Enter'], (err3) => {
|
|
578
|
+
if (err3) {
|
|
579
|
+
return reject(new Error(`tmux send-keys failed: ${err3.message}`));
|
|
580
|
+
}
|
|
581
|
+
resolve();
|
|
582
|
+
});
|
|
583
|
+
}, 50);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function sendPromptToManagedSession(id, prompt) {
|
|
590
|
+
const session = managedSessions.get(id);
|
|
591
|
+
if (!session) {
|
|
592
|
+
return { ok: false, error: 'Session not found' };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!session.tmuxSession) {
|
|
596
|
+
return { ok: false, error: 'Session has no tmux session' };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (session.status === SESSION_STATUS.OFFLINE) {
|
|
600
|
+
return { ok: false, error: 'Session is offline' };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
await sendToTmux(session.tmuxSession, prompt);
|
|
605
|
+
session.lastActivity = Date.now();
|
|
606
|
+
return { ok: true };
|
|
607
|
+
} catch (e) {
|
|
608
|
+
return { ok: false, error: e.message };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// =============================================================================
|
|
613
|
+
// Health Monitoring
|
|
614
|
+
// =============================================================================
|
|
615
|
+
|
|
616
|
+
function checkSessionHealth() {
|
|
617
|
+
execFile('tmux', ['list-sessions', '-F', '#{session_name}'], (error, stdout) => {
|
|
618
|
+
if (error) {
|
|
619
|
+
let changed = false;
|
|
620
|
+
for (const session of managedSessions.values()) {
|
|
621
|
+
if (session.status !== SESSION_STATUS.OFFLINE) {
|
|
622
|
+
session.status = SESSION_STATUS.OFFLINE;
|
|
623
|
+
changed = true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (changed) {
|
|
627
|
+
broadcastManagedSessions();
|
|
628
|
+
saveManagedSessions();
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const activeSessions = new Set(stdout.trim().split('\n').filter(Boolean));
|
|
634
|
+
let changed = false;
|
|
635
|
+
|
|
636
|
+
for (const session of managedSessions.values()) {
|
|
637
|
+
if (!session.tmuxSession) continue;
|
|
638
|
+
|
|
639
|
+
const isAlive = activeSessions.has(session.tmuxSession);
|
|
640
|
+
let newStatus;
|
|
641
|
+
|
|
642
|
+
if (isAlive) {
|
|
643
|
+
newStatus = session.status === SESSION_STATUS.OFFLINE ? SESSION_STATUS.IDLE : session.status;
|
|
644
|
+
} else {
|
|
645
|
+
newStatus = SESSION_STATUS.OFFLINE;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (session.status !== newStatus) {
|
|
649
|
+
session.status = newStatus;
|
|
650
|
+
if (newStatus === SESSION_STATUS.OFFLINE) {
|
|
651
|
+
session.currentTool = undefined;
|
|
652
|
+
}
|
|
653
|
+
changed = true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (changed) {
|
|
658
|
+
broadcastManagedSessions();
|
|
659
|
+
saveManagedSessions();
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function checkWorkingTimeout() {
|
|
665
|
+
const now = Date.now();
|
|
666
|
+
const WORKING_TIMEOUT_MS = WORKING_TIMEOUT || 120000;
|
|
667
|
+
let changed = false;
|
|
668
|
+
|
|
669
|
+
for (const session of managedSessions.values()) {
|
|
670
|
+
if (session.status === SESSION_STATUS.WORKING) {
|
|
671
|
+
const timeSinceActivity = now - (session.lastActivity || 0);
|
|
672
|
+
if (timeSinceActivity > WORKING_TIMEOUT_MS) {
|
|
673
|
+
console.log(` Session "${session.name}" timed out after ${Math.round(timeSinceActivity / 1000)}s of no activity`);
|
|
674
|
+
session.status = SESSION_STATUS.IDLE;
|
|
675
|
+
session.currentTool = undefined;
|
|
676
|
+
changed = true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (changed) {
|
|
682
|
+
broadcastManagedSessions();
|
|
683
|
+
saveManagedSessions();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// =============================================================================
|
|
688
|
+
// Permission Detection
|
|
689
|
+
// =============================================================================
|
|
690
|
+
|
|
691
|
+
function detectPermissionPrompt(output) {
|
|
692
|
+
const lines = output.split('\n');
|
|
693
|
+
|
|
694
|
+
let proceedLineIdx = -1;
|
|
695
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
|
|
696
|
+
if (/(Do you want|Would you like) to proceed\?/i.test(lines[i])) {
|
|
697
|
+
proceedLineIdx = i;
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (proceedLineIdx === -1) return null;
|
|
703
|
+
|
|
704
|
+
let hasFooter = false;
|
|
705
|
+
let hasSelector = false;
|
|
706
|
+
for (let i = proceedLineIdx + 1; i < Math.min(lines.length, proceedLineIdx + 15); i++) {
|
|
707
|
+
if (/Esc to cancel|ctrl-g to edit/i.test(lines[i])) {
|
|
708
|
+
hasFooter = true;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
if (/^\s*❯/.test(lines[i])) {
|
|
712
|
+
hasSelector = true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (!hasFooter && !hasSelector) return null;
|
|
717
|
+
|
|
718
|
+
let tool = 'Permission';
|
|
719
|
+
for (let i = proceedLineIdx; i >= Math.max(0, proceedLineIdx - 20); i--) {
|
|
720
|
+
const toolMatch = lines[i].match(/[●◐·]\s*(\w+)\s*\(/);
|
|
721
|
+
if (toolMatch) {
|
|
722
|
+
tool = toolMatch[1];
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
const cmdMatch = lines[i].match(/^\s*(Bash|Read|Write|Edit|Grep|Glob|Task|WebFetch|WebSearch)\s+\w+/i);
|
|
726
|
+
if (cmdMatch) {
|
|
727
|
+
tool = cmdMatch[1];
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return { tool, context: lines.slice(Math.max(0, proceedLineIdx - 5), proceedLineIdx + 8).join('\n') };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function pollPermissions(session) {
|
|
736
|
+
if (!session.tmuxSession) return;
|
|
737
|
+
|
|
738
|
+
execFile('tmux', ['capture-pane', '-t', session.tmuxSession, '-p', '-S', '-50'],
|
|
739
|
+
{ timeout: 2000, maxBuffer: 1024 * 1024 },
|
|
740
|
+
(error, stdout) => {
|
|
741
|
+
if (error) return;
|
|
742
|
+
|
|
743
|
+
const prompt = detectPermissionPrompt(stdout);
|
|
744
|
+
const existing = pendingPermissions.get(session.id);
|
|
745
|
+
|
|
746
|
+
if (prompt && !existing) {
|
|
747
|
+
pendingPermissions.set(session.id, {
|
|
748
|
+
tool: prompt.tool,
|
|
749
|
+
detectedAt: Date.now(),
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
console.log(` Permission prompt detected for "${session.name}": ${prompt.tool}`);
|
|
753
|
+
|
|
754
|
+
if (session.status !== SESSION_STATUS.WAITING) {
|
|
755
|
+
session.status = SESSION_STATUS.WAITING;
|
|
756
|
+
session.currentTool = prompt.tool;
|
|
757
|
+
broadcastManagedSessions();
|
|
758
|
+
saveManagedSessions();
|
|
759
|
+
}
|
|
760
|
+
} else if (!prompt && existing) {
|
|
761
|
+
pendingPermissions.delete(session.id);
|
|
762
|
+
console.log(` Permission prompt resolved for "${session.name}"`);
|
|
763
|
+
|
|
764
|
+
if (session.status === SESSION_STATUS.WAITING) {
|
|
765
|
+
session.status = SESSION_STATUS.WORKING;
|
|
766
|
+
session.currentTool = undefined;
|
|
767
|
+
broadcastManagedSessions();
|
|
768
|
+
saveManagedSessions();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function checkPermissionPrompts() {
|
|
776
|
+
for (const session of managedSessions.values()) {
|
|
777
|
+
if (session.tmuxSession && session.status !== SESSION_STATUS.OFFLINE) {
|
|
778
|
+
pollPermissions(session);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// =============================================================================
|
|
784
|
+
// Broadcasting
|
|
785
|
+
// =============================================================================
|
|
786
|
+
|
|
787
|
+
function broadcastManagedSessions() {
|
|
788
|
+
const data = JSON.stringify({ type: 'sessions', sessions: getManagedSessions() });
|
|
789
|
+
for (const client of sseClients) {
|
|
790
|
+
try {
|
|
791
|
+
client.write(`data: ${data}\n\n`);
|
|
792
|
+
} catch (e) {
|
|
793
|
+
sseClients.delete(client);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// =============================================================================
|
|
799
|
+
// Initialization
|
|
800
|
+
// =============================================================================
|
|
801
|
+
|
|
802
|
+
function startHealthChecks() {
|
|
803
|
+
setInterval(checkSessionHealth, 5000);
|
|
804
|
+
setInterval(checkWorkingTimeout, 10000);
|
|
805
|
+
setInterval(checkPermissionPrompts, 1000);
|
|
806
|
+
checkSessionHealth();
|
|
807
|
+
console.log(' Status monitoring started (health: 5s, timeout: 10s, permissions: 1s)');
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function init() {
|
|
811
|
+
loadManagedSessions();
|
|
812
|
+
startHealthChecks();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
module.exports = {
|
|
816
|
+
// Initialization
|
|
817
|
+
init,
|
|
818
|
+
loadManagedSessions,
|
|
819
|
+
saveManagedSessions,
|
|
820
|
+
startHealthChecks,
|
|
821
|
+
|
|
822
|
+
// Session retrieval
|
|
823
|
+
getManagedSessions,
|
|
824
|
+
getManagedSession,
|
|
825
|
+
findManagedSessionByClaudeId,
|
|
826
|
+
findManagedSessionByCwd,
|
|
827
|
+
setMetadataCallback,
|
|
828
|
+
|
|
829
|
+
// Session CRUD
|
|
830
|
+
createManagedSession,
|
|
831
|
+
updateManagedSession,
|
|
832
|
+
deleteManagedSession,
|
|
833
|
+
restartManagedSession,
|
|
834
|
+
|
|
835
|
+
// Session discovery & updates
|
|
836
|
+
discoverSession,
|
|
837
|
+
updateSessionFromEvent,
|
|
838
|
+
linkClaudeSession,
|
|
839
|
+
|
|
840
|
+
// Session rename
|
|
841
|
+
renameSession,
|
|
842
|
+
|
|
843
|
+
// Hidden sessions
|
|
844
|
+
loadHiddenSessions,
|
|
845
|
+
hideSession,
|
|
846
|
+
unhideSession,
|
|
847
|
+
isSessionHidden,
|
|
848
|
+
permanentDeleteSession,
|
|
849
|
+
|
|
850
|
+
// Tmux interaction
|
|
851
|
+
sendToTmux,
|
|
852
|
+
sendPromptToManagedSession,
|
|
853
|
+
|
|
854
|
+
// Health & permissions
|
|
855
|
+
checkSessionHealth,
|
|
856
|
+
checkWorkingTimeout,
|
|
857
|
+
checkPermissionPrompts,
|
|
858
|
+
detectPermissionPrompt,
|
|
859
|
+
|
|
860
|
+
// Broadcasting
|
|
861
|
+
broadcastManagedSessions,
|
|
862
|
+
|
|
863
|
+
// Direct access
|
|
864
|
+
managedSessions,
|
|
865
|
+
claudeToManagedMap,
|
|
866
|
+
|
|
867
|
+
// Custom names
|
|
868
|
+
loadCustomNames,
|
|
869
|
+
saveCustomNames
|
|
870
|
+
};
|