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.
@@ -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
+ };