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.
- package/bin/mstro.js +2 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +151 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +7 -1
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +30 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -0
- package/dist/server/cli/headless/stall-assessor.js +184 -0
- package/dist/server/cli/headless/stall-assessor.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +9 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +21 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +65 -5
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +32 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +8 -5
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/settings.d.ts +25 -0
- package/dist/server/services/settings.d.ts.map +1 -0
- package/dist/server/services/settings.js +72 -0
- package/dist/server/services/settings.js.map +1 -0
- package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
- package/dist/server/services/websocket/autocomplete.js +12 -15
- package/dist/server/services/websocket/autocomplete.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +99 -2
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +618 -157
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +38 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -0
- package/dist/server/services/websocket/session-registry.js +154 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server/cli/headless/RESEARCH.md +627 -0
- package/server/cli/headless/claude-invoker.ts +192 -1
- package/server/cli/headless/runner.ts +7 -1
- package/server/cli/headless/stall-assessor.ts +245 -0
- package/server/cli/headless/types.ts +9 -1
- package/server/cli/improvisation-session-manager.ts +73 -5
- package/server/index.ts +4 -1
- package/server/mcp/bouncer-integration.ts +32 -0
- package/server/services/platform.ts +8 -5
- package/server/services/settings.ts +89 -0
- package/server/services/websocket/autocomplete.ts +18 -14
- package/server/services/websocket/handler.ts +677 -170
- package/server/services/websocket/session-registry.ts +180 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
1679
|
-
|
|
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
|
|
1685
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1794
|
-
|
|
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
|
|
1800
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
listenerCleanup();
|
|
1965
|
-
this.terminalListenerCleanups.delete(terminalId);
|
|
2352
|
+
// Close regular PTY
|
|
2353
|
+
const ptyManager = getPTYManager();
|
|
2354
|
+
ptyManager.close(terminalId);
|
|
1966
2355
|
}
|
|
1967
2356
|
|
|
1968
|
-
//
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
-
//
|
|
2015
|
-
|
|
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
|
|
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
|
|