mstro-app 0.1.54 → 0.1.57

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 +2 -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 +618 -157
  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 +2 -2
  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 +677 -170
  56. package/server/services/websocket/session-registry.ts +180 -0
  57. package/server/services/websocket/types.ts +31 -2
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
13
13
  import { homedir } from 'node:os';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
16
+ import { AnalyticsEvents, trackEvent } from '../analytics.js';
16
17
  import {
17
18
  createDirectory,
18
19
  createFile,
@@ -22,9 +23,11 @@ import {
22
23
  writeFile
23
24
  } from '../files.js';
24
25
  import { captureException } from '../sentry.js';
26
+ import { getModel, getSettings, setModel } from '../settings.js';
25
27
  import { getPTYManager } from '../terminal/pty-manager.js';
26
28
  import { AutocompleteService } from './autocomplete.js';
27
29
  import { readFileContent } from './file-utils.js';
30
+ import { SessionRegistry } from './session-registry.js';
28
31
  import type { FrecencyData, GitDirectorySetResponse, GitFileStatus, GitLogEntry, GitRepoInfo, GitReposDiscoveredResponse, GitStatusResponse, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
29
32
 
30
33
  export interface UsageReport {
@@ -35,6 +38,38 @@ export interface UsageReport {
35
38
 
36
39
  export type UsageReporter = (report: UsageReport) => void;
37
40
 
41
+ /** Convert a single movement record into OutputLine-compatible entries */
42
+ function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: any[]; assistantResponse?: string; errorOutput?: string; tokensUsed: number }): any[] {
43
+ const lines: any[] = [];
44
+ const ts = new Date(movement.timestamp).getTime();
45
+
46
+ lines.push({ type: 'user', text: `> ${movement.userPrompt}`, timestamp: ts });
47
+
48
+ if (movement.thinkingOutput) {
49
+ lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
50
+ }
51
+
52
+ if (movement.toolUseHistory) {
53
+ for (const tool of movement.toolUseHistory) {
54
+ lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
55
+ if (tool.result !== undefined) {
56
+ lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
57
+ }
58
+ }
59
+ }
60
+
61
+ if (movement.assistantResponse) {
62
+ lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
63
+ }
64
+
65
+ if (movement.errorOutput) {
66
+ lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
67
+ }
68
+
69
+ lines.push({ type: 'system', text: `Command completed (tokens: ${movement.tokensUsed.toLocaleString()})`, timestamp: ts });
70
+ return lines;
71
+ }
72
+
38
73
  export class WebSocketImproviseHandler {
39
74
  private sessions: Map<string, ImprovisationSessionManager> = new Map();
40
75
  private connections: Map<WSContext, Map<string, string>> = new Map();
@@ -43,6 +78,10 @@ export class WebSocketImproviseHandler {
43
78
  private usageReporter: UsageReporter | null = null;
44
79
  /** Per-tab selected git directory (tabId -> directory path) */
45
80
  private gitDirectories: Map<string, string> = new Map();
81
+ /** Persistent tab→session mapping that survives WS disconnections */
82
+ private sessionRegistry: SessionRegistry | null = null;
83
+ /** All connected WS contexts (for broadcasting to all clients) */
84
+ private allConnections: Set<WSContext> = new Set();
46
85
 
47
86
  constructor() {
48
87
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
@@ -50,6 +89,16 @@ export class WebSocketImproviseHandler {
50
89
  this.autocompleteService = new AutocompleteService(frecencyData);
51
90
  }
52
91
 
92
+ /**
93
+ * Lazily initialize session registry for a working directory
94
+ */
95
+ private getRegistry(workingDir: string): SessionRegistry {
96
+ if (!this.sessionRegistry) {
97
+ this.sessionRegistry = new SessionRegistry(workingDir);
98
+ }
99
+ return this.sessionRegistry;
100
+ }
101
+
53
102
  /**
54
103
  * Set the usage reporter callback for sending usage data to platform
55
104
  */
@@ -100,6 +149,7 @@ export class WebSocketImproviseHandler {
100
149
  */
101
150
  handleConnection(ws: WSContext, _workingDir: string): void {
102
151
  this.connections.set(ws, new Map());
152
+ this.allConnections.add(ws);
103
153
  }
104
154
 
105
155
  /**
@@ -134,7 +184,7 @@ export class WebSocketImproviseHandler {
134
184
  this.send(ws, { type: 'pong', tabId });
135
185
  return;
136
186
  case 'initTab':
137
- return void await this.initializeTab(ws, tabId, workingDir);
187
+ return void await this.initializeTab(ws, tabId, workingDir, msg.data?.tabName);
138
188
  case 'resumeSession':
139
189
  if (!msg.data?.historicalSessionId) throw new Error('Historical session ID is required');
140
190
  return void await this.resumeHistoricalSession(ws, tabId, workingDir, msg.data.historicalSessionId);
@@ -172,6 +222,7 @@ export class WebSocketImproviseHandler {
172
222
  case 'createDirectory':
173
223
  case 'deleteFile':
174
224
  case 'renameFile':
225
+ case 'notifyFileOpened':
175
226
  return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
176
227
  case 'gitStatus':
177
228
  case 'gitStage':
@@ -183,6 +234,26 @@ export class WebSocketImproviseHandler {
183
234
  case 'gitDiscoverRepos':
184
235
  case 'gitSetDirectory':
185
236
  return this.handleGitMessage(ws, msg, tabId, workingDir);
237
+ // Session sync messages
238
+ case 'getActiveTabs':
239
+ return this.handleGetActiveTabs(ws, workingDir);
240
+ case 'createTab':
241
+ return void await this.handleCreateTab(ws, workingDir, msg.data?.tabName, msg.data?.optimisticTabId);
242
+ case 'reorderTabs':
243
+ return this.handleReorderTabs(ws, workingDir, msg.data?.tabOrder);
244
+ case 'syncTabMeta':
245
+ return this.handleSyncTabMeta(ws, msg, tabId, workingDir);
246
+ case 'syncPromptText':
247
+ return this.handleSyncPromptText(ws, msg, tabId);
248
+ case 'removeTab':
249
+ return this.handleRemoveTab(ws, tabId, workingDir);
250
+ case 'markTabViewed':
251
+ return this.handleMarkTabViewed(ws, tabId, workingDir);
252
+ // Settings messages
253
+ case 'getSettings':
254
+ return this.handleGetSettings(ws);
255
+ case 'updateSettings':
256
+ return this.handleUpdateSettings(ws, msg);
186
257
  default:
187
258
  throw new Error(`Unknown message type: ${msg.type}`);
188
259
  }
@@ -212,12 +283,16 @@ export class WebSocketImproviseHandler {
212
283
  }
213
284
  case 'new': {
214
285
  const oldSession = this.requireSession(ws, tabId);
215
- const newSession = oldSession.startNewSession();
286
+ const newSession = oldSession.startNewSession({ model: getModel() });
216
287
  this.setupSessionListeners(newSession, ws, tabId);
217
288
  const newSessionId = newSession.getSessionInfo().sessionId;
218
289
  this.sessions.set(newSessionId, newSession);
219
290
  const tabMap = this.connections.get(ws);
220
291
  if (tabMap) tabMap.set(tabId, newSessionId);
292
+ // Update registry with new session ID
293
+ if (this.sessionRegistry) {
294
+ this.sessionRegistry.updateTabSession(tabId, newSessionId);
295
+ }
221
296
  this.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
222
297
  break;
223
298
  }
@@ -344,28 +419,81 @@ export class WebSocketImproviseHandler {
344
419
  private handleWriteFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
345
420
  if (!msg.data?.filePath) throw new Error('File path is required');
346
421
  if (msg.data.content === undefined) throw new Error('Content is required');
347
- this.sendFileResult(ws, 'fileWritten', tabId, writeFile(msg.data.filePath, msg.data.content, workingDir));
422
+ const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
423
+ this.sendFileResult(ws, 'fileWritten', tabId, result);
424
+ if (result.success) {
425
+ this.broadcastToOthers(ws, {
426
+ type: 'fileContentChanged',
427
+ data: { path: result.path, content: msg.data.content }
428
+ });
429
+ }
348
430
  }
349
431
 
350
432
  private handleCreateFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
351
433
  if (!msg.data?.filePath) throw new Error('File path is required');
352
- this.sendFileResult(ws, 'fileCreated', tabId, createFile(msg.data.filePath, workingDir));
434
+ const result = createFile(msg.data.filePath, workingDir);
435
+ this.sendFileResult(ws, 'fileCreated', tabId, result);
436
+ if (result.success && result.path) {
437
+ const name = result.path.split('/').pop() || 'unknown';
438
+ this.broadcastToOthers(ws, {
439
+ type: 'fileCreated',
440
+ data: { path: result.path, name, size: 0, modifiedAt: new Date().toISOString() }
441
+ });
442
+ }
353
443
  }
354
444
 
355
445
  private handleCreateDirectory(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
356
446
  if (!msg.data?.dirPath) throw new Error('Directory path is required');
357
- this.sendFileResult(ws, 'directoryCreated', tabId, createDirectory(msg.data.dirPath, workingDir));
447
+ const result = createDirectory(msg.data.dirPath, workingDir);
448
+ this.sendFileResult(ws, 'directoryCreated', tabId, result);
449
+ if (result.success && result.path) {
450
+ const name = result.path.split('/').pop() || 'unknown';
451
+ this.broadcastToOthers(ws, {
452
+ type: 'directoryCreated',
453
+ data: { path: result.path, name }
454
+ });
455
+ }
358
456
  }
359
457
 
360
458
  private handleDeleteFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
361
459
  if (!msg.data?.filePath) throw new Error('File path is required');
362
- this.sendFileResult(ws, 'fileDeleted', tabId, deleteFile(msg.data.filePath, workingDir));
460
+ const result = deleteFile(msg.data.filePath, workingDir);
461
+ this.sendFileResult(ws, 'fileDeleted', tabId, result);
462
+ if (result.success && result.path) {
463
+ this.broadcastToOthers(ws, {
464
+ type: 'fileDeleted',
465
+ data: { path: result.path }
466
+ });
467
+ }
363
468
  }
364
469
 
365
470
  private handleRenameFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
366
471
  if (!msg.data?.oldPath) throw new Error('Old path is required');
367
472
  if (!msg.data?.newPath) throw new Error('New path is required');
368
- this.sendFileResult(ws, 'fileRenamed', tabId, renameFile(msg.data.oldPath, msg.data.newPath, workingDir));
473
+ const result = renameFile(msg.data.oldPath, msg.data.newPath, workingDir);
474
+ this.sendFileResult(ws, 'fileRenamed', tabId, result);
475
+ if (result.success && result.path) {
476
+ const name = result.path.split('/').pop() || 'unknown';
477
+ this.broadcastToOthers(ws, {
478
+ type: 'fileRenamed',
479
+ data: { oldPath: msg.data.oldPath, newPath: result.path, name }
480
+ });
481
+ }
482
+ }
483
+
484
+ private handleNotifyFileOpened(ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
485
+ if (!msg.data?.filePath) return;
486
+ const fileData = readFileContent(msg.data.filePath, workingDir);
487
+ if (!fileData.error) {
488
+ this.broadcastToOthers(ws, {
489
+ type: 'fileOpened',
490
+ data: {
491
+ path: msg.data.filePath,
492
+ fileName: fileData.fileName,
493
+ content: fileData.content
494
+ }
495
+ });
496
+ }
369
497
  }
370
498
 
371
499
  private handleFileExplorerMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
@@ -376,6 +504,7 @@ export class WebSocketImproviseHandler {
376
504
  createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
377
505
  deleteFile: () => this.handleDeleteFile(ws, msg, tabId, workingDir),
378
506
  renameFile: () => this.handleRenameFile(ws, msg, tabId, workingDir),
507
+ notifyFileOpened: () => this.handleNotifyFileOpened(ws, msg, workingDir),
379
508
  };
380
509
  handlers[msg.type]?.();
381
510
  }
@@ -406,11 +535,20 @@ export class WebSocketImproviseHandler {
406
535
 
407
536
  session.on('onMovementStart', (sequenceNumber: number, prompt: string) => {
408
537
  this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
538
+ // Broadcast execution state to ALL clients so tab indicators update
539
+ // even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
540
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true } });
409
541
  });
410
542
 
411
543
  session.on('onMovementComplete', (movement: any) => {
412
544
  this.send(ws, { type: 'movementComplete', tabId, data: movement });
413
545
 
546
+ // Mark tab as having unviewed completion (persisted across CLI restarts)
547
+ this.sessionRegistry?.markTabUnviewed(tabId);
548
+
549
+ // Broadcast execution state + completion dot to ALL clients
550
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
551
+
414
552
  // Report usage to platform if reporter is configured
415
553
  if (this.usageReporter && movement.tokensUsed) {
416
554
  this.usageReporter({
@@ -423,6 +561,8 @@ export class WebSocketImproviseHandler {
423
561
 
424
562
  session.on('onMovementError', (error: Error) => {
425
563
  this.send(ws, { type: 'movementError', tabId, data: { message: error.message } });
564
+ // Broadcast execution stopped to ALL clients
565
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
426
566
  });
427
567
 
428
568
  session.on('onSessionUpdate', (history: any) => {
@@ -450,17 +590,24 @@ export class WebSocketImproviseHandler {
450
590
  historicalSessionId: string
451
591
  ): Promise<void> {
452
592
  const tabMap = this.connections.get(ws);
593
+ const registry = this.getRegistry(workingDir);
453
594
 
595
+ // Check per-connection map first (same WS reconnect)
454
596
  const existingSessionId = tabMap?.get(tabId);
455
597
  if (existingSessionId) {
456
598
  const existingSession = this.sessions.get(existingSessionId);
457
599
  if (existingSession) {
458
- this.setupSessionListeners(existingSession, ws, tabId);
459
- this.send(ws, {
460
- type: 'tabInitialized',
461
- tabId,
462
- data: existingSession.getSessionInfo()
463
- });
600
+ this.reattachSession(existingSession, ws, tabId, registry);
601
+ return;
602
+ }
603
+ }
604
+
605
+ // Check session registry (cross-connection reattach)
606
+ const registrySessionId = registry.getTabSession(tabId);
607
+ if (registrySessionId) {
608
+ const inMemorySession = this.sessions.get(registrySessionId);
609
+ if (inMemorySession) {
610
+ this.reattachSession(inMemorySession, ws, tabId, registry);
464
611
  return;
465
612
  }
466
613
  }
@@ -469,13 +616,10 @@ export class WebSocketImproviseHandler {
469
616
  let isNewSession = false;
470
617
 
471
618
  try {
472
- session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId);
619
+ session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
473
620
  } catch (error: any) {
474
- // Historical session not found on disk - this can happen if the server
475
- // restarted before any prompts were executed (history is only saved after
476
- // the first prompt). Fall back to creating a fresh session.
477
621
  console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error.message}. Creating new session.`);
478
- session = new ImprovisationSessionManager({ workingDir });
622
+ session = new ImprovisationSessionManager({ workingDir, model: getModel() });
479
623
  isNewSession = true;
480
624
  }
481
625
 
@@ -488,12 +632,14 @@ export class WebSocketImproviseHandler {
488
632
  tabMap.set(tabId, sessionId);
489
633
  }
490
634
 
635
+ registry.registerTab(tabId, sessionId);
636
+
491
637
  this.send(ws, {
492
638
  type: 'tabInitialized',
493
639
  tabId,
494
640
  data: {
495
641
  ...session.getSessionInfo(),
496
- // Let the client know if we had to create a new session instead of resuming
642
+ outputHistory: this.buildOutputHistory(session),
497
643
  resumeFailed: isNewSession,
498
644
  originalSessionId: isNewSession ? historicalSessionId : undefined
499
645
  }
@@ -501,26 +647,58 @@ export class WebSocketImproviseHandler {
501
647
  }
502
648
 
503
649
  /**
504
- * Initialize a new tab with its own session
650
+ * Initialize a tab with its own session.
651
+ * Checks (in order): per-connection map → session registry → disk history → new session.
505
652
  */
506
- private async initializeTab(ws: WSContext, tabId: string, workingDir: string): Promise<void> {
653
+ private async initializeTab(ws: WSContext, tabId: string, workingDir: string, tabName?: string): Promise<void> {
507
654
  const tabMap = this.connections.get(ws);
655
+ const registry = this.getRegistry(workingDir);
508
656
 
657
+ // 1. Check per-connection map (same WS reconnect)
509
658
  const existingSessionId = tabMap?.get(tabId);
510
659
  if (existingSessionId) {
511
660
  const existingSession = this.sessions.get(existingSessionId);
512
661
  if (existingSession) {
513
- this.setupSessionListeners(existingSession, ws, tabId);
662
+ this.reattachSession(existingSession, ws, tabId, registry);
663
+ return;
664
+ }
665
+ }
666
+
667
+ // 2. Check session registry (cross-connection reattach, e.g. browser refresh)
668
+ const registrySessionId = registry.getTabSession(tabId);
669
+ if (registrySessionId) {
670
+ // Try in-memory first
671
+ const inMemorySession = this.sessions.get(registrySessionId);
672
+ if (inMemorySession) {
673
+ this.reattachSession(inMemorySession, ws, tabId, registry);
674
+ return;
675
+ }
676
+
677
+ // Try resuming from disk
678
+ try {
679
+ const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
680
+ this.setupSessionListeners(diskSession, ws, tabId);
681
+ const diskSessionId = diskSession.getSessionInfo().sessionId;
682
+ this.sessions.set(diskSessionId, diskSession);
683
+ if (tabMap) tabMap.set(tabId, diskSessionId);
684
+ registry.touchTab(tabId);
685
+
514
686
  this.send(ws, {
515
687
  type: 'tabInitialized',
516
688
  tabId,
517
- data: existingSession.getSessionInfo()
689
+ data: {
690
+ ...diskSession.getSessionInfo(),
691
+ outputHistory: this.buildOutputHistory(diskSession),
692
+ }
518
693
  });
519
694
  return;
695
+ } catch {
696
+ // Disk session not found — fall through to create new
520
697
  }
521
698
  }
522
699
 
523
- const session = new ImprovisationSessionManager({ workingDir });
700
+ // 3. Create new session
701
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
524
702
  this.setupSessionListeners(session, ws, tabId);
525
703
 
526
704
  const sessionId = session.getSessionInfo().sessionId;
@@ -530,6 +708,13 @@ export class WebSocketImproviseHandler {
530
708
  tabMap.set(tabId, sessionId);
531
709
  }
532
710
 
711
+ registry.registerTab(tabId, sessionId, tabName);
712
+ const registeredTab = registry.getTab(tabId);
713
+ this.broadcastToAll({
714
+ type: 'tabCreated',
715
+ data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, sessionInfo: session.getSessionInfo() }
716
+ });
717
+
533
718
  this.send(ws, {
534
719
  type: 'tabInitialized',
535
720
  tabId,
@@ -537,6 +722,92 @@ export class WebSocketImproviseHandler {
537
722
  });
538
723
  }
539
724
 
725
+ /**
726
+ * Reattach to an existing in-memory session.
727
+ * Sends output history (completed movements + in-progress events) for state restoration.
728
+ */
729
+ private reattachSession(
730
+ session: ImprovisationSessionManager,
731
+ ws: WSContext,
732
+ tabId: string,
733
+ registry: SessionRegistry
734
+ ): void {
735
+ this.setupSessionListeners(session, ws, tabId);
736
+
737
+ const tabMap = this.connections.get(ws);
738
+ const sessionId = session.getSessionInfo().sessionId;
739
+ if (tabMap) tabMap.set(tabId, sessionId);
740
+ registry.touchTab(tabId);
741
+
742
+ // Build output history from completed movements
743
+ const outputHistory = this.buildOutputHistory(session);
744
+
745
+ // If currently executing, append in-progress events
746
+ const executionEvents = session.isExecuting
747
+ ? session.getExecutionEventLog()
748
+ : undefined;
749
+
750
+ this.send(ws, {
751
+ type: 'tabInitialized',
752
+ tabId,
753
+ data: {
754
+ ...session.getSessionInfo(),
755
+ outputHistory,
756
+ isExecuting: session.isExecuting,
757
+ executionEvents,
758
+ }
759
+ });
760
+ }
761
+
762
+ /**
763
+ * Build OutputLine-compatible history from a session's completed movements.
764
+ * Converts MovementRecords into the same format the web client uses for display.
765
+ */
766
+ private buildOutputHistory(session: ImprovisationSessionManager): any[] {
767
+ const history = session.getHistory();
768
+ return history.movements.flatMap(convertMovementToLines);
769
+ }
770
+
771
+ /**
772
+ * Send a message to all connected clients EXCEPT the sender.
773
+ * Used for multi-client sync (e.g., tab created by one client, others should know).
774
+ */
775
+ private broadcastToOthers(sender: WSContext, response: WebSocketResponse): void {
776
+ for (const ws of this.allConnections) {
777
+ if (ws !== sender) {
778
+ this.send(ws, response);
779
+ }
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Send a message to ALL connected clients (including sender).
785
+ */
786
+ private broadcastToAll(response: WebSocketResponse): void {
787
+ for (const ws of this.allConnections) {
788
+ this.send(ws, response);
789
+ }
790
+ }
791
+
792
+ // ========== Settings Handlers ==========
793
+
794
+ /**
795
+ * Return current machine-wide settings to the requesting client.
796
+ */
797
+ private handleGetSettings(ws: WSContext): void {
798
+ this.send(ws, { type: 'settings', data: getSettings() });
799
+ }
800
+
801
+ /**
802
+ * Update settings and broadcast to all connected clients.
803
+ */
804
+ private handleUpdateSettings(_ws: WSContext, msg: WebSocketMessage): void {
805
+ if (msg.data?.model !== undefined) {
806
+ setModel(msg.data.model);
807
+ }
808
+ this.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
809
+ }
810
+
540
811
  /**
541
812
  * Get session for a specific tab
542
813
  */
@@ -552,9 +823,17 @@ export class WebSocketImproviseHandler {
552
823
 
553
824
  /**
554
825
  * Handle connection close
826
+ * Note: Sessions are NOT destroyed — they persist for reconnection.
827
+ * Only the per-connection tab mapping is removed.
555
828
  */
556
829
  handleClose(ws: WSContext): void {
557
830
  this.connections.delete(ws);
831
+ this.allConnections.delete(ws);
832
+
833
+ // Remove ws from all terminal subscriber sets
834
+ for (const subs of this.terminalSubscribers.values()) {
835
+ subs.delete(ws);
836
+ }
558
837
  }
559
838
 
560
839
  /**
@@ -829,6 +1108,200 @@ export class WebSocketImproviseHandler {
829
1108
  cleanupStaleSessions(): void {
830
1109
  }
831
1110
 
1111
+ // ============================================
1112
+ // Session sync methods
1113
+ // ============================================
1114
+
1115
+ /**
1116
+ * Handle getActiveTabs — returns all registered tabs and their state.
1117
+ * Used by new clients (multi-device, multi-browser) to discover existing tabs.
1118
+ */
1119
+ private handleGetActiveTabs(ws: WSContext, workingDir: string): void {
1120
+ const registry = this.getRegistry(workingDir);
1121
+ const allTabs = registry.getAllTabs();
1122
+
1123
+ const tabs: Record<string, any> = {};
1124
+ for (const [tabId, regTab] of Object.entries(allTabs)) {
1125
+ const session = this.sessions.get(regTab.sessionId);
1126
+ if (session) {
1127
+ tabs[tabId] = {
1128
+ tabName: regTab.tabName,
1129
+ createdAt: regTab.createdAt,
1130
+ order: regTab.order,
1131
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
1132
+ sessionInfo: session.getSessionInfo(),
1133
+ isExecuting: session.isExecuting,
1134
+ outputHistory: this.buildOutputHistory(session),
1135
+ executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
1136
+ };
1137
+ } else {
1138
+ // Session not in memory — try to provide basic info from registry
1139
+ tabs[tabId] = {
1140
+ tabName: regTab.tabName,
1141
+ createdAt: regTab.createdAt,
1142
+ order: regTab.order,
1143
+ hasUnviewedCompletion: regTab.hasUnviewedCompletion,
1144
+ sessionId: regTab.sessionId,
1145
+ isExecuting: false,
1146
+ outputHistory: [],
1147
+ };
1148
+ }
1149
+ }
1150
+
1151
+ this.send(ws, { type: 'activeTabs', data: { tabs } });
1152
+ }
1153
+
1154
+ /**
1155
+ * Handle syncTabMeta — update tab metadata (name) from a client.
1156
+ */
1157
+ private handleSyncTabMeta(_ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
1158
+ const registry = this.getRegistry(workingDir);
1159
+ if (msg.data?.tabName) {
1160
+ registry.updateTabName(tabId, msg.data.tabName);
1161
+ // Broadcast rename to all clients (relay handles fan-out)
1162
+ this.broadcastToAll({
1163
+ type: 'tabRenamed',
1164
+ data: { tabId, tabName: msg.data.tabName }
1165
+ });
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Handle syncPromptText — relay prompt text changes to all clients.
1171
+ * Ephemeral: not persisted, just broadcast for live collaboration.
1172
+ */
1173
+ private handleSyncPromptText(_ws: WSContext, msg: WebSocketMessage, tabId: string): void {
1174
+ if (typeof msg.data?.text !== 'string') return;
1175
+ this.broadcastToAll({
1176
+ type: 'promptTextSync',
1177
+ tabId,
1178
+ data: { tabId, text: msg.data.text }
1179
+ });
1180
+ }
1181
+
1182
+ /**
1183
+ * Handle removeTab — client is removing a tab.
1184
+ */
1185
+ private handleRemoveTab(_ws: WSContext, tabId: string, workingDir: string): void {
1186
+ const registry = this.getRegistry(workingDir);
1187
+ registry.unregisterTab(tabId);
1188
+
1189
+ // Broadcast to all clients (broadcastToAll ensures relay-connected clients receive it)
1190
+ this.broadcastToAll({
1191
+ type: 'tabRemoved',
1192
+ data: { tabId }
1193
+ });
1194
+ }
1195
+
1196
+ /**
1197
+ * Handle markTabViewed — a client has viewed a tab's completed output.
1198
+ * Persists viewed state and broadcasts to all clients so the green dot
1199
+ * disappears on every device.
1200
+ */
1201
+ private handleMarkTabViewed(_ws: WSContext, tabId: string, workingDir: string): void {
1202
+ const registry = this.getRegistry(workingDir);
1203
+ registry.markTabViewed(tabId);
1204
+
1205
+ this.broadcastToAll({
1206
+ type: 'tabViewed',
1207
+ data: { tabId }
1208
+ });
1209
+ }
1210
+
1211
+ /**
1212
+ * Handle createTab — CLI registers the tab and broadcasts to all clients.
1213
+ *
1214
+ * When optimisticTabId is provided, CLI reuses that ID as the authoritative tab ID.
1215
+ * The requesting client already created a local tab with this ID (optimistic UI),
1216
+ * so there's no reconciliation needed — the tab ID is the same everywhere.
1217
+ * The initTab flow (useTabInit) will handle session creation for the requesting client.
1218
+ *
1219
+ * Other clients that don't have this tab will add it via the tabCreated broadcast.
1220
+ */
1221
+ private async handleCreateTab(ws: WSContext, workingDir: string, tabName?: string, optimisticTabId?: string): Promise<void> {
1222
+ const registry = this.getRegistry(workingDir);
1223
+
1224
+ // Use the client's optimistic ID when available — avoids reconciliation.
1225
+ // Fall back to server-generated ID if no optimistic ID provided.
1226
+ const tabId = optimisticTabId || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1227
+
1228
+ // Check if this tab was already registered by initTab (race: useTabInit fires first)
1229
+ const existingSession = registry.getTabSession(tabId);
1230
+ if (existingSession) {
1231
+ // Tab already initialized — broadcast to all clients.
1232
+ // Must use broadcastToAll because all web clients share a single
1233
+ // platformRelayContext — broadcastToOthers would skip the relay entirely,
1234
+ // preventing other browser instances from discovering the new tab.
1235
+ const regTab = registry.getTab(tabId);
1236
+ this.broadcastToAll({
1237
+ type: 'tabCreated',
1238
+ data: {
1239
+ tabId,
1240
+ tabName: regTab?.tabName || 'Chat',
1241
+ createdAt: regTab?.createdAt,
1242
+ order: regTab?.order,
1243
+ sessionInfo: this.sessions.get(existingSession)?.getSessionInfo(),
1244
+ }
1245
+ });
1246
+ return;
1247
+ }
1248
+
1249
+ // Create new session and register
1250
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
1251
+ this.setupSessionListeners(session, ws, tabId);
1252
+
1253
+ const sessionId = session.getSessionInfo().sessionId;
1254
+ this.sessions.set(sessionId, session);
1255
+
1256
+ const tabMap = this.connections.get(ws);
1257
+ if (tabMap) tabMap.set(tabId, sessionId);
1258
+
1259
+ registry.registerTab(tabId, sessionId, tabName);
1260
+ const registeredTab = registry.getTab(tabId);
1261
+
1262
+ // Broadcast to ALL clients — the requesting client already has the tab
1263
+ // (optimistic UI) and will ignore the duplicate via !currentTabs.has(tabId).
1264
+ // Must use broadcastToAll so other browser instances via the shared
1265
+ // platformRelayContext receive the tabCreated event.
1266
+ this.broadcastToAll({
1267
+ type: 'tabCreated',
1268
+ data: {
1269
+ tabId,
1270
+ tabName: registeredTab?.tabName || 'Chat',
1271
+ createdAt: registeredTab?.createdAt,
1272
+ order: registeredTab?.order,
1273
+ sessionInfo: session.getSessionInfo(),
1274
+ }
1275
+ });
1276
+
1277
+ // Send tabInitialized to the requesting client so useTabInit resolves
1278
+ this.send(ws, {
1279
+ type: 'tabInitialized',
1280
+ tabId,
1281
+ data: session.getSessionInfo()
1282
+ });
1283
+ }
1284
+
1285
+ /**
1286
+ * Handle reorderTabs — client is reordering tabs.
1287
+ */
1288
+ private handleReorderTabs(_ws: WSContext, workingDir: string, tabOrder?: string[]): void {
1289
+ if (!Array.isArray(tabOrder)) return;
1290
+ const registry = this.getRegistry(workingDir);
1291
+ registry.reorderTabs(tabOrder);
1292
+
1293
+ // Build order mapping for broadcast
1294
+ const allTabs = registry.getAllTabs();
1295
+ const orderMap = tabOrder
1296
+ .filter((id) => allTabs[id])
1297
+ .map((id) => ({ tabId: id, order: allTabs[id].order }));
1298
+
1299
+ this.broadcastToAll({
1300
+ type: 'tabsReordered',
1301
+ data: { tabOrder: orderMap }
1302
+ });
1303
+ }
1304
+
832
1305
  /**
833
1306
  * Generate a notification summary using Claude Haiku
834
1307
  * Sends the result as a notificationSummary message
@@ -1675,58 +2148,11 @@ Respond with ONLY the commit message, nothing else.`;
1675
2148
  return;
1676
2149
  }
1677
2150
 
1678
- // Clean up any existing listeners for this terminal to prevent duplicates
1679
- const existingCleanup = this.terminalListenerCleanups.get(terminalId);
1680
- if (existingCleanup) {
1681
- existingCleanup();
1682
- }
2151
+ // Add this WS as a subscriber for this terminal's output
2152
+ this.addTerminalSubscriber(terminalId, ws);
1683
2153
 
1684
- // Set up event listeners for this terminal
1685
- const onOutput = (tid: string, data: string) => {
1686
- if (tid === terminalId) {
1687
- this.send(ws, {
1688
- type: 'terminalOutput',
1689
- terminalId,
1690
- data: { output: data }
1691
- });
1692
- }
1693
- };
1694
-
1695
- const onExit = (tid: string, exitCode: number) => {
1696
- if (tid === terminalId) {
1697
- this.send(ws, {
1698
- type: 'terminalExit',
1699
- terminalId,
1700
- data: { exitCode }
1701
- });
1702
- // Clean up listeners
1703
- ptyManager.off('output', onOutput);
1704
- ptyManager.off('exit', onExit);
1705
- ptyManager.off('error', onError);
1706
- this.terminalListenerCleanups.delete(terminalId);
1707
- }
1708
- };
1709
-
1710
- const onError = (tid: string, error: string) => {
1711
- if (tid === terminalId) {
1712
- this.send(ws, {
1713
- type: 'terminalError',
1714
- terminalId,
1715
- data: { error }
1716
- });
1717
- }
1718
- };
1719
-
1720
- ptyManager.on('output', onOutput);
1721
- ptyManager.on('exit', onExit);
1722
- ptyManager.on('error', onError);
1723
-
1724
- // Store cleanup function for this terminal
1725
- this.terminalListenerCleanups.set(terminalId, () => {
1726
- ptyManager.off('output', onOutput);
1727
- ptyManager.off('exit', onExit);
1728
- ptyManager.off('error', onError);
1729
- });
2154
+ // Set up broadcast listeners (idempotent only creates once per terminal)
2155
+ this.setupTerminalBroadcastListeners(terminalId);
1730
2156
 
1731
2157
  try {
1732
2158
  // Create or reconnect to the PTY process
@@ -1738,25 +2164,34 @@ Respond with ONLY the commit message, nothing else.`;
1738
2164
  requestedShell
1739
2165
  );
1740
2166
 
1741
- // If reconnecting, send scrollback buffer first
2167
+ // If reconnecting, send scrollback buffer to THIS client only
1742
2168
  if (isReconnect) {
1743
2169
  const scrollback = ptyManager.getScrollback(terminalId);
1744
2170
  if (scrollback.length > 0) {
1745
- // Send scrollback as a single replay message
1746
2171
  this.send(ws, {
1747
2172
  type: 'terminalScrollback',
1748
2173
  terminalId,
1749
2174
  data: { lines: scrollback }
1750
2175
  });
1751
2176
  }
2177
+ } else {
2178
+ // New terminal — broadcast to other clients so they can create matching tabs
2179
+ this.broadcastToOthers(ws, {
2180
+ type: 'terminalCreated',
2181
+ data: { terminalId, shell, cwd, persistent: false }
2182
+ });
1752
2183
  }
1753
2184
 
1754
- // Send ready message
2185
+ // Send ready message to THIS client
1755
2186
  this.send(ws, {
1756
2187
  type: 'terminalReady',
1757
2188
  terminalId,
1758
2189
  data: { shell, cwd, isReconnect }
1759
2190
  });
2191
+ trackEvent(AnalyticsEvents.TERMINAL_SESSION_CREATED, {
2192
+ shell,
2193
+ is_reconnect: isReconnect,
2194
+ });
1760
2195
  } catch (error: any) {
1761
2196
  console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
1762
2197
  this.send(ws, {
@@ -1764,11 +2199,7 @@ Respond with ONLY the commit message, nothing else.`;
1764
2199
  terminalId,
1765
2200
  data: { error: error.message || 'Failed to create terminal' }
1766
2201
  });
1767
- // Clean up listeners
1768
- ptyManager.off('output', onOutput);
1769
- ptyManager.off('exit', onExit);
1770
- ptyManager.off('error', onError);
1771
- this.terminalListenerCleanups.delete(terminalId);
2202
+ this.removeTerminalSubscriber(terminalId, ws);
1772
2203
  }
1773
2204
  }
1774
2205
 
@@ -1790,59 +2221,13 @@ Respond with ONLY the commit message, nothing else.`;
1790
2221
  return;
1791
2222
  }
1792
2223
 
1793
- // Clean up any existing listeners for this terminal to prevent duplicates
1794
- const existingCleanup = this.terminalListenerCleanups.get(terminalId);
1795
- if (existingCleanup) {
1796
- existingCleanup();
1797
- }
2224
+ // Add this WS as a subscriber for this terminal's output
2225
+ this.addTerminalSubscriber(terminalId, ws);
1798
2226
 
1799
- // Set up event listeners for this terminal
1800
- const onOutput = (tid: string, data: string) => {
1801
- if (tid === terminalId) {
1802
- this.send(ws, {
1803
- type: 'terminalOutput',
1804
- terminalId,
1805
- data: { output: data }
1806
- });
1807
- }
1808
- };
2227
+ // Set up broadcast listeners (idempotent only creates once per terminal)
2228
+ this.setupTerminalBroadcastListeners(terminalId);
1809
2229
 
1810
- const onExit = (tid: string, exitCode: number) => {
1811
- if (tid === terminalId) {
1812
- this.send(ws, {
1813
- type: 'terminalExit',
1814
- terminalId,
1815
- data: { exitCode }
1816
- });
1817
- ptyManager.off('output', onOutput);
1818
- ptyManager.off('exit', onExit);
1819
- ptyManager.off('error', onError);
1820
- this.terminalListenerCleanups.delete(terminalId);
1821
- }
1822
- };
1823
-
1824
- const onError = (tid: string, error: string) => {
1825
- if (tid === terminalId) {
1826
- this.send(ws, {
1827
- type: 'terminalError',
1828
- terminalId,
1829
- data: { error }
1830
- });
1831
- }
1832
- };
1833
-
1834
- ptyManager.on('output', onOutput);
1835
- ptyManager.on('exit', onExit);
1836
- ptyManager.on('error', onError);
1837
-
1838
- // Store cleanup function for this terminal
1839
- this.terminalListenerCleanups.set(terminalId, () => {
1840
- ptyManager.off('output', onOutput);
1841
- ptyManager.off('exit', onExit);
1842
- ptyManager.off('error', onError);
1843
- });
1844
-
1845
- // Send scrollback buffer
2230
+ // Send scrollback buffer to THIS client only
1846
2231
  const scrollback = ptyManager.getScrollback(terminalId);
1847
2232
  if (scrollback.length > 0) {
1848
2233
  this.send(ws, {
@@ -1945,7 +2330,8 @@ Respond with ONLY the commit message, nothing else.`;
1945
2330
  /**
1946
2331
  * Handle terminal close
1947
2332
  */
1948
- private handleTerminalClose(_ws: WSContext, terminalId: string): void {
2333
+ private handleTerminalClose(ws: WSContext, terminalId: string): void {
2334
+ trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
1949
2335
 
1950
2336
  // Check if this is a persistent terminal first
1951
2337
  const persistentHandler = this.persistentHandlers.get(terminalId);
@@ -1955,19 +2341,27 @@ Respond with ONLY the commit message, nothing else.`;
1955
2341
  // For persistent terminals, close actually kills the tmux session
1956
2342
  const ptyManager = getPTYManager();
1957
2343
  ptyManager.closePersistent(terminalId);
1958
- return;
1959
- }
2344
+ } else {
2345
+ // Clean up event listeners
2346
+ const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
2347
+ if (listenerCleanup) {
2348
+ listenerCleanup();
2349
+ this.terminalListenerCleanups.delete(terminalId);
2350
+ }
1960
2351
 
1961
- // Clean up event listeners
1962
- const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
1963
- if (listenerCleanup) {
1964
- listenerCleanup();
1965
- this.terminalListenerCleanups.delete(terminalId);
2352
+ // Close regular PTY
2353
+ const ptyManager = getPTYManager();
2354
+ ptyManager.close(terminalId);
1966
2355
  }
1967
2356
 
1968
- // Otherwise use regular PTY
1969
- const ptyManager = getPTYManager();
1970
- ptyManager.close(terminalId);
2357
+ // Clean up subscribers
2358
+ this.terminalSubscribers.delete(terminalId);
2359
+
2360
+ // Broadcast to other clients
2361
+ this.broadcastToOthers(ws, {
2362
+ type: 'terminalClosed',
2363
+ data: { terminalId }
2364
+ });
1971
2365
  }
1972
2366
 
1973
2367
  // Persistent terminal handlers for tmux-backed sessions
@@ -1976,9 +2370,131 @@ Respond with ONLY the commit message, nothing else.`;
1976
2370
  // Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
1977
2371
  private terminalListenerCleanups: Map<string, () => void> = new Map();
1978
2372
 
2373
+ // Track which WS connections are subscribed to each terminal's output
2374
+ private terminalSubscribers: Map<string, Set<WSContext>> = new Map();
2375
+
2376
+ /**
2377
+ * Add a WS connection as a subscriber for terminal output.
2378
+ */
2379
+ private addTerminalSubscriber(terminalId: string, ws: WSContext): void {
2380
+ let subs = this.terminalSubscribers.get(terminalId);
2381
+ if (!subs) {
2382
+ subs = new Set();
2383
+ this.terminalSubscribers.set(terminalId, subs);
2384
+ }
2385
+ subs.add(ws);
2386
+ }
2387
+
2388
+ /**
2389
+ * Remove a WS subscriber from a terminal and clean up if no subscribers remain.
2390
+ */
2391
+ private removeTerminalSubscriber(terminalId: string, ws: WSContext): void {
2392
+ const subs = this.terminalSubscribers.get(terminalId);
2393
+ if (!subs) return;
2394
+ subs.delete(ws);
2395
+ if (subs.size > 0) return;
2396
+ this.terminalSubscribers.delete(terminalId);
2397
+ const cleanup = this.terminalListenerCleanups.get(terminalId);
2398
+ if (cleanup) {
2399
+ cleanup();
2400
+ this.terminalListenerCleanups.delete(terminalId);
2401
+ }
2402
+ }
2403
+
2404
+ /**
2405
+ * Attach persistent (tmux) terminal handlers for output/exit broadcasting.
2406
+ */
2407
+ private attachPersistentHandlers(terminalId: string, ptyManager: ReturnType<typeof getPTYManager>): void {
2408
+ const handlers = ptyManager.attachPersistent(
2409
+ terminalId,
2410
+ (output: string) => {
2411
+ const subs = this.terminalSubscribers.get(terminalId);
2412
+ if (subs) {
2413
+ for (const sub of subs) {
2414
+ this.send(sub, { type: 'terminalOutput', terminalId, data: { output } });
2415
+ }
2416
+ }
2417
+ },
2418
+ (exitCode: number) => {
2419
+ const subs = this.terminalSubscribers.get(terminalId);
2420
+ if (subs) {
2421
+ for (const sub of subs) {
2422
+ this.send(sub, { type: 'terminalExit', terminalId, data: { exitCode } });
2423
+ }
2424
+ }
2425
+ this.persistentHandlers.delete(terminalId);
2426
+ this.terminalSubscribers.delete(terminalId);
2427
+ }
2428
+ );
2429
+ if (handlers) {
2430
+ this.persistentHandlers.set(terminalId, handlers);
2431
+ }
2432
+ }
2433
+
2434
+ /**
2435
+ * Set up PTY event listeners that broadcast to all subscribers.
2436
+ * Only creates listeners once per terminal (idempotent).
2437
+ */
2438
+ private setupTerminalBroadcastListeners(terminalId: string): void {
2439
+ // Already set up - don't duplicate
2440
+ if (this.terminalListenerCleanups.has(terminalId)) return;
2441
+
2442
+ const ptyManager = getPTYManager();
2443
+
2444
+ const onOutput = (tid: string, data: string) => {
2445
+ if (tid === terminalId) {
2446
+ const subs = this.terminalSubscribers.get(terminalId);
2447
+ if (subs) {
2448
+ for (const ws of subs) {
2449
+ this.send(ws, { type: 'terminalOutput', terminalId, data: { output: data } });
2450
+ }
2451
+ }
2452
+ }
2453
+ };
2454
+
2455
+ const onExit = (tid: string, exitCode: number) => {
2456
+ if (tid === terminalId) {
2457
+ const subs = this.terminalSubscribers.get(terminalId);
2458
+ if (subs) {
2459
+ for (const ws of subs) {
2460
+ this.send(ws, { type: 'terminalExit', terminalId, data: { exitCode } });
2461
+ }
2462
+ }
2463
+ // Clean up
2464
+ ptyManager.off('output', onOutput);
2465
+ ptyManager.off('exit', onExit);
2466
+ ptyManager.off('error', onError);
2467
+ this.terminalListenerCleanups.delete(terminalId);
2468
+ this.terminalSubscribers.delete(terminalId);
2469
+ }
2470
+ };
2471
+
2472
+ const onError = (tid: string, error: string) => {
2473
+ if (tid === terminalId) {
2474
+ const subs = this.terminalSubscribers.get(terminalId);
2475
+ if (subs) {
2476
+ for (const ws of subs) {
2477
+ this.send(ws, { type: 'terminalError', terminalId, data: { error } });
2478
+ }
2479
+ }
2480
+ }
2481
+ };
2482
+
2483
+ ptyManager.on('output', onOutput);
2484
+ ptyManager.on('exit', onExit);
2485
+ ptyManager.on('error', onError);
2486
+
2487
+ this.terminalListenerCleanups.set(terminalId, () => {
2488
+ ptyManager.off('output', onOutput);
2489
+ ptyManager.off('exit', onExit);
2490
+ ptyManager.off('error', onError);
2491
+ });
2492
+ }
2493
+
1979
2494
  /**
1980
2495
  * Initialize a persistent (tmux-backed) terminal session
1981
- * These sessions survive server restarts
2496
+ * These sessions survive server restarts.
2497
+ * Uses subscriber pattern for multi-client output broadcasting.
1982
2498
  */
1983
2499
  private handleTerminalInitPersistent(
1984
2500
  ws: WSContext,
@@ -2001,6 +2517,9 @@ Respond with ONLY the commit message, nothing else.`;
2001
2517
  return;
2002
2518
  }
2003
2519
 
2520
+ // Add this WS as a subscriber for this terminal's output
2521
+ this.addTerminalSubscriber(terminalId, ws);
2522
+
2004
2523
  try {
2005
2524
  // Create or reconnect to the persistent session
2006
2525
  const { shell, cwd, isReconnect } = ptyManager.createPersistent(
@@ -2011,31 +2530,12 @@ Respond with ONLY the commit message, nothing else.`;
2011
2530
  requestedShell
2012
2531
  );
2013
2532
 
2014
- // Attach to the session for I/O
2015
- const handlers = ptyManager.attachPersistent(
2016
- terminalId,
2017
- (output: string) => {
2018
- this.send(ws, {
2019
- type: 'terminalOutput',
2020
- terminalId,
2021
- data: { output }
2022
- });
2023
- },
2024
- (exitCode: number) => {
2025
- this.send(ws, {
2026
- type: 'terminalExit',
2027
- terminalId,
2028
- data: { exitCode }
2029
- });
2030
- this.persistentHandlers.delete(terminalId);
2031
- }
2032
- );
2033
-
2034
- if (handlers) {
2035
- this.persistentHandlers.set(terminalId, handlers);
2533
+ // Only attach if we don't already have handlers (first subscriber)
2534
+ if (!this.persistentHandlers.has(terminalId)) {
2535
+ this.attachPersistentHandlers(terminalId, ptyManager);
2036
2536
  }
2037
2537
 
2038
- // If reconnecting, send scrollback buffer first
2538
+ // If reconnecting, send scrollback buffer to THIS client only
2039
2539
  if (isReconnect) {
2040
2540
  const scrollback = ptyManager.getPersistentScrollback(terminalId);
2041
2541
  if (scrollback.length > 0) {
@@ -2045,9 +2545,15 @@ Respond with ONLY the commit message, nothing else.`;
2045
2545
  data: { lines: scrollback }
2046
2546
  });
2047
2547
  }
2548
+ } else {
2549
+ // New terminal — broadcast to other clients so they can create matching tabs
2550
+ this.broadcastToOthers(ws, {
2551
+ type: 'terminalCreated',
2552
+ data: { terminalId, shell, cwd, persistent: true }
2553
+ });
2048
2554
  }
2049
2555
 
2050
- // Send ready message
2556
+ // Send ready message to THIS client
2051
2557
  this.send(ws, {
2052
2558
  type: 'terminalReady',
2053
2559
  terminalId,
@@ -2060,6 +2566,7 @@ Respond with ONLY the commit message, nothing else.`;
2060
2566
  terminalId,
2061
2567
  data: { error: error.message || 'Failed to create persistent terminal' }
2062
2568
  });
2569
+ this.removeTerminalSubscriber(terminalId, ws);
2063
2570
  }
2064
2571
  }
2065
2572