claudehq 1.0.0 → 1.0.2

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,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
+ };