mstro-app 0.2.0 → 0.3.0
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/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +79 -49
- package/bin/mstro.js +240 -37
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +133 -27
- 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 +23 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +20 -1
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +30 -24
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +19 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +221 -29
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +0 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +13 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +50 -3
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -359
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +67 -2328
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +63 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +155 -31
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/runner.ts +25 -0
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +25 -0
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +33 -25
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +277 -30
- package/server/index.ts +0 -4
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/services/analytics.ts +13 -1
- package/server/services/platform.ts +12 -1
- package/server/services/terminal/pty-manager.ts +53 -3
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +83 -2678
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +135 -0
- package/bin/release.sh +0 -110
|
@@ -1,102 +1,43 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
-
|
|
4
|
-
* WebSocket Handler for Improvisation Sessions
|
|
5
|
-
*
|
|
6
|
-
* Manages WebSocket connections for real-time improvisation sessions.
|
|
7
|
-
* Integrates with ImprovisationSessionManager to execute Claude Code commands.
|
|
8
|
-
*/
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
10
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
4
|
import { homedir } from 'node:os';
|
|
12
5
|
import { dirname, join } from 'node:path';
|
|
13
|
-
import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
14
|
-
import { AnalyticsEvents, trackEvent } from '../analytics.js';
|
|
15
|
-
import { createDirectory, createFile, deleteFile, listDirectory, renameFile, writeFile } from '../files.js';
|
|
16
|
-
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
17
6
|
import { captureException } from '../sentry.js';
|
|
18
|
-
import { getModel, getPrBaseBranch, getSettings, setModel, setPrBaseBranch } from '../settings.js';
|
|
19
|
-
import { getPTYManager } from '../terminal/pty-manager.js';
|
|
20
7
|
import { AutocompleteService } from './autocomplete.js';
|
|
21
|
-
import {
|
|
8
|
+
import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
|
|
9
|
+
import { handleGitMessage } from './git-handlers.js';
|
|
10
|
+
import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistoricalSession } from './session-handlers.js';
|
|
22
11
|
import { SessionRegistry } from './session-registry.js';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
for (const tool of tools) {
|
|
27
|
-
lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
|
|
28
|
-
if (tool.result !== undefined) {
|
|
29
|
-
lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return lines;
|
|
33
|
-
}
|
|
34
|
-
/** Convert a single movement record into OutputLine-compatible entries */
|
|
35
|
-
function convertMovementToLines(movement) {
|
|
36
|
-
const lines = [];
|
|
37
|
-
const ts = new Date(movement.timestamp).getTime();
|
|
38
|
-
lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
|
|
39
|
-
if (movement.thinkingOutput) {
|
|
40
|
-
lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
|
|
41
|
-
}
|
|
42
|
-
if (movement.toolUseHistory) {
|
|
43
|
-
lines.push(...convertToolHistoryToLines(movement.toolUseHistory, ts));
|
|
44
|
-
}
|
|
45
|
-
if (movement.assistantResponse) {
|
|
46
|
-
lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
|
|
47
|
-
}
|
|
48
|
-
if (movement.errorOutput) {
|
|
49
|
-
lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
|
|
50
|
-
}
|
|
51
|
-
const durationText = movement.durationMs
|
|
52
|
-
? `Completed in ${(movement.durationMs / 1000).toFixed(2)}s`
|
|
53
|
-
: 'Completed';
|
|
54
|
-
lines.push({ type: 'system', text: durationText, timestamp: ts });
|
|
55
|
-
return lines;
|
|
56
|
-
}
|
|
57
|
-
/** Detect git provider from remote URL */
|
|
58
|
-
function detectGitProvider(remoteUrl) {
|
|
59
|
-
if (remoteUrl.includes('github.com'))
|
|
60
|
-
return 'github';
|
|
61
|
-
if (remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab'))
|
|
62
|
-
return 'gitlab';
|
|
63
|
-
return 'unknown';
|
|
64
|
-
}
|
|
12
|
+
import { generateNotificationSummary, handleGetSettings, handleUpdateSettings } from './settings-handlers.js';
|
|
13
|
+
import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncPromptText, handleSyncTabMeta } from './tab-handlers.js';
|
|
14
|
+
import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
|
|
65
15
|
export class WebSocketImproviseHandler {
|
|
66
16
|
sessions = new Map();
|
|
67
17
|
connections = new Map();
|
|
68
18
|
autocompleteService;
|
|
69
19
|
frecencyPath;
|
|
70
20
|
usageReporter = null;
|
|
71
|
-
/** Per-tab selected git directory (tabId -> directory path) */
|
|
72
21
|
gitDirectories = new Map();
|
|
73
|
-
/** Persistent tab→session mapping that survives WS disconnections */
|
|
74
22
|
sessionRegistry = null;
|
|
75
|
-
/** All connected WS contexts (for broadcasting to all clients) */
|
|
76
23
|
allConnections = new Set();
|
|
24
|
+
activeSearches = new Map();
|
|
25
|
+
terminalListenerCleanups = new Map();
|
|
26
|
+
terminalSubscribers = new Map();
|
|
77
27
|
constructor() {
|
|
78
28
|
this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
|
|
79
29
|
const frecencyData = this.loadFrecencyData();
|
|
80
30
|
this.autocompleteService = new AutocompleteService(frecencyData);
|
|
81
31
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Lazily initialize session registry for a working directory
|
|
84
|
-
*/
|
|
85
32
|
getRegistry(workingDir) {
|
|
86
33
|
if (!this.sessionRegistry) {
|
|
87
34
|
this.sessionRegistry = new SessionRegistry(workingDir);
|
|
88
35
|
}
|
|
89
36
|
return this.sessionRegistry;
|
|
90
37
|
}
|
|
91
|
-
/**
|
|
92
|
-
* Set the usage reporter callback for sending usage data to platform
|
|
93
|
-
*/
|
|
94
38
|
setUsageReporter(reporter) {
|
|
95
39
|
this.usageReporter = reporter;
|
|
96
40
|
}
|
|
97
|
-
/**
|
|
98
|
-
* Load frecency data from disk
|
|
99
|
-
*/
|
|
100
41
|
loadFrecencyData() {
|
|
101
42
|
try {
|
|
102
43
|
if (existsSync(this.frecencyPath)) {
|
|
@@ -109,9 +50,6 @@ export class WebSocketImproviseHandler {
|
|
|
109
50
|
}
|
|
110
51
|
return {};
|
|
111
52
|
}
|
|
112
|
-
/**
|
|
113
|
-
* Save frecency data to disk
|
|
114
|
-
*/
|
|
115
53
|
saveFrecencyData() {
|
|
116
54
|
try {
|
|
117
55
|
const dir = dirname(this.frecencyPath);
|
|
@@ -124,28 +62,18 @@ export class WebSocketImproviseHandler {
|
|
|
124
62
|
console.error('[WebSocketImproviseHandler] Error saving frecency data:', error);
|
|
125
63
|
}
|
|
126
64
|
}
|
|
127
|
-
/**
|
|
128
|
-
* Record a file selection for frecency scoring
|
|
129
|
-
*/
|
|
130
65
|
recordFileSelection(filePath) {
|
|
131
66
|
this.autocompleteService.recordFileSelection(filePath);
|
|
132
67
|
this.saveFrecencyData();
|
|
133
68
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Handle new WebSocket connection
|
|
136
|
-
*/
|
|
137
69
|
handleConnection(ws, _workingDir) {
|
|
138
70
|
this.connections.set(ws, new Map());
|
|
139
71
|
this.allConnections.add(ws);
|
|
140
72
|
}
|
|
141
|
-
/**
|
|
142
|
-
* Handle incoming WebSocket message
|
|
143
|
-
*/
|
|
144
73
|
async handleMessage(ws, message, workingDir) {
|
|
145
74
|
try {
|
|
146
75
|
const msg = JSON.parse(message);
|
|
147
76
|
const tabId = msg.tabId || 'default';
|
|
148
|
-
// Extract sandbox permission injected by server relay (for sandboxed shared users)
|
|
149
77
|
const permission = msg._permission;
|
|
150
78
|
delete msg._permission;
|
|
151
79
|
await this.dispatchMessage(ws, msg, tabId, workingDir, permission);
|
|
@@ -159,46 +87,52 @@ export class WebSocketImproviseHandler {
|
|
|
159
87
|
});
|
|
160
88
|
}
|
|
161
89
|
}
|
|
162
|
-
/**
|
|
163
|
-
* Dispatch a parsed message to the appropriate handler
|
|
164
|
-
*/
|
|
165
90
|
async dispatchMessage(ws, msg, tabId, workingDir, permission) {
|
|
166
91
|
switch (msg.type) {
|
|
167
92
|
case 'ping':
|
|
168
93
|
this.send(ws, { type: 'pong', tabId });
|
|
169
94
|
return;
|
|
170
95
|
case 'initTab':
|
|
171
|
-
return void await
|
|
96
|
+
return void await initializeTab(this, ws, tabId, workingDir, msg.data?.tabName);
|
|
172
97
|
case 'resumeSession':
|
|
173
98
|
if (!msg.data?.historicalSessionId)
|
|
174
99
|
throw new Error('Historical session ID is required');
|
|
175
|
-
return void await
|
|
100
|
+
return void await resumeHistoricalSession(this, ws, tabId, workingDir, msg.data.historicalSessionId);
|
|
101
|
+
// Session messages
|
|
176
102
|
case 'execute':
|
|
177
103
|
case 'cancel':
|
|
178
104
|
case 'getHistory':
|
|
179
105
|
case 'new':
|
|
180
106
|
case 'approve':
|
|
181
107
|
case 'reject':
|
|
182
|
-
return
|
|
108
|
+
return handleSessionMessage(this, ws, msg, tabId, permission);
|
|
109
|
+
// History messages
|
|
183
110
|
case 'getSessions':
|
|
184
111
|
case 'getSessionsCount':
|
|
185
112
|
case 'getSessionById':
|
|
186
113
|
case 'deleteSession':
|
|
187
114
|
case 'clearHistory':
|
|
188
115
|
case 'searchHistory':
|
|
189
|
-
return
|
|
116
|
+
return handleHistoryMessage(this, ws, msg, tabId, workingDir);
|
|
117
|
+
// File autocomplete/read
|
|
190
118
|
case 'autocomplete':
|
|
191
119
|
case 'readFile':
|
|
192
120
|
case 'recordSelection':
|
|
121
|
+
return handleFileMessage(this, ws, msg, tabId, workingDir, permission);
|
|
122
|
+
// Notification summary
|
|
193
123
|
case 'requestNotificationSummary':
|
|
194
|
-
|
|
124
|
+
if (!msg.data?.prompt || !msg.data?.output)
|
|
125
|
+
throw new Error('Prompt and output are required for notification summary');
|
|
126
|
+
return void await generateNotificationSummary(this, ws, tabId, msg.data.prompt, msg.data.output, workingDir);
|
|
127
|
+
// Terminal messages
|
|
195
128
|
case 'terminalInit':
|
|
196
129
|
case 'terminalReconnect':
|
|
197
130
|
case 'terminalList':
|
|
198
131
|
case 'terminalInput':
|
|
199
132
|
case 'terminalResize':
|
|
200
133
|
case 'terminalClose':
|
|
201
|
-
return
|
|
134
|
+
return handleTerminalMessage(this, ws, msg, tabId, workingDir, permission);
|
|
135
|
+
// File explorer messages
|
|
202
136
|
case 'listDirectory':
|
|
203
137
|
case 'writeFile':
|
|
204
138
|
case 'createFile':
|
|
@@ -206,535 +140,80 @@ export class WebSocketImproviseHandler {
|
|
|
206
140
|
case 'deleteFile':
|
|
207
141
|
case 'renameFile':
|
|
208
142
|
case 'notifyFileOpened':
|
|
209
|
-
|
|
143
|
+
case 'searchFileContents':
|
|
144
|
+
case 'cancelSearch':
|
|
145
|
+
case 'findDefinition':
|
|
146
|
+
return handleFileExplorerMessage(this, ws, msg, tabId, workingDir, permission);
|
|
147
|
+
// Git messages
|
|
210
148
|
case 'gitStatus':
|
|
211
149
|
case 'gitStage':
|
|
212
150
|
case 'gitUnstage':
|
|
213
151
|
case 'gitCommit':
|
|
214
152
|
case 'gitCommitWithAI':
|
|
215
153
|
case 'gitPush':
|
|
154
|
+
case 'gitPull':
|
|
216
155
|
case 'gitLog':
|
|
217
156
|
case 'gitDiscoverRepos':
|
|
218
157
|
case 'gitSetDirectory':
|
|
219
158
|
case 'gitGetRemoteInfo':
|
|
220
159
|
case 'gitCreatePR':
|
|
221
160
|
case 'gitGeneratePRDescription':
|
|
222
|
-
|
|
223
|
-
|
|
161
|
+
case 'gitListBranches':
|
|
162
|
+
case 'gitCheckout':
|
|
163
|
+
case 'gitCreateBranch':
|
|
164
|
+
case 'gitDeleteBranch':
|
|
165
|
+
case 'gitDiff':
|
|
166
|
+
case 'gitListTags':
|
|
167
|
+
case 'gitCreateTag':
|
|
168
|
+
case 'gitPushTag':
|
|
169
|
+
case 'gitWorktreeList':
|
|
170
|
+
case 'gitWorktreeCreate':
|
|
171
|
+
case 'gitWorktreeRemove':
|
|
172
|
+
case 'tabWorktreeSwitch':
|
|
173
|
+
case 'gitWorktreePush':
|
|
174
|
+
case 'gitWorktreeCreatePR':
|
|
175
|
+
case 'gitMergePreview':
|
|
176
|
+
case 'gitWorktreeMerge':
|
|
177
|
+
case 'gitMergeAbort':
|
|
178
|
+
case 'gitMergeComplete':
|
|
179
|
+
return handleGitMessage(this, ws, msg, tabId, workingDir);
|
|
180
|
+
// Tab sync messages
|
|
224
181
|
case 'getActiveTabs':
|
|
225
|
-
return
|
|
182
|
+
return handleGetActiveTabs(this, ws, workingDir);
|
|
226
183
|
case 'createTab':
|
|
227
|
-
return void await
|
|
184
|
+
return void await handleCreateTab(this, ws, workingDir, msg.data?.tabName, msg.data?.optimisticTabId);
|
|
228
185
|
case 'reorderTabs':
|
|
229
|
-
return
|
|
186
|
+
return handleReorderTabs(this, ws, workingDir, msg.data?.tabOrder);
|
|
230
187
|
case 'syncTabMeta':
|
|
231
|
-
return
|
|
188
|
+
return handleSyncTabMeta(this, ws, msg, tabId, workingDir);
|
|
232
189
|
case 'syncPromptText':
|
|
233
|
-
return
|
|
190
|
+
return handleSyncPromptText(this, ws, msg, tabId);
|
|
234
191
|
case 'removeTab':
|
|
235
|
-
return
|
|
192
|
+
return handleRemoveTab(this, ws, tabId, workingDir);
|
|
236
193
|
case 'markTabViewed':
|
|
237
|
-
return
|
|
194
|
+
return handleMarkTabViewed(this, ws, tabId, workingDir);
|
|
238
195
|
// Settings messages
|
|
239
196
|
case 'getSettings':
|
|
240
|
-
return
|
|
197
|
+
return handleGetSettings(this, ws);
|
|
241
198
|
case 'updateSettings':
|
|
242
|
-
return
|
|
199
|
+
return handleUpdateSettings(this, ws, msg);
|
|
243
200
|
default:
|
|
244
201
|
throw new Error(`Unknown message type: ${msg.type}`);
|
|
245
202
|
}
|
|
246
203
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
switch (msg.type) {
|
|
252
|
-
case 'execute': {
|
|
253
|
-
if (!msg.data?.prompt)
|
|
254
|
-
throw new Error('Prompt is required');
|
|
255
|
-
const session = this.requireSession(ws, tabId);
|
|
256
|
-
const sandboxed = permission === 'control' || permission === 'view';
|
|
257
|
-
session.executePrompt(msg.data.prompt, msg.data.attachments, { sandboxed });
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
case 'cancel': {
|
|
261
|
-
const session = this.requireSession(ws, tabId);
|
|
262
|
-
session.cancel();
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
case 'getHistory': {
|
|
266
|
-
const session = this.requireSession(ws, tabId);
|
|
267
|
-
this.send(ws, { type: 'history', tabId, data: session.getHistory() });
|
|
268
|
-
break;
|
|
269
|
-
}
|
|
270
|
-
case 'new': {
|
|
271
|
-
const oldSession = this.requireSession(ws, tabId);
|
|
272
|
-
const newSession = oldSession.startNewSession({ model: getModel() });
|
|
273
|
-
this.setupSessionListeners(newSession, ws, tabId);
|
|
274
|
-
const newSessionId = newSession.getSessionInfo().sessionId;
|
|
275
|
-
this.sessions.set(newSessionId, newSession);
|
|
276
|
-
const tabMap = this.connections.get(ws);
|
|
277
|
-
if (tabMap)
|
|
278
|
-
tabMap.set(tabId, newSessionId);
|
|
279
|
-
// Update registry with new session ID
|
|
280
|
-
if (this.sessionRegistry) {
|
|
281
|
-
this.sessionRegistry.updateTabSession(tabId, newSessionId);
|
|
282
|
-
}
|
|
283
|
-
this.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
case 'approve': {
|
|
287
|
-
const session = this.requireSession(ws, tabId);
|
|
288
|
-
session.respondToApproval?.(true);
|
|
289
|
-
this.send(ws, { type: 'output', tabId, data: { text: '\n✅ Approved - proceeding with operation\n' } });
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
case 'reject': {
|
|
293
|
-
const session = this.requireSession(ws, tabId);
|
|
294
|
-
session.respondToApproval?.(false);
|
|
295
|
-
this.send(ws, { type: 'output', tabId, data: { text: '\n🚫 Rejected - operation cancelled\n' } });
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Handle history/session listing messages
|
|
302
|
-
*/
|
|
303
|
-
handleHistoryMessage(ws, msg, tabId, workingDir) {
|
|
304
|
-
switch (msg.type) {
|
|
305
|
-
case 'getSessions': {
|
|
306
|
-
const result = this.getSessionsList(workingDir, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
|
|
307
|
-
this.send(ws, { type: 'sessions', tabId, data: result });
|
|
308
|
-
break;
|
|
309
|
-
}
|
|
310
|
-
case 'getSessionsCount':
|
|
311
|
-
this.send(ws, { type: 'sessionsCount', tabId, data: { total: this.getSessionsCount(workingDir) } });
|
|
312
|
-
break;
|
|
313
|
-
case 'getSessionById':
|
|
314
|
-
if (!msg.data?.sessionId)
|
|
315
|
-
throw new Error('Session ID is required');
|
|
316
|
-
this.send(ws, { type: 'sessionData', tabId, data: this.getSessionById(workingDir, msg.data.sessionId) });
|
|
317
|
-
break;
|
|
318
|
-
case 'deleteSession':
|
|
319
|
-
if (!msg.data?.sessionId)
|
|
320
|
-
throw new Error('Session ID is required');
|
|
321
|
-
this.send(ws, { type: 'sessionDeleted', tabId, data: this.deleteSession(workingDir, msg.data.sessionId) });
|
|
322
|
-
break;
|
|
323
|
-
case 'clearHistory':
|
|
324
|
-
this.send(ws, { type: 'historyCleared', tabId, data: this.clearAllSessions(workingDir) });
|
|
325
|
-
break;
|
|
326
|
-
case 'searchHistory': {
|
|
327
|
-
if (!msg.data?.query)
|
|
328
|
-
throw new Error('Search query is required');
|
|
329
|
-
const result = this.searchSessions(workingDir, msg.data.query, msg.data?.limit ?? 20, msg.data?.offset ?? 0);
|
|
330
|
-
this.send(ws, { type: 'searchResults', tabId, data: { ...result, query: msg.data.query } });
|
|
331
|
-
break;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Handle file-related messages (autocomplete, readFile, recordSelection, notifications)
|
|
337
|
-
*/
|
|
338
|
-
handleFileMessage(ws, msg, tabId, workingDir, permission) {
|
|
339
|
-
switch (msg.type) {
|
|
340
|
-
case 'autocomplete':
|
|
341
|
-
if (!msg.data?.partialPath)
|
|
342
|
-
throw new Error('Partial path is required');
|
|
343
|
-
this.send(ws, { type: 'autocomplete', tabId, data: { completions: this.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
|
|
344
|
-
break;
|
|
345
|
-
case 'readFile':
|
|
346
|
-
this.handleReadFile(ws, msg, tabId, workingDir, permission);
|
|
347
|
-
break;
|
|
348
|
-
case 'recordSelection':
|
|
349
|
-
if (msg.data?.filePath)
|
|
350
|
-
this.recordFileSelection(msg.data.filePath);
|
|
351
|
-
break;
|
|
352
|
-
case 'requestNotificationSummary':
|
|
353
|
-
if (!msg.data?.prompt || !msg.data?.output)
|
|
354
|
-
throw new Error('Prompt and output are required for notification summary');
|
|
355
|
-
this.generateNotificationSummary(ws, tabId, msg.data.prompt, msg.data.output, workingDir);
|
|
356
|
-
break;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
handleReadFile(ws, msg, tabId, workingDir, permission) {
|
|
360
|
-
if (!msg.data?.filePath)
|
|
361
|
-
throw new Error('File path is required');
|
|
362
|
-
const isSandboxed = permission === 'control' || permission === 'view';
|
|
363
|
-
if (isSandboxed) {
|
|
364
|
-
const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
|
|
365
|
-
if (!validation.valid) {
|
|
366
|
-
this.send(ws, { type: 'fileContent', tabId, data: { path: msg.data.filePath, fileName: msg.data.filePath.split('/').pop() || '', content: '', error: 'Sandboxed: path outside project directory' } });
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Handle terminal messages
|
|
374
|
-
*/
|
|
375
|
-
handleTerminalMessage(ws, msg, tabId, workingDir, permission) {
|
|
376
|
-
const termId = msg.terminalId || tabId;
|
|
377
|
-
switch (msg.type) {
|
|
378
|
-
case 'terminalInit':
|
|
379
|
-
this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows, permission);
|
|
380
|
-
break;
|
|
381
|
-
case 'terminalReconnect':
|
|
382
|
-
this.handleTerminalReconnect(ws, termId);
|
|
383
|
-
break;
|
|
384
|
-
case 'terminalList':
|
|
385
|
-
this.handleTerminalList(ws);
|
|
386
|
-
break;
|
|
387
|
-
case 'terminalInput':
|
|
388
|
-
this.handleTerminalInput(ws, termId, msg.data?.input);
|
|
389
|
-
break;
|
|
390
|
-
case 'terminalResize':
|
|
391
|
-
this.handleTerminalResize(ws, termId, msg.data?.cols, msg.data?.rows);
|
|
392
|
-
break;
|
|
393
|
-
case 'terminalClose':
|
|
394
|
-
this.handleTerminalClose(ws, termId);
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Handle file explorer operations with success/error response pattern
|
|
400
|
-
*/
|
|
401
|
-
sendFileResult(ws, type, tabId, result, successData) {
|
|
402
|
-
const data = result.success
|
|
403
|
-
? { success: true, path: result.path, ...successData }
|
|
404
|
-
: { success: false, path: result.path, error: result.error };
|
|
405
|
-
this.send(ws, { type, tabId, data });
|
|
406
|
-
}
|
|
407
|
-
handleListDirectory(ws, msg, tabId, workingDir) {
|
|
408
|
-
if (msg.data?.dirPath === undefined)
|
|
409
|
-
throw new Error('Directory path is required');
|
|
410
|
-
const result = listDirectory(msg.data.dirPath, workingDir, msg.data.showHidden ?? false);
|
|
411
|
-
this.send(ws, { type: 'directoryListing', tabId, data: result.success ? { success: true, path: msg.data.dirPath, entries: result.entries } : { success: false, path: msg.data.dirPath, error: result.error } });
|
|
412
|
-
}
|
|
413
|
-
handleWriteFile(ws, msg, tabId, workingDir) {
|
|
414
|
-
if (!msg.data?.filePath)
|
|
415
|
-
throw new Error('File path is required');
|
|
416
|
-
if (msg.data.content === undefined)
|
|
417
|
-
throw new Error('Content is required');
|
|
418
|
-
const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
|
|
419
|
-
this.sendFileResult(ws, 'fileWritten', tabId, result);
|
|
420
|
-
if (result.success) {
|
|
421
|
-
this.broadcastToOthers(ws, {
|
|
422
|
-
type: 'fileContentChanged',
|
|
423
|
-
data: { path: result.path, content: msg.data.content }
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
handleCreateFile(ws, msg, tabId, workingDir) {
|
|
428
|
-
if (!msg.data?.filePath)
|
|
429
|
-
throw new Error('File path is required');
|
|
430
|
-
const result = createFile(msg.data.filePath, workingDir);
|
|
431
|
-
this.sendFileResult(ws, 'fileCreated', tabId, result);
|
|
432
|
-
if (result.success && result.path) {
|
|
433
|
-
const name = result.path.split('/').pop() || 'unknown';
|
|
434
|
-
this.broadcastToOthers(ws, {
|
|
435
|
-
type: 'fileCreated',
|
|
436
|
-
data: { path: result.path, name, size: 0, modifiedAt: new Date().toISOString() }
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
handleCreateDirectory(ws, msg, tabId, workingDir) {
|
|
441
|
-
if (!msg.data?.dirPath)
|
|
442
|
-
throw new Error('Directory path is required');
|
|
443
|
-
const result = createDirectory(msg.data.dirPath, workingDir);
|
|
444
|
-
this.sendFileResult(ws, 'directoryCreated', tabId, result);
|
|
445
|
-
if (result.success && result.path) {
|
|
446
|
-
const name = result.path.split('/').pop() || 'unknown';
|
|
447
|
-
this.broadcastToOthers(ws, {
|
|
448
|
-
type: 'directoryCreated',
|
|
449
|
-
data: { path: result.path, name }
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
handleDeleteFile(ws, msg, tabId, workingDir) {
|
|
454
|
-
if (!msg.data?.filePath)
|
|
455
|
-
throw new Error('File path is required');
|
|
456
|
-
const result = deleteFile(msg.data.filePath, workingDir);
|
|
457
|
-
this.sendFileResult(ws, 'fileDeleted', tabId, result);
|
|
458
|
-
if (result.success && result.path) {
|
|
459
|
-
this.broadcastToOthers(ws, {
|
|
460
|
-
type: 'fileDeleted',
|
|
461
|
-
data: { path: result.path }
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
handleRenameFile(ws, msg, tabId, workingDir) {
|
|
466
|
-
if (!msg.data?.oldPath)
|
|
467
|
-
throw new Error('Old path is required');
|
|
468
|
-
if (!msg.data?.newPath)
|
|
469
|
-
throw new Error('New path is required');
|
|
470
|
-
const result = renameFile(msg.data.oldPath, msg.data.newPath, workingDir);
|
|
471
|
-
this.sendFileResult(ws, 'fileRenamed', tabId, result);
|
|
472
|
-
if (result.success && result.path) {
|
|
473
|
-
const name = result.path.split('/').pop() || 'unknown';
|
|
474
|
-
this.broadcastToOthers(ws, {
|
|
475
|
-
type: 'fileRenamed',
|
|
476
|
-
data: { oldPath: msg.data.oldPath, newPath: result.path, name }
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
handleNotifyFileOpened(ws, msg, workingDir) {
|
|
481
|
-
if (!msg.data?.filePath)
|
|
482
|
-
return;
|
|
483
|
-
const fileData = readFileContent(msg.data.filePath, workingDir);
|
|
484
|
-
if (!fileData.error) {
|
|
485
|
-
this.broadcastToOthers(ws, {
|
|
486
|
-
type: 'fileOpened',
|
|
487
|
-
data: {
|
|
488
|
-
path: msg.data.filePath,
|
|
489
|
-
fileName: fileData.fileName,
|
|
490
|
-
content: fileData.content
|
|
491
|
-
}
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
handleFileExplorerMessage(ws, msg, tabId, workingDir, permission) {
|
|
496
|
-
const isSandboxed = permission === 'control' || permission === 'view';
|
|
497
|
-
const handlers = {
|
|
498
|
-
listDirectory: () => {
|
|
499
|
-
// Sandboxed users can only list directories within the project
|
|
500
|
-
if (isSandboxed && msg.data?.dirPath) {
|
|
501
|
-
const validation = validatePathWithinWorkingDir(msg.data.dirPath, workingDir);
|
|
502
|
-
if (!validation.valid) {
|
|
503
|
-
this.send(ws, { type: 'directoryListing', tabId, data: { success: false, path: msg.data.dirPath, error: 'Sandboxed: path outside project directory' } });
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
this.handleListDirectory(ws, msg, tabId, workingDir);
|
|
508
|
-
},
|
|
509
|
-
writeFile: () => this.handleWriteFile(ws, msg, tabId, workingDir),
|
|
510
|
-
createFile: () => this.handleCreateFile(ws, msg, tabId, workingDir),
|
|
511
|
-
createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
|
|
512
|
-
deleteFile: () => this.handleDeleteFile(ws, msg, tabId, workingDir),
|
|
513
|
-
renameFile: () => this.handleRenameFile(ws, msg, tabId, workingDir),
|
|
514
|
-
notifyFileOpened: () => this.handleNotifyFileOpened(ws, msg, workingDir),
|
|
515
|
-
};
|
|
516
|
-
handlers[msg.type]?.();
|
|
517
|
-
}
|
|
518
|
-
/**
|
|
519
|
-
* Get a session or throw
|
|
520
|
-
*/
|
|
521
|
-
requireSession(ws, tabId) {
|
|
522
|
-
const session = this.getSession(ws, tabId);
|
|
523
|
-
if (!session)
|
|
524
|
-
throw new Error(`No session found for tab ${tabId}`);
|
|
525
|
-
return session;
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Set up event listeners for a session
|
|
529
|
-
*/
|
|
530
|
-
setupSessionListeners(session, ws, tabId) {
|
|
531
|
-
// Remove any existing listeners to prevent duplicates on reattach/reconnect
|
|
532
|
-
session.removeAllListeners();
|
|
533
|
-
session.on('onOutput', (text) => {
|
|
534
|
-
this.send(ws, { type: 'output', tabId, data: { text, timestamp: Date.now() } });
|
|
535
|
-
});
|
|
536
|
-
session.on('onThinking', (text) => {
|
|
537
|
-
this.send(ws, { type: 'thinking', tabId, data: { text } });
|
|
538
|
-
});
|
|
539
|
-
session.on('onMovementStart', (sequenceNumber, prompt) => {
|
|
540
|
-
this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp } });
|
|
541
|
-
// Broadcast execution state to ALL clients so tab indicators update
|
|
542
|
-
// even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
|
|
543
|
-
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
|
|
544
|
-
});
|
|
545
|
-
session.on('onMovementComplete', (movement) => {
|
|
546
|
-
this.send(ws, { type: 'movementComplete', tabId, data: movement });
|
|
547
|
-
// Mark tab as having unviewed completion (persisted across CLI restarts)
|
|
548
|
-
this.sessionRegistry?.markTabUnviewed(tabId);
|
|
549
|
-
// Broadcast execution state + completion dot to ALL clients
|
|
550
|
-
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
|
|
551
|
-
// Report usage to platform if reporter is configured
|
|
552
|
-
if (this.usageReporter && movement.tokensUsed) {
|
|
553
|
-
this.usageReporter({
|
|
554
|
-
tokensUsed: movement.tokensUsed,
|
|
555
|
-
sessionId: session.getSessionInfo().sessionId,
|
|
556
|
-
movementId: `${movement.sequenceNumber}`
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
});
|
|
560
|
-
session.on('onMovementError', (error) => {
|
|
561
|
-
this.send(ws, { type: 'movementError', tabId, data: { message: error.message } });
|
|
562
|
-
// Broadcast execution stopped to ALL clients
|
|
563
|
-
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
|
|
564
|
-
});
|
|
565
|
-
session.on('onSessionUpdate', (history) => {
|
|
566
|
-
this.send(ws, { type: 'sessionUpdate', tabId, data: history });
|
|
567
|
-
});
|
|
568
|
-
session.on('onPlanNeedsConfirmation', (plan) => {
|
|
569
|
-
this.send(ws, { type: 'approvalRequired', tabId, data: plan });
|
|
570
|
-
});
|
|
571
|
-
session.on('onToolUse', (event) => {
|
|
572
|
-
this.send(ws, { type: 'toolUse', tabId, data: { ...event, timestamp: Date.now() } });
|
|
573
|
-
});
|
|
204
|
+
handleClose(ws) {
|
|
205
|
+
this.connections.delete(ws);
|
|
206
|
+
this.allConnections.delete(ws);
|
|
207
|
+
cleanupTerminalSubscribers(this, ws);
|
|
574
208
|
}
|
|
575
|
-
|
|
576
|
-
* Resume a historical session for conversation continuity
|
|
577
|
-
* Falls back to creating a new session if the historical session cannot be found
|
|
578
|
-
* (e.g., server restarted before the session was saved to disk)
|
|
579
|
-
*/
|
|
580
|
-
async resumeHistoricalSession(ws, tabId, workingDir, historicalSessionId) {
|
|
581
|
-
const tabMap = this.connections.get(ws);
|
|
582
|
-
const registry = this.getRegistry(workingDir);
|
|
583
|
-
// Check per-connection map first (same WS reconnect)
|
|
584
|
-
const existingSessionId = tabMap?.get(tabId);
|
|
585
|
-
if (existingSessionId) {
|
|
586
|
-
const existingSession = this.sessions.get(existingSessionId);
|
|
587
|
-
if (existingSession) {
|
|
588
|
-
this.reattachSession(existingSession, ws, tabId, registry);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
// Check session registry (cross-connection reattach)
|
|
593
|
-
const registrySessionId = registry.getTabSession(tabId);
|
|
594
|
-
if (registrySessionId) {
|
|
595
|
-
const inMemorySession = this.sessions.get(registrySessionId);
|
|
596
|
-
if (inMemorySession) {
|
|
597
|
-
this.reattachSession(inMemorySession, ws, tabId, registry);
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
let session;
|
|
602
|
-
let isNewSession = false;
|
|
209
|
+
send(ws, response) {
|
|
603
210
|
try {
|
|
604
|
-
|
|
211
|
+
ws.send(JSON.stringify(response));
|
|
605
212
|
}
|
|
606
213
|
catch (error) {
|
|
607
|
-
console.
|
|
608
|
-
session = new ImprovisationSessionManager({ workingDir, model: getModel() });
|
|
609
|
-
isNewSession = true;
|
|
610
|
-
}
|
|
611
|
-
this.setupSessionListeners(session, ws, tabId);
|
|
612
|
-
const sessionId = session.getSessionInfo().sessionId;
|
|
613
|
-
this.sessions.set(sessionId, session);
|
|
614
|
-
if (tabMap) {
|
|
615
|
-
tabMap.set(tabId, sessionId);
|
|
616
|
-
}
|
|
617
|
-
registry.registerTab(tabId, sessionId);
|
|
618
|
-
this.send(ws, {
|
|
619
|
-
type: 'tabInitialized',
|
|
620
|
-
tabId,
|
|
621
|
-
data: {
|
|
622
|
-
...session.getSessionInfo(),
|
|
623
|
-
outputHistory: this.buildOutputHistory(session),
|
|
624
|
-
resumeFailed: isNewSession,
|
|
625
|
-
originalSessionId: isNewSession ? historicalSessionId : undefined
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Initialize a tab with its own session.
|
|
631
|
-
* Checks (in order): per-connection map → session registry → disk history → new session.
|
|
632
|
-
*/
|
|
633
|
-
async initializeTab(ws, tabId, workingDir, tabName) {
|
|
634
|
-
const tabMap = this.connections.get(ws);
|
|
635
|
-
const registry = this.getRegistry(workingDir);
|
|
636
|
-
// 1. Check per-connection map (same WS reconnect)
|
|
637
|
-
const existingSessionId = tabMap?.get(tabId);
|
|
638
|
-
if (existingSessionId) {
|
|
639
|
-
const existingSession = this.sessions.get(existingSessionId);
|
|
640
|
-
if (existingSession) {
|
|
641
|
-
this.reattachSession(existingSession, ws, tabId, registry);
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
// 2. Check session registry (cross-connection reattach, e.g. browser refresh)
|
|
646
|
-
const registrySessionId = registry.getTabSession(tabId);
|
|
647
|
-
if (registrySessionId) {
|
|
648
|
-
// Try in-memory first
|
|
649
|
-
const inMemorySession = this.sessions.get(registrySessionId);
|
|
650
|
-
if (inMemorySession) {
|
|
651
|
-
this.reattachSession(inMemorySession, ws, tabId, registry);
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
// Try resuming from disk
|
|
655
|
-
try {
|
|
656
|
-
const diskSession = ImprovisationSessionManager.resumeFromHistory(workingDir, registrySessionId);
|
|
657
|
-
this.setupSessionListeners(diskSession, ws, tabId);
|
|
658
|
-
const diskSessionId = diskSession.getSessionInfo().sessionId;
|
|
659
|
-
this.sessions.set(diskSessionId, diskSession);
|
|
660
|
-
if (tabMap)
|
|
661
|
-
tabMap.set(tabId, diskSessionId);
|
|
662
|
-
registry.touchTab(tabId);
|
|
663
|
-
this.send(ws, {
|
|
664
|
-
type: 'tabInitialized',
|
|
665
|
-
tabId,
|
|
666
|
-
data: {
|
|
667
|
-
...diskSession.getSessionInfo(),
|
|
668
|
-
outputHistory: this.buildOutputHistory(diskSession),
|
|
669
|
-
}
|
|
670
|
-
});
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
catch {
|
|
674
|
-
// Disk session not found — fall through to create new
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
// 3. Create new session
|
|
678
|
-
const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
|
|
679
|
-
this.setupSessionListeners(session, ws, tabId);
|
|
680
|
-
const sessionId = session.getSessionInfo().sessionId;
|
|
681
|
-
this.sessions.set(sessionId, session);
|
|
682
|
-
if (tabMap) {
|
|
683
|
-
tabMap.set(tabId, sessionId);
|
|
214
|
+
console.error('[WebSocketImproviseHandler] Error sending message:', error);
|
|
684
215
|
}
|
|
685
|
-
registry.registerTab(tabId, sessionId, tabName);
|
|
686
|
-
const registeredTab = registry.getTab(tabId);
|
|
687
|
-
this.broadcastToAll({
|
|
688
|
-
type: 'tabCreated',
|
|
689
|
-
data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, sessionInfo: session.getSessionInfo() }
|
|
690
|
-
});
|
|
691
|
-
this.send(ws, {
|
|
692
|
-
type: 'tabInitialized',
|
|
693
|
-
tabId,
|
|
694
|
-
data: session.getSessionInfo()
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
/**
|
|
698
|
-
* Reattach to an existing in-memory session.
|
|
699
|
-
* Sends output history (completed movements + in-progress events) for state restoration.
|
|
700
|
-
*/
|
|
701
|
-
reattachSession(session, ws, tabId, registry) {
|
|
702
|
-
this.setupSessionListeners(session, ws, tabId);
|
|
703
|
-
const tabMap = this.connections.get(ws);
|
|
704
|
-
const sessionId = session.getSessionInfo().sessionId;
|
|
705
|
-
if (tabMap)
|
|
706
|
-
tabMap.set(tabId, sessionId);
|
|
707
|
-
registry.touchTab(tabId);
|
|
708
|
-
// Build output history from completed movements
|
|
709
|
-
const outputHistory = this.buildOutputHistory(session);
|
|
710
|
-
// If currently executing, append in-progress events
|
|
711
|
-
const executionEvents = session.isExecuting
|
|
712
|
-
? session.getExecutionEventLog()
|
|
713
|
-
: undefined;
|
|
714
|
-
this.send(ws, {
|
|
715
|
-
type: 'tabInitialized',
|
|
716
|
-
tabId,
|
|
717
|
-
data: {
|
|
718
|
-
...session.getSessionInfo(),
|
|
719
|
-
outputHistory,
|
|
720
|
-
isExecuting: session.isExecuting,
|
|
721
|
-
executionEvents,
|
|
722
|
-
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
723
|
-
}
|
|
724
|
-
});
|
|
725
216
|
}
|
|
726
|
-
/**
|
|
727
|
-
* Build OutputLine-compatible history from a session's completed movements.
|
|
728
|
-
* Converts MovementRecords into the same format the web client uses for display.
|
|
729
|
-
*/
|
|
730
|
-
buildOutputHistory(session) {
|
|
731
|
-
const history = session.getHistory();
|
|
732
|
-
return history.movements.flatMap(convertMovementToLines);
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* Send a message to all connected clients EXCEPT the sender.
|
|
736
|
-
* Used for multi-client sync (e.g., tab created by one client, others should know).
|
|
737
|
-
*/
|
|
738
217
|
broadcastToOthers(sender, response) {
|
|
739
218
|
for (const ws of this.allConnections) {
|
|
740
219
|
if (ws !== sender) {
|
|
@@ -742,1755 +221,15 @@ export class WebSocketImproviseHandler {
|
|
|
742
221
|
}
|
|
743
222
|
}
|
|
744
223
|
}
|
|
745
|
-
/**
|
|
746
|
-
* Send a message to ALL connected clients (including sender).
|
|
747
|
-
*/
|
|
748
224
|
broadcastToAll(response) {
|
|
749
225
|
for (const ws of this.allConnections) {
|
|
750
226
|
this.send(ws, response);
|
|
751
227
|
}
|
|
752
228
|
}
|
|
753
|
-
// ========== Settings Handlers ==========
|
|
754
|
-
/**
|
|
755
|
-
* Return current machine-wide settings to the requesting client.
|
|
756
|
-
*/
|
|
757
|
-
handleGetSettings(ws) {
|
|
758
|
-
this.send(ws, { type: 'settings', data: getSettings() });
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Update settings and broadcast to all connected clients.
|
|
762
|
-
*/
|
|
763
|
-
handleUpdateSettings(_ws, msg) {
|
|
764
|
-
if (msg.data?.model !== undefined) {
|
|
765
|
-
setModel(msg.data.model);
|
|
766
|
-
}
|
|
767
|
-
this.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
|
|
768
|
-
}
|
|
769
|
-
/**
|
|
770
|
-
* Get session for a specific tab
|
|
771
|
-
*/
|
|
772
|
-
getSession(ws, tabId) {
|
|
773
|
-
const tabMap = this.connections.get(ws);
|
|
774
|
-
if (!tabMap)
|
|
775
|
-
return null;
|
|
776
|
-
const sessionId = tabMap.get(tabId);
|
|
777
|
-
if (!sessionId)
|
|
778
|
-
return null;
|
|
779
|
-
return this.sessions.get(sessionId) || null;
|
|
780
|
-
}
|
|
781
|
-
/**
|
|
782
|
-
* Handle connection close
|
|
783
|
-
* Note: Sessions are NOT destroyed — they persist for reconnection.
|
|
784
|
-
* Only the per-connection tab mapping is removed.
|
|
785
|
-
*/
|
|
786
|
-
handleClose(ws) {
|
|
787
|
-
this.connections.delete(ws);
|
|
788
|
-
this.allConnections.delete(ws);
|
|
789
|
-
// Remove ws from all terminal subscriber sets
|
|
790
|
-
for (const subs of this.terminalSubscribers.values()) {
|
|
791
|
-
subs.delete(ws);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
/**
|
|
795
|
-
* Send message to WebSocket client
|
|
796
|
-
*/
|
|
797
|
-
send(ws, response) {
|
|
798
|
-
try {
|
|
799
|
-
ws.send(JSON.stringify(response));
|
|
800
|
-
}
|
|
801
|
-
catch (error) {
|
|
802
|
-
console.error('[WebSocketImproviseHandler] Error sending message:', error);
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
/**
|
|
806
|
-
* Get count of all historical sessions without reading file contents
|
|
807
|
-
*/
|
|
808
|
-
getSessionsCount(workingDir) {
|
|
809
|
-
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
810
|
-
if (!existsSync(sessionsDir)) {
|
|
811
|
-
return 0;
|
|
812
|
-
}
|
|
813
|
-
return readdirSync(sessionsDir)
|
|
814
|
-
.filter((name) => name.endsWith('.json'))
|
|
815
|
-
.length;
|
|
816
|
-
}
|
|
817
|
-
/**
|
|
818
|
-
* Get paginated list of historical sessions from disk
|
|
819
|
-
* Returns minimal metadata - movements are stripped to just userPrompt preview
|
|
820
|
-
*/
|
|
821
|
-
getSessionsList(workingDir, limit = 20, offset = 0) {
|
|
822
|
-
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
823
|
-
if (!existsSync(sessionsDir)) {
|
|
824
|
-
return { sessions: [], total: 0, hasMore: false };
|
|
825
|
-
}
|
|
826
|
-
// Get sorted file list (newest first) without reading contents
|
|
827
|
-
const historyFiles = readdirSync(sessionsDir)
|
|
828
|
-
.filter((name) => name.endsWith('.json'))
|
|
829
|
-
.sort((a, b) => {
|
|
830
|
-
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
831
|
-
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
832
|
-
return timestampB - timestampA;
|
|
833
|
-
});
|
|
834
|
-
const total = historyFiles.length;
|
|
835
|
-
// Only read the files we need for this page
|
|
836
|
-
const pageFiles = historyFiles.slice(offset, offset + limit);
|
|
837
|
-
const sessions = pageFiles.map((filename) => {
|
|
838
|
-
const historyPath = join(sessionsDir, filename);
|
|
839
|
-
try {
|
|
840
|
-
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
841
|
-
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
842
|
-
// Return minimal metadata - only prompt previews, not full movement data
|
|
843
|
-
const movementPreviews = (historyData.movements || []).slice(0, 3).map((m) => ({
|
|
844
|
-
userPrompt: m.userPrompt?.slice(0, 100) || ''
|
|
845
|
-
}));
|
|
846
|
-
return {
|
|
847
|
-
sessionId: historyData.sessionId,
|
|
848
|
-
startedAt: historyData.startedAt,
|
|
849
|
-
lastActivityAt: historyData.lastActivityAt,
|
|
850
|
-
totalTokens: historyData.totalTokens,
|
|
851
|
-
movementCount: historyData.movements?.length || 0,
|
|
852
|
-
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
853
|
-
movements: movementPreviews
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
catch {
|
|
857
|
-
return null;
|
|
858
|
-
}
|
|
859
|
-
}).filter(Boolean);
|
|
860
|
-
return {
|
|
861
|
-
sessions,
|
|
862
|
-
total,
|
|
863
|
-
hasMore: offset + limit < total
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
/**
|
|
867
|
-
* Get a full session by ID (includes all movement data)
|
|
868
|
-
*/
|
|
869
|
-
getSessionById(workingDir, sessionId) {
|
|
870
|
-
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
871
|
-
if (!existsSync(sessionsDir)) {
|
|
872
|
-
return null;
|
|
873
|
-
}
|
|
874
|
-
const historyFiles = readdirSync(sessionsDir)
|
|
875
|
-
.filter((name) => name.endsWith('.json'));
|
|
876
|
-
for (const filename of historyFiles) {
|
|
877
|
-
const historyPath = join(sessionsDir, filename);
|
|
878
|
-
try {
|
|
879
|
-
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
880
|
-
if (historyData.sessionId === sessionId) {
|
|
881
|
-
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
882
|
-
return {
|
|
883
|
-
sessionId: historyData.sessionId,
|
|
884
|
-
startedAt: historyData.startedAt,
|
|
885
|
-
lastActivityAt: historyData.lastActivityAt,
|
|
886
|
-
totalTokens: historyData.totalTokens,
|
|
887
|
-
movementCount: historyData.movements?.length || 0,
|
|
888
|
-
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
889
|
-
movements: historyData.movements || [],
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
catch {
|
|
894
|
-
// Skip files that can't be parsed
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
return null;
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
|
-
* Delete a single session from disk
|
|
901
|
-
*/
|
|
902
|
-
deleteSession(workingDir, sessionId) {
|
|
903
|
-
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
904
|
-
if (!existsSync(sessionsDir)) {
|
|
905
|
-
return { sessionId, success: false };
|
|
906
|
-
}
|
|
907
|
-
try {
|
|
908
|
-
const historyFiles = readdirSync(sessionsDir)
|
|
909
|
-
.filter((name) => name.endsWith('.json'));
|
|
910
|
-
for (const filename of historyFiles) {
|
|
911
|
-
const historyPath = join(sessionsDir, filename);
|
|
912
|
-
try {
|
|
913
|
-
const historyData = JSON.parse(readFileSync(historyPath, 'utf-8'));
|
|
914
|
-
if (historyData.sessionId === sessionId) {
|
|
915
|
-
unlinkSync(historyPath);
|
|
916
|
-
return { sessionId, success: true };
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
catch {
|
|
920
|
-
// Skip files that can't be parsed
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
return { sessionId, success: false };
|
|
924
|
-
}
|
|
925
|
-
catch (error) {
|
|
926
|
-
console.error('[WebSocketImproviseHandler] Error deleting session:', error);
|
|
927
|
-
return { sessionId, success: false };
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
/**
|
|
931
|
-
* Clear all sessions from disk
|
|
932
|
-
*/
|
|
933
|
-
clearAllSessions(workingDir) {
|
|
934
|
-
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
935
|
-
if (!existsSync(sessionsDir)) {
|
|
936
|
-
return { success: true, deletedCount: 0 };
|
|
937
|
-
}
|
|
938
|
-
try {
|
|
939
|
-
const historyFiles = readdirSync(sessionsDir)
|
|
940
|
-
.filter((name) => name.endsWith('.json'));
|
|
941
|
-
let deletedCount = 0;
|
|
942
|
-
for (const filename of historyFiles) {
|
|
943
|
-
const historyPath = join(sessionsDir, filename);
|
|
944
|
-
try {
|
|
945
|
-
unlinkSync(historyPath);
|
|
946
|
-
deletedCount++;
|
|
947
|
-
}
|
|
948
|
-
catch {
|
|
949
|
-
// Skip files that can't be deleted
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
return { success: true, deletedCount };
|
|
953
|
-
}
|
|
954
|
-
catch (error) {
|
|
955
|
-
console.error('[WebSocketImproviseHandler] Error clearing sessions:', error);
|
|
956
|
-
return { success: false, deletedCount: 0 };
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
/**
|
|
960
|
-
* Search sessions using grep on the history directory
|
|
961
|
-
* Searches through session file contents for matching text
|
|
962
|
-
* Returns paginated results with minimal metadata
|
|
963
|
-
*/
|
|
964
|
-
movementMatchesQuery(movements, lowerQuery) {
|
|
965
|
-
if (!movements)
|
|
966
|
-
return false;
|
|
967
|
-
return movements.some((m) => m.userPrompt?.toLowerCase().includes(lowerQuery) ||
|
|
968
|
-
m.summary?.toLowerCase().includes(lowerQuery) ||
|
|
969
|
-
m.assistantResponse?.toLowerCase().includes(lowerQuery));
|
|
970
|
-
}
|
|
971
|
-
buildSessionSummary(historyData) {
|
|
972
|
-
const firstPrompt = historyData.movements?.[0]?.userPrompt || '';
|
|
973
|
-
const movementPreviews = (historyData.movements || []).slice(0, 3).map((m) => ({
|
|
974
|
-
userPrompt: m.userPrompt?.slice(0, 100) || ''
|
|
975
|
-
}));
|
|
976
|
-
return {
|
|
977
|
-
sessionId: historyData.sessionId,
|
|
978
|
-
startedAt: historyData.startedAt,
|
|
979
|
-
lastActivityAt: historyData.lastActivityAt,
|
|
980
|
-
totalTokens: historyData.totalTokens,
|
|
981
|
-
movementCount: historyData.movements?.length || 0,
|
|
982
|
-
title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
|
|
983
|
-
movements: movementPreviews
|
|
984
|
-
};
|
|
985
|
-
}
|
|
986
|
-
searchSessions(workingDir, query, limit = 20, offset = 0) {
|
|
987
|
-
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
988
|
-
if (!existsSync(sessionsDir)) {
|
|
989
|
-
return { sessions: [], total: 0, hasMore: false };
|
|
990
|
-
}
|
|
991
|
-
const lowerQuery = query.toLowerCase();
|
|
992
|
-
try {
|
|
993
|
-
const historyFiles = readdirSync(sessionsDir)
|
|
994
|
-
.filter((name) => name.endsWith('.json'))
|
|
995
|
-
.sort((a, b) => {
|
|
996
|
-
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
997
|
-
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
998
|
-
return timestampB - timestampA;
|
|
999
|
-
});
|
|
1000
|
-
const allMatches = [];
|
|
1001
|
-
for (const filename of historyFiles) {
|
|
1002
|
-
try {
|
|
1003
|
-
const content = readFileSync(join(sessionsDir, filename), 'utf-8');
|
|
1004
|
-
const historyData = JSON.parse(content);
|
|
1005
|
-
if (this.movementMatchesQuery(historyData.movements, lowerQuery)) {
|
|
1006
|
-
allMatches.push(this.buildSessionSummary(historyData));
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
catch {
|
|
1010
|
-
// Skip files that can't be parsed
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
const total = allMatches.length;
|
|
1014
|
-
return {
|
|
1015
|
-
sessions: allMatches.slice(offset, offset + limit),
|
|
1016
|
-
total,
|
|
1017
|
-
hasMore: offset + limit < total
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
catch (error) {
|
|
1021
|
-
console.error('[WebSocketImproviseHandler] Error searching sessions:', error);
|
|
1022
|
-
return { sessions: [], total: 0, hasMore: false };
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
/**
|
|
1026
|
-
* Cleanup session
|
|
1027
|
-
*/
|
|
1028
229
|
cleanupSession(sessionId) {
|
|
1029
230
|
this.sessions.delete(sessionId);
|
|
1030
231
|
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Clean up stale sessions
|
|
1033
|
-
*/
|
|
1034
232
|
cleanupStaleSessions() {
|
|
1035
233
|
}
|
|
1036
|
-
// ============================================
|
|
1037
|
-
// Session sync methods
|
|
1038
|
-
// ============================================
|
|
1039
|
-
/**
|
|
1040
|
-
* Handle getActiveTabs — returns all registered tabs and their state.
|
|
1041
|
-
* Used by new clients (multi-device, multi-browser) to discover existing tabs.
|
|
1042
|
-
*/
|
|
1043
|
-
handleGetActiveTabs(ws, workingDir) {
|
|
1044
|
-
const registry = this.getRegistry(workingDir);
|
|
1045
|
-
const allTabs = registry.getAllTabs();
|
|
1046
|
-
const tabs = {};
|
|
1047
|
-
for (const [tabId, regTab] of Object.entries(allTabs)) {
|
|
1048
|
-
const session = this.sessions.get(regTab.sessionId);
|
|
1049
|
-
if (session) {
|
|
1050
|
-
tabs[tabId] = {
|
|
1051
|
-
tabName: regTab.tabName,
|
|
1052
|
-
createdAt: regTab.createdAt,
|
|
1053
|
-
order: regTab.order,
|
|
1054
|
-
hasUnviewedCompletion: regTab.hasUnviewedCompletion,
|
|
1055
|
-
sessionInfo: session.getSessionInfo(),
|
|
1056
|
-
isExecuting: session.isExecuting,
|
|
1057
|
-
outputHistory: this.buildOutputHistory(session),
|
|
1058
|
-
executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
|
|
1059
|
-
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
else {
|
|
1063
|
-
// Session not in memory — try to provide basic info from registry
|
|
1064
|
-
tabs[tabId] = {
|
|
1065
|
-
tabName: regTab.tabName,
|
|
1066
|
-
createdAt: regTab.createdAt,
|
|
1067
|
-
order: regTab.order,
|
|
1068
|
-
hasUnviewedCompletion: regTab.hasUnviewedCompletion,
|
|
1069
|
-
sessionId: regTab.sessionId,
|
|
1070
|
-
isExecuting: false,
|
|
1071
|
-
outputHistory: [],
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
this.send(ws, { type: 'activeTabs', data: { tabs } });
|
|
1076
|
-
}
|
|
1077
|
-
/**
|
|
1078
|
-
* Handle syncTabMeta — update tab metadata (name) from a client.
|
|
1079
|
-
*/
|
|
1080
|
-
handleSyncTabMeta(_ws, msg, tabId, workingDir) {
|
|
1081
|
-
const registry = this.getRegistry(workingDir);
|
|
1082
|
-
if (msg.data?.tabName) {
|
|
1083
|
-
registry.updateTabName(tabId, msg.data.tabName);
|
|
1084
|
-
// Broadcast rename to all clients (relay handles fan-out)
|
|
1085
|
-
this.broadcastToAll({
|
|
1086
|
-
type: 'tabRenamed',
|
|
1087
|
-
data: { tabId, tabName: msg.data.tabName }
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
/**
|
|
1092
|
-
* Handle syncPromptText — relay prompt text changes to all clients.
|
|
1093
|
-
* Ephemeral: not persisted, just broadcast for live collaboration.
|
|
1094
|
-
*/
|
|
1095
|
-
handleSyncPromptText(_ws, msg, tabId) {
|
|
1096
|
-
if (typeof msg.data?.text !== 'string')
|
|
1097
|
-
return;
|
|
1098
|
-
this.broadcastToAll({
|
|
1099
|
-
type: 'promptTextSync',
|
|
1100
|
-
tabId,
|
|
1101
|
-
data: { tabId, text: msg.data.text }
|
|
1102
|
-
});
|
|
1103
|
-
}
|
|
1104
|
-
/**
|
|
1105
|
-
* Handle removeTab — client is removing a tab.
|
|
1106
|
-
*/
|
|
1107
|
-
handleRemoveTab(_ws, tabId, workingDir) {
|
|
1108
|
-
const registry = this.getRegistry(workingDir);
|
|
1109
|
-
registry.unregisterTab(tabId);
|
|
1110
|
-
// Broadcast to all clients (broadcastToAll ensures relay-connected clients receive it)
|
|
1111
|
-
this.broadcastToAll({
|
|
1112
|
-
type: 'tabRemoved',
|
|
1113
|
-
data: { tabId }
|
|
1114
|
-
});
|
|
1115
|
-
}
|
|
1116
|
-
/**
|
|
1117
|
-
* Handle markTabViewed — a client has viewed a tab's completed output.
|
|
1118
|
-
* Persists viewed state and broadcasts to all clients so the green dot
|
|
1119
|
-
* disappears on every device.
|
|
1120
|
-
*/
|
|
1121
|
-
handleMarkTabViewed(_ws, tabId, workingDir) {
|
|
1122
|
-
const registry = this.getRegistry(workingDir);
|
|
1123
|
-
registry.markTabViewed(tabId);
|
|
1124
|
-
this.broadcastToAll({
|
|
1125
|
-
type: 'tabViewed',
|
|
1126
|
-
data: { tabId }
|
|
1127
|
-
});
|
|
1128
|
-
}
|
|
1129
|
-
/**
|
|
1130
|
-
* Handle createTab — CLI registers the tab and broadcasts to all clients.
|
|
1131
|
-
*
|
|
1132
|
-
* When optimisticTabId is provided, CLI reuses that ID as the authoritative tab ID.
|
|
1133
|
-
* The requesting client already created a local tab with this ID (optimistic UI),
|
|
1134
|
-
* so there's no reconciliation needed — the tab ID is the same everywhere.
|
|
1135
|
-
* The initTab flow (useTabInit) will handle session creation for the requesting client.
|
|
1136
|
-
*
|
|
1137
|
-
* Other clients that don't have this tab will add it via the tabCreated broadcast.
|
|
1138
|
-
*/
|
|
1139
|
-
async handleCreateTab(ws, workingDir, tabName, optimisticTabId) {
|
|
1140
|
-
const registry = this.getRegistry(workingDir);
|
|
1141
|
-
// Use the client's optimistic ID when available — avoids reconciliation.
|
|
1142
|
-
// Fall back to server-generated ID if no optimistic ID provided.
|
|
1143
|
-
const tabId = optimisticTabId || `tab-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1144
|
-
// Check if this tab was already registered by initTab (race: useTabInit fires first)
|
|
1145
|
-
const existingSession = registry.getTabSession(tabId);
|
|
1146
|
-
if (existingSession) {
|
|
1147
|
-
// Tab already initialized — broadcast to all clients.
|
|
1148
|
-
// Must use broadcastToAll because all web clients share a single
|
|
1149
|
-
// platformRelayContext — broadcastToOthers would skip the relay entirely,
|
|
1150
|
-
// preventing other browser instances from discovering the new tab.
|
|
1151
|
-
const regTab = registry.getTab(tabId);
|
|
1152
|
-
this.broadcastToAll({
|
|
1153
|
-
type: 'tabCreated',
|
|
1154
|
-
data: {
|
|
1155
|
-
tabId,
|
|
1156
|
-
tabName: regTab?.tabName || 'Chat',
|
|
1157
|
-
createdAt: regTab?.createdAt,
|
|
1158
|
-
order: regTab?.order,
|
|
1159
|
-
sessionInfo: this.sessions.get(existingSession)?.getSessionInfo(),
|
|
1160
|
-
}
|
|
1161
|
-
});
|
|
1162
|
-
return;
|
|
1163
|
-
}
|
|
1164
|
-
// Create new session and register
|
|
1165
|
-
const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
|
|
1166
|
-
this.setupSessionListeners(session, ws, tabId);
|
|
1167
|
-
const sessionId = session.getSessionInfo().sessionId;
|
|
1168
|
-
this.sessions.set(sessionId, session);
|
|
1169
|
-
const tabMap = this.connections.get(ws);
|
|
1170
|
-
if (tabMap)
|
|
1171
|
-
tabMap.set(tabId, sessionId);
|
|
1172
|
-
registry.registerTab(tabId, sessionId, tabName);
|
|
1173
|
-
const registeredTab = registry.getTab(tabId);
|
|
1174
|
-
// Broadcast to ALL clients — the requesting client already has the tab
|
|
1175
|
-
// (optimistic UI) and will ignore the duplicate via !currentTabs.has(tabId).
|
|
1176
|
-
// Must use broadcastToAll so other browser instances via the shared
|
|
1177
|
-
// platformRelayContext receive the tabCreated event.
|
|
1178
|
-
this.broadcastToAll({
|
|
1179
|
-
type: 'tabCreated',
|
|
1180
|
-
data: {
|
|
1181
|
-
tabId,
|
|
1182
|
-
tabName: registeredTab?.tabName || 'Chat',
|
|
1183
|
-
createdAt: registeredTab?.createdAt,
|
|
1184
|
-
order: registeredTab?.order,
|
|
1185
|
-
sessionInfo: session.getSessionInfo(),
|
|
1186
|
-
}
|
|
1187
|
-
});
|
|
1188
|
-
// Send tabInitialized to the requesting client so useTabInit resolves
|
|
1189
|
-
this.send(ws, {
|
|
1190
|
-
type: 'tabInitialized',
|
|
1191
|
-
tabId,
|
|
1192
|
-
data: session.getSessionInfo()
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
/**
|
|
1196
|
-
* Handle reorderTabs — client is reordering tabs.
|
|
1197
|
-
*/
|
|
1198
|
-
handleReorderTabs(_ws, workingDir, tabOrder) {
|
|
1199
|
-
if (!Array.isArray(tabOrder))
|
|
1200
|
-
return;
|
|
1201
|
-
const registry = this.getRegistry(workingDir);
|
|
1202
|
-
registry.reorderTabs(tabOrder);
|
|
1203
|
-
// Build order mapping for broadcast
|
|
1204
|
-
const allTabs = registry.getAllTabs();
|
|
1205
|
-
const orderMap = tabOrder
|
|
1206
|
-
.filter((id) => allTabs[id])
|
|
1207
|
-
.map((id) => ({ tabId: id, order: allTabs[id].order }));
|
|
1208
|
-
this.broadcastToAll({
|
|
1209
|
-
type: 'tabsReordered',
|
|
1210
|
-
data: { tabOrder: orderMap }
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
|
-
* Generate a notification summary using Claude Haiku
|
|
1215
|
-
* Sends the result as a notificationSummary message
|
|
1216
|
-
*/
|
|
1217
|
-
async generateNotificationSummary(ws, tabId, userPrompt, output, workingDir) {
|
|
1218
|
-
try {
|
|
1219
|
-
// Create temp directory if it doesn't exist
|
|
1220
|
-
const tempDir = join(workingDir, '.mstro', 'tmp');
|
|
1221
|
-
if (!existsSync(tempDir)) {
|
|
1222
|
-
mkdirSync(tempDir, { recursive: true });
|
|
1223
|
-
}
|
|
1224
|
-
// Truncate output if too long (keep first and last parts for context)
|
|
1225
|
-
let truncatedOutput = output;
|
|
1226
|
-
if (output.length > 4000) {
|
|
1227
|
-
const firstPart = output.slice(0, 2000);
|
|
1228
|
-
const lastPart = output.slice(-1500);
|
|
1229
|
-
truncatedOutput = `${firstPart}\n\n... [output truncated] ...\n\n${lastPart}`;
|
|
1230
|
-
}
|
|
1231
|
-
// Build the prompt for summary generation
|
|
1232
|
-
const summaryPrompt = `You are generating a SHORT browser notification summary for a completed task.
|
|
1233
|
-
The user ran a task and wants a brief notification to remind them what happened.
|
|
1234
|
-
|
|
1235
|
-
USER'S ORIGINAL PROMPT:
|
|
1236
|
-
"${userPrompt}"
|
|
1237
|
-
|
|
1238
|
-
TASK OUTPUT (may be truncated):
|
|
1239
|
-
${truncatedOutput}
|
|
1240
|
-
|
|
1241
|
-
Generate a notification summary following these rules:
|
|
1242
|
-
1. Maximum 100 characters (this is a browser notification)
|
|
1243
|
-
2. Focus on the OUTCOME, not the process
|
|
1244
|
-
3. Be specific about what was accomplished
|
|
1245
|
-
4. Use past tense (e.g., "Fixed bug in auth.ts", "Added 3 new tests")
|
|
1246
|
-
5. If there was an error, mention it briefly
|
|
1247
|
-
6. No emojis, no markdown, just plain text
|
|
1248
|
-
|
|
1249
|
-
Respond with ONLY the summary text, nothing else.`;
|
|
1250
|
-
// Write prompt to temp file
|
|
1251
|
-
const promptFile = join(tempDir, `notif-summary-${Date.now()}.txt`);
|
|
1252
|
-
writeFileSync(promptFile, summaryPrompt);
|
|
1253
|
-
const systemPrompt = 'You are a notification summary assistant. Respond with only the summary text, no preamble or explanation.';
|
|
1254
|
-
const args = [
|
|
1255
|
-
'--print',
|
|
1256
|
-
'--model', 'haiku',
|
|
1257
|
-
'--system-prompt', systemPrompt,
|
|
1258
|
-
promptFile
|
|
1259
|
-
];
|
|
1260
|
-
const claude = spawn('claude', args, {
|
|
1261
|
-
cwd: workingDir,
|
|
1262
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
1263
|
-
});
|
|
1264
|
-
let stdout = '';
|
|
1265
|
-
let stderr = '';
|
|
1266
|
-
claude.stdout?.on('data', (data) => {
|
|
1267
|
-
stdout += data.toString();
|
|
1268
|
-
});
|
|
1269
|
-
claude.stderr?.on('data', (data) => {
|
|
1270
|
-
stderr += data.toString();
|
|
1271
|
-
});
|
|
1272
|
-
claude.on('close', (code) => {
|
|
1273
|
-
// Clean up temp file
|
|
1274
|
-
try {
|
|
1275
|
-
unlinkSync(promptFile);
|
|
1276
|
-
}
|
|
1277
|
-
catch {
|
|
1278
|
-
// Ignore cleanup errors
|
|
1279
|
-
}
|
|
1280
|
-
let summary;
|
|
1281
|
-
if (code === 0 && stdout.trim()) {
|
|
1282
|
-
// Truncate if somehow still too long
|
|
1283
|
-
summary = stdout.trim().slice(0, 150);
|
|
1284
|
-
}
|
|
1285
|
-
else {
|
|
1286
|
-
console.error('[WebSocketImproviseHandler] Claude error:', stderr || 'Unknown error');
|
|
1287
|
-
// Fallback to basic summary
|
|
1288
|
-
summary = this.createFallbackSummary(userPrompt);
|
|
1289
|
-
}
|
|
1290
|
-
this.send(ws, {
|
|
1291
|
-
type: 'notificationSummary',
|
|
1292
|
-
tabId,
|
|
1293
|
-
data: { summary }
|
|
1294
|
-
});
|
|
1295
|
-
});
|
|
1296
|
-
claude.on('error', (err) => {
|
|
1297
|
-
console.error('[WebSocketImproviseHandler] Failed to spawn Claude:', err);
|
|
1298
|
-
const summary = this.createFallbackSummary(userPrompt);
|
|
1299
|
-
this.send(ws, {
|
|
1300
|
-
type: 'notificationSummary',
|
|
1301
|
-
tabId,
|
|
1302
|
-
data: { summary }
|
|
1303
|
-
});
|
|
1304
|
-
});
|
|
1305
|
-
// Timeout after 10 seconds
|
|
1306
|
-
setTimeout(() => {
|
|
1307
|
-
claude.kill();
|
|
1308
|
-
const summary = this.createFallbackSummary(userPrompt);
|
|
1309
|
-
this.send(ws, {
|
|
1310
|
-
type: 'notificationSummary',
|
|
1311
|
-
tabId,
|
|
1312
|
-
data: { summary }
|
|
1313
|
-
});
|
|
1314
|
-
}, 10000);
|
|
1315
|
-
}
|
|
1316
|
-
catch (error) {
|
|
1317
|
-
console.error('[WebSocketImproviseHandler] Error generating summary:', error);
|
|
1318
|
-
const summary = this.createFallbackSummary(userPrompt);
|
|
1319
|
-
this.send(ws, {
|
|
1320
|
-
type: 'notificationSummary',
|
|
1321
|
-
tabId,
|
|
1322
|
-
data: { summary }
|
|
1323
|
-
});
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
/**
|
|
1327
|
-
* Create a fallback summary when AI summarization fails
|
|
1328
|
-
*/
|
|
1329
|
-
createFallbackSummary(userPrompt) {
|
|
1330
|
-
const truncated = userPrompt.slice(0, 60);
|
|
1331
|
-
if (userPrompt.length > 60) {
|
|
1332
|
-
return `Completed: "${truncated}..."`;
|
|
1333
|
-
}
|
|
1334
|
-
return `Completed: "${truncated}"`;
|
|
1335
|
-
}
|
|
1336
|
-
// ============================================
|
|
1337
|
-
// Git handling methods
|
|
1338
|
-
// ============================================
|
|
1339
|
-
/**
|
|
1340
|
-
* Handle git-related messages
|
|
1341
|
-
*/
|
|
1342
|
-
handleGitMessage(ws, msg, tabId, workingDir) {
|
|
1343
|
-
// Get the effective git directory (selected or working dir)
|
|
1344
|
-
const gitDir = this.gitDirectories.get(tabId) || workingDir;
|
|
1345
|
-
const handlers = {
|
|
1346
|
-
gitStatus: () => this.handleGitStatus(ws, tabId, gitDir),
|
|
1347
|
-
gitStage: () => this.handleGitStage(ws, msg, tabId, gitDir),
|
|
1348
|
-
gitUnstage: () => this.handleGitUnstage(ws, msg, tabId, gitDir),
|
|
1349
|
-
gitCommit: () => this.handleGitCommit(ws, msg, tabId, gitDir),
|
|
1350
|
-
gitCommitWithAI: () => this.handleGitCommitWithAI(ws, msg, tabId, gitDir),
|
|
1351
|
-
gitPush: () => this.handleGitPush(ws, tabId, gitDir),
|
|
1352
|
-
gitLog: () => this.handleGitLog(ws, msg, tabId, gitDir),
|
|
1353
|
-
gitDiscoverRepos: () => this.handleGitDiscoverRepos(ws, tabId, workingDir),
|
|
1354
|
-
gitSetDirectory: () => this.handleGitSetDirectory(ws, msg, tabId, workingDir),
|
|
1355
|
-
gitGetRemoteInfo: () => this.handleGitGetRemoteInfo(ws, tabId, gitDir),
|
|
1356
|
-
gitCreatePR: () => this.handleGitCreatePR(ws, msg, tabId, gitDir),
|
|
1357
|
-
gitGeneratePRDescription: () => this.handleGitGeneratePRDescription(ws, msg, tabId, gitDir),
|
|
1358
|
-
};
|
|
1359
|
-
handlers[msg.type]?.();
|
|
1360
|
-
}
|
|
1361
|
-
/**
|
|
1362
|
-
* Execute a git command and return stdout
|
|
1363
|
-
*/
|
|
1364
|
-
executeGitCommand(args, workingDir) {
|
|
1365
|
-
return new Promise((resolve) => {
|
|
1366
|
-
const git = spawn('git', args, {
|
|
1367
|
-
cwd: workingDir,
|
|
1368
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
1369
|
-
});
|
|
1370
|
-
let stdout = '';
|
|
1371
|
-
let stderr = '';
|
|
1372
|
-
git.stdout?.on('data', (data) => {
|
|
1373
|
-
stdout += data.toString();
|
|
1374
|
-
});
|
|
1375
|
-
git.stderr?.on('data', (data) => {
|
|
1376
|
-
stderr += data.toString();
|
|
1377
|
-
});
|
|
1378
|
-
git.on('close', (code) => {
|
|
1379
|
-
resolve({ stdout, stderr, exitCode: code ?? 1 });
|
|
1380
|
-
});
|
|
1381
|
-
git.on('error', (err) => {
|
|
1382
|
-
resolve({ stdout: '', stderr: err.message, exitCode: 1 });
|
|
1383
|
-
});
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
|
-
/** Map of simple escape sequences to their character values */
|
|
1387
|
-
static ESCAPE_CHARS = {
|
|
1388
|
-
'\\': '\\',
|
|
1389
|
-
'"': '"',
|
|
1390
|
-
'n': '\n',
|
|
1391
|
-
't': '\t',
|
|
1392
|
-
'r': '\r',
|
|
1393
|
-
};
|
|
1394
|
-
/**
|
|
1395
|
-
* Unquote a git-quoted path (C-style quoting)
|
|
1396
|
-
* Git quotes paths containing spaces, special chars, or non-ASCII with double quotes
|
|
1397
|
-
* and uses backslash escapes inside (e.g., \", \\, \n, \t, \nnn for octal)
|
|
1398
|
-
*/
|
|
1399
|
-
unquoteGitPath(path) {
|
|
1400
|
-
// If not quoted, return as-is
|
|
1401
|
-
if (!path.startsWith('"') || !path.endsWith('"')) {
|
|
1402
|
-
return path;
|
|
1403
|
-
}
|
|
1404
|
-
// Remove surrounding quotes and process escape sequences
|
|
1405
|
-
const inner = path.slice(1, -1);
|
|
1406
|
-
let result = '';
|
|
1407
|
-
let i = 0;
|
|
1408
|
-
while (i < inner.length) {
|
|
1409
|
-
if (inner[i] !== '\\' || i + 1 >= inner.length) {
|
|
1410
|
-
result += inner[i];
|
|
1411
|
-
i++;
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
const next = inner[i + 1];
|
|
1415
|
-
const escaped = WebSocketImproviseHandler.ESCAPE_CHARS[next];
|
|
1416
|
-
if (escaped !== undefined) {
|
|
1417
|
-
result += escaped;
|
|
1418
|
-
i += 2;
|
|
1419
|
-
}
|
|
1420
|
-
else if (this.isOctalEscape(inner, i)) {
|
|
1421
|
-
result += String.fromCharCode(parseInt(inner.slice(i + 1, i + 4), 8));
|
|
1422
|
-
i += 4;
|
|
1423
|
-
}
|
|
1424
|
-
else {
|
|
1425
|
-
result += inner[i];
|
|
1426
|
-
i++;
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
return result;
|
|
1430
|
-
}
|
|
1431
|
-
/** Check if position i starts an octal escape sequence (\nnn) */
|
|
1432
|
-
isOctalEscape(str, i) {
|
|
1433
|
-
return i + 3 < str.length &&
|
|
1434
|
-
/[0-7]/.test(str[i + 1]) &&
|
|
1435
|
-
/[0-7]{2}/.test(str.slice(i + 2, i + 4));
|
|
1436
|
-
}
|
|
1437
|
-
/**
|
|
1438
|
-
* Parse git status --porcelain output into structured format
|
|
1439
|
-
*/
|
|
1440
|
-
parseGitStatus(porcelainOutput) {
|
|
1441
|
-
const staged = [];
|
|
1442
|
-
const unstaged = [];
|
|
1443
|
-
const untracked = [];
|
|
1444
|
-
const lines = porcelainOutput.split('\n').filter(line => line.length >= 4);
|
|
1445
|
-
for (const line of lines) {
|
|
1446
|
-
const indexStatus = line[0];
|
|
1447
|
-
const workTreeStatus = line[1];
|
|
1448
|
-
const rawPath = line.slice(3);
|
|
1449
|
-
// Unquote the path (git quotes paths with spaces/special chars)
|
|
1450
|
-
const path = this.unquoteGitPath(rawPath);
|
|
1451
|
-
// Handle renamed files (format: "R old -> new" or R "old" -> "new")
|
|
1452
|
-
let filePath = path;
|
|
1453
|
-
let originalPath;
|
|
1454
|
-
if (rawPath.includes(' -> ')) {
|
|
1455
|
-
const parts = rawPath.split(' -> ');
|
|
1456
|
-
originalPath = this.unquoteGitPath(parts[0]);
|
|
1457
|
-
filePath = this.unquoteGitPath(parts[1]);
|
|
1458
|
-
}
|
|
1459
|
-
// Untracked files
|
|
1460
|
-
if (indexStatus === '?' && workTreeStatus === '?') {
|
|
1461
|
-
untracked.push({
|
|
1462
|
-
path: filePath,
|
|
1463
|
-
status: '?',
|
|
1464
|
-
staged: false,
|
|
1465
|
-
});
|
|
1466
|
-
continue;
|
|
1467
|
-
}
|
|
1468
|
-
// Staged changes (index has changes)
|
|
1469
|
-
if (indexStatus !== ' ' && indexStatus !== '?') {
|
|
1470
|
-
staged.push({
|
|
1471
|
-
path: filePath,
|
|
1472
|
-
status: indexStatus,
|
|
1473
|
-
staged: true,
|
|
1474
|
-
originalPath,
|
|
1475
|
-
});
|
|
1476
|
-
}
|
|
1477
|
-
// Unstaged changes (worktree has changes)
|
|
1478
|
-
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
|
1479
|
-
unstaged.push({
|
|
1480
|
-
path: filePath,
|
|
1481
|
-
status: workTreeStatus,
|
|
1482
|
-
staged: false,
|
|
1483
|
-
originalPath,
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
return { staged, unstaged, untracked };
|
|
1488
|
-
}
|
|
1489
|
-
/**
|
|
1490
|
-
* Handle git status request
|
|
1491
|
-
*/
|
|
1492
|
-
async handleGitStatus(ws, tabId, workingDir) {
|
|
1493
|
-
try {
|
|
1494
|
-
// Get porcelain status
|
|
1495
|
-
const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
|
|
1496
|
-
if (statusResult.exitCode !== 0) {
|
|
1497
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: statusResult.stderr || statusResult.stdout || 'Failed to get git status' } });
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
// Get current branch
|
|
1501
|
-
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
1502
|
-
const branch = branchResult.stdout.trim() || 'HEAD';
|
|
1503
|
-
// Get ahead/behind counts and upstream tracking info
|
|
1504
|
-
let ahead = 0;
|
|
1505
|
-
let behind = 0;
|
|
1506
|
-
let hasUpstream = false;
|
|
1507
|
-
const trackingResult = await this.executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
|
|
1508
|
-
if (trackingResult.exitCode === 0) {
|
|
1509
|
-
hasUpstream = true;
|
|
1510
|
-
const parts = trackingResult.stdout.trim().split(/\s+/);
|
|
1511
|
-
ahead = parseInt(parts[0], 10) || 0;
|
|
1512
|
-
behind = parseInt(parts[1], 10) || 0;
|
|
1513
|
-
}
|
|
1514
|
-
else {
|
|
1515
|
-
// No upstream - count local commits as ahead
|
|
1516
|
-
const localResult = await this.executeGitCommand(['rev-list', '--count', 'HEAD'], workingDir);
|
|
1517
|
-
if (localResult.exitCode === 0) {
|
|
1518
|
-
ahead = parseInt(localResult.stdout.trim(), 10) || 0;
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
const { staged, unstaged, untracked } = this.parseGitStatus(statusResult.stdout);
|
|
1522
|
-
const response = {
|
|
1523
|
-
branch,
|
|
1524
|
-
isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0,
|
|
1525
|
-
staged,
|
|
1526
|
-
unstaged,
|
|
1527
|
-
untracked,
|
|
1528
|
-
ahead,
|
|
1529
|
-
behind,
|
|
1530
|
-
hasUpstream,
|
|
1531
|
-
};
|
|
1532
|
-
this.send(ws, { type: 'gitStatus', tabId, data: response });
|
|
1533
|
-
}
|
|
1534
|
-
catch (error) {
|
|
1535
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
/**
|
|
1539
|
-
* Handle git stage request
|
|
1540
|
-
*/
|
|
1541
|
-
async handleGitStage(ws, msg, tabId, workingDir) {
|
|
1542
|
-
const stageAll = !!msg.data?.stageAll;
|
|
1543
|
-
const paths = msg.data?.paths;
|
|
1544
|
-
if (!stageAll && (!paths || paths.length === 0)) {
|
|
1545
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for staging' } });
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
try {
|
|
1549
|
-
// Use `git add -A` for staging all (handles new, modified, and deleted files reliably)
|
|
1550
|
-
// Use `git add -- ...paths` for staging specific files
|
|
1551
|
-
const args = stageAll ? ['add', '-A'] : ['add', '--', ...paths];
|
|
1552
|
-
const result = await this.executeGitCommand(args, workingDir);
|
|
1553
|
-
if (result.exitCode !== 0) {
|
|
1554
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to stage files' } });
|
|
1555
|
-
return;
|
|
1556
|
-
}
|
|
1557
|
-
this.send(ws, { type: 'gitStaged', tabId, data: { paths: paths || [] } });
|
|
1558
|
-
}
|
|
1559
|
-
catch (error) {
|
|
1560
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
/**
|
|
1564
|
-
* Handle git unstage request
|
|
1565
|
-
*/
|
|
1566
|
-
async handleGitUnstage(ws, msg, tabId, workingDir) {
|
|
1567
|
-
const paths = msg.data?.paths;
|
|
1568
|
-
if (!paths || paths.length === 0) {
|
|
1569
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for unstaging' } });
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
try {
|
|
1573
|
-
const result = await this.executeGitCommand(['reset', 'HEAD', '--', ...paths], workingDir);
|
|
1574
|
-
if (result.exitCode !== 0) {
|
|
1575
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to unstage files' } });
|
|
1576
|
-
return;
|
|
1577
|
-
}
|
|
1578
|
-
this.send(ws, { type: 'gitUnstaged', tabId, data: { paths } });
|
|
1579
|
-
}
|
|
1580
|
-
catch (error) {
|
|
1581
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
/**
|
|
1585
|
-
* Handle git commit request (with user-provided message)
|
|
1586
|
-
*/
|
|
1587
|
-
async handleGitCommit(ws, msg, tabId, workingDir) {
|
|
1588
|
-
const message = msg.data?.message;
|
|
1589
|
-
if (!message) {
|
|
1590
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Commit message is required' } });
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
try {
|
|
1594
|
-
// Commit all staged changes directly - no pre-check to avoid race conditions
|
|
1595
|
-
const result = await this.executeGitCommand(['commit', '-m', message], workingDir);
|
|
1596
|
-
if (result.exitCode !== 0) {
|
|
1597
|
-
let errorMsg = result.stderr || result.stdout || 'Failed to commit';
|
|
1598
|
-
if (errorMsg.includes('nothing to commit') || errorMsg.includes('no changes added')) {
|
|
1599
|
-
errorMsg = 'No changes staged for commit. Use "Stage" to add files before committing.';
|
|
1600
|
-
// Refresh status to sync UI
|
|
1601
|
-
this.handleGitStatus(ws, tabId, workingDir);
|
|
1602
|
-
}
|
|
1603
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: errorMsg } });
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
// Get the new commit hash
|
|
1607
|
-
const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
|
|
1608
|
-
const hash = hashResult.stdout.trim();
|
|
1609
|
-
this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message } });
|
|
1610
|
-
// Proactively send updated status so the UI reflects new ahead/behind counts
|
|
1611
|
-
this.handleGitStatus(ws, tabId, workingDir);
|
|
1612
|
-
}
|
|
1613
|
-
catch (error) {
|
|
1614
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
/**
|
|
1618
|
-
* Handle git commit with AI-generated message
|
|
1619
|
-
* Uses Claude Code to analyze staged changes and generate a commit message
|
|
1620
|
-
*/
|
|
1621
|
-
async handleGitCommitWithAI(ws, msg, tabId, workingDir) {
|
|
1622
|
-
try {
|
|
1623
|
-
// First check if there are staged changes
|
|
1624
|
-
const statusResult = await this.executeGitCommand(['status', '--porcelain=v1'], workingDir);
|
|
1625
|
-
const { staged } = this.parseGitStatus(statusResult.stdout);
|
|
1626
|
-
if (staged.length === 0) {
|
|
1627
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'No staged changes to commit' } });
|
|
1628
|
-
return;
|
|
1629
|
-
}
|
|
1630
|
-
// Get the diff of staged changes
|
|
1631
|
-
const diffResult = await this.executeGitCommand(['diff', '--cached'], workingDir);
|
|
1632
|
-
const diff = diffResult.stdout;
|
|
1633
|
-
// Get recent commit messages for style reference
|
|
1634
|
-
const logResult = await this.executeGitCommand(['log', '--oneline', '-5'], workingDir);
|
|
1635
|
-
const recentCommits = logResult.stdout.trim();
|
|
1636
|
-
// Create temp directory if it doesn't exist
|
|
1637
|
-
const tempDir = join(workingDir, '.mstro', 'tmp');
|
|
1638
|
-
if (!existsSync(tempDir)) {
|
|
1639
|
-
mkdirSync(tempDir, { recursive: true });
|
|
1640
|
-
}
|
|
1641
|
-
// Truncate diff if too long
|
|
1642
|
-
let truncatedDiff = diff;
|
|
1643
|
-
if (diff.length > 8000) {
|
|
1644
|
-
truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
|
|
1645
|
-
}
|
|
1646
|
-
// Build prompt for commit message generation
|
|
1647
|
-
const prompt = `You are generating a git commit message for the following staged changes.
|
|
1648
|
-
|
|
1649
|
-
RECENT COMMIT MESSAGES (for style reference):
|
|
1650
|
-
${recentCommits || 'No recent commits'}
|
|
1651
|
-
|
|
1652
|
-
STAGED FILES:
|
|
1653
|
-
${staged.map(f => `${f.status} ${f.path}`).join('\n')}
|
|
1654
|
-
|
|
1655
|
-
DIFF OF STAGED CHANGES:
|
|
1656
|
-
${truncatedDiff}
|
|
1657
|
-
|
|
1658
|
-
Generate a commit message following these rules:
|
|
1659
|
-
1. First line: imperative mood, max 72 characters (e.g., "Add user authentication", "Fix memory leak in parser")
|
|
1660
|
-
2. If the changes are complex, add a blank line then bullet points explaining the key changes
|
|
1661
|
-
3. Focus on the "why" not just the "what"
|
|
1662
|
-
4. Match the style of recent commits if possible
|
|
1663
|
-
5. No emojis unless the repo already uses them
|
|
1664
|
-
|
|
1665
|
-
Respond with ONLY the commit message, nothing else.`;
|
|
1666
|
-
// Write prompt to temp file
|
|
1667
|
-
const promptFile = join(tempDir, `commit-msg-${Date.now()}.txt`);
|
|
1668
|
-
writeFileSync(promptFile, prompt);
|
|
1669
|
-
const systemPrompt = 'You are a commit message assistant. Respond with only the commit message, no preamble or explanation.';
|
|
1670
|
-
const args = [
|
|
1671
|
-
'--print',
|
|
1672
|
-
'--model', 'haiku',
|
|
1673
|
-
'--system-prompt', systemPrompt,
|
|
1674
|
-
promptFile
|
|
1675
|
-
];
|
|
1676
|
-
const claude = spawn('claude', args, {
|
|
1677
|
-
cwd: workingDir,
|
|
1678
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
1679
|
-
});
|
|
1680
|
-
let stdout = '';
|
|
1681
|
-
let stderr = '';
|
|
1682
|
-
claude.stdout?.on('data', (data) => {
|
|
1683
|
-
stdout += data.toString();
|
|
1684
|
-
});
|
|
1685
|
-
claude.stderr?.on('data', (data) => {
|
|
1686
|
-
stderr += data.toString();
|
|
1687
|
-
});
|
|
1688
|
-
claude.on('close', async (code) => {
|
|
1689
|
-
// Clean up temp file
|
|
1690
|
-
try {
|
|
1691
|
-
unlinkSync(promptFile);
|
|
1692
|
-
}
|
|
1693
|
-
catch {
|
|
1694
|
-
// Ignore cleanup errors
|
|
1695
|
-
}
|
|
1696
|
-
if (code !== 0 || !stdout.trim()) {
|
|
1697
|
-
console.error('[WebSocketImproviseHandler] Claude commit message error:', stderr || 'No output');
|
|
1698
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
|
|
1699
|
-
return;
|
|
1700
|
-
}
|
|
1701
|
-
// Post-process to extract just the commit message
|
|
1702
|
-
// Claude sometimes outputs reasoning before the actual message
|
|
1703
|
-
const commitMessage = this.extractCommitMessage(stdout.trim());
|
|
1704
|
-
const autoCommit = !!msg.data?.autoCommit;
|
|
1705
|
-
// Send the generated message for preview (include autoCommit flag so frontend knows if commit is pending)
|
|
1706
|
-
this.send(ws, { type: 'gitCommitMessage', tabId, data: { message: commitMessage, autoCommit } });
|
|
1707
|
-
// If autoCommit is true, proceed with the commit
|
|
1708
|
-
if (msg.data?.autoCommit) {
|
|
1709
|
-
const commitResult = await this.executeGitCommand(['commit', '-m', commitMessage], workingDir);
|
|
1710
|
-
if (commitResult.exitCode !== 0) {
|
|
1711
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: commitResult.stderr || commitResult.stdout || 'Failed to commit' } });
|
|
1712
|
-
return;
|
|
1713
|
-
}
|
|
1714
|
-
// Get the new commit hash
|
|
1715
|
-
const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
|
|
1716
|
-
const hash = hashResult.stdout.trim();
|
|
1717
|
-
this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message: commitMessage } });
|
|
1718
|
-
// Proactively send updated status so the UI reflects new ahead/behind counts
|
|
1719
|
-
this.handleGitStatus(ws, tabId, workingDir);
|
|
1720
|
-
}
|
|
1721
|
-
});
|
|
1722
|
-
claude.on('error', (err) => {
|
|
1723
|
-
console.error('[WebSocketImproviseHandler] Failed to spawn Claude for commit:', err);
|
|
1724
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
|
|
1725
|
-
});
|
|
1726
|
-
// Timeout after 30 seconds
|
|
1727
|
-
setTimeout(() => {
|
|
1728
|
-
claude.kill();
|
|
1729
|
-
}, 30000);
|
|
1730
|
-
}
|
|
1731
|
-
catch (error) {
|
|
1732
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
/**
|
|
1736
|
-
* Extract the actual commit message from Claude's output.
|
|
1737
|
-
* Sometimes Claude outputs reasoning before the actual message, so we need to parse it.
|
|
1738
|
-
*/
|
|
1739
|
-
extractCommitMessage(output) {
|
|
1740
|
-
// Look for common patterns where Claude introduces the commit message
|
|
1741
|
-
const patterns = [
|
|
1742
|
-
/(?:here'?s?\s+(?:the\s+)?commit\s+message:?\s*\n+)([\s\S]+)/i,
|
|
1743
|
-
/(?:commit\s+message:?\s*\n+)([\s\S]+)/i,
|
|
1744
|
-
/(?:suggested\s+commit\s+message:?\s*\n+)([\s\S]+)/i,
|
|
1745
|
-
];
|
|
1746
|
-
for (const pattern of patterns) {
|
|
1747
|
-
const match = output.match(pattern);
|
|
1748
|
-
if (match?.[1]) {
|
|
1749
|
-
return this.stripCoauthorLines(match[1].trim());
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
// Split into paragraphs for analysis
|
|
1753
|
-
const paragraphs = output.split(/\n\n+/).filter(p => p.trim());
|
|
1754
|
-
// If only one paragraph, return it as-is
|
|
1755
|
-
if (paragraphs.length <= 1) {
|
|
1756
|
-
return this.stripCoauthorLines(output.trim());
|
|
1757
|
-
}
|
|
1758
|
-
const firstParagraph = paragraphs[0].trim();
|
|
1759
|
-
const firstLine = firstParagraph.split('\n')[0].trim();
|
|
1760
|
-
// Check if first paragraph looks like reasoning/self-talk
|
|
1761
|
-
// Reasoning typically: starts with certain words, is conversational, explains what will happen
|
|
1762
|
-
const reasoningPatterns = [
|
|
1763
|
-
/^(Now|Based|Looking|After|Here|Let me|I\s+(can|will|see|notice|'ll|would))/i,
|
|
1764
|
-
/^The\s+\w+\s+(file|changes?|commit|diff)/i,
|
|
1765
|
-
/\b(I can|I will|I'll|let me|analyzing|looking at)\b/i,
|
|
1766
|
-
];
|
|
1767
|
-
const looksLikeReasoning = reasoningPatterns.some(p => p.test(firstParagraph));
|
|
1768
|
-
// Also check if first line is too long or conversational for a commit title
|
|
1769
|
-
const firstLineTooLong = firstLine.length > 80;
|
|
1770
|
-
const endsWithPeriod = firstLine.endsWith('.');
|
|
1771
|
-
if (looksLikeReasoning || (firstLineTooLong && endsWithPeriod)) {
|
|
1772
|
-
// Skip the first paragraph (reasoning) and return the rest
|
|
1773
|
-
const commitMessage = paragraphs.slice(1).join('\n\n').trim();
|
|
1774
|
-
// Validate the extracted message has a reasonable first line
|
|
1775
|
-
const extractedFirstLine = commitMessage.split('\n')[0].trim();
|
|
1776
|
-
if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
|
|
1777
|
-
return this.stripCoauthorLines(commitMessage);
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
// Check if the second paragraph looks like a proper commit title
|
|
1781
|
-
// (short, starts with capital, imperative mood)
|
|
1782
|
-
if (paragraphs.length >= 2) {
|
|
1783
|
-
const secondParagraph = paragraphs[1].trim();
|
|
1784
|
-
const secondFirstLine = secondParagraph.split('\n')[0].trim();
|
|
1785
|
-
// Commit titles are typically short and start with imperative verb
|
|
1786
|
-
if (secondFirstLine.length <= 72 &&
|
|
1787
|
-
/^[A-Z][a-z]/.test(secondFirstLine) &&
|
|
1788
|
-
!secondFirstLine.endsWith('.')) {
|
|
1789
|
-
// Return from second paragraph onwards
|
|
1790
|
-
return this.stripCoauthorLines(paragraphs.slice(1).join('\n\n').trim());
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
// Fall back to original output if we can't identify a better message
|
|
1794
|
-
return this.stripCoauthorLines(output.trim());
|
|
1795
|
-
}
|
|
1796
|
-
/**
|
|
1797
|
-
* Strip injected coauthor/attribution lines from a commit message.
|
|
1798
|
-
* The Claude Code CLI appends "Co-Authored-By" lines to LLM output.
|
|
1799
|
-
* We detect and remove them by matching known marker strings.
|
|
1800
|
-
*/
|
|
1801
|
-
stripCoauthorLines(message) {
|
|
1802
|
-
const lines = message.split('\n');
|
|
1803
|
-
const markers = ['co-authored', 'authored-by', 'haiku', 'noreply@anthropic.com'];
|
|
1804
|
-
const result = [];
|
|
1805
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1806
|
-
const lower = lines[i].toLowerCase();
|
|
1807
|
-
if (markers.some(m => lower.includes(m))) {
|
|
1808
|
-
// Also remove a blank line immediately before this one
|
|
1809
|
-
if (result.length > 0 && result[result.length - 1].trim() === '') {
|
|
1810
|
-
result.pop();
|
|
1811
|
-
}
|
|
1812
|
-
continue;
|
|
1813
|
-
}
|
|
1814
|
-
result.push(lines[i]);
|
|
1815
|
-
}
|
|
1816
|
-
// Don't return empty - keep at least the first line of the original
|
|
1817
|
-
if (result.length === 0)
|
|
1818
|
-
return lines[0]?.trim() || message;
|
|
1819
|
-
return result.join('\n').trimEnd();
|
|
1820
|
-
}
|
|
1821
|
-
/**
|
|
1822
|
-
* Handle git push request
|
|
1823
|
-
*/
|
|
1824
|
-
async handleGitPush(ws, tabId, workingDir) {
|
|
1825
|
-
try {
|
|
1826
|
-
// Check if branch has an upstream, if not use --set-upstream
|
|
1827
|
-
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
1828
|
-
const branch = branchResult.stdout.trim();
|
|
1829
|
-
const upstreamCheck = await this.executeGitCommand(['rev-parse', '--abbrev-ref', `${branch}@{u}`], workingDir);
|
|
1830
|
-
const hasUpstream = upstreamCheck.exitCode === 0;
|
|
1831
|
-
const pushArgs = hasUpstream ? ['push'] : ['push', '-u', 'origin', branch];
|
|
1832
|
-
const result = await this.executeGitCommand(pushArgs, workingDir);
|
|
1833
|
-
if (result.exitCode !== 0) {
|
|
1834
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
this.send(ws, { type: 'gitPushed', tabId, data: { output: result.stdout || result.stderr } });
|
|
1838
|
-
}
|
|
1839
|
-
catch (error) {
|
|
1840
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
/**
|
|
1844
|
-
* Handle git log request
|
|
1845
|
-
*/
|
|
1846
|
-
async handleGitLog(ws, msg, tabId, workingDir) {
|
|
1847
|
-
const limit = msg.data?.limit ?? 10;
|
|
1848
|
-
try {
|
|
1849
|
-
const result = await this.executeGitCommand([
|
|
1850
|
-
'log',
|
|
1851
|
-
`-${limit}`,
|
|
1852
|
-
'--format=%H|%h|%s|%an|%aI'
|
|
1853
|
-
], workingDir);
|
|
1854
|
-
if (result.exitCode !== 0) {
|
|
1855
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to get log' } });
|
|
1856
|
-
return;
|
|
1857
|
-
}
|
|
1858
|
-
const entries = result.stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
1859
|
-
const [hash, shortHash, subject, author, date] = line.split('|');
|
|
1860
|
-
return { hash, shortHash, subject, author, date };
|
|
1861
|
-
});
|
|
1862
|
-
this.send(ws, { type: 'gitLog', tabId, data: { entries } });
|
|
1863
|
-
}
|
|
1864
|
-
catch (error) {
|
|
1865
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
/** Directories to skip when scanning for git repos */
|
|
1869
|
-
static SKIP_DIRS = ['node_modules', 'vendor', '.git'];
|
|
1870
|
-
/** Get the current branch name for a git repository */
|
|
1871
|
-
async getRepoBranch(repoPath) {
|
|
1872
|
-
const result = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
|
|
1873
|
-
return result.exitCode === 0 ? result.stdout.trim() : undefined;
|
|
1874
|
-
}
|
|
1875
|
-
/** Check if a directory name should be skipped when scanning */
|
|
1876
|
-
shouldSkipDir(name) {
|
|
1877
|
-
return name.startsWith('.') || WebSocketImproviseHandler.SKIP_DIRS.includes(name);
|
|
1878
|
-
}
|
|
1879
|
-
/** Recursively scan directories for git repositories */
|
|
1880
|
-
async scanForGitRepos(dir, depth, maxDepth, repos) {
|
|
1881
|
-
if (depth > maxDepth)
|
|
1882
|
-
return;
|
|
1883
|
-
let entries;
|
|
1884
|
-
try {
|
|
1885
|
-
entries = readdirSync(dir);
|
|
1886
|
-
}
|
|
1887
|
-
catch {
|
|
1888
|
-
return;
|
|
1889
|
-
}
|
|
1890
|
-
for (const name of entries) {
|
|
1891
|
-
if (this.shouldSkipDir(name))
|
|
1892
|
-
continue;
|
|
1893
|
-
const fullPath = join(dir, name);
|
|
1894
|
-
const gitPath = join(fullPath, '.git');
|
|
1895
|
-
if (existsSync(gitPath)) {
|
|
1896
|
-
repos.push({ path: fullPath, name, branch: await this.getRepoBranch(fullPath) });
|
|
1897
|
-
}
|
|
1898
|
-
else {
|
|
1899
|
-
await this.scanForGitRepos(fullPath, depth + 1, maxDepth, repos);
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
/**
|
|
1904
|
-
* Discover git repositories in the working directory and subdirectories
|
|
1905
|
-
*/
|
|
1906
|
-
async handleGitDiscoverRepos(ws, tabId, workingDir) {
|
|
1907
|
-
try {
|
|
1908
|
-
const repos = [];
|
|
1909
|
-
const rootIsGitRepo = existsSync(join(workingDir, '.git'));
|
|
1910
|
-
if (rootIsGitRepo) {
|
|
1911
|
-
repos.push({
|
|
1912
|
-
path: workingDir,
|
|
1913
|
-
name: workingDir.split('/').pop() || workingDir,
|
|
1914
|
-
branch: await this.getRepoBranch(workingDir),
|
|
1915
|
-
});
|
|
1916
|
-
}
|
|
1917
|
-
else {
|
|
1918
|
-
await this.scanForGitRepos(workingDir, 1, 3, repos);
|
|
1919
|
-
}
|
|
1920
|
-
const response = {
|
|
1921
|
-
repos,
|
|
1922
|
-
rootIsGitRepo,
|
|
1923
|
-
selectedDirectory: this.gitDirectories.get(tabId) || null,
|
|
1924
|
-
};
|
|
1925
|
-
this.send(ws, { type: 'gitReposDiscovered', tabId, data: response });
|
|
1926
|
-
}
|
|
1927
|
-
catch (error) {
|
|
1928
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
/**
|
|
1932
|
-
* Set the git directory for operations
|
|
1933
|
-
*/
|
|
1934
|
-
async handleGitSetDirectory(ws, msg, tabId, workingDir) {
|
|
1935
|
-
const directory = msg.data?.directory;
|
|
1936
|
-
if (!directory) {
|
|
1937
|
-
// Clear the selected directory, use working dir
|
|
1938
|
-
this.gitDirectories.delete(tabId);
|
|
1939
|
-
const response = {
|
|
1940
|
-
directory: workingDir,
|
|
1941
|
-
isValid: existsSync(join(workingDir, '.git')),
|
|
1942
|
-
};
|
|
1943
|
-
this.send(ws, { type: 'gitDirectorySet', tabId, data: response });
|
|
1944
|
-
// Refresh status with new directory
|
|
1945
|
-
this.handleGitStatus(ws, tabId, workingDir);
|
|
1946
|
-
return;
|
|
1947
|
-
}
|
|
1948
|
-
// Validate the directory exists and has a .git folder
|
|
1949
|
-
const gitPath = join(directory, '.git');
|
|
1950
|
-
const isValid = existsSync(gitPath);
|
|
1951
|
-
if (isValid) {
|
|
1952
|
-
this.gitDirectories.set(tabId, directory);
|
|
1953
|
-
}
|
|
1954
|
-
const response = {
|
|
1955
|
-
directory,
|
|
1956
|
-
isValid,
|
|
1957
|
-
};
|
|
1958
|
-
this.send(ws, { type: 'gitDirectorySet', tabId, data: response });
|
|
1959
|
-
// Refresh status with new directory
|
|
1960
|
-
if (isValid) {
|
|
1961
|
-
this.handleGitStatus(ws, tabId, directory);
|
|
1962
|
-
this.handleGitLog(ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
/**
|
|
1966
|
-
* Get remote info for PR creation (remote URL, provider, default branch)
|
|
1967
|
-
*/
|
|
1968
|
-
async handleGitGetRemoteInfo(ws, tabId, workingDir) {
|
|
1969
|
-
try {
|
|
1970
|
-
const remoteResult = await this.executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
|
|
1971
|
-
if (remoteResult.exitCode !== 0) {
|
|
1972
|
-
this.send(ws, { type: 'gitRemoteInfo', tabId, data: { hasRemote: false } });
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
const remoteUrl = remoteResult.stdout.trim();
|
|
1976
|
-
const provider = detectGitProvider(remoteUrl);
|
|
1977
|
-
const defaultBranch = await this.getDefaultBranch(workingDir);
|
|
1978
|
-
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
1979
|
-
const currentBranch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : '';
|
|
1980
|
-
const cliStatus = await this.checkGitCliStatus(provider);
|
|
1981
|
-
const remoteBranches = await this.listRemoteBranches(workingDir);
|
|
1982
|
-
const preferredBaseBranch = getPrBaseBranch(remoteUrl) ?? undefined;
|
|
1983
|
-
this.send(ws, {
|
|
1984
|
-
type: 'gitRemoteInfo',
|
|
1985
|
-
tabId,
|
|
1986
|
-
data: {
|
|
1987
|
-
hasRemote: true,
|
|
1988
|
-
remoteUrl,
|
|
1989
|
-
provider,
|
|
1990
|
-
defaultBranch,
|
|
1991
|
-
currentBranch,
|
|
1992
|
-
...cliStatus,
|
|
1993
|
-
remoteBranches,
|
|
1994
|
-
preferredBaseBranch,
|
|
1995
|
-
},
|
|
1996
|
-
});
|
|
1997
|
-
}
|
|
1998
|
-
catch (error) {
|
|
1999
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
async getDefaultBranch(workingDir) {
|
|
2003
|
-
const result = await this.executeGitCommand(['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], workingDir);
|
|
2004
|
-
return result.exitCode === 0 ? result.stdout.trim().replace('origin/', '') : 'main';
|
|
2005
|
-
}
|
|
2006
|
-
async checkGitCliStatus(provider) {
|
|
2007
|
-
const cliBin = provider === 'github' ? 'gh' : provider === 'gitlab' ? 'glab' : null;
|
|
2008
|
-
if (!cliBin)
|
|
2009
|
-
return { hasGhCli: false, ghCliAuthenticated: false };
|
|
2010
|
-
const installed = await this.spawnCheck(cliBin, ['--version']);
|
|
2011
|
-
if (!installed)
|
|
2012
|
-
return { hasGhCli: false, ghCliAuthenticated: false };
|
|
2013
|
-
const authenticated = await this.spawnCheck(cliBin, ['auth', 'status']);
|
|
2014
|
-
return { hasGhCli: true, ghCliAuthenticated: authenticated, ghCliBinary: cliBin };
|
|
2015
|
-
}
|
|
2016
|
-
async listRemoteBranches(workingDir) {
|
|
2017
|
-
const result = await this.executeGitCommand(['branch', '-r', '--list', 'origin/*'], workingDir);
|
|
2018
|
-
if (result.exitCode !== 0)
|
|
2019
|
-
return [];
|
|
2020
|
-
return result.stdout.split('\n')
|
|
2021
|
-
.map(line => line.trim())
|
|
2022
|
-
.filter(line => line && !line.includes('->'))
|
|
2023
|
-
.map(line => line.replace('origin/', ''))
|
|
2024
|
-
.filter(Boolean)
|
|
2025
|
-
.sort();
|
|
2026
|
-
}
|
|
2027
|
-
/** Check if a binary runs successfully (exit code 0) */
|
|
2028
|
-
spawnCheck(bin, args) {
|
|
2029
|
-
return new Promise((resolve) => {
|
|
2030
|
-
const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2031
|
-
proc.on('close', (code) => resolve(code === 0));
|
|
2032
|
-
proc.on('error', () => resolve(false));
|
|
2033
|
-
});
|
|
2034
|
-
}
|
|
2035
|
-
/** Detect which CLI binary to use for PR creation based on remote URL */
|
|
2036
|
-
detectPRCliBin(remoteUrl) {
|
|
2037
|
-
const isGitHub = remoteUrl.includes('github.com');
|
|
2038
|
-
const isGitLab = remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab');
|
|
2039
|
-
const cliBin = isGitHub ? 'gh' : isGitLab ? 'glab' : null;
|
|
2040
|
-
return { cliBin, isGitHub, isGitLab };
|
|
2041
|
-
}
|
|
2042
|
-
/** Send PR success and optionally persist base branch */
|
|
2043
|
-
sendPRCreated(ws, tabId, url, method, remoteUrl, baseBranch) {
|
|
2044
|
-
if (baseBranch)
|
|
2045
|
-
setPrBaseBranch(remoteUrl, baseBranch);
|
|
2046
|
-
this.send(ws, { type: 'gitPRCreated', tabId, data: { url, method } });
|
|
2047
|
-
}
|
|
2048
|
-
/**
|
|
2049
|
-
* Create a pull/merge request using gh CLI (GitHub) or open browser URL (fallback)
|
|
2050
|
-
*/
|
|
2051
|
-
async handleGitCreatePR(ws, msg, tabId, workingDir) {
|
|
2052
|
-
const { title, body, baseBranch, draft } = msg.data ?? {};
|
|
2053
|
-
if (!title) {
|
|
2054
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'PR title is required' } });
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
try {
|
|
2058
|
-
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
2059
|
-
if (branchResult.exitCode !== 0) {
|
|
2060
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to detect current branch' } });
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
const remoteResult = await this.executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
|
|
2064
|
-
if (remoteResult.exitCode !== 0) {
|
|
2065
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'No remote origin configured' } });
|
|
2066
|
-
return;
|
|
2067
|
-
}
|
|
2068
|
-
const headBranch = branchResult.stdout.trim();
|
|
2069
|
-
const remoteUrl = remoteResult.stdout.trim();
|
|
2070
|
-
const { cliBin, isGitHub, isGitLab } = this.detectPRCliBin(remoteUrl);
|
|
2071
|
-
const cliResult = await this.tryCliPRCreate(cliBin, { title, body, baseBranch, draft, headBranch }, workingDir);
|
|
2072
|
-
if (cliResult.created) {
|
|
2073
|
-
this.sendPRCreated(ws, tabId, cliResult.url, isGitHub ? 'gh' : 'glab', remoteUrl, baseBranch);
|
|
2074
|
-
return;
|
|
2075
|
-
}
|
|
2076
|
-
if (cliResult.error) {
|
|
2077
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: cliResult.error } });
|
|
2078
|
-
return;
|
|
2079
|
-
}
|
|
2080
|
-
const prUrl = this.buildBrowserPRUrl(remoteUrl, headBranch, baseBranch, title, body, isGitHub, isGitLab);
|
|
2081
|
-
if (prUrl) {
|
|
2082
|
-
this.sendPRCreated(ws, tabId, prUrl, 'browser', remoteUrl, baseBranch);
|
|
2083
|
-
}
|
|
2084
|
-
else {
|
|
2085
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Could not determine remote URL format for PR creation' } });
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
catch (error) {
|
|
2089
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
/** Attempt to create a PR/MR via CLI. Returns { created, url, error } */
|
|
2093
|
-
async tryCliPRCreate(cliBin, opts, workingDir) {
|
|
2094
|
-
if (!cliBin)
|
|
2095
|
-
return { created: false }; // No CLI for this provider
|
|
2096
|
-
// Check if CLI is installed
|
|
2097
|
-
const installed = await this.spawnCheck(cliBin, ['--version']);
|
|
2098
|
-
if (!installed)
|
|
2099
|
-
return { created: false }; // Not installed, fall through to browser
|
|
2100
|
-
// Build CLI args
|
|
2101
|
-
const args = cliBin === 'gh'
|
|
2102
|
-
? ['pr', 'create', '--title', opts.title]
|
|
2103
|
-
: ['mr', 'create', '--title', opts.title, '--yes']; // glab mr create
|
|
2104
|
-
if (opts.body)
|
|
2105
|
-
args.push('--body', opts.body);
|
|
2106
|
-
if (opts.baseBranch) {
|
|
2107
|
-
args.push(cliBin === 'gh' ? '--base' : '--target-branch', opts.baseBranch);
|
|
2108
|
-
}
|
|
2109
|
-
if (opts.draft)
|
|
2110
|
-
args.push('--draft');
|
|
2111
|
-
const result = await this.spawnWithOutput(cliBin, args, workingDir);
|
|
2112
|
-
if (result.exitCode === 0) {
|
|
2113
|
-
const urlMatch = result.stdout.match(/https?:\/\/\S+/);
|
|
2114
|
-
return { created: true, url: urlMatch ? urlMatch[0] : result.stdout.trim() };
|
|
2115
|
-
}
|
|
2116
|
-
return { created: false, error: this.classifyCliPRError(cliBin, result, opts.headBranch) };
|
|
2117
|
-
}
|
|
2118
|
-
/** Classify a CLI PR creation error into a user-facing message */
|
|
2119
|
-
classifyCliPRError(cliBin, result, headBranch) {
|
|
2120
|
-
const combined = result.stderr + result.stdout;
|
|
2121
|
-
const lower = combined.toLowerCase();
|
|
2122
|
-
if (lower.includes('already exists')) {
|
|
2123
|
-
const existingUrl = combined.match(/https?:\/\/\S+/);
|
|
2124
|
-
return existingUrl
|
|
2125
|
-
? `A pull request already exists for ${headBranch}: ${existingUrl[0]}`
|
|
2126
|
-
: `A pull request already exists for ${headBranch}`;
|
|
2127
|
-
}
|
|
2128
|
-
if (lower.includes('auth') || lower.includes('401') || lower.includes('token') || lower.includes('log in')) {
|
|
2129
|
-
return `${cliBin} is not authenticated. Run: ${cliBin} auth login`;
|
|
2130
|
-
}
|
|
2131
|
-
if (lower.includes('must first push') || lower.includes('failed to push') || lower.includes('no upstream')) {
|
|
2132
|
-
return `Branch "${headBranch}" has not been pushed to remote. Push first, then create the PR.`;
|
|
2133
|
-
}
|
|
2134
|
-
return `${cliBin} failed: ${(result.stderr || result.stdout).trim()}`;
|
|
2135
|
-
}
|
|
2136
|
-
/** Spawn a process and capture stdout/stderr */
|
|
2137
|
-
spawnWithOutput(bin, args, cwd) {
|
|
2138
|
-
return new Promise((resolve) => {
|
|
2139
|
-
const proc = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2140
|
-
let stdout = '';
|
|
2141
|
-
let stderr = '';
|
|
2142
|
-
proc.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
2143
|
-
proc.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
2144
|
-
proc.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
|
|
2145
|
-
proc.on('error', (err) => resolve({ stdout: '', stderr: err.message, exitCode: 1 }));
|
|
2146
|
-
});
|
|
2147
|
-
}
|
|
2148
|
-
/** Build a browser URL for PR creation (fallback when no CLI) */
|
|
2149
|
-
buildBrowserPRUrl(remoteUrl, headBranch, baseBranch, title, body, isGitHub, isGitLab) {
|
|
2150
|
-
const sshMatch = remoteUrl.match(/[:/]([^/]+)\/([^/.]+)(?:\.git)?$/);
|
|
2151
|
-
if (!sshMatch)
|
|
2152
|
-
return '';
|
|
2153
|
-
const [, owner, repo] = sshMatch;
|
|
2154
|
-
const base = baseBranch || 'main';
|
|
2155
|
-
if (isGitHub) {
|
|
2156
|
-
return `https://github.com/${owner}/${repo}/compare/${base}...${headBranch}?expand=1&title=${encodeURIComponent(title)}${body ? `&body=${encodeURIComponent(body)}` : ''}`;
|
|
2157
|
-
}
|
|
2158
|
-
if (isGitLab) {
|
|
2159
|
-
return `https://gitlab.com/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${headBranch}&merge_request[target_branch]=${base}&merge_request[title]=${encodeURIComponent(title)}`;
|
|
2160
|
-
}
|
|
2161
|
-
return '';
|
|
2162
|
-
}
|
|
2163
|
-
/**
|
|
2164
|
-
* Generate a PR title and description using Haiku, based on the diff against the base branch.
|
|
2165
|
-
*/
|
|
2166
|
-
async handleGitGeneratePRDescription(ws, msg, tabId, workingDir) {
|
|
2167
|
-
const baseBranch = msg.data?.baseBranch || 'main';
|
|
2168
|
-
try {
|
|
2169
|
-
// Get commit list for context
|
|
2170
|
-
const logResult = await this.executeGitCommand(['log', `${baseBranch}..HEAD`, '--oneline'], workingDir);
|
|
2171
|
-
const commits = logResult.exitCode === 0 ? logResult.stdout.trim() : '';
|
|
2172
|
-
if (!commits) {
|
|
2173
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: `No commits found between ${baseBranch} and HEAD` } });
|
|
2174
|
-
return;
|
|
2175
|
-
}
|
|
2176
|
-
// Get diff against base
|
|
2177
|
-
const diffResult = await this.executeGitCommand(['diff', `${baseBranch}...HEAD`], workingDir);
|
|
2178
|
-
const diff = diffResult.exitCode === 0 ? diffResult.stdout : '';
|
|
2179
|
-
// Get changed files summary
|
|
2180
|
-
const statResult = await this.executeGitCommand(['diff', `${baseBranch}...HEAD`, '--stat'], workingDir);
|
|
2181
|
-
const stat = statResult.exitCode === 0 ? statResult.stdout.trim() : '';
|
|
2182
|
-
// Truncate diff if too long (same pattern as commit message generation)
|
|
2183
|
-
let truncatedDiff = diff;
|
|
2184
|
-
if (diff.length > 8000) {
|
|
2185
|
-
truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
|
|
2186
|
-
}
|
|
2187
|
-
// Build prompt
|
|
2188
|
-
const tempDir = join(workingDir, '.mstro', 'tmp');
|
|
2189
|
-
if (!existsSync(tempDir)) {
|
|
2190
|
-
mkdirSync(tempDir, { recursive: true });
|
|
2191
|
-
}
|
|
2192
|
-
const prompt = `You are generating a pull request title and description for the following changes.
|
|
2193
|
-
|
|
2194
|
-
COMMITS (${baseBranch}..HEAD):
|
|
2195
|
-
${commits}
|
|
2196
|
-
|
|
2197
|
-
FILES CHANGED:
|
|
2198
|
-
${stat}
|
|
2199
|
-
|
|
2200
|
-
DIFF:
|
|
2201
|
-
${truncatedDiff}
|
|
2202
|
-
|
|
2203
|
-
Generate a pull request title and description following these rules:
|
|
2204
|
-
1. TITLE: First line must be the PR title — imperative mood, under 70 characters
|
|
2205
|
-
2. Leave a blank line after the title
|
|
2206
|
-
3. BODY: Write a concise description in markdown with:
|
|
2207
|
-
- A "## Summary" section with 1-3 bullet points explaining what changed and why
|
|
2208
|
-
- Optionally a "## Details" section if the changes are complex
|
|
2209
|
-
4. Focus on the "why" not just the "what"
|
|
2210
|
-
5. No emojis
|
|
2211
|
-
|
|
2212
|
-
Respond with ONLY the title and description, nothing else.`;
|
|
2213
|
-
const promptFile = join(tempDir, `pr-desc-${Date.now()}.txt`);
|
|
2214
|
-
writeFileSync(promptFile, prompt);
|
|
2215
|
-
const systemPrompt = 'You are a pull request description assistant. Respond with only the PR title and description, no preamble or explanation.';
|
|
2216
|
-
const args = [
|
|
2217
|
-
'--print',
|
|
2218
|
-
'--model', 'haiku',
|
|
2219
|
-
'--system-prompt', systemPrompt,
|
|
2220
|
-
promptFile
|
|
2221
|
-
];
|
|
2222
|
-
const claude = spawn('claude', args, {
|
|
2223
|
-
cwd: workingDir,
|
|
2224
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
2225
|
-
});
|
|
2226
|
-
let stdout = '';
|
|
2227
|
-
let stderr = '';
|
|
2228
|
-
claude.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
2229
|
-
claude.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
2230
|
-
claude.on('close', (code) => {
|
|
2231
|
-
try {
|
|
2232
|
-
unlinkSync(promptFile);
|
|
2233
|
-
}
|
|
2234
|
-
catch { /* ignore */ }
|
|
2235
|
-
if (code !== 0 || !stdout.trim()) {
|
|
2236
|
-
console.error('[WebSocketImproviseHandler] Claude PR description error:', stderr || 'No output');
|
|
2237
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
|
|
2238
|
-
return;
|
|
2239
|
-
}
|
|
2240
|
-
// Parse: first line = title, rest = body
|
|
2241
|
-
const output = this.stripCoauthorLines(stdout.trim());
|
|
2242
|
-
const lines = output.split('\n');
|
|
2243
|
-
const title = lines[0].trim();
|
|
2244
|
-
const body = lines.slice(1).join('\n').trim();
|
|
2245
|
-
this.send(ws, { type: 'gitPRDescription', tabId, data: { title, body } });
|
|
2246
|
-
});
|
|
2247
|
-
claude.on('error', (err) => {
|
|
2248
|
-
console.error('[WebSocketImproviseHandler] Failed to spawn Claude for PR description:', err);
|
|
2249
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
|
|
2250
|
-
});
|
|
2251
|
-
setTimeout(() => { claude.kill(); }, 30000);
|
|
2252
|
-
}
|
|
2253
|
-
catch (error) {
|
|
2254
|
-
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
// ============================================
|
|
2258
|
-
// Terminal handling methods
|
|
2259
|
-
// ============================================
|
|
2260
|
-
/**
|
|
2261
|
-
* Initialize a new terminal session or reconnect to existing one
|
|
2262
|
-
*/
|
|
2263
|
-
handleTerminalInit(ws, terminalId, workingDir, requestedShell, cols, rows, permission) {
|
|
2264
|
-
const ptyManager = getPTYManager();
|
|
2265
|
-
// Check if PTY is available (node-pty requires native compilation)
|
|
2266
|
-
if (!ptyManager.isPtyAvailable()) {
|
|
2267
|
-
this.send(ws, {
|
|
2268
|
-
type: 'terminalError',
|
|
2269
|
-
terminalId,
|
|
2270
|
-
data: {
|
|
2271
|
-
error: 'PTY_NOT_AVAILABLE',
|
|
2272
|
-
instructions: ptyManager.getPtyInstallInstructions()
|
|
2273
|
-
}
|
|
2274
|
-
});
|
|
2275
|
-
return;
|
|
2276
|
-
}
|
|
2277
|
-
// Add this WS as a subscriber for this terminal's output
|
|
2278
|
-
this.addTerminalSubscriber(terminalId, ws);
|
|
2279
|
-
// Set up broadcast listeners (idempotent — only creates once per terminal)
|
|
2280
|
-
this.setupTerminalBroadcastListeners(terminalId);
|
|
2281
|
-
try {
|
|
2282
|
-
// Create or reconnect to the PTY process
|
|
2283
|
-
// Both 'control' and 'view' users get sandboxed terminals
|
|
2284
|
-
const { shell, cwd, isReconnect } = ptyManager.create(terminalId, workingDir, cols || 80, rows || 24, requestedShell, { sandboxed: permission === 'control' || permission === 'view' });
|
|
2285
|
-
if (!isReconnect) {
|
|
2286
|
-
// New terminal — broadcast to other clients so they can create matching tabs
|
|
2287
|
-
this.broadcastToOthers(ws, {
|
|
2288
|
-
type: 'terminalCreated',
|
|
2289
|
-
data: { terminalId, shell, cwd }
|
|
2290
|
-
});
|
|
2291
|
-
}
|
|
2292
|
-
// Send ready message to THIS client
|
|
2293
|
-
this.send(ws, {
|
|
2294
|
-
type: 'terminalReady',
|
|
2295
|
-
terminalId,
|
|
2296
|
-
data: { shell, cwd, isReconnect }
|
|
2297
|
-
});
|
|
2298
|
-
trackEvent(AnalyticsEvents.TERMINAL_SESSION_CREATED, {
|
|
2299
|
-
shell,
|
|
2300
|
-
is_reconnect: isReconnect,
|
|
2301
|
-
});
|
|
2302
|
-
}
|
|
2303
|
-
catch (error) {
|
|
2304
|
-
console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
|
|
2305
|
-
this.send(ws, {
|
|
2306
|
-
type: 'terminalError',
|
|
2307
|
-
terminalId,
|
|
2308
|
-
data: { error: error.message || 'Failed to create terminal' }
|
|
2309
|
-
});
|
|
2310
|
-
this.removeTerminalSubscriber(terminalId, ws);
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
2313
|
-
/**
|
|
2314
|
-
* Reconnect to an existing terminal session
|
|
2315
|
-
*/
|
|
2316
|
-
handleTerminalReconnect(ws, terminalId) {
|
|
2317
|
-
const ptyManager = getPTYManager();
|
|
2318
|
-
// Check if session exists
|
|
2319
|
-
const sessionInfo = ptyManager.getSessionInfo(terminalId);
|
|
2320
|
-
if (!sessionInfo) {
|
|
2321
|
-
this.send(ws, {
|
|
2322
|
-
type: 'terminalError',
|
|
2323
|
-
terminalId,
|
|
2324
|
-
data: { error: 'Terminal session not found', sessionNotFound: true }
|
|
2325
|
-
});
|
|
2326
|
-
return;
|
|
2327
|
-
}
|
|
2328
|
-
// Add this WS as a subscriber for this terminal's output
|
|
2329
|
-
this.addTerminalSubscriber(terminalId, ws);
|
|
2330
|
-
// Set up broadcast listeners (idempotent — only creates once per terminal)
|
|
2331
|
-
this.setupTerminalBroadcastListeners(terminalId);
|
|
2332
|
-
// Send ready message indicating reconnection
|
|
2333
|
-
this.send(ws, {
|
|
2334
|
-
type: 'terminalReady',
|
|
2335
|
-
terminalId,
|
|
2336
|
-
data: {
|
|
2337
|
-
shell: sessionInfo.shell,
|
|
2338
|
-
cwd: sessionInfo.cwd,
|
|
2339
|
-
isReconnect: true
|
|
2340
|
-
}
|
|
2341
|
-
});
|
|
2342
|
-
// Force a resize to trigger SIGWINCH, causing the shell to redraw its prompt
|
|
2343
|
-
ptyManager.resize(terminalId, sessionInfo.cols, sessionInfo.rows);
|
|
2344
|
-
}
|
|
2345
|
-
/**
|
|
2346
|
-
* List all active terminal sessions
|
|
2347
|
-
*/
|
|
2348
|
-
handleTerminalList(ws) {
|
|
2349
|
-
const ptyManager = getPTYManager();
|
|
2350
|
-
const terminalIds = ptyManager.getActiveTerminals();
|
|
2351
|
-
const terminals = terminalIds.map(id => {
|
|
2352
|
-
const info = ptyManager.getSessionInfo(id);
|
|
2353
|
-
return info ? { id, ...info } : null;
|
|
2354
|
-
}).filter(Boolean);
|
|
2355
|
-
this.send(ws, {
|
|
2356
|
-
type: 'terminalList',
|
|
2357
|
-
data: { terminals }
|
|
2358
|
-
});
|
|
2359
|
-
}
|
|
2360
|
-
/**
|
|
2361
|
-
* Handle terminal input
|
|
2362
|
-
*/
|
|
2363
|
-
handleTerminalInput(ws, terminalId, input) {
|
|
2364
|
-
if (!input) {
|
|
2365
|
-
return;
|
|
2366
|
-
}
|
|
2367
|
-
const ptyManager = getPTYManager();
|
|
2368
|
-
const success = ptyManager.write(terminalId, input);
|
|
2369
|
-
if (!success) {
|
|
2370
|
-
this.send(ws, {
|
|
2371
|
-
type: 'terminalError',
|
|
2372
|
-
terminalId,
|
|
2373
|
-
data: { error: 'Terminal not found or write failed' }
|
|
2374
|
-
});
|
|
2375
|
-
}
|
|
2376
|
-
}
|
|
2377
|
-
/**
|
|
2378
|
-
* Handle terminal resize
|
|
2379
|
-
*/
|
|
2380
|
-
handleTerminalResize(_ws, terminalId, cols, rows) {
|
|
2381
|
-
if (!cols || !rows) {
|
|
2382
|
-
return;
|
|
2383
|
-
}
|
|
2384
|
-
const ptyManager = getPTYManager();
|
|
2385
|
-
ptyManager.resize(terminalId, cols, rows);
|
|
2386
|
-
}
|
|
2387
|
-
/**
|
|
2388
|
-
* Handle terminal close
|
|
2389
|
-
*/
|
|
2390
|
-
handleTerminalClose(ws, terminalId) {
|
|
2391
|
-
trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
|
|
2392
|
-
// Clean up event listeners
|
|
2393
|
-
const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
|
|
2394
|
-
if (listenerCleanup) {
|
|
2395
|
-
listenerCleanup();
|
|
2396
|
-
this.terminalListenerCleanups.delete(terminalId);
|
|
2397
|
-
}
|
|
2398
|
-
// Close PTY
|
|
2399
|
-
const ptyManager = getPTYManager();
|
|
2400
|
-
ptyManager.close(terminalId);
|
|
2401
|
-
// Clean up subscribers
|
|
2402
|
-
this.terminalSubscribers.delete(terminalId);
|
|
2403
|
-
// Broadcast to other clients
|
|
2404
|
-
this.broadcastToOthers(ws, {
|
|
2405
|
-
type: 'terminalClosed',
|
|
2406
|
-
data: { terminalId }
|
|
2407
|
-
});
|
|
2408
|
-
}
|
|
2409
|
-
// Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
|
|
2410
|
-
terminalListenerCleanups = new Map();
|
|
2411
|
-
// Track which WS connections are subscribed to each terminal's output
|
|
2412
|
-
terminalSubscribers = new Map();
|
|
2413
|
-
/**
|
|
2414
|
-
* Add a WS connection as a subscriber for terminal output.
|
|
2415
|
-
*/
|
|
2416
|
-
addTerminalSubscriber(terminalId, ws) {
|
|
2417
|
-
let subs = this.terminalSubscribers.get(terminalId);
|
|
2418
|
-
if (!subs) {
|
|
2419
|
-
subs = new Set();
|
|
2420
|
-
this.terminalSubscribers.set(terminalId, subs);
|
|
2421
|
-
}
|
|
2422
|
-
subs.add(ws);
|
|
2423
|
-
}
|
|
2424
|
-
/**
|
|
2425
|
-
* Remove a WS subscriber from a terminal and clean up if no subscribers remain.
|
|
2426
|
-
*/
|
|
2427
|
-
removeTerminalSubscriber(terminalId, ws) {
|
|
2428
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2429
|
-
if (!subs)
|
|
2430
|
-
return;
|
|
2431
|
-
subs.delete(ws);
|
|
2432
|
-
if (subs.size > 0)
|
|
2433
|
-
return;
|
|
2434
|
-
this.terminalSubscribers.delete(terminalId);
|
|
2435
|
-
const cleanup = this.terminalListenerCleanups.get(terminalId);
|
|
2436
|
-
if (cleanup) {
|
|
2437
|
-
cleanup();
|
|
2438
|
-
this.terminalListenerCleanups.delete(terminalId);
|
|
2439
|
-
}
|
|
2440
|
-
}
|
|
2441
|
-
/**
|
|
2442
|
-
* Set up PTY event listeners that broadcast to all subscribers.
|
|
2443
|
-
* Only creates listeners once per terminal (idempotent).
|
|
2444
|
-
*/
|
|
2445
|
-
setupTerminalBroadcastListeners(terminalId) {
|
|
2446
|
-
// Already set up - don't duplicate
|
|
2447
|
-
if (this.terminalListenerCleanups.has(terminalId))
|
|
2448
|
-
return;
|
|
2449
|
-
const ptyManager = getPTYManager();
|
|
2450
|
-
const onOutput = (tid, data) => {
|
|
2451
|
-
if (tid === terminalId) {
|
|
2452
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2453
|
-
if (subs) {
|
|
2454
|
-
for (const ws of subs) {
|
|
2455
|
-
this.send(ws, { type: 'terminalOutput', terminalId, data: { output: data } });
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
};
|
|
2460
|
-
const onExit = (tid, exitCode) => {
|
|
2461
|
-
if (tid === terminalId) {
|
|
2462
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2463
|
-
if (subs) {
|
|
2464
|
-
for (const ws of subs) {
|
|
2465
|
-
this.send(ws, { type: 'terminalExit', terminalId, data: { exitCode } });
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
// Clean up
|
|
2469
|
-
ptyManager.off('output', onOutput);
|
|
2470
|
-
ptyManager.off('exit', onExit);
|
|
2471
|
-
ptyManager.off('error', onError);
|
|
2472
|
-
this.terminalListenerCleanups.delete(terminalId);
|
|
2473
|
-
this.terminalSubscribers.delete(terminalId);
|
|
2474
|
-
}
|
|
2475
|
-
};
|
|
2476
|
-
const onError = (tid, error) => {
|
|
2477
|
-
if (tid === terminalId) {
|
|
2478
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2479
|
-
if (subs) {
|
|
2480
|
-
for (const ws of subs) {
|
|
2481
|
-
this.send(ws, { type: 'terminalError', terminalId, data: { error } });
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
};
|
|
2486
|
-
ptyManager.on('output', onOutput);
|
|
2487
|
-
ptyManager.on('exit', onExit);
|
|
2488
|
-
ptyManager.on('error', onError);
|
|
2489
|
-
this.terminalListenerCleanups.set(terminalId, () => {
|
|
2490
|
-
ptyManager.off('output', onOutput);
|
|
2491
|
-
ptyManager.off('exit', onExit);
|
|
2492
|
-
ptyManager.off('error', onError);
|
|
2493
|
-
});
|
|
2494
|
-
}
|
|
2495
234
|
}
|
|
2496
235
|
//# sourceMappingURL=handler.js.map
|