mstro-app 0.1.53 → 0.1.56

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.
Files changed (57) hide show
  1. package/bin/mstro.js +3 -1
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +151 -0
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  6. package/dist/server/cli/headless/runner.js +7 -1
  7. package/dist/server/cli/headless/runner.js.map +1 -1
  8. package/dist/server/cli/headless/stall-assessor.d.ts +30 -0
  9. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -0
  10. package/dist/server/cli/headless/stall-assessor.js +184 -0
  11. package/dist/server/cli/headless/stall-assessor.js.map +1 -0
  12. package/dist/server/cli/headless/types.d.ts +9 -1
  13. package/dist/server/cli/headless/types.d.ts.map +1 -1
  14. package/dist/server/cli/improvisation-session-manager.d.ts +21 -2
  15. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-session-manager.js +65 -5
  17. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  18. package/dist/server/index.js +4 -1
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  21. package/dist/server/mcp/bouncer-integration.js +32 -0
  22. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  23. package/dist/server/services/platform.d.ts.map +1 -1
  24. package/dist/server/services/platform.js +8 -5
  25. package/dist/server/services/platform.js.map +1 -1
  26. package/dist/server/services/settings.d.ts +25 -0
  27. package/dist/server/services/settings.d.ts.map +1 -0
  28. package/dist/server/services/settings.js +72 -0
  29. package/dist/server/services/settings.js.map +1 -0
  30. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
  31. package/dist/server/services/websocket/autocomplete.js +12 -15
  32. package/dist/server/services/websocket/autocomplete.js.map +1 -1
  33. package/dist/server/services/websocket/handler.d.ts +99 -2
  34. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  35. package/dist/server/services/websocket/handler.js +627 -184
  36. package/dist/server/services/websocket/handler.js.map +1 -1
  37. package/dist/server/services/websocket/session-registry.d.ts +38 -0
  38. package/dist/server/services/websocket/session-registry.d.ts.map +1 -0
  39. package/dist/server/services/websocket/session-registry.js +154 -0
  40. package/dist/server/services/websocket/session-registry.js.map +1 -0
  41. package/dist/server/services/websocket/types.d.ts +2 -2
  42. package/dist/server/services/websocket/types.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/server/cli/headless/RESEARCH.md +627 -0
  45. package/server/cli/headless/claude-invoker.ts +192 -1
  46. package/server/cli/headless/runner.ts +7 -1
  47. package/server/cli/headless/stall-assessor.ts +245 -0
  48. package/server/cli/headless/types.ts +9 -1
  49. package/server/cli/improvisation-session-manager.ts +73 -5
  50. package/server/index.ts +4 -1
  51. package/server/mcp/bouncer-integration.ts +32 -0
  52. package/server/services/platform.ts +8 -5
  53. package/server/services/settings.ts +89 -0
  54. package/server/services/websocket/autocomplete.ts +18 -14
  55. package/server/services/websocket/handler.ts +687 -200
  56. package/server/services/websocket/session-registry.ts +180 -0
  57. package/server/services/websocket/types.ts +31 -2
@@ -11,11 +11,39 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
14
+ import { AnalyticsEvents, trackEvent } from '../analytics.js';
14
15
  import { createDirectory, createFile, deleteFile, listDirectory, renameFile, writeFile } from '../files.js';
15
16
  import { captureException } from '../sentry.js';
17
+ import { getModel, getSettings, setModel } from '../settings.js';
16
18
  import { getPTYManager } from '../terminal/pty-manager.js';
17
19
  import { AutocompleteService } from './autocomplete.js';
18
20
  import { readFileContent } from './file-utils.js';
