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