claudehq 1.0.2 → 1.0.5
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/lib/core/claude-events.js +2 -1
- package/lib/core/config.js +39 -1
- package/lib/data/orchestration.js +941 -0
- package/lib/index.js +211 -23
- package/lib/orchestration/executor.js +635 -0
- package/lib/routes/orchestration.js +417 -0
- package/lib/routes/spawner.js +335 -0
- package/lib/sessions/manager.js +36 -9
- package/lib/spawner/index.js +51 -0
- package/lib/spawner/path-validator.js +366 -0
- package/lib/spawner/projects-manager.js +421 -0
- package/lib/spawner/session-spawner.js +1010 -0
- package/package.json +1 -1
- package/public/index.html +399 -18
- package/lib/server.js +0 -9364
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Spawner - Spawn and manage Claude Code sessions
|
|
3
|
+
*
|
|
4
|
+
* Main module for creating Claude Code sessions in tmux.
|
|
5
|
+
* Provides safe command building, model selection, and lifecycle management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { execFile } = require('child_process');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const { validateDirectoryPath, validateTmuxSessionName, normalizePath } = require('./path-validator');
|
|
15
|
+
const { createProjectsManager } = require('./projects-manager');
|
|
16
|
+
|
|
17
|
+
// Session status constants
|
|
18
|
+
const SESSION_STATUS = {
|
|
19
|
+
IDLE: 'idle',
|
|
20
|
+
WORKING: 'working',
|
|
21
|
+
WAITING: 'waiting',
|
|
22
|
+
OFFLINE: 'offline'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Available Claude models
|
|
26
|
+
const CLAUDE_MODELS = {
|
|
27
|
+
SONNET: 'sonnet',
|
|
28
|
+
OPUS: 'opus',
|
|
29
|
+
HAIKU: 'haiku'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Default configuration
|
|
33
|
+
const DEFAULT_CONFIG = {
|
|
34
|
+
dataDir: path.join(os.homedir(), '.claude', 'tasks-board'),
|
|
35
|
+
sessionPrefix: 'tasks-board',
|
|
36
|
+
healthCheckInterval: 5000,
|
|
37
|
+
workingTimeout: 5 * 60 * 1000,
|
|
38
|
+
permissionPollInterval: 1000,
|
|
39
|
+
defaultModel: null, // Use Claude's default
|
|
40
|
+
defaultSkipPermissions: true,
|
|
41
|
+
defaultContinue: true
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a session spawner instance
|
|
46
|
+
* @param {Object} options - Configuration options
|
|
47
|
+
* @returns {Object} Session spawner instance
|
|
48
|
+
*/
|
|
49
|
+
function createSessionSpawner(options = {}) {
|
|
50
|
+
const config = { ...DEFAULT_CONFIG, ...options };
|
|
51
|
+
const dataDir = config.dataDir;
|
|
52
|
+
const sessionsFile = path.join(dataDir, 'spawned-sessions.json');
|
|
53
|
+
|
|
54
|
+
// In-memory session storage
|
|
55
|
+
const sessions = new Map();
|
|
56
|
+
const claudeToSessionMap = new Map(); // Claude session ID -> spawned session ID
|
|
57
|
+
let sessionCounter = 0;
|
|
58
|
+
|
|
59
|
+
// Permission tracking
|
|
60
|
+
const pendingPermissions = new Map();
|
|
61
|
+
|
|
62
|
+
// Projects manager
|
|
63
|
+
const projectsManager = createProjectsManager({ dataDir });
|
|
64
|
+
|
|
65
|
+
// Health check interval handles
|
|
66
|
+
let healthCheckInterval = null;
|
|
67
|
+
let workingTimeoutInterval = null;
|
|
68
|
+
let permissionPollInterval = null;
|
|
69
|
+
|
|
70
|
+
// Callbacks for external notification
|
|
71
|
+
let onSessionUpdate = null;
|
|
72
|
+
let onPermissionDetected = null;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate a short random ID
|
|
76
|
+
* @returns {string} Short ID
|
|
77
|
+
*/
|
|
78
|
+
function shortId() {
|
|
79
|
+
return crypto.randomBytes(4).toString('hex');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate a unique session ID
|
|
84
|
+
* @returns {string} UUID
|
|
85
|
+
*/
|
|
86
|
+
function generateSessionId() {
|
|
87
|
+
return crypto.randomUUID();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate a tmux session name
|
|
92
|
+
* @param {string} suffix - Optional suffix
|
|
93
|
+
* @returns {string} Tmux session name
|
|
94
|
+
*/
|
|
95
|
+
function generateTmuxSessionName(suffix = null) {
|
|
96
|
+
const name = suffix
|
|
97
|
+
? `${config.sessionPrefix}-${suffix}`
|
|
98
|
+
: `${config.sessionPrefix}-${shortId()}`;
|
|
99
|
+
return name;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ensure data directory exists
|
|
104
|
+
*/
|
|
105
|
+
function ensureDataDir() {
|
|
106
|
+
if (!fs.existsSync(dataDir)) {
|
|
107
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Load sessions from disk
|
|
113
|
+
*/
|
|
114
|
+
function loadSessions() {
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(sessionsFile)) {
|
|
117
|
+
const content = fs.readFileSync(sessionsFile, 'utf-8');
|
|
118
|
+
const data = JSON.parse(content);
|
|
119
|
+
|
|
120
|
+
sessions.clear();
|
|
121
|
+
claudeToSessionMap.clear();
|
|
122
|
+
|
|
123
|
+
if (Array.isArray(data.sessions)) {
|
|
124
|
+
for (const session of data.sessions) {
|
|
125
|
+
// Mark all as offline on load - health check will update
|
|
126
|
+
session.status = SESSION_STATUS.OFFLINE;
|
|
127
|
+
session.currentTool = null;
|
|
128
|
+
sessions.set(session.id, session);
|
|
129
|
+
|
|
130
|
+
if (session.claudeSessionId) {
|
|
131
|
+
claudeToSessionMap.set(session.claudeSessionId, session.id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof data.sessionCounter === 'number') {
|
|
137
|
+
sessionCounter = data.sessionCounter;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(` Loaded ${sessions.size} spawned sessions`);
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error('Error loading spawned sessions:', e.message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Save sessions to disk
|
|
149
|
+
*/
|
|
150
|
+
function saveSessions() {
|
|
151
|
+
try {
|
|
152
|
+
ensureDataDir();
|
|
153
|
+
|
|
154
|
+
const data = {
|
|
155
|
+
version: 1,
|
|
156
|
+
updatedAt: new Date().toISOString(),
|
|
157
|
+
sessions: Array.from(sessions.values()),
|
|
158
|
+
claudeToSessionMap: Array.from(claudeToSessionMap.entries()),
|
|
159
|
+
sessionCounter
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(sessionsFile, JSON.stringify(data, null, 2));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error('Error saving spawned sessions:', e.message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the Claude command with flags
|
|
170
|
+
* @param {Object} opts - Command options
|
|
171
|
+
* @returns {string} Claude command string
|
|
172
|
+
*/
|
|
173
|
+
function buildClaudeCommand(opts = {}) {
|
|
174
|
+
const args = ['claude'];
|
|
175
|
+
|
|
176
|
+
// Continue conversation
|
|
177
|
+
if (opts.continue !== false && config.defaultContinue) {
|
|
178
|
+
args.push('-c');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Skip permissions
|
|
182
|
+
if (opts.skipPermissions !== false && config.defaultSkipPermissions) {
|
|
183
|
+
args.push('--dangerously-skip-permissions');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Model selection
|
|
187
|
+
const model = opts.model || config.defaultModel;
|
|
188
|
+
if (model && model !== 'sonnet') {
|
|
189
|
+
args.push('--model', model);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Initial prompt (if provided, use -p for print mode with prompt)
|
|
193
|
+
// Note: We typically send prompts after spawning instead
|
|
194
|
+
if (opts.initialPrompt) {
|
|
195
|
+
// Don't add -p here as we want interactive mode
|
|
196
|
+
// We'll send the prompt via tmux after spawning
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return args.join(' ');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Spawn a new Claude Code session
|
|
204
|
+
* @param {Object} opts - Spawn options
|
|
205
|
+
* @param {string} opts.name - Session display name
|
|
206
|
+
* @param {string} opts.cwd - Working directory
|
|
207
|
+
* @param {string} opts.model - Claude model (sonnet, opus, haiku)
|
|
208
|
+
* @param {boolean} opts.skipPermissions - Skip permission prompts
|
|
209
|
+
* @param {boolean} opts.continue - Continue previous conversation
|
|
210
|
+
* @param {string} opts.initialPrompt - Initial prompt to send
|
|
211
|
+
* @returns {Promise<Object>} Result with session or error
|
|
212
|
+
*/
|
|
213
|
+
async function spawnSession(opts = {}) {
|
|
214
|
+
// Validate working directory
|
|
215
|
+
const cwd = opts.cwd || process.cwd();
|
|
216
|
+
const cwdValidation = validateDirectoryPath(cwd);
|
|
217
|
+
if (!cwdValidation.valid) {
|
|
218
|
+
return { error: cwdValidation.error };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const workingDirectory = cwdValidation.path;
|
|
222
|
+
|
|
223
|
+
// Generate session info
|
|
224
|
+
const id = generateSessionId();
|
|
225
|
+
sessionCounter++;
|
|
226
|
+
const name = opts.name || `Claude ${sessionCounter}`;
|
|
227
|
+
const tmuxSessionName = generateTmuxSessionName();
|
|
228
|
+
|
|
229
|
+
// Validate tmux session name
|
|
230
|
+
try {
|
|
231
|
+
validateTmuxSessionName(tmuxSessionName);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return { error: e.message };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Build command
|
|
237
|
+
const claudeCmd = buildClaudeCommand({
|
|
238
|
+
model: opts.model,
|
|
239
|
+
skipPermissions: opts.skipPermissions,
|
|
240
|
+
continue: opts.continue,
|
|
241
|
+
initialPrompt: opts.initialPrompt
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Spawn tmux session
|
|
245
|
+
return new Promise((resolve) => {
|
|
246
|
+
execFile('tmux', [
|
|
247
|
+
'new-session',
|
|
248
|
+
'-d',
|
|
249
|
+
'-s', tmuxSessionName,
|
|
250
|
+
'-c', workingDirectory,
|
|
251
|
+
claudeCmd
|
|
252
|
+
], (error) => {
|
|
253
|
+
if (error) {
|
|
254
|
+
console.error(`Failed to spawn session: ${error.message}`);
|
|
255
|
+
resolve({ error: `Failed to spawn session: ${error.message}` });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Create session record
|
|
260
|
+
const session = {
|
|
261
|
+
id,
|
|
262
|
+
name,
|
|
263
|
+
tmuxSession: tmuxSessionName,
|
|
264
|
+
status: SESSION_STATUS.IDLE,
|
|
265
|
+
claudeSessionId: null,
|
|
266
|
+
cwd: workingDirectory,
|
|
267
|
+
model: opts.model || null,
|
|
268
|
+
createdAt: Date.now(),
|
|
269
|
+
lastActivity: Date.now(),
|
|
270
|
+
currentTool: null,
|
|
271
|
+
metadata: opts.metadata || {}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
sessions.set(id, session);
|
|
275
|
+
saveSessions();
|
|
276
|
+
|
|
277
|
+
// Track project
|
|
278
|
+
projectsManager.trackProject(workingDirectory, path.basename(workingDirectory));
|
|
279
|
+
|
|
280
|
+
console.log(` Spawned session "${name}" (${id.slice(0, 8)}) -> tmux:${tmuxSessionName} in ${workingDirectory}`);
|
|
281
|
+
|
|
282
|
+
// Notify listeners
|
|
283
|
+
notifySessionUpdate();
|
|
284
|
+
|
|
285
|
+
// Send initial prompt if provided
|
|
286
|
+
if (opts.initialPrompt) {
|
|
287
|
+
setTimeout(() => {
|
|
288
|
+
sendPrompt(id, opts.initialPrompt);
|
|
289
|
+
}, 1000); // Wait for Claude to initialize
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
resolve({ success: true, session });
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Send text to a tmux session safely
|
|
299
|
+
* @param {string} tmuxSession - Tmux session name
|
|
300
|
+
* @param {string} text - Text to send
|
|
301
|
+
* @returns {Promise<Object>} Result
|
|
302
|
+
*/
|
|
303
|
+
async function sendToTmux(tmuxSession, text) {
|
|
304
|
+
// Validate session name
|
|
305
|
+
try {
|
|
306
|
+
validateTmuxSessionName(tmuxSession);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return { error: e.message };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return new Promise((resolve) => {
|
|
312
|
+
// Use a temp file to safely inject text (prevents shell injection)
|
|
313
|
+
const tempFile = `/tmp/claude-tasks-prompt-${Date.now()}-${shortId()}.txt`;
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
fs.writeFileSync(tempFile, text);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
resolve({ error: `Failed to write temp file: ${e.message}` });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Load into tmux buffer
|
|
323
|
+
execFile('tmux', ['load-buffer', tempFile], (err) => {
|
|
324
|
+
if (err) {
|
|
325
|
+
try { fs.unlinkSync(tempFile); } catch (e) { /* ignore */ }
|
|
326
|
+
resolve({ error: `tmux load-buffer failed: ${err.message}` });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Paste into session
|
|
331
|
+
execFile('tmux', ['paste-buffer', '-t', tmuxSession], (err2) => {
|
|
332
|
+
try { fs.unlinkSync(tempFile); } catch (e) { /* ignore */ }
|
|
333
|
+
|
|
334
|
+
if (err2) {
|
|
335
|
+
resolve({ error: `tmux paste-buffer failed: ${err2.message}` });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Send Enter key
|
|
340
|
+
setTimeout(() => {
|
|
341
|
+
execFile('tmux', ['send-keys', '-t', tmuxSession, 'Enter'], (err3) => {
|
|
342
|
+
if (err3) {
|
|
343
|
+
resolve({ error: `tmux send-keys failed: ${err3.message}` });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
resolve({ success: true });
|
|
347
|
+
});
|
|
348
|
+
}, 50);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Send a prompt to a session
|
|
356
|
+
* @param {string} sessionId - Session ID
|
|
357
|
+
* @param {string} prompt - Prompt text
|
|
358
|
+
* @returns {Promise<Object>} Result
|
|
359
|
+
*/
|
|
360
|
+
async function sendPrompt(sessionId, prompt) {
|
|
361
|
+
const session = sessions.get(sessionId);
|
|
362
|
+
if (!session) {
|
|
363
|
+
return { error: 'Session not found' };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!session.tmuxSession) {
|
|
367
|
+
return { error: 'Session has no tmux session' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (session.status === SESSION_STATUS.OFFLINE) {
|
|
371
|
+
return { error: 'Session is offline' };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const result = await sendToTmux(session.tmuxSession, prompt);
|
|
375
|
+
if (result.success) {
|
|
376
|
+
session.lastActivity = Date.now();
|
|
377
|
+
saveSessions();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Send Ctrl+C to cancel current operation
|
|
385
|
+
* @param {string} sessionId - Session ID
|
|
386
|
+
* @returns {Promise<Object>} Result
|
|
387
|
+
*/
|
|
388
|
+
async function cancelSession(sessionId) {
|
|
389
|
+
const session = sessions.get(sessionId);
|
|
390
|
+
if (!session) {
|
|
391
|
+
return { error: 'Session not found' };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!session.tmuxSession) {
|
|
395
|
+
return { error: 'Session has no tmux session' };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return new Promise((resolve) => {
|
|
399
|
+
execFile('tmux', ['send-keys', '-t', session.tmuxSession, 'C-c'], (err) => {
|
|
400
|
+
if (err) {
|
|
401
|
+
resolve({ error: err.message });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
resolve({ success: true });
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Kill a session
|
|
411
|
+
* @param {string} sessionId - Session ID
|
|
412
|
+
* @returns {Promise<Object>} Result
|
|
413
|
+
*/
|
|
414
|
+
async function killSession(sessionId) {
|
|
415
|
+
const session = sessions.get(sessionId);
|
|
416
|
+
if (!session) {
|
|
417
|
+
return { error: 'Session not found' };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
if (session.tmuxSession) {
|
|
422
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSession], (error) => {
|
|
423
|
+
if (error) {
|
|
424
|
+
console.log(` Note: tmux session ${session.tmuxSession} may already be dead`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
removeSession(sessionId);
|
|
428
|
+
resolve({ success: true });
|
|
429
|
+
});
|
|
430
|
+
} else {
|
|
431
|
+
removeSession(sessionId);
|
|
432
|
+
resolve({ success: true });
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Remove a session from tracking
|
|
439
|
+
* @param {string} sessionId - Session ID
|
|
440
|
+
*/
|
|
441
|
+
function removeSession(sessionId) {
|
|
442
|
+
const session = sessions.get(sessionId);
|
|
443
|
+
if (session) {
|
|
444
|
+
if (session.claudeSessionId) {
|
|
445
|
+
claudeToSessionMap.delete(session.claudeSessionId);
|
|
446
|
+
}
|
|
447
|
+
sessions.delete(sessionId);
|
|
448
|
+
pendingPermissions.delete(sessionId);
|
|
449
|
+
saveSessions();
|
|
450
|
+
notifySessionUpdate();
|
|
451
|
+
console.log(` Removed session ${sessionId.slice(0, 8)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Restart a session
|
|
457
|
+
* @param {string} sessionId - Session ID
|
|
458
|
+
* @returns {Promise<Object>} Result with session or error
|
|
459
|
+
*/
|
|
460
|
+
async function restartSession(sessionId) {
|
|
461
|
+
const session = sessions.get(sessionId);
|
|
462
|
+
if (!session) {
|
|
463
|
+
return { error: 'Session not found' };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const cwd = session.cwd || process.cwd();
|
|
467
|
+
const name = session.name;
|
|
468
|
+
const model = session.model;
|
|
469
|
+
|
|
470
|
+
// Kill old tmux session
|
|
471
|
+
if (session.tmuxSession) {
|
|
472
|
+
await new Promise((resolve) => {
|
|
473
|
+
execFile('tmux', ['kill-session', '-t', session.tmuxSession], () => resolve());
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Create new tmux session
|
|
478
|
+
const tmuxSessionName = generateTmuxSessionName();
|
|
479
|
+
const claudeCmd = buildClaudeCommand({ model });
|
|
480
|
+
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
execFile('tmux', [
|
|
483
|
+
'new-session',
|
|
484
|
+
'-d',
|
|
485
|
+
'-s', tmuxSessionName,
|
|
486
|
+
'-c', cwd,
|
|
487
|
+
claudeCmd
|
|
488
|
+
], (error) => {
|
|
489
|
+
if (error) {
|
|
490
|
+
resolve({ error: `Failed to restart session: ${error.message}` });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Update session
|
|
495
|
+
session.tmuxSession = tmuxSessionName;
|
|
496
|
+
session.status = SESSION_STATUS.IDLE;
|
|
497
|
+
session.currentTool = null;
|
|
498
|
+
session.lastActivity = Date.now();
|
|
499
|
+
session.claudeSessionId = null; // Will be re-linked on activity
|
|
500
|
+
|
|
501
|
+
// Clear old mapping
|
|
502
|
+
for (const [claudeId, mappedId] of claudeToSessionMap.entries()) {
|
|
503
|
+
if (mappedId === sessionId) {
|
|
504
|
+
claudeToSessionMap.delete(claudeId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
saveSessions();
|
|
509
|
+
notifySessionUpdate();
|
|
510
|
+
|
|
511
|
+
console.log(` Restarted session "${name}" -> tmux:${tmuxSessionName}`);
|
|
512
|
+
resolve({ success: true, session });
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Update a session
|
|
519
|
+
* @param {string} sessionId - Session ID
|
|
520
|
+
* @param {Object} updates - Updates to apply
|
|
521
|
+
* @returns {Object} Result
|
|
522
|
+
*/
|
|
523
|
+
function updateSession(sessionId, updates) {
|
|
524
|
+
const session = sessions.get(sessionId);
|
|
525
|
+
if (!session) {
|
|
526
|
+
return { error: 'Session not found' };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (updates.name !== undefined) {
|
|
530
|
+
session.name = updates.name;
|
|
531
|
+
}
|
|
532
|
+
if (updates.metadata !== undefined) {
|
|
533
|
+
session.metadata = { ...session.metadata, ...updates.metadata };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
saveSessions();
|
|
537
|
+
notifySessionUpdate();
|
|
538
|
+
|
|
539
|
+
return { success: true, session };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Get a session by ID
|
|
544
|
+
* @param {string} sessionId - Session ID
|
|
545
|
+
* @returns {Object|null} Session or null
|
|
546
|
+
*/
|
|
547
|
+
function getSession(sessionId) {
|
|
548
|
+
return sessions.get(sessionId) || null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Get all sessions
|
|
553
|
+
* @returns {Array} Array of sessions
|
|
554
|
+
*/
|
|
555
|
+
function listSessions() {
|
|
556
|
+
return Array.from(sessions.values());
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Find session by Claude session ID
|
|
561
|
+
* @param {string} claudeSessionId - Claude session ID
|
|
562
|
+
* @returns {Object|null} Session or null
|
|
563
|
+
*/
|
|
564
|
+
function findByClaudeSessionId(claudeSessionId) {
|
|
565
|
+
const sessionId = claudeToSessionMap.get(claudeSessionId);
|
|
566
|
+
if (sessionId) {
|
|
567
|
+
return sessions.get(sessionId);
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Link a Claude session ID to a spawned session
|
|
574
|
+
* @param {string} claudeSessionId - Claude session ID
|
|
575
|
+
* @param {string} sessionId - Spawned session ID
|
|
576
|
+
*/
|
|
577
|
+
function linkClaudeSession(claudeSessionId, sessionId) {
|
|
578
|
+
claudeToSessionMap.set(claudeSessionId, sessionId);
|
|
579
|
+
const session = sessions.get(sessionId);
|
|
580
|
+
if (session) {
|
|
581
|
+
session.claudeSessionId = claudeSessionId;
|
|
582
|
+
saveSessions();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Update session from a Claude hook event
|
|
588
|
+
* @param {Object} event - Hook event
|
|
589
|
+
*/
|
|
590
|
+
function updateFromEvent(event) {
|
|
591
|
+
if (!event.sessionId) return;
|
|
592
|
+
|
|
593
|
+
let session = findByClaudeSessionId(event.sessionId);
|
|
594
|
+
|
|
595
|
+
if (!session) {
|
|
596
|
+
// Try to match by cwd
|
|
597
|
+
if (event.cwd) {
|
|
598
|
+
const normalizedCwd = normalizePath(event.cwd);
|
|
599
|
+
for (const s of sessions.values()) {
|
|
600
|
+
if (s.cwd === normalizedCwd && !s.claudeSessionId) {
|
|
601
|
+
session = s;
|
|
602
|
+
linkClaudeSession(event.sessionId, s.id);
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!session) return;
|
|
610
|
+
|
|
611
|
+
const prevStatus = session.status;
|
|
612
|
+
session.lastActivity = Date.now();
|
|
613
|
+
if (event.cwd) {
|
|
614
|
+
session.cwd = normalizePath(event.cwd);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
switch (event.type) {
|
|
618
|
+
case 'pre_tool_use':
|
|
619
|
+
session.status = SESSION_STATUS.WORKING;
|
|
620
|
+
session.currentTool = event.tool;
|
|
621
|
+
break;
|
|
622
|
+
|
|
623
|
+
case 'post_tool_use':
|
|
624
|
+
session.currentTool = null;
|
|
625
|
+
break;
|
|
626
|
+
|
|
627
|
+
case 'user_prompt_submit':
|
|
628
|
+
session.status = SESSION_STATUS.WORKING;
|
|
629
|
+
session.currentTool = null;
|
|
630
|
+
break;
|
|
631
|
+
|
|
632
|
+
case 'stop':
|
|
633
|
+
case 'session_end':
|
|
634
|
+
session.status = SESSION_STATUS.IDLE;
|
|
635
|
+
session.currentTool = null;
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (prevStatus !== session.status) {
|
|
640
|
+
saveSessions();
|
|
641
|
+
notifySessionUpdate();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Check health of all sessions
|
|
647
|
+
*/
|
|
648
|
+
function checkHealth() {
|
|
649
|
+
execFile('tmux', ['list-sessions', '-F', '#{session_name}'], (error, stdout) => {
|
|
650
|
+
if (error) {
|
|
651
|
+
// No tmux server or sessions
|
|
652
|
+
let changed = false;
|
|
653
|
+
for (const session of sessions.values()) {
|
|
654
|
+
if (session.status !== SESSION_STATUS.OFFLINE) {
|
|
655
|
+
session.status = SESSION_STATUS.OFFLINE;
|
|
656
|
+
session.currentTool = null;
|
|
657
|
+
changed = true;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (changed) {
|
|
661
|
+
saveSessions();
|
|
662
|
+
notifySessionUpdate();
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const activeSessions = new Set(stdout.trim().split('\n').filter(Boolean));
|
|
668
|
+
let changed = false;
|
|
669
|
+
|
|
670
|
+
for (const session of sessions.values()) {
|
|
671
|
+
if (!session.tmuxSession) continue;
|
|
672
|
+
|
|
673
|
+
const isAlive = activeSessions.has(session.tmuxSession);
|
|
674
|
+
const newStatus = isAlive
|
|
675
|
+
? (session.status === SESSION_STATUS.OFFLINE ? SESSION_STATUS.IDLE : session.status)
|
|
676
|
+
: SESSION_STATUS.OFFLINE;
|
|
677
|
+
|
|
678
|
+
if (session.status !== newStatus) {
|
|
679
|
+
session.status = newStatus;
|
|
680
|
+
if (newStatus === SESSION_STATUS.OFFLINE) {
|
|
681
|
+
session.currentTool = null;
|
|
682
|
+
}
|
|
683
|
+
changed = true;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (changed) {
|
|
688
|
+
saveSessions();
|
|
689
|
+
notifySessionUpdate();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Check for working timeout
|
|
696
|
+
*/
|
|
697
|
+
function checkWorkingTimeout() {
|
|
698
|
+
const now = Date.now();
|
|
699
|
+
let changed = false;
|
|
700
|
+
|
|
701
|
+
for (const session of sessions.values()) {
|
|
702
|
+
if (session.status === SESSION_STATUS.WORKING) {
|
|
703
|
+
const timeSinceActivity = now - (session.lastActivity || 0);
|
|
704
|
+
if (timeSinceActivity > config.workingTimeout) {
|
|
705
|
+
console.log(` Session "${session.name}" timed out after ${Math.round(timeSinceActivity / 1000)}s`);
|
|
706
|
+
session.status = SESSION_STATUS.IDLE;
|
|
707
|
+
session.currentTool = null;
|
|
708
|
+
changed = true;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (changed) {
|
|
714
|
+
saveSessions();
|
|
715
|
+
notifySessionUpdate();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Detect permission prompt in tmux output
|
|
721
|
+
* @param {string} output - Tmux pane output
|
|
722
|
+
* @returns {Object|null} Permission info or null
|
|
723
|
+
*/
|
|
724
|
+
function detectPermissionPrompt(output) {
|
|
725
|
+
const lines = output.split('\n');
|
|
726
|
+
|
|
727
|
+
let proceedLineIdx = -1;
|
|
728
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
|
|
729
|
+
if (/(Do you want|Would you like) to proceed\?/i.test(lines[i])) {
|
|
730
|
+
proceedLineIdx = i;
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (proceedLineIdx === -1) return null;
|
|
736
|
+
|
|
737
|
+
// Look for confirmation footer or selector
|
|
738
|
+
let hasFooter = false;
|
|
739
|
+
let hasSelector = false;
|
|
740
|
+
for (let i = proceedLineIdx + 1; i < Math.min(lines.length, proceedLineIdx + 15); i++) {
|
|
741
|
+
if (/Esc to cancel|ctrl-g to edit/i.test(lines[i])) {
|
|
742
|
+
hasFooter = true;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
if (/^\s*❯/.test(lines[i])) {
|
|
746
|
+
hasSelector = true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (!hasFooter && !hasSelector) return null;
|
|
751
|
+
|
|
752
|
+
// Find tool name
|
|
753
|
+
let tool = 'Permission';
|
|
754
|
+
for (let i = proceedLineIdx; i >= Math.max(0, proceedLineIdx - 20); i--) {
|
|
755
|
+
const toolMatch = lines[i].match(/[●◐·]\s*(\w+)\s*\(/);
|
|
756
|
+
if (toolMatch) {
|
|
757
|
+
tool = toolMatch[1];
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
const cmdMatch = lines[i].match(/^\s*(Bash|Read|Write|Edit|Grep|Glob|Task|WebFetch|WebSearch)\s+\w+/i);
|
|
761
|
+
if (cmdMatch) {
|
|
762
|
+
tool = cmdMatch[1];
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return { tool, context: lines.slice(Math.max(0, proceedLineIdx - 5), proceedLineIdx + 8).join('\n') };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Poll a session for permission prompts
|
|
772
|
+
* @param {Object} session - Session to poll
|
|
773
|
+
*/
|
|
774
|
+
function pollSessionPermissions(session) {
|
|
775
|
+
if (!session.tmuxSession) return;
|
|
776
|
+
|
|
777
|
+
execFile('tmux', ['capture-pane', '-t', session.tmuxSession, '-p', '-S', '-50'],
|
|
778
|
+
{ timeout: 2000, maxBuffer: 1024 * 1024 },
|
|
779
|
+
(error, stdout) => {
|
|
780
|
+
if (error) return;
|
|
781
|
+
|
|
782
|
+
const prompt = detectPermissionPrompt(stdout);
|
|
783
|
+
const existing = pendingPermissions.get(session.id);
|
|
784
|
+
|
|
785
|
+
if (prompt && !existing) {
|
|
786
|
+
pendingPermissions.set(session.id, {
|
|
787
|
+
tool: prompt.tool,
|
|
788
|
+
detectedAt: Date.now()
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
console.log(` Permission prompt detected for "${session.name}": ${prompt.tool}`);
|
|
792
|
+
|
|
793
|
+
if (session.status !== SESSION_STATUS.WAITING) {
|
|
794
|
+
session.status = SESSION_STATUS.WAITING;
|
|
795
|
+
session.currentTool = prompt.tool;
|
|
796
|
+
saveSessions();
|
|
797
|
+
notifySessionUpdate();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (onPermissionDetected) {
|
|
801
|
+
onPermissionDetected(session.id, prompt);
|
|
802
|
+
}
|
|
803
|
+
} else if (!prompt && existing) {
|
|
804
|
+
pendingPermissions.delete(session.id);
|
|
805
|
+
console.log(` Permission prompt resolved for "${session.name}"`);
|
|
806
|
+
|
|
807
|
+
if (session.status === SESSION_STATUS.WAITING) {
|
|
808
|
+
session.status = SESSION_STATUS.WORKING;
|
|
809
|
+
session.currentTool = null;
|
|
810
|
+
saveSessions();
|
|
811
|
+
notifySessionUpdate();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Check all sessions for permission prompts
|
|
820
|
+
*/
|
|
821
|
+
function checkPermissions() {
|
|
822
|
+
for (const session of sessions.values()) {
|
|
823
|
+
if (session.tmuxSession && session.status !== SESSION_STATUS.OFFLINE) {
|
|
824
|
+
pollSessionPermissions(session);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Respond to a permission prompt
|
|
831
|
+
* @param {string} sessionId - Session ID
|
|
832
|
+
* @param {string} response - Response ('yes', 'no', '1', '2', etc.)
|
|
833
|
+
* @returns {Promise<Object>} Result
|
|
834
|
+
*/
|
|
835
|
+
async function respondToPermission(sessionId, response) {
|
|
836
|
+
const session = sessions.get(sessionId);
|
|
837
|
+
if (!session) {
|
|
838
|
+
return { error: 'Session not found' };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (!session.tmuxSession) {
|
|
842
|
+
return { error: 'Session has no tmux session' };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Map common responses to numbers
|
|
846
|
+
let key = response;
|
|
847
|
+
if (response.toLowerCase() === 'yes' || response === 'y') {
|
|
848
|
+
key = '1';
|
|
849
|
+
} else if (response.toLowerCase() === 'no' || response === 'n') {
|
|
850
|
+
key = '2';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return new Promise((resolve) => {
|
|
854
|
+
execFile('tmux', ['send-keys', '-t', session.tmuxSession, key], (err) => {
|
|
855
|
+
if (err) {
|
|
856
|
+
resolve({ error: err.message });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Clear pending permission
|
|
861
|
+
pendingPermissions.delete(sessionId);
|
|
862
|
+
session.status = SESSION_STATUS.WORKING;
|
|
863
|
+
session.currentTool = null;
|
|
864
|
+
saveSessions();
|
|
865
|
+
notifySessionUpdate();
|
|
866
|
+
|
|
867
|
+
resolve({ success: true });
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Notify listeners of session update
|
|
874
|
+
*/
|
|
875
|
+
function notifySessionUpdate() {
|
|
876
|
+
if (onSessionUpdate) {
|
|
877
|
+
onSessionUpdate(listSessions());
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Set callback for session updates
|
|
883
|
+
* @param {Function} callback - Callback function
|
|
884
|
+
*/
|
|
885
|
+
function setOnSessionUpdate(callback) {
|
|
886
|
+
onSessionUpdate = callback;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Set callback for permission detection
|
|
891
|
+
* @param {Function} callback - Callback function
|
|
892
|
+
*/
|
|
893
|
+
function setOnPermissionDetected(callback) {
|
|
894
|
+
onPermissionDetected = callback;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Start health monitoring
|
|
899
|
+
*/
|
|
900
|
+
function startHealthChecks() {
|
|
901
|
+
if (healthCheckInterval) {
|
|
902
|
+
clearInterval(healthCheckInterval);
|
|
903
|
+
}
|
|
904
|
+
if (workingTimeoutInterval) {
|
|
905
|
+
clearInterval(workingTimeoutInterval);
|
|
906
|
+
}
|
|
907
|
+
if (permissionPollInterval) {
|
|
908
|
+
clearInterval(permissionPollInterval);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
healthCheckInterval = setInterval(checkHealth, config.healthCheckInterval);
|
|
912
|
+
workingTimeoutInterval = setInterval(checkWorkingTimeout, 10000);
|
|
913
|
+
permissionPollInterval = setInterval(checkPermissions, config.permissionPollInterval);
|
|
914
|
+
|
|
915
|
+
// Run initial health check
|
|
916
|
+
checkHealth();
|
|
917
|
+
|
|
918
|
+
console.log(' Session spawner health monitoring started');
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Stop health monitoring
|
|
923
|
+
*/
|
|
924
|
+
function stopHealthChecks() {
|
|
925
|
+
if (healthCheckInterval) {
|
|
926
|
+
clearInterval(healthCheckInterval);
|
|
927
|
+
healthCheckInterval = null;
|
|
928
|
+
}
|
|
929
|
+
if (workingTimeoutInterval) {
|
|
930
|
+
clearInterval(workingTimeoutInterval);
|
|
931
|
+
workingTimeoutInterval = null;
|
|
932
|
+
}
|
|
933
|
+
if (permissionPollInterval) {
|
|
934
|
+
clearInterval(permissionPollInterval);
|
|
935
|
+
permissionPollInterval = null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Initialize the spawner
|
|
941
|
+
*/
|
|
942
|
+
function init() {
|
|
943
|
+
loadSessions();
|
|
944
|
+
startHealthChecks();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Shutdown the spawner
|
|
949
|
+
*/
|
|
950
|
+
function shutdown() {
|
|
951
|
+
stopHealthChecks();
|
|
952
|
+
saveSessions();
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return {
|
|
956
|
+
// Session lifecycle
|
|
957
|
+
spawnSession,
|
|
958
|
+
killSession,
|
|
959
|
+
restartSession,
|
|
960
|
+
removeSession,
|
|
961
|
+
|
|
962
|
+
// Session interaction
|
|
963
|
+
sendPrompt,
|
|
964
|
+
cancelSession,
|
|
965
|
+
respondToPermission,
|
|
966
|
+
|
|
967
|
+
// Session management
|
|
968
|
+
getSession,
|
|
969
|
+
listSessions,
|
|
970
|
+
updateSession,
|
|
971
|
+
findByClaudeSessionId,
|
|
972
|
+
linkClaudeSession,
|
|
973
|
+
updateFromEvent,
|
|
974
|
+
|
|
975
|
+
// Health monitoring
|
|
976
|
+
checkHealth,
|
|
977
|
+
checkWorkingTimeout,
|
|
978
|
+
checkPermissions,
|
|
979
|
+
startHealthChecks,
|
|
980
|
+
stopHealthChecks,
|
|
981
|
+
|
|
982
|
+
// Projects
|
|
983
|
+
projectsManager,
|
|
984
|
+
|
|
985
|
+
// Callbacks
|
|
986
|
+
setOnSessionUpdate,
|
|
987
|
+
setOnPermissionDetected,
|
|
988
|
+
|
|
989
|
+
// Lifecycle
|
|
990
|
+
init,
|
|
991
|
+
shutdown,
|
|
992
|
+
loadSessions,
|
|
993
|
+
saveSessions,
|
|
994
|
+
|
|
995
|
+
// Direct access (for testing)
|
|
996
|
+
_sessions: sessions,
|
|
997
|
+
_claudeToSessionMap: claudeToSessionMap,
|
|
998
|
+
|
|
999
|
+
// Constants
|
|
1000
|
+
SESSION_STATUS,
|
|
1001
|
+
CLAUDE_MODELS
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
module.exports = {
|
|
1006
|
+
createSessionSpawner,
|
|
1007
|
+
SESSION_STATUS,
|
|
1008
|
+
CLAUDE_MODELS,
|
|
1009
|
+
DEFAULT_CONFIG
|
|
1010
|
+
};
|