21
+ import { SessionRegistry } from './session-registry.js';
22
+ /** Convert a single movement record into OutputLine-compatible entries */
23
+ function convertMovementToLines(movement) {
24
+ const lines = [];
25
+ const ts = new Date(movement.timestamp).getTime();
26
+ lines.push({ type: 'user', text: `> ${movement.userPrompt}`, timestamp: ts });
27
+ if (movement.thinkingOutput) {
28
+ lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
29
+ }
30
+ if (movement.toolUseHistory) {
31
+ for (const tool of movement.toolUseHistory) {
32
+ lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
33
+ if (tool.result !== undefined) {
34
+ lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
35
+ }
36
+ }
37
+ }
38
+ if (movement.assistantResponse) {
39
+ lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
40
+ }
41
+ if (movement.errorOutput) {
42
+ lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
43
+ }
44
+ lines.push({ type: 'system', text: `Command completed (tokens: ${movement.tokensUsed.toLocaleString()})`, timestamp: ts });
45
+ return lines;
46
+ }
19
47
  export class WebSocketImproviseHandler {
20
48
  sessions = new Map();
21
49
  connections = new Map();
@@ -24,11 +52,24 @@ export class WebSocketImproviseHandler {
24
52
  usageReporter = null;
25
53
  /** Per-tab selected git directory (tabId -> directory path) */
26
54
  gitDirectories = new Map();
55
+ /** Persistent tab→session mapping that survives WS disconnections */
56
+ sessionRegistry = null;
57
+ /** All connected WS contexts (for broadcasting to all clients) */
58
+ allConnections = new Set();
27
59
  constructor() {
28
60
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
29
61
  const frecencyData = this.loadFrecencyData();
30
62
  this.autocompleteService = new AutocompleteService(frecencyData);
31
63
  }
64
+ /**
65
+ * Lazily initialize session registry for a working directory
66
+ */
67
+ getRegistry(workingDir) {
68
+ if (!this.sessionRegistry) {
69
+ this.sessionRegistry = new SessionRegistry(workingDir);
70
+ }
71
+ return this.sessionRegistry;
72
+ }
32
73
  /**
33
74
  * Set the usage reporter callback for sending usage data to platform
34
75
  */
@@ -77,6 +118,7 @@ export class WebSocketImproviseHandler {
77
118
  */
78
119
  handleConnection(ws, _workingDir) {
79
120
  this.connections.set(ws, new Map());
121
+ this.allConnections.add(ws);
80
122
  }
81
123
  /**
82
124
  * Handle incoming WebSocket message
@@ -105,7 +147,7 @@ export class WebSocketImproviseHandler {
105
147
  this.send(ws, { type: 'pong', tabId });
106
148
  return;
107
149
  case 'initTab':
108
- return void await this.initializeTab(ws, tabId, workingDir);
150
+ return void await this.initializeTab(ws, tabId, workingDir, msg.data?.tabName);
109
151
  case 'resumeSession':
110
152
  if (!msg.data?.historicalSessionId)
111
153
  throw new Error('Historical session ID is required');
@@ -144,6 +186,7 @@ export class WebSocketImproviseHandler {
144
186
  case 'createDirectory':
145
187
  case 'deleteFile':
146
188
  case 'renameFile':
189
+ case 'notifyFileOpened':
147
190
  return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
148
191
  case 'gitStatus':
149
192
  case 'gitStage':
@@ -155,6 +198,26 @@ export class WebSocketImproviseHandler {
155
198
  case 'gitDiscoverRepos':
156
199
  case 'gitSetDirectory':
157
200
  return this.handleGitMessage(ws, msg, tabId, workingDir);
201
+ // Session sync messages
202
+ case 'getActiveTabs':
203
+ return this.handleGetActiveTabs(ws, workingDir);
204
+ case 'createTab':
205
+ return void await this.handleCreateTab(ws, workingDir, msg.data?.tabName, msg.data?.optimisticTabId);
206
+ case 'reorderTabs':
207
+ return this.handleReorderTabs(ws, workingDir, msg.data?.tabOrder);
208
+ case 'syncTabMeta':
209
+ return this.handleSyncTabMeta(ws, msg, tabId, workingDir);
210
+ case 'syncPromptText':
211
+ return this.handleSyncPromptText(ws, msg, tabId);
212
+ case 'removeTab':
213
+ return this.handleRemoveTab(ws, tabId, workingDir);
214
+ case 'markTabViewed':
215
+ return this.handleMarkTabViewed(ws, tabId, workingDir);
216
+ // Settings messages
217
+ case 'getSettings':
218
+ return this.handleGetSettings(ws);
219
+ case 'updateSettings':
220
+ return this.handleUpdateSettings(ws, msg);
158
221
  default:
159
222
  throw new Error(`Unknown message type: ${msg.type}`);
160
223
  }
@@ -184,13 +247,17 @@ export class WebSocketImproviseHandler {
184
247
  }
185
248
  case 'new': {
186
249
  const oldSession = this.requireSession(ws, tabId);
187
- const newSession = oldSession.startNewSession();
250
+ const newSession = oldSession.startNewSession({ model: getModel() });
188
251
  this.setupSessionListeners(newSession, ws, tabId);
189
252
  const newSessionId = newSession.getSessionInfo().sessionId;
190
253
  this.sessions.set(newSessionId, newSession);
191
254
  const tabMap = this.connections.get(ws);
192
255
  if (tabMap)
193
256
  tabMap.set(tabId, newSessionId);
257
+ // Update registry with new session ID
258
+ if (this.sessionRegistry) {
259
+ this.sessionRegistry.updateTabSession(tabId, newSessionId);
260
+ }
194
261
  this.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
195
262
  break;
196
263
  }
@@ -321,29 +388,82 @@ export class WebSocketImproviseHandler {
321
388
  throw new Error('File path is required');
322
389
  if (msg.data.content === undefined)
323
390
  throw new Error('Content is required');
324
- this.sendFileResult(ws, 'fileWritten', tabId, writeFile(msg.data.filePath, msg.data.content, workingDir));
391
+ const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
392
+ this.sendFileResult(ws, 'fileWritten', tabId, result);
393
+ if (result.success) {
394
+ this.broadcastToOthers(ws, {
395
+ type: 'fileContentChanged',
396
+ data: { path: result.path, content: msg.data.content }
397
+ });
398
+ }
325
399
  }
326
400
  handleCreateFile(ws, msg, tabId, workingDir) {
327
401
  if (!msg.data?.filePath)
328
402
  throw new Error('File path is required');
329
- this.sendFileResult(ws, 'fileCreated', tabId, createFile(msg.data.filePath, workingDir));
403
+ const result = createFile(msg.data.filePath, workingDir);
404
+ this.sendFileResult(ws, 'fileCreated', tabId, result);
405
+ if (result.success && result.path) {
406
+ const name = result.path.split('/').pop() || 'unknown';
407
+ this.broadcastToOthers(ws, {
408
+ type: 'fileCreated',
409
+ data: { path: result.path, name, size: 0, modifiedAt: new Date().toISOString() }
410
+ });
411
+ }
330
412
  }
331
413
  handleCreateDirectory(ws, msg, tabId, workingDir) {
332
414
  if (!msg.data?.dirPath)
333
415
  throw new Error('Directory path is required');
334
- this.sendFileResult(ws, 'directoryCreated', tabId, createDirectory(msg.data.dirPath, workingDir));
416
+ const result = createDirectory(msg.data.dirPath, workingDir);
417
+ this.sendFileResult(ws, 'directoryCreated', tabId, result);
418
+ if (result.success && result.path) {
419
+ const name = result.path.split('/').pop() || 'unknown';
420
+ this.broadcastToOthers(ws, {
421
+ type: 'directoryCreated',
422
+ data: { path: result.path, name }
423
+ });
424
+ }
335
425
  }
336
426
  handleDeleteFile(ws, msg, tabId, workingDir) {
337
427
  if (!msg.data?.filePath)
338
428
  throw new Error('File path is required');
339
- this.sendFileResult(ws, 'fileDeleted', tabId, deleteFile(msg.data.filePath, workingDir));
429
+ const result = deleteFile(msg.data.filePath, workingDir);
430
+ this.sendFileResult(ws, 'fileDeleted', tabId, result);
431
+ if (result.success && result.path) {
432
+ this.broadcastToOthers(ws, {
433
+ type: 'fileDeleted',
434
+ data: { path: result.path }
435
+ });
436
+ }
340
437
  }
341
438
  handleRenameFile(ws, msg, tabId, workingDir) {
342
439
  if (!msg.data?.oldPath)
343
440
  throw new Error('Old path is required');
344
441
  if (!msg.data?.newPath)
345
442
  throw new Error('New path is required');
346
- this.sendFileResult(ws, 'fileRenamed', tabId, renameFile(msg.data.oldPath, msg.data.newPath, workingDir));
443
+ const result = renameFile(msg.data.oldPath, msg.data.newPath, workingDir);
444
+ this.sendFileResult(ws, 'fileRenamed', tabId, result);
445
+ if (result.success && result.path) {
446
+ const name = result.path.split('/').pop() || 'unknown';
447
+ this.broadcastToOthers(ws, {
448
+ type: 'fileRenamed',
449
+ data: { oldPath: msg.data.oldPath, newPath: result.path, name }
450
+ });
451
+ }
452
+ }
453
+ handleNotifyFileOpened(ws, msg, workingDir) {
454
+ if (!msg.data?.filePath)
455
+ return;
456
+ const fileData = readFileContent(msg.data.filePath, workingDir);
457
+ if (!fileData.error) {
458
+ this.broadcastToOthers(ws, {
459
+ type: 'fileOpened',
460
+ data: {
461
+ path: msg.data.filePath,
462
+ fileName: fileData.fileName,
463
+ content: fileData.content
464
+ }
465
+ });
466
+ }
347
467
  }
348
468
  handleFileExplorerMessage(ws, msg, tabId, workingDir) {
349
469
  const handlers = {
@@ -353,6 +473,7 @@ export class WebSocketImproviseHandler {
353
473
  createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
354
474
  deleteFile: () => this.handleDeleteFile(ws, msg, tabId, workingDir),
355
475
  renameFile: () => this.handleRenameFile(ws, msg, tabId, workingDir),
476
+ notifyFileOpened: () => this.handleNotifyFileOpened(ws, msg, workingDir),
356
477
  };
357
478
  handlers[msg.type]?.();
358
479
  }
@@ -379,9 +500,16 @@ export class WebSocketImproviseHandler {
379
500
  });
380
501
  session.on('onMovementStart', (sequenceNumber, prompt) => {
381
502
  this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
503
+ // Broadcast execution state to ALL clients so tab indicators update
504
+ // even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
505
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true } });
382
506
  });
383
507
  session.on('onMovementComplete', (movement) => {
384
508
  this.send(ws, { type: 'movementComplete', tabId, data: movement });
509
+ // Mark tab as having unviewed completion (persisted across CLI restarts)
510
+ this.sessionRegistry?.markTabUnviewed(tabId);
511
+ // Broadcast execution state + completion dot to ALL clients
512
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
385
513
  // Report usage to platform if reporter is configured
386
514
  if (this.usageReporter && movement.tokensUsed) {
387
515
  this.usageReporter({
@@ -393,6 +521,8 @@ export class WebSocketImproviseHandler {
393
521
  });
394
522
  session.on('onMovementError', (error) => {
395
523
  this.send(ws, { type: 'movementError', tabId, data: { message: error.message } });
524
+ // Broadcast execution stopped to ALL clients
525
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
396
526
  });
397
527
  session.on('onSessionUpdate', (history) => {
398
528
  this.send(ws, { type: 'sessionUpdate', tabId, data: history });
@@ -411,30 +541,33 @@ export class WebSocketImproviseHandler {
411
541
  */
412
542
  async resumeHistoricalSession(ws, tabId, workingDir, historicalSessionId) {
413
543
  const tabMap = this.connections.get(ws);
544
+ const registry = this.getRegistry(workingDir);
545
+ // Check per-connection map first (same WS reconnect)
414
546
  const existingSessionId = tabMap?.get(tabId);
415
547
  if (existingSessionId) {
416
548
  const existingSession = this.sessions.get(existingSessionId);
417
549
  if (existingSession) {
418
- this.setupSessionListeners(existingSession, ws, tabId);
419
- this.send(ws, {
420
- type: 'tabInitialized',
421
- tabId,
422
- data: existingSession.getSessionInfo()
423
- });
550
+ this.reattachSession(existingSession, ws, tabId, registry);
551
+ return;
552
+ }
553
+ }
554
+ // Check session registry (cross-connection reattach)
555
+ const registrySessionId = registry.getTabSession(tabId);
556
+ if (registrySessionId) {
557
+ const inMemorySession = this.sessions.get(registrySessionId);
558
+ if (inMemorySession) {
559
+ this.reattachSession(inMemorySession, ws, tabId, registry);
424
560
  return;
425
561
  }
426
562
  }
427
563
  let session;
428
564
  let isNewSession = false;
429
565
  try {
430
- session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId);
566
+ session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
431
567
  }
432
568
  catch (error) {
433
- // Historical session not found on disk - this can happen if the server
434
- // restarted before any prompts were executed (history is only saved after
435
- // the first prompt). Fall back to creating a fresh session.
436
569
  console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
437
- session = new ImprovisationSessionManager({ workingDir });
570
+ session = new ImprovisationSessionManager({ workingDir, model: getModel() });
438
571
  isNewSession = true;
439
572
  }
440
573
  this.setupSessionListeners(session, ws, tabId);
@@ -443,48 +576,157 @@ export class WebSocketImproviseHandler {
443
576
  if (tabMap) {
444
577
  tabMap.set(tabId, sessionId);
445
578
  }
579
+ registry.registerTab(tabId, sessionId);
446
580
  this.send(ws, {
447
581
  type: 'tabInitialized',
448
582
  tabId,
449
583
  data: {
450
584
  ...session.getSessionInfo(),
451
- // Let the client know if we had to create a new session instead of resuming
585
+ outputHistory: this.buildOutputHistory(session),
452
586
  resumeFailed: isNewSession,
453
587
  originalSessionId: isNewSession ? historicalSessionId : undefined
454
588
  }
455
589
  });
456
590
  }
457
591
  /**
458
- * Initialize a new tab with its own session
592
+ * Initialize a tab with its own session.
593
+ * Checks (in order): per-connection map → session registry → disk history → new session.
459
594
  */
460
- async initializeTab(ws, tabId, workingDir) {
595
+ async initializeTab(ws, tabId, workingDir, tabName) {
461
596
  const tabMap = this.connections.get(ws);
597
+ const registry = this.getRegistry(workingDir);
598
+ // 1. Check per-connection map (same WS reconnect)
462
599
  const existingSessionId = tabMap?.get(tabId);
463
600
  if (existingSessionId) {
464
601
  const existingSession = this.sessions.get(existingSessionId);
465
602
  if (existingSession) {
466
- this.setupSessionListeners(existingSession, ws, tabId);
603
+ this.reattachSession(existingSession, ws, tabId, registry);
604
+ return;
605
+ }
606
+ }
607
+ // 2. Check session registry (cross-connection reattach, e.g. browser refresh)
608
+ const registrySessionId = registry.getTabSession(tabId);
609
+ if (registrySessionId) {
610
+ // Try in-memory first
611
+ const inMemorySession = this.sessions.get(registrySessionId);
612
+ if (inMemorySession) {
613
+ this.reattachSession(inMemorySession, ws, tabId, registry);
614
+ return;
615
+ }
616
+ // Try resuming from disk
617
+ try {
618
+ const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
619
+ this.setupSessionListeners(diskSession, ws, tabId);
620
+ const diskSessionId = diskSession.getSessionInfo().sessionId;
621
+ this.sessions.set(diskSessionId, diskSession);
622
+ if (tabMap)
623
+ tabMap.set(tabId, diskSessionId);
624
+ registry.touchTab(tabId);
467
625
  this.send(ws, {
468
626
  type: 'tabInitialized',
469
627
  tabId,
470
- data: existingSession.getSessionInfo()
628
+ data: {
629
+ ...diskSession.getSessionInfo(),
630
+ outputHistory: this.buildOutputHistory(diskSession),
631
+ }
471
632
  });
472
633
  return;
473
634
  }
635
+ catch {
636
+ // Disk session not found — fall through to create new
637
+ }
474
638
  }
475
- const session = new ImprovisationSessionManager({ workingDir });
639
+ // 3. Create new session
640
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
476
641
  this.setupSessionListeners(session, ws, tabId);
477
642
  const sessionId = session.getSessionInfo().sessionId;
478
643
  this.sessions.set(sessionId, session);
479
644
  if (tabMap) {
480
645
  tabMap.set(tabId, sessionId);
481
646
  }
647
+ registry.registerTab(tabId, sessionId, tabName);
648
+ const registeredTab = registry.getTab(tabId);
649
+ this.broadcastToAll({
650
+ type: 'tabCreated',
651
+ data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, sessionInfo: session.getSessionInfo() }
652
+ });
482
653
  this.send(ws, {
483
654
  type: 'tabInitialized',
484
655
  tabId,
485
656
  data: session.getSessionInfo()
486
657
  });
487
658
  }
659
+ /**
660
+ * Reattach to an existing in-memory session.
661
+ * Sends output history (completed movements + in-progress events) for state restoration.
662
+ */
663
+ reattachSession(session, ws, tabId, registry) {
664
+ this.setupSessionListeners(session, ws, tabId);
665
+ const tabMap = this.connections.get(ws);
666
+ const sessionId = session.getSessionInfo().sessionId;
667
+ if (tabMap)
668
+ tabMap.set(tabId, sessionId);
669
+ registry.touchTab(tabId);
670
+ // Build output history from completed movements
671
+ const outputHistory = this.buildOutputHistory(session);
672
+ // If currently executing, append in-progress events
673
+ const executionEvents = session.isExecuting
674
+ ? session.getExecutionEventLog()
675
+ : undefined;
676
+ this.send(ws, {
677
+ type: 'tabInitialized',
678
+ tabId,
679
+ data: {
680
+ ...session.getSessionInfo(),
681
+ outputHistory,
682
+ isExecuting: session.isExecuting,
683
+ executionEvents,
684
+ }
685
+ });
686
+ }
687
+ /**
688
+ * Build OutputLine-compatible history from a session's completed movements.
689
+ * Converts MovementRecords into the same format the web client uses for display.
690
+ */
691
+ buildOutputHistory(session) {
692
+ const history = session.getHistory();
693
+ return history.movements.flatMap(convertMovementToLines);
694
+ }
695
+ /**
696
+ * Send a message to all connected clients EXCEPT the sender.
697
+ * Used for multi-client sync (e.g., tab created by one client, others should know).
698
+ */
699
+ broadcastToOthers(sender, response) {
700
+ for (const ws of this.allConnections) {
701
+ if (ws !== sender) {
702
+ this.send(ws, response);
703
+ }
704
+ }
705
+ }
706
+ /**
707
+ * Send a message to ALL connected clients (including sender).
708
+ */
709
+ broadcastToAll(response) {
710
+ for (const ws of this.allConnections) {
711
+ this.send(ws, response);
712
+ }
713
+ }
714
+ // ========== Settings Handlers ==========
715
+ /**
716
+ * Return current machine-wide settings to the requesting client.
717
+ */
718
+ handleGetSettings(ws) {
719
+ this.send(ws, { type: 'settings', data: getSettings() });
720
+ }
721
+ /**
722
+ * Update settings and broadcast to all connected clients.
723
+ */
724
+ handleUpdateSettings(_ws, msg) {
725
+ if (msg.data?.model !== undefined) {
726
+ setModel(msg.data.model);
727
+ }
728
+ this.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
729
+ }
488
730
  /**
489
731
  * Get session for a specific tab
490
732
  */
@@ -499,9 +741,16 @@ export class WebSocketImproviseHandler {
499
741
  }
500
742
  /**
501
743
  * Handle connection close
744
+ * Note: Sessions are NOT destroyed — they persist for reconnection.
745
+ * Only the per-connection tab mapping is removed.
502
746
  */
503
747
  handleClose(ws) {
504
748
  this.connections.delete(ws);
749
+ this.allConnections.delete(ws);
750
+ // Remove ws from all terminal subscriber sets
751
+ for (const subs of this.terminalSubscribers.values()) {
752
+ subs.delete(ws);
753
+ }
505
754
  }
506
755
  /**
507
756
  * Send message to WebSocket client
@@ -745,6 +994,182 @@ export class WebSocketImproviseHandler {
745
994
  */
746
995
  cleanupStaleSessions() {
747
996
  }
997
+ // ============================================
998
+ // Session sync methods
999
+ // ============================================
1000
+ /**
1001
+ * Handle getActiveTabs — returns all registered tabs and their state.
1002
+ * Used by new clients (multi-device, multi-browser) to discover existing tabs.
1003
+ */
1004
+ handleGetActiveTabs(ws, workingDir) {
1005
+ const registry = this.getRegistry(workingDir);
1006
+ const allTabs = registry.getAllTabs();
1007
+ const tabs = {};
1008
+ for (const [tabId, regTab] of Object.entries(allTabs)) {
1009
+ const session = this.sessions.get(regTab.sessionId);
1010
+ if (session) {
1011
+ tabs[tabId] = {
1012
+ tabName: regTab.tabName,
1013
+ createdAt: regTab.createdAt,
1014
+ order: regTab.order,
1015
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
1016
+ sessionInfo: session.getSessionInfo(),
1017
+ isExecuting: session.isExecuting,
1018
+ outputHistory: this.buildOutputHistory(session),
1019
+ executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
1020
+ };
1021
+ }
1022
+ else {
1023
+ // Session not in memory — try to provide basic info from registry
1024
+ tabs[tabId] = {
1025
+ tabName: regTab.tabName,
1026
+ createdAt: regTab.createdAt,
1027
+ order: regTab.order,
1028
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
1029
+ sessionId: regTab.sessionId,
1030
+ isExecuting: false,
1031
+ outputHistory: [],
1032
+ };
1033
+ }
1034
+ }
1035
+ this.send(ws, { type: 'activeTabs', data: { tabs } });
1036
+ }
1037
+ /**
1038
+ * Handle syncTabMeta — update tab metadata (name) from a client.
1039
+ */
1040
+ handleSyncTabMeta(_ws, msg, tabId, workingDir) {
1041
+ const registry = this.getRegistry(workingDir);
1042
+ if (msg.data?.tabName) {
1043
+ registry.updateTabName(tabId, msg.data.tabName);
1044
+ // Broadcast rename to all clients (relay handles fan-out)
1045
+ this.broadcastToAll({
1046
+ type: 'tabRenamed',
1047
+ data: { tabId, tabName: msg.data.tabName }
1048
+ });
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Handle syncPromptText — relay prompt text changes to all clients.
1053
+ * Ephemeral: not persisted, just broadcast for live collaboration.
1054
+ */
1055
+ handleSyncPromptText(_ws, msg, tabId) {
1056
+ if (typeof msg.data?.text !== 'string')
1057
+ return;
1058
+ this.broadcastToAll({
1059
+ type: 'promptTextSync',
1060
+ tabId,
1061
+ data: { tabId, text: msg.data.text }
1062
+ });
1063
+ }
1064
+ /**
1065
+ * Handle removeTab — client is removing a tab.
1066
+ */
1067
+ handleRemoveTab(_ws, tabId, workingDir) {
1068
+ const registry = this.getRegistry(workingDir);
1069
+ registry.unregisterTab(tabId);
1070
+ // Broadcast to all clients (broadcastToAll ensures relay-connected clients receive it)
1071
+ this.broadcastToAll({
1072
+ type: 'tabRemoved',
1073
+ data: { tabId }
1074
+ });
1075
+ }
1076
+ /**
1077
+ * Handle markTabViewed — a client has viewed a tab's completed output.
1078
+ * Persists viewed state and broadcasts to all clients so the green dot
1079
+ * disappears on every device.
1080
+ */
1081
+ handleMarkTabViewed(_ws, tabId, workingDir) {
1082
+ const registry = this.getRegistry(workingDir);
1083
+ registry.markTabViewed(tabId);
1084
+ this.broadcastToAll({
1085
+ type: 'tabViewed',
1086
+ data: { tabId }
1087
+ });
1088
+ }
1089
+ /**
1090
+ * Handle createTab — CLI registers the tab and broadcasts to all clients.
1091
+ *
1092
+ * When optimisticTabId is provided, CLI reuses that ID as the authoritative tab ID.
1093
+ * The requesting client already created a local tab with this ID (optimistic UI),
1094
+ * so there's no reconciliation needed — the tab ID is the same everywhere.
1095
+ * The initTab flow (useTabInit) will handle session creation for the requesting client.
1096
+ *
1097
+ * Other clients that don't have this tab will add it via the tabCreated broadcast.
1098
+ */
1099
+ async handleCreateTab(ws, workingDir, tabName, optimisticTabId) {
1100
+ const registry = this.getRegistry(workingDir);
1101
+ // Use the client's optimistic ID when available — avoids reconciliation.
1102
+ // Fall back to server-generated ID if no optimistic ID provided.
1103
+ const tabId = optimisticTabId || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1104
+ // Check if this tab was already registered by initTab (race: useTabInit fires first)
1105
+ const existingSession = registry.getTabSession(tabId);
1106
+ if (existingSession) {
1107
+ // Tab already initialized — broadcast to all clients.
1108
+ // Must use broadcastToAll because all web clients share a single
1109
+ // platformRelayContext — broadcastToOthers would skip the relay entirely,
1110
+ // preventing other browser instances from discovering the new tab.
1111
+ const regTab = registry.getTab(tabId);
1112
+ this.broadcastToAll({
1113
+ type: 'tabCreated',
1114
+ data: {
1115
+ tabId,
1116
+ tabName: regTab?.tabName || 'Chat',
1117
+ createdAt: regTab?.createdAt,
1118
+ order: regTab?.order,
1119
+ sessionInfo: this.sessions.get(existingSession)?.getSessionInfo(),
1120
+ }
1121
+ });
1122
+ return;
1123
+ }
1124
+ // Create new session and register
1125
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
1126
+ this.setupSessionListeners(session, ws, tabId);
1127
+ const sessionId = session.getSessionInfo().sessionId;
1128
+ this.sessions.set(sessionId, session);
1129
+ const tabMap = this.connections.get(ws);
1130
+ if (tabMap)
1131
+ tabMap.set(tabId, sessionId);
1132
+ registry.registerTab(tabId, sessionId, tabName);
1133
+ const registeredTab = registry.getTab(tabId);
1134
+ // Broadcast to ALL clients — the requesting client already has the tab
1135
+ // (optimistic UI) and will ignore the duplicate via !currentTabs.has(tabId).
1136
+ // Must use broadcastToAll so other browser instances via the shared
1137
+ // platformRelayContext receive the tabCreated event.
1138
+ this.broadcastToAll({
1139
+ type: 'tabCreated',
1140
+ data: {
1141
+ tabId,
1142
+ tabName: registeredTab?.tabName || 'Chat',
1143
+ createdAt: registeredTab?.createdAt,
1144
+ order: registeredTab?.order,
1145
+ sessionInfo: session.getSessionInfo(),
1146
+ }
1147
+ });
1148
+ // Send tabInitialized to the requesting client so useTabInit resolves
1149
+ this.send(ws, {
1150
+ type: 'tabInitialized',
1151
+ tabId,
1152
+ data: session.getSessionInfo()
1153
+ });
1154
+ }
1155
+ /**
1156
+ * Handle reorderTabs — client is reordering tabs.
1157
+ */
1158
+ handleReorderTabs(_ws, workingDir, tabOrder) {
1159
+ if (!Array.isArray(tabOrder))
1160
+ return;
1161
+ const registry = this.getRegistry(workingDir);
1162
+ registry.reorderTabs(tabOrder);
1163
+ // Build order mapping for broadcast
1164
+ const allTabs = registry.getAllTabs();
1165
+ const orderMap = tabOrder
1166
+ .filter((id) => allTabs[id])
1167
+ .map((id) => ({ tabId: id, order: allTabs[id].order }));
1168
+ this.broadcastToAll({
1169
+ type: 'tabsReordered',
1170
+ data: { tabOrder: orderMap }
1171
+ });
1172
+ }
748
1173
  /**
749
1174
  * Generate a notification summary using Claude Haiku
750
1175
  * Sends the result as a notificationSummary message
@@ -973,10 +1398,8 @@ Respond with ONLY the summary text, nothing else.`;
973
1398
  const staged = [];
974
1399
  const unstaged = [];
975
1400
  const untracked = [];
976
- const lines = porcelainOutput.trim().split('\n').filter(Boolean);
1401
+ const lines = porcelainOutput.split('\n').filter(line => line.length >= 4);
977
1402
  for (const line of lines) {
978
- if (line.length < 4)
979
- continue;
980
1403
  const indexStatus = line[0];
981
1404
  const workTreeStatus = line[1];
982
1405
  const rawPath = line.slice(3);
@@ -1063,28 +1486,22 @@ Respond with ONLY the summary text, nothing else.`;
1063
1486
  * Handle git stage request
1064
1487
  */
1065
1488
  async handleGitStage(ws, msg, tabId, workingDir) {
1489
+ const stageAll = !!msg.data?.stageAll;
1066
1490
  const paths = msg.data?.paths;
1067
- if (!paths || paths.length === 0) {
1491
+ if (!stageAll && (!paths || paths.length === 0)) {
1068
1492
  this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for staging' } });
1069
1493
  return;
1070
1494
  }
1071
1495
  try {
1072
- const result = await this.executeGitCommand(['add', '--', ...paths], workingDir);
1496
+ // Use `git add -A` for staging all (handles new, modified, and deleted files reliably)
1497
+ // Use `git add -- ...paths` for staging specific files
1498
+ const args = stageAll ? ['add', '-A'] : ['add', '--', ...paths];
1499
+ const result = await this.executeGitCommand(args, workingDir);
1073
1500
  if (result.exitCode !== 0) {
1074
1501
  this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to stage files' } });
1075
1502
  return;
1076
1503
  }
1077
- // Verify files were actually staged by checking status
1078
- const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1079
- const { staged } = this.parseGitStatus(statusResult.stdout);
1080
- const stagedPaths = staged.map(f => f.path);
1081
- // Check if all requested files are now staged
1082
- const notStaged = paths.filter(p => !stagedPaths.includes(p));
1083
- if (notStaged.length > 0) {
1084
- // Some files weren't staged - they might not exist or have no changes
1085
- this.send(ws, { type: 'gitError', tabId, data: { error: `Some files could not be staged: ${notStaged.join(', ')}` } });
1086
- }
1087
- this.send(ws, { type: 'gitStaged', tabId, data: { paths } });
1504
+ this.send(ws, { type: 'gitStaged', tabId, data: { paths: paths || [] } });
1088
1505
  }
1089
1506
  catch (error) {
1090
1507
  this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
@@ -1121,20 +1538,10 @@ Respond with ONLY the summary text, nothing else.`;
1121
1538
  return;
1122
1539
  }
1123
1540
  try {
1124
- // First check if there are actually staged changes
1125
- const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
1126
- const { staged } = this.parseGitStatus(statusResult.stdout);
1127
- if (staged.length === 0) {
1128
- // No staged changes - refresh status on client and show clear error
1129
- this.handleGitStatus(ws, tabId, workingDir);
1130
- this.send(ws, { type: 'gitError', tabId, data: { error: 'No changes staged for commit. Use "Stage" to add files before committing.' } });
1131
- return;
1132
- }
1541
+ // Commit all staged changes directly - no pre-check to avoid race conditions
1133
1542
  const result = await this.executeGitCommand(['commit', '-m', message], workingDir);
1134
1543
  if (result.exitCode !== 0) {
1135
- // Parse the error to provide a cleaner message
1136
1544
  let errorMsg = result.stderr || result.stdout || 'Failed to commit';
1137
- // If it's a "nothing to commit" error, provide clearer message
1138
1545
  if (errorMsg.includes('nothing to commit') || errorMsg.includes('no changes added')) {
1139
1546
  errorMsg = 'No changes staged for commit. Use "Stage" to add files before committing.';
1140
1547
  // Refresh status to sync UI
@@ -1487,61 +1894,17 @@ Respond with ONLY the commit message, nothing else.`;
1487
1894
  });
1488
1895
  return;
1489
1896
  }
1490
- // Clean up any existing listeners for this terminal to prevent duplicates
1491
- const existingCleanup = this.terminalListenerCleanups.get(terminalId);
1492
- if (existingCleanup) {
1493
- existingCleanup();
1494
- }
1495
- // Set up event listeners for this terminal
1496
- const onOutput = (tid, data) => {
1497
- if (tid === terminalId) {
1498
- this.send(ws, {
1499
- type: 'terminalOutput',
1500
- terminalId,
1501
- data: { output: data }
1502
- });
1503
- }
1504
- };
1505
- const onExit = (tid, exitCode) => {
1506
- if (tid === terminalId) {
1507
- this.send(ws, {
1508
- type: 'terminalExit',
1509
- terminalId,
1510
- data: { exitCode }
1511
- });
1512
- // Clean up listeners
1513
- ptyManager.off('output', onOutput);
1514
- ptyManager.off('exit', onExit);
1515
- ptyManager.off('error', onError);
1516
- this.terminalListenerCleanups.delete(terminalId);
1517
- }
1518
- };
1519
- const onError = (tid, error) => {
1520
- if (tid === terminalId) {
1521
- this.send(ws, {
1522
- type: 'terminalError',
1523
- terminalId,
1524
- data: { error }
1525
- });
1526
- }
1527
- };
1528
- ptyManager.on('output', onOutput);
1529
- ptyManager.on('exit', onExit);
1530
- ptyManager.on('error', onError);
1531
- // Store cleanup function for this terminal
1532
- this.terminalListenerCleanups.set(terminalId, () => {
1533
- ptyManager.off('output', onOutput);
1534
- ptyManager.off('exit', onExit);
1535
- ptyManager.off('error', onError);
1536
- });
1897
+ // Add this WS as a subscriber for this terminal's output
1898
+ this.addTerminalSubscriber(terminalId, ws);
1899
+ // Set up broadcast listeners (idempotent — only creates once per terminal)
1900
+ this.setupTerminalBroadcastListeners(terminalId);
1537
1901
  try {
1538
1902
  // Create or reconnect to the PTY process
1539
1903
  const { shell, cwd, isReconnect } = ptyManager.create(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
1540
- // If reconnecting, send scrollback buffer first
1904
+ // If reconnecting, send scrollback buffer to THIS client only
1541
1905
  if (isReconnect) {
1542
1906
  const scrollback = ptyManager.getScrollback(terminalId);
1543
1907
  if (scrollback.length > 0) {
1544
- // Send scrollback as a single replay message
1545
1908
  this.send(ws, {
1546
1909
  type: 'terminalScrollback',
1547
1910
  terminalId,
@@ -1549,12 +1912,23 @@ Respond with ONLY the commit message, nothing else.`;
1549
1912
  });
1550
1913
  }
1551
1914
  }
1552
- // Send ready message
1915
+ else {
1916
+ // New terminal — broadcast to other clients so they can create matching tabs
1917
+ this.broadcastToOthers(ws, {
1918
+ type: 'terminalCreated',
1919
+ data: { terminalId, shell, cwd, persistent: false }
1920
+ });
1921
+ }
1922
+ // Send ready message to THIS client
1553
1923
  this.send(ws, {
1554
1924
  type: 'terminalReady',
1555
1925
  terminalId,
1556
1926
  data: { shell, cwd, isReconnect }
1557
1927
  });
1928
+ trackEvent(AnalyticsEvents.TERMINAL_SESSION_CREATED, {
1929
+ shell,
1930
+ is_reconnect: isReconnect,
1931
+ });
1558
1932
  }
1559
1933
  catch (error) {
1560
1934
  console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
@@ -1563,11 +1937,7 @@ Respond with ONLY the commit message, nothing else.`;
1563
1937
  terminalId,
1564
1938
  data: { error: error.message || 'Failed to create terminal' }
1565
1939
  });
1566
- // Clean up listeners
1567
- ptyManager.off('output', onOutput);
1568
- ptyManager.off('exit', onExit);
1569
- ptyManager.off('error', onError);
1570
- this.terminalListenerCleanups.delete(terminalId);
1940
+ this.removeTerminalSubscriber(terminalId, ws);
1571
1941
  }
1572
1942
  }
1573
1943
  /**
@@ -1585,53 +1955,11 @@ Respond with ONLY the commit message, nothing else.`;
1585
1955
  });
1586
1956
  return;
1587
1957
  }
1588
- // Clean up any existing listeners for this terminal to prevent duplicates
1589
- const existingCleanup = this.terminalListenerCleanups.get(terminalId);
1590
- if (existingCleanup) {
1591
- existingCleanup();
1592
- }
1593
- // Set up event listeners for this terminal
1594
- const onOutput = (tid, data) => {
1595
- if (tid === terminalId) {
1596
- this.send(ws, {
1597
- type: 'terminalOutput',
1598
- terminalId,
1599
- data: { output: data }
1600
- });
1601
- }
1602
- };
1603
- const onExit = (tid, exitCode) => {
1604
- if (tid === terminalId) {
1605
- this.send(ws, {
1606
- type: 'terminalExit',
1607
- terminalId,
1608
- data: { exitCode }
1609
- });
1610
- ptyManager.off('output', onOutput);
1611
- ptyManager.off('exit', onExit);
1612
- ptyManager.off('error', onError);
1613
- this.terminalListenerCleanups.delete(terminalId);
1614
- }
1615
- };
1616
- const onError = (tid, error) => {
1617
- if (tid === terminalId) {
1618
- this.send(ws, {
1619
- type: 'terminalError',
1620
- terminalId,
1621
- data: { error }
1622
- });
1623
- }
1624
- };
1625
- ptyManager.on('output', onOutput);
1626
- ptyManager.on('exit', onExit);
1627
- ptyManager.on('error', onError);
1628
- // Store cleanup function for this terminal
1629
- this.terminalListenerCleanups.set(terminalId, () => {
1630
- ptyManager.off('output', onOutput);
1631
- ptyManager.off('exit', onExit);
1632
- ptyManager.off('error', onError);
1633
- });
1634
- // Send scrollback buffer
1958
+ // Add this WS as a subscriber for this terminal's output
1959
+ this.addTerminalSubscriber(terminalId, ws);
1960
+ // Set up broadcast listeners (idempotent — only creates once per terminal)
1961
+ this.setupTerminalBroadcastListeners(terminalId);
1962
+ // Send scrollback buffer to THIS client only
1635
1963
  const scrollback = ptyManager.getScrollback(terminalId);
1636
1964
  if (scrollback.length > 0) {
1637
1965
  this.send(ws, {
@@ -1712,7 +2040,8 @@ Respond with ONLY the commit message, nothing else.`;
1712
2040
  /**
1713
2041
  * Handle terminal close
1714
2042
  */
1715
- handleTerminalClose(_ws, terminalId) {
2043
+ handleTerminalClose(ws, terminalId) {
2044
+ trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
1716
2045
  // Check if this is a persistent terminal first
1717
2046
  const persistentHandler = this.persistentHandlers.get(terminalId);
1718
2047
  if (persistentHandler) {
@@ -1721,25 +2050,143 @@ Respond with ONLY the commit message, nothing else.`;
1721
2050
  // For persistent terminals, close actually kills the tmux session
1722
2051
  const ptyManager = getPTYManager();
1723
2052
  ptyManager.closePersistent(terminalId);
1724
- return;
1725
2053
  }
1726
- // Clean up event listeners
1727
- const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
1728
- if (listenerCleanup) {
1729
- listenerCleanup();
1730
- this.terminalListenerCleanups.delete(terminalId);
1731
- }
1732
- // Otherwise use regular PTY
1733
- const ptyManager = getPTYManager();
1734
- ptyManager.close(terminalId);
2054
+ else {
2055
+ // Clean up event listeners
2056
+ const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
2057
+ if (listenerCleanup) {
2058
+ listenerCleanup();
2059
+ this.terminalListenerCleanups.delete(terminalId);
2060
+ }
2061
+ // Close regular PTY
2062
+ const ptyManager = getPTYManager();
2063
+ ptyManager.close(terminalId);
2064
+ }
2065
+ // Clean up subscribers
2066
+ this.terminalSubscribers.delete(terminalId);
2067
+ // Broadcast to other clients
2068
+ this.broadcastToOthers(ws, {
2069
+ type: 'terminalClosed',
2070
+ data: { terminalId }
2071
+ });
1735
2072
  }
1736
2073
  // Persistent terminal handlers for tmux-backed sessions
1737
2074
  persistentHandlers = new Map();
1738
2075
  // Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
1739
2076
  terminalListenerCleanups = new Map();
2077
+ // Track which WS connections are subscribed to each terminal's output
2078
+ terminalSubscribers = new Map();
2079
+ /**
2080
+ * Add a WS connection as a subscriber for terminal output.
2081
+ */
2082
+ addTerminalSubscriber(terminalId, ws) {
2083
+ let subs = this.terminalSubscribers.get(terminalId);
2084
+ if (!subs) {
2085
+ subs = new Set();
2086
+ this.terminalSubscribers.set(terminalId, subs);
2087
+ }
2088
+ subs.add(ws);
2089
+ }
2090
+ /**
2091
+ * Remove a WS subscriber from a terminal and clean up if no subscribers remain.
2092
+ */
2093
+ removeTerminalSubscriber(terminalId, ws) {
2094
+ const subs = this.terminalSubscribers.get(terminalId);
2095
+ if (!subs)
2096
+ return;
2097
+ subs.delete(ws);
2098
+ if (subs.size > 0)
2099
+ return;
2100
+ this.terminalSubscribers.delete(terminalId);
2101
+ const cleanup = this.terminalListenerCleanups.get(terminalId);
2102
+ if (cleanup) {
2103
+ cleanup();
2104
+ this.terminalListenerCleanups.delete(terminalId);
2105
+ }
2106
+ }
2107
+ /**
2108
+ * Attach persistent (tmux) terminal handlers for output/exit broadcasting.
2109
+ */
2110
+ attachPersistentHandlers(terminalId, ptyManager) {
2111
+ const handlers = ptyManager.attachPersistent(terminalId, (output) => {
2112
+ const subs = this.terminalSubscribers.get(terminalId);
2113
+ if (subs) {
2114
+ for (const sub of subs) {
2115
+ this.send(sub, { type: 'terminalOutput', terminalId, data: { output } });
2116
+ }
2117
+ }
2118
+ }, (exitCode) => {
2119
+ const subs = this.terminalSubscribers.get(terminalId);
2120
+ if (subs) {
2121
+ for (const sub of subs) {
2122
+ this.send(sub, { type: 'terminalExit', terminalId, data: { exitCode } });
2123
+ }
2124
+ }
2125
+ this.persistentHandlers.delete(terminalId);
2126
+ this.terminalSubscribers.delete(terminalId);
2127
+ });
2128
+ if (handlers) {
2129
+ this.persistentHandlers.set(terminalId, handlers);
2130
+ }
2131
+ }
2132
+ /**
2133
+ * Set up PTY event listeners that broadcast to all subscribers.
2134
+ * Only creates listeners once per terminal (idempotent).
2135
+ */
2136
+ setupTerminalBroadcastListeners(terminalId) {
2137
+ // Already set up - don't duplicate
2138
+ if (this.terminalListenerCleanups.has(terminalId))
2139
+ return;
2140
+ const ptyManager = getPTYManager();
2141
+ const onOutput = (tid, data) => {
2142
+ if (tid === terminalId) {
2143
+ const subs = this.terminalSubscribers.get(terminalId);
2144
+ if (subs) {
2145
+ for (const ws of subs) {
2146
+ this.send(ws, { type: 'terminalOutput', terminalId, data: { output: data } });
2147
+ }
2148
+ }
2149
+ }
2150
+ };
2151
+ const onExit = (tid, exitCode) => {
2152
+ if (tid === terminalId) {
2153
+ const subs = this.terminalSubscribers.get(terminalId);
2154
+ if (subs) {
2155
+ for (const ws of subs) {
2156
+ this.send(ws, { type: 'terminalExit', terminalId, data: { exitCode } });
2157
+ }
2158
+ }
2159
+ // Clean up
2160
+ ptyManager.off('output', onOutput);
2161
+ ptyManager.off('exit', onExit);
2162
+ ptyManager.off('error', onError);
2163
+ this.terminalListenerCleanups.delete(terminalId);
2164
+ this.terminalSubscribers.delete(terminalId);
2165
+ }
2166
+ };
2167
+ const onError = (tid, error) => {
2168
+ if (tid === terminalId) {
2169
+ const subs = this.terminalSubscribers.get(terminalId);
2170
+ if (subs) {
2171
+ for (const ws of subs) {
2172
+ this.send(ws, { type: 'terminalError', terminalId, data: { error } });
2173
+ }
2174
+ }
2175
+ }
2176
+ };
2177
+ ptyManager.on('output', onOutput);
2178
+ ptyManager.on('exit', onExit);
2179
+ ptyManager.on('error', onError);
2180
+ this.terminalListenerCleanups.set(terminalId, () => {
2181
+ ptyManager.off('output', onOutput);
2182
+ ptyManager.off('exit', onExit);
2183
+ ptyManager.off('error', onError);
2184
+ });
2185
+ }
1740
2186
  /**
1741
2187
  * Initialize a persistent (tmux-backed) terminal session
1742
- * These sessions survive server restarts
2188
+ * These sessions survive server restarts.
2189
+ * Uses subscriber pattern for multi-client output broadcasting.
1743
2190
  */
1744
2191
  handleTerminalInitPersistent(ws, terminalId, workingDir, requestedShell, cols, rows) {
1745
2192
  const ptyManager = getPTYManager();
@@ -1752,28 +2199,16 @@ Respond with ONLY the commit message, nothing else.`;
1752
2199
  });
1753
2200
  return;
1754
2201
  }
2202
+ // Add this WS as a subscriber for this terminal's output
2203
+ this.addTerminalSubscriber(terminalId, ws);
1755
2204
  try {
1756
2205
  // Create or reconnect to the persistent session
1757
2206
  const { shell, cwd, isReconnect } = ptyManager.createPersistent(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
1758
- // Attach to the session for I/O
1759
- const handlers = ptyManager.attachPersistent(terminalId, (output) => {
1760
- this.send(ws, {
1761
- type: 'terminalOutput',
1762
- terminalId,
1763
- data: { output }
1764
- });
1765
- }, (exitCode) => {
1766
- this.send(ws, {
1767
- type: 'terminalExit',
1768
- terminalId,
1769
- data: { exitCode }
1770
- });
1771
- this.persistentHandlers.delete(terminalId);
1772
- });
1773
- if (handlers) {
1774
- this.persistentHandlers.set(terminalId, handlers);
2207
+ // Only attach if we don't already have handlers (first subscriber)
2208
+ if (!this.persistentHandlers.has(terminalId)) {
2209
+ this.attachPersistentHandlers(terminalId, ptyManager);
1775
2210
  }
1776
- // If reconnecting, send scrollback buffer first
2211
+ // If reconnecting, send scrollback buffer to THIS client only
1777
2212
  if (isReconnect) {
1778
2213
  const scrollback = ptyManager.getPersistentScrollback(terminalId);
1779
2214
  if (scrollback.length > 0) {
@@ -1784,7 +2219,14 @@ Respond with ONLY the commit message, nothing else.`;
1784
2219
  });
1785
2220
  }
1786
2221
  }
1787
- // Send ready message
2222
+ else {
2223
+ // New terminal — broadcast to other clients so they can create matching tabs
2224
+ this.broadcastToOthers(ws, {
2225
+ type: 'terminalCreated',
2226
+ data: { terminalId, shell, cwd, persistent: true }
2227
+ });
2228
+ }
2229
+ // Send ready message to THIS client
1788
2230
  this.send(ws, {
1789
2231
  type: 'terminalReady',
1790
2232
  terminalId,
@@ -1798,6 +2240,7 @@ Respond with ONLY the commit message, nothing else.`;
1798
2240
  terminalId,
1799
2241
  data: { error: error.message || 'Failed to create persistent terminal' }
1800
2242
  });
2243
+ this.removeTerminalSubscriber(terminalId, ws);
1801
2244
  }
1802
2245
  }
1803
2246
  /**