mstro-app 0.1.57 → 0.2.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/bin/commands/login.js +27 -14
- package/bin/commands/logout.js +35 -1
- package/bin/commands/status.js +1 -1
- package/bin/mstro.js +5 -108
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +432 -103
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +2 -1
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
- package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
- package/dist/server/cli/headless/prompt-utils.js +40 -5
- package/dist/server/cli/headless/prompt-utils.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +29 -7
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +77 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +336 -20
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +67 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
- package/dist/server/cli/headless/tool-watchdog.js +296 -0
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +80 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +109 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +737 -132
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +5 -10
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +18 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +2 -2
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js +12 -8
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/security-patterns.d.ts.map +1 -1
- package/dist/server/mcp/security-patterns.js +9 -4
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/routes/improvise.js +6 -6
- package/dist/server/routes/improvise.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -0
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -3
- 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 +4 -9
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sandbox-utils.d.ts +6 -0
- package/dist/server/services/sandbox-utils.d.ts.map +1 -0
- package/dist/server/services/sandbox-utils.js +72 -0
- package/dist/server/services/sandbox-utils.js.map +1 -0
- package/dist/server/services/settings.d.ts +6 -0
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +21 -0
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +3 -51
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +14 -100
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +36 -15
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +452 -223
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +6 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/hooks/bouncer.sh +11 -4
- package/package.json +4 -1
- package/server/cli/headless/claude-invoker.ts +602 -119
- package/server/cli/headless/index.ts +7 -1
- package/server/cli/headless/prompt-utils.ts +37 -5
- package/server/cli/headless/runner.ts +30 -8
- package/server/cli/headless/stall-assessor.ts +453 -22
- package/server/cli/headless/tool-watchdog.ts +390 -0
- package/server/cli/headless/types.ts +84 -1
- package/server/cli/improvisation-session-manager.ts +884 -143
- package/server/index.ts +5 -10
- package/server/mcp/bouncer-integration.ts +28 -0
- package/server/mcp/security-audit.ts +12 -8
- package/server/mcp/security-patterns.ts +8 -2
- package/server/routes/improvise.ts +6 -6
- package/server/services/analytics.ts +13 -3
- package/server/services/platform.test.ts +0 -10
- package/server/services/platform.ts +4 -10
- package/server/services/sandbox-utils.ts +78 -0
- package/server/services/settings.ts +25 -0
- package/server/services/terminal/pty-manager.ts +16 -127
- package/server/services/websocket/handler.ts +515 -251
- package/server/services/websocket/types.ts +10 -4
- package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
- package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
- package/dist/server/services/terminal/tmux-manager.js +0 -352
- package/dist/server/services/terminal/tmux-manager.js.map +0 -1
- package/server/services/terminal/tmux-manager.ts +0 -426
|
@@ -13,27 +13,34 @@ import { dirname, join } from 'node:path';
|
|
|
13
13
|
import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
14
14
|
import { AnalyticsEvents, trackEvent } from '../analytics.js';
|
|
15
15
|
import { createDirectory, createFile, deleteFile, listDirectory, renameFile, writeFile } from '../files.js';
|
|
16
|
+
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
16
17
|
import { captureException } from '../sentry.js';
|
|
17
|
-
import { getModel, getSettings, setModel } from '../settings.js';
|
|
18
|
+
import { getModel, getPrBaseBranch, getSettings, setModel, setPrBaseBranch } from '../settings.js';
|
|
18
19
|
import { getPTYManager } from '../terminal/pty-manager.js';
|
|
19
20
|
import { AutocompleteService } from './autocomplete.js';
|
|
20
21
|
import { readFileContent } from './file-utils.js';
|
|
21
22
|
import { SessionRegistry } from './session-registry.js';
|
|
23
|
+
/** Convert tool history entries into OutputLine-compatible lines */
|
|
24
|
+
function convertToolHistoryToLines(tools, ts) {
|
|
25
|
+
const lines = [];
|
|
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
|
+
}
|
|
22
34
|
/** Convert a single movement record into OutputLine-compatible entries */
|
|
23
35
|
function convertMovementToLines(movement) {
|
|
24
36
|
const lines = [];
|
|
25
37
|
const ts = new Date(movement.timestamp).getTime();
|
|
26
|
-
lines.push({ type: 'user', text:
|
|
38
|
+
lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
|
|
27
39
|
if (movement.thinkingOutput) {
|
|
28
40
|
lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
|
|
29
41
|
}
|
|
30
42
|
if (movement.toolUseHistory) {
|
|
31
|
-
|
|
32
|
-
lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
|
|
33
|
-
if (tool.result !== undefined) {
|
|
34
|
-
lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
43
|
+
lines.push(...convertToolHistoryToLines(movement.toolUseHistory, ts));
|
|
37
44
|
}
|
|
38
45
|
if (movement.assistantResponse) {
|
|
39
46
|
lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
|
|
@@ -41,9 +48,20 @@ function convertMovementToLines(movement) {
|
|
|
41
48
|
if (movement.errorOutput) {
|
|
42
49
|
lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
|
|
43
50
|
}
|
|
44
|
-
|
|
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 });
|
|
45
55
|
return lines;
|
|
46
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
|
+
}
|
|
47
65
|
export class WebSocketImproviseHandler {
|
|
48
66
|
sessions = new Map();
|
|
49
67
|
connections = new Map();
|
|
@@ -127,7 +145,10 @@ export class WebSocketImproviseHandler {
|
|
|
127
145
|
try {
|
|
128
146
|
const msg = JSON.parse(message);
|
|
129
147
|
const tabId = msg.tabId || 'default';
|
|
130
|
-
|
|
148
|
+
// Extract sandbox permission injected by server relay (for sandboxed shared users)
|
|
149
|
+
const permission = msg._permission;
|
|
150
|
+
delete msg._permission;
|
|
151
|
+
await this.dispatchMessage(ws, msg, tabId, workingDir, permission);
|
|
131
152
|
}
|
|
132
153
|
catch (error) {
|
|
133
154
|
console.error('[WebSocketImproviseHandler] Error handling message:', error);
|
|
@@ -141,7 +162,7 @@ export class WebSocketImproviseHandler {
|
|
|
141
162
|
/**
|
|
142
163
|
* Dispatch a parsed message to the appropriate handler
|
|
143
164
|
*/
|
|
144
|
-
async dispatchMessage(ws, msg, tabId, workingDir) {
|
|
165
|
+
async dispatchMessage(ws, msg, tabId, workingDir, permission) {
|
|
145
166
|
switch (msg.type) {
|
|
146
167
|
case 'ping':
|
|
147
168
|
this.send(ws, { type: 'pong', tabId });
|
|
@@ -158,7 +179,7 @@ export class WebSocketImproviseHandler {
|
|
|
158
179
|
case 'new':
|
|
159
180
|
case 'approve':
|
|
160
181
|
case 'reject':
|
|
161
|
-
return this.handleSessionMessage(ws, msg, tabId);
|
|
182
|
+
return this.handleSessionMessage(ws, msg, tabId, permission);
|
|
162
183
|
case 'getSessions':
|
|
163
184
|
case 'getSessionsCount':
|
|
164
185
|
case 'getSessionById':
|
|
@@ -170,16 +191,14 @@ export class WebSocketImproviseHandler {
|
|
|
170
191
|
case 'readFile':
|
|
171
192
|
case 'recordSelection':
|
|
172
193
|
case 'requestNotificationSummary':
|
|
173
|
-
return this.handleFileMessage(ws, msg, tabId, workingDir);
|
|
194
|
+
return this.handleFileMessage(ws, msg, tabId, workingDir, permission);
|
|
174
195
|
case 'terminalInit':
|
|
175
196
|
case 'terminalReconnect':
|
|
176
197
|
case 'terminalList':
|
|
177
|
-
case 'terminalInitPersistent':
|
|
178
|
-
case 'terminalListPersistent':
|
|
179
198
|
case 'terminalInput':
|
|
180
199
|
case 'terminalResize':
|
|
181
200
|
case 'terminalClose':
|
|
182
|
-
return this.handleTerminalMessage(ws, msg, tabId, workingDir);
|
|
201
|
+
return this.handleTerminalMessage(ws, msg, tabId, workingDir, permission);
|
|
183
202
|
case 'listDirectory':
|
|
184
203
|
case 'writeFile':
|
|
185
204
|
case 'createFile':
|
|
@@ -187,7 +206,7 @@ export class WebSocketImproviseHandler {
|
|
|
187
206
|
case 'deleteFile':
|
|
188
207
|
case 'renameFile':
|
|
189
208
|
case 'notifyFileOpened':
|
|
190
|
-
return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
|
|
209
|
+
return this.handleFileExplorerMessage(ws, msg, tabId, workingDir, permission);
|
|
191
210
|
case 'gitStatus':
|
|
192
211
|
case 'gitStage':
|
|
193
212
|
case 'gitUnstage':
|
|
@@ -197,6 +216,9 @@ export class WebSocketImproviseHandler {
|
|
|
197
216
|
case 'gitLog':
|
|
198
217
|
case 'gitDiscoverRepos':
|
|
199
218
|
case 'gitSetDirectory':
|
|
219
|
+
case 'gitGetRemoteInfo':
|
|
220
|
+
case 'gitCreatePR':
|
|
221
|
+
case 'gitGeneratePRDescription':
|
|
200
222
|
return this.handleGitMessage(ws, msg, tabId, workingDir);
|
|
201
223
|
// Session sync messages
|
|
202
224
|
case 'getActiveTabs':
|
|
@@ -225,19 +247,19 @@ export class WebSocketImproviseHandler {
|
|
|
225
247
|
/**
|
|
226
248
|
* Handle session-related messages (execute, cancel, history, new, approve, reject)
|
|
227
249
|
*/
|
|
228
|
-
handleSessionMessage(ws, msg, tabId) {
|
|
250
|
+
handleSessionMessage(ws, msg, tabId, permission) {
|
|
229
251
|
switch (msg.type) {
|
|
230
252
|
case 'execute': {
|
|
231
253
|
if (!msg.data?.prompt)
|
|
232
254
|
throw new Error('Prompt is required');
|
|
233
255
|
const session = this.requireSession(ws, tabId);
|
|
234
|
-
|
|
256
|
+
const sandboxed = permission === 'control' || permission === 'view';
|
|
257
|
+
session.executePrompt(msg.data.prompt, msg.data.attachments, { sandboxed });
|
|
235
258
|
break;
|
|
236
259
|
}
|
|
237
260
|
case 'cancel': {
|
|
238
261
|
const session = this.requireSession(ws, tabId);
|
|
239
262
|
session.cancel();
|
|
240
|
-
this.send(ws, { type: 'output', tabId, data: { text: '\n⚠️ Operation cancelled\n' } });
|
|
241
263
|
break;
|
|
242
264
|
}
|
|
243
265
|
case 'getHistory': {
|
|
@@ -313,7 +335,7 @@ export class WebSocketImproviseHandler {
|
|
|
313
335
|
/**
|
|
314
336
|
* Handle file-related messages (autocomplete, readFile, recordSelection, notifications)
|
|
315
337
|
*/
|
|
316
|
-
handleFileMessage(ws, msg, tabId, workingDir) {
|
|
338
|
+
handleFileMessage(ws, msg, tabId, workingDir, permission) {
|
|
317
339
|
switch (msg.type) {
|
|
318
340
|
case 'autocomplete':
|
|
319
341
|
if (!msg.data?.partialPath)
|
|
@@ -321,9 +343,7 @@ export class WebSocketImproviseHandler {
|
|
|
321
343
|
this.send(ws, { type: 'autocomplete', tabId, data: { completions: this.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
|
|
322
344
|
break;
|
|
323
345
|
case 'readFile':
|
|
324
|
-
|
|
325
|
-
throw new Error('File path is required');
|
|
326
|
-
this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
|
|
346
|
+
this.handleReadFile(ws, msg, tabId, workingDir, permission);
|
|
327
347
|
break;
|
|
328
348
|
case 'recordSelection':
|
|
329
349
|
if (msg.data?.filePath)
|
|
@@ -336,14 +356,27 @@ export class WebSocketImproviseHandler {
|
|
|
336
356
|
break;
|
|
337
357
|
}
|
|
338
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
|
+
}
|
|
339
372
|
/**
|
|
340
373
|
* Handle terminal messages
|
|
341
374
|
*/
|
|
342
|
-
handleTerminalMessage(ws, msg, tabId, workingDir) {
|
|
375
|
+
handleTerminalMessage(ws, msg, tabId, workingDir, permission) {
|
|
343
376
|
const termId = msg.terminalId || tabId;
|
|
344
377
|
switch (msg.type) {
|
|
345
378
|
case 'terminalInit':
|
|
346
|
-
this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
|
|
379
|
+
this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows, permission);
|
|
347
380
|
break;
|
|
348
381
|
case 'terminalReconnect':
|
|
349
382
|
this.handleTerminalReconnect(ws, termId);
|
|
@@ -351,12 +384,6 @@ export class WebSocketImproviseHandler {
|
|
|
351
384
|
case 'terminalList':
|
|
352
385
|
this.handleTerminalList(ws);
|
|
353
386
|
break;
|
|
354
|
-
case 'terminalInitPersistent':
|
|
355
|
-
this.handleTerminalInitPersistent(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
|
|
356
|
-
break;
|
|
357
|
-
case 'terminalListPersistent':
|
|
358
|
-
this.handleTerminalListPersistent(ws);
|
|
359
|
-
break;
|
|
360
387
|
case 'terminalInput':
|
|
361
388
|
this.handleTerminalInput(ws, termId, msg.data?.input);
|
|
362
389
|
break;
|
|
@@ -465,9 +492,20 @@ export class WebSocketImproviseHandler {
|
|
|
465
492
|
});
|
|
466
493
|
}
|
|
467
494
|
}
|
|
468
|
-
handleFileExplorerMessage(ws, msg, tabId, workingDir) {
|
|
495
|
+
handleFileExplorerMessage(ws, msg, tabId, workingDir, permission) {
|
|
496
|
+
const isSandboxed = permission === 'control' || permission === 'view';
|
|
469
497
|
const handlers = {
|
|
470
|
-
listDirectory: () =>
|
|
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
|
+
},
|
|
471
509
|
writeFile: () => this.handleWriteFile(ws, msg, tabId, workingDir),
|
|
472
510
|
createFile: () => this.handleCreateFile(ws, msg, tabId, workingDir),
|
|
473
511
|
createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
|
|
@@ -499,10 +537,10 @@ export class WebSocketImproviseHandler {
|
|
|
499
537
|
this.send(ws, { type: 'thinking', tabId, data: { text } });
|
|
500
538
|
});
|
|
501
539
|
session.on('onMovementStart', (sequenceNumber, prompt) => {
|
|
502
|
-
this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
|
|
540
|
+
this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp } });
|
|
503
541
|
// Broadcast execution state to ALL clients so tab indicators update
|
|
504
542
|
// even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
|
|
505
|
-
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true } });
|
|
543
|
+
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
|
|
506
544
|
});
|
|
507
545
|
session.on('onMovementComplete', (movement) => {
|
|
508
546
|
this.send(ws, { type: 'movementComplete', tabId, data: movement });
|
|
@@ -681,6 +719,7 @@ export class WebSocketImproviseHandler {
|
|
|
681
719
|
outputHistory,
|
|
682
720
|
isExecuting: session.isExecuting,
|
|
683
721
|
executionEvents,
|
|
722
|
+
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
684
723
|
}
|
|
685
724
|
});
|
|
686
725
|
}
|
|
@@ -767,12 +806,12 @@ export class WebSocketImproviseHandler {
|
|
|
767
806
|
* Get count of all historical sessions without reading file contents
|
|
768
807
|
*/
|
|
769
808
|
getSessionsCount(workingDir) {
|
|
770
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
809
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
771
810
|
if (!existsSync(sessionsDir)) {
|
|
772
811
|
return 0;
|
|
773
812
|
}
|
|
774
813
|
return readdirSync(sessionsDir)
|
|
775
|
-
.filter((name) => name.
|
|
814
|
+
.filter((name) => name.endsWith('.json'))
|
|
776
815
|
.length;
|
|
777
816
|
}
|
|
778
817
|
/**
|
|
@@ -780,16 +819,16 @@ export class WebSocketImproviseHandler {
|
|
|
780
819
|
* Returns minimal metadata - movements are stripped to just userPrompt preview
|
|
781
820
|
*/
|
|
782
821
|
getSessionsList(workingDir, limit = 20, offset = 0) {
|
|
783
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
822
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
784
823
|
if (!existsSync(sessionsDir)) {
|
|
785
824
|
return { sessions: [], total: 0, hasMore: false };
|
|
786
825
|
}
|
|
787
826
|
// Get sorted file list (newest first) without reading contents
|
|
788
827
|
const historyFiles = readdirSync(sessionsDir)
|
|
789
|
-
.filter((name) => name.
|
|
828
|
+
.filter((name) => name.endsWith('.json'))
|
|
790
829
|
.sort((a, b) => {
|
|
791
|
-
const timestampA = parseInt(a.replace('
|
|
792
|
-
const timestampB = parseInt(b.replace('
|
|
830
|
+
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
831
|
+
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
793
832
|
return timestampB - timestampA;
|
|
794
833
|
});
|
|
795
834
|
const total = historyFiles.length;
|
|
@@ -828,12 +867,12 @@ export class WebSocketImproviseHandler {
|
|
|
828
867
|
* Get a full session by ID (includes all movement data)
|
|
829
868
|
*/
|
|
830
869
|
getSessionById(workingDir, sessionId) {
|
|
831
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
870
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
832
871
|
if (!existsSync(sessionsDir)) {
|
|
833
872
|
return null;
|
|
834
873
|
}
|
|
835
874
|
const historyFiles = readdirSync(sessionsDir)
|
|
836
|
-
.filter((name) => name.
|
|
875
|
+
.filter((name) => name.endsWith('.json'));
|
|
837
876
|
for (const filename of historyFiles) {
|
|
838
877
|
const historyPath = join(sessionsDir, filename);
|
|
839
878
|
try {
|
|
@@ -861,13 +900,13 @@ export class WebSocketImproviseHandler {
|
|
|
861
900
|
* Delete a single session from disk
|
|
862
901
|
*/
|
|
863
902
|
deleteSession(workingDir, sessionId) {
|
|
864
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
903
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
865
904
|
if (!existsSync(sessionsDir)) {
|
|
866
905
|
return { sessionId, success: false };
|
|
867
906
|
}
|
|
868
907
|
try {
|
|
869
908
|
const historyFiles = readdirSync(sessionsDir)
|
|
870
|
-
.filter((name) => name.
|
|
909
|
+
.filter((name) => name.endsWith('.json'));
|
|
871
910
|
for (const filename of historyFiles) {
|
|
872
911
|
const historyPath = join(sessionsDir, filename);
|
|
873
912
|
try {
|
|
@@ -892,13 +931,13 @@ export class WebSocketImproviseHandler {
|
|
|
892
931
|
* Clear all sessions from disk
|
|
893
932
|
*/
|
|
894
933
|
clearAllSessions(workingDir) {
|
|
895
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
934
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
896
935
|
if (!existsSync(sessionsDir)) {
|
|
897
936
|
return { success: true, deletedCount: 0 };
|
|
898
937
|
}
|
|
899
938
|
try {
|
|
900
939
|
const historyFiles = readdirSync(sessionsDir)
|
|
901
|
-
.filter((name) => name.
|
|
940
|
+
.filter((name) => name.endsWith('.json'));
|
|
902
941
|
let deletedCount = 0;
|
|
903
942
|
for (const filename of historyFiles) {
|
|
904
943
|
const historyPath = join(sessionsDir, filename);
|
|
@@ -945,17 +984,17 @@ export class WebSocketImproviseHandler {
|
|
|
945
984
|
};
|
|
946
985
|
}
|
|
947
986
|
searchSessions(workingDir, query, limit = 20, offset = 0) {
|
|
948
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
987
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
949
988
|
if (!existsSync(sessionsDir)) {
|
|
950
989
|
return { sessions: [], total: 0, hasMore: false };
|
|
951
990
|
}
|
|
952
991
|
const lowerQuery = query.toLowerCase();
|
|
953
992
|
try {
|
|
954
993
|
const historyFiles = readdirSync(sessionsDir)
|
|
955
|
-
.filter((name) => name.
|
|
994
|
+
.filter((name) => name.endsWith('.json'))
|
|
956
995
|
.sort((a, b) => {
|
|
957
|
-
const timestampA = parseInt(a.replace('
|
|
958
|
-
const timestampB = parseInt(b.replace('
|
|
996
|
+
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
997
|
+
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
959
998
|
return timestampB - timestampA;
|
|
960
999
|
});
|
|
961
1000
|
const allMatches = [];
|
|
@@ -1017,6 +1056,7 @@ export class WebSocketImproviseHandler {
|
|
|
1017
1056
|
isExecuting: session.isExecuting,
|
|
1018
1057
|
outputHistory: this.buildOutputHistory(session),
|
|
1019
1058
|
executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
|
|
1059
|
+
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
1020
1060
|
};
|
|
1021
1061
|
}
|
|
1022
1062
|
else {
|
|
@@ -1312,6 +1352,9 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1312
1352
|
gitLog: () => this.handleGitLog(ws, msg, tabId, gitDir),
|
|
1313
1353
|
gitDiscoverRepos: () => this.handleGitDiscoverRepos(ws, tabId, workingDir),
|
|
1314
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),
|
|
1315
1358
|
};
|
|
1316
1359
|
handlers[msg.type]?.();
|
|
1317
1360
|
}
|
|
@@ -1457,15 +1500,24 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1457
1500
|
// Get current branch
|
|
1458
1501
|
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
1459
1502
|
const branch = branchResult.stdout.trim() || 'HEAD';
|
|
1460
|
-
// Get ahead/behind counts
|
|
1503
|
+
// Get ahead/behind counts and upstream tracking info
|
|
1461
1504
|
let ahead = 0;
|
|
1462
1505
|
let behind = 0;
|
|
1506
|
+
let hasUpstream = false;
|
|
1463
1507
|
const trackingResult = await this.executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
|
|
1464
1508
|
if (trackingResult.exitCode === 0) {
|
|
1509
|
+
hasUpstream = true;
|
|
1465
1510
|
const parts = trackingResult.stdout.trim().split(/\s+/);
|
|
1466
1511
|
ahead = parseInt(parts[0], 10) || 0;
|
|
1467
1512
|
behind = parseInt(parts[1], 10) || 0;
|
|
1468
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
|
+
}
|
|
1469
1521
|
const { staged, unstaged, untracked } = this.parseGitStatus(statusResult.stdout);
|
|
1470
1522
|
const response = {
|
|
1471
1523
|
branch,
|
|
@@ -1475,6 +1527,7 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1475
1527
|
untracked,
|
|
1476
1528
|
ahead,
|
|
1477
1529
|
behind,
|
|
1530
|
+
hasUpstream,
|
|
1478
1531
|
};
|
|
1479
1532
|
this.send(ws, { type: 'gitStatus', tabId, data: response });
|
|
1480
1533
|
}
|
|
@@ -1554,6 +1607,8 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1554
1607
|
const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
|
|
1555
1608
|
const hash = hashResult.stdout.trim();
|
|
1556
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);
|
|
1557
1612
|
}
|
|
1558
1613
|
catch (error) {
|
|
1559
1614
|
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
@@ -1660,6 +1715,8 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1660
1715
|
const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
|
|
1661
1716
|
const hash = hashResult.stdout.trim();
|
|
1662
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);
|
|
1663
1720
|
}
|
|
1664
1721
|
});
|
|
1665
1722
|
claude.on('error', (err) => {
|
|
@@ -1689,14 +1746,14 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1689
1746
|
for (const pattern of patterns) {
|
|
1690
1747
|
const match = output.match(pattern);
|
|
1691
1748
|
if (match?.[1]) {
|
|
1692
|
-
return match[1].trim();
|
|
1749
|
+
return this.stripCoauthorLines(match[1].trim());
|
|
1693
1750
|
}
|
|
1694
1751
|
}
|
|
1695
1752
|
// Split into paragraphs for analysis
|
|
1696
1753
|
const paragraphs = output.split(/\n\n+/).filter(p => p.trim());
|
|
1697
1754
|
// If only one paragraph, return it as-is
|
|
1698
1755
|
if (paragraphs.length <= 1) {
|
|
1699
|
-
return output.trim();
|
|
1756
|
+
return this.stripCoauthorLines(output.trim());
|
|
1700
1757
|
}
|
|
1701
1758
|
const firstParagraph = paragraphs[0].trim();
|
|
1702
1759
|
const firstLine = firstParagraph.split('\n')[0].trim();
|
|
@@ -1717,7 +1774,7 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1717
1774
|
// Validate the extracted message has a reasonable first line
|
|
1718
1775
|
const extractedFirstLine = commitMessage.split('\n')[0].trim();
|
|
1719
1776
|
if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
|
|
1720
|
-
return commitMessage;
|
|
1777
|
+
return this.stripCoauthorLines(commitMessage);
|
|
1721
1778
|
}
|
|
1722
1779
|
}
|
|
1723
1780
|
// Check if the second paragraph looks like a proper commit title
|
|
@@ -1730,18 +1787,49 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1730
1787
|
/^[A-Z][a-z]/.test(secondFirstLine) &&
|
|
1731
1788
|
!secondFirstLine.endsWith('.')) {
|
|
1732
1789
|
// Return from second paragraph onwards
|
|
1733
|
-
return paragraphs.slice(1).join('\n\n').trim();
|
|
1790
|
+
return this.stripCoauthorLines(paragraphs.slice(1).join('\n\n').trim());
|
|
1734
1791
|
}
|
|
1735
1792
|
}
|
|
1736
1793
|
// Fall back to original output if we can't identify a better message
|
|
1737
|
-
return output.trim();
|
|
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();
|
|
1738
1820
|
}
|
|
1739
1821
|
/**
|
|
1740
1822
|
* Handle git push request
|
|
1741
1823
|
*/
|
|
1742
1824
|
async handleGitPush(ws, tabId, workingDir) {
|
|
1743
1825
|
try {
|
|
1744
|
-
|
|
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);
|
|
1745
1833
|
if (result.exitCode !== 0) {
|
|
1746
1834
|
this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
|
|
1747
1835
|
return;
|
|
@@ -1874,13 +1962,305 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1874
1962
|
this.handleGitLog(ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
|
|
1875
1963
|
}
|
|
1876
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
|
+
}
|
|
1877
2257
|
// ============================================
|
|
1878
2258
|
// Terminal handling methods
|
|
1879
2259
|
// ============================================
|
|
1880
2260
|
/**
|
|
1881
2261
|
* Initialize a new terminal session or reconnect to existing one
|
|
1882
2262
|
*/
|
|
1883
|
-
handleTerminalInit(ws, terminalId, workingDir, requestedShell, cols, rows) {
|
|
2263
|
+
handleTerminalInit(ws, terminalId, workingDir, requestedShell, cols, rows, permission) {
|
|
1884
2264
|
const ptyManager = getPTYManager();
|
|
1885
2265
|
// Check if PTY is available (node-pty requires native compilation)
|
|
1886
2266
|
if (!ptyManager.isPtyAvailable()) {
|
|
@@ -1900,23 +2280,13 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1900
2280
|
this.setupTerminalBroadcastListeners(terminalId);
|
|
1901
2281
|
try {
|
|
1902
2282
|
// Create or reconnect to the PTY process
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
if (isReconnect) {
|
|
1906
|
-
const scrollback = ptyManager.getScrollback(terminalId);
|
|
1907
|
-
if (scrollback.length > 0) {
|
|
1908
|
-
this.send(ws, {
|
|
1909
|
-
type: 'terminalScrollback',
|
|
1910
|
-
terminalId,
|
|
1911
|
-
data: { lines: scrollback }
|
|
1912
|
-
});
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
else {
|
|
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) {
|
|
1916
2286
|
// New terminal — broadcast to other clients so they can create matching tabs
|
|
1917
2287
|
this.broadcastToOthers(ws, {
|
|
1918
2288
|
type: 'terminalCreated',
|
|
1919
|
-
data: { terminalId, shell, cwd
|
|
2289
|
+
data: { terminalId, shell, cwd }
|
|
1920
2290
|
});
|
|
1921
2291
|
}
|
|
1922
2292
|
// Send ready message to THIS client
|
|
@@ -1959,15 +2329,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1959
2329
|
this.addTerminalSubscriber(terminalId, ws);
|
|
1960
2330
|
// Set up broadcast listeners (idempotent — only creates once per terminal)
|
|
1961
2331
|
this.setupTerminalBroadcastListeners(terminalId);
|
|
1962
|
-
// Send scrollback buffer to THIS client only
|
|
1963
|
-
const scrollback = ptyManager.getScrollback(terminalId);
|
|
1964
|
-
if (scrollback.length > 0) {
|
|
1965
|
-
this.send(ws, {
|
|
1966
|
-
type: 'terminalScrollback',
|
|
1967
|
-
terminalId,
|
|
1968
|
-
data: { lines: scrollback }
|
|
1969
|
-
});
|
|
1970
|
-
}
|
|
1971
2332
|
// Send ready message indicating reconnection
|
|
1972
2333
|
this.send(ws, {
|
|
1973
2334
|
type: 'terminalReady',
|
|
@@ -2003,13 +2364,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2003
2364
|
if (!input) {
|
|
2004
2365
|
return;
|
|
2005
2366
|
}
|
|
2006
|
-
// Check if this is a persistent terminal first
|
|
2007
|
-
const persistentHandler = this.persistentHandlers.get(terminalId);
|
|
2008
|
-
if (persistentHandler) {
|
|
2009
|
-
persistentHandler.write(input);
|
|
2010
|
-
return;
|
|
2011
|
-
}
|
|
2012
|
-
// Otherwise use regular PTY
|
|
2013
2367
|
const ptyManager = getPTYManager();
|
|
2014
2368
|
const success = ptyManager.write(terminalId, input);
|
|
2015
2369
|
if (!success) {
|
|
@@ -2027,13 +2381,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2027
2381
|
if (!cols || !rows) {
|
|
2028
2382
|
return;
|
|
2029
2383
|
}
|
|
2030
|
-
// Check if this is a persistent terminal first
|
|
2031
|
-
const persistentHandler = this.persistentHandlers.get(terminalId);
|
|
2032
|
-
if (persistentHandler) {
|
|
2033
|
-
persistentHandler.resize(cols, rows);
|
|
2034
|
-
return;
|
|
2035
|
-
}
|
|
2036
|
-
// Otherwise use regular PTY
|
|
2037
2384
|
const ptyManager = getPTYManager();
|
|
2038
2385
|
ptyManager.resize(terminalId, cols, rows);
|
|
2039
2386
|
}
|
|
@@ -2042,26 +2389,15 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2042
2389
|
*/
|
|
2043
2390
|
handleTerminalClose(ws, terminalId) {
|
|
2044
2391
|
trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
|
|
2045
|
-
//
|
|
2046
|
-
const
|
|
2047
|
-
if (
|
|
2048
|
-
|
|
2049
|
-
this.
|
|
2050
|
-
// For persistent terminals, close actually kills the tmux session
|
|
2051
|
-
const ptyManager = getPTYManager();
|
|
2052
|
-
ptyManager.closePersistent(terminalId);
|
|
2053
|
-
}
|
|
2054
|
-
else {
|
|
2055
|
-
// Clean up event listeners
|
|
2056
|
-
const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
|
|
2057
|
-
if (listenerCleanup) {
|
|
2058
|
-
listenerCleanup();
|
|
2059
|
-
this.terminalListenerCleanups.delete(terminalId);
|
|
2060
|
-
}
|
|
2061
|
-
// Close regular PTY
|
|
2062
|
-
const ptyManager = getPTYManager();
|
|
2063
|
-
ptyManager.close(terminalId);
|
|
2392
|
+
// Clean up event listeners
|
|
2393
|
+
const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
|
|
2394
|
+
if (listenerCleanup) {
|
|
2395
|
+
listenerCleanup();
|
|
2396
|
+
this.terminalListenerCleanups.delete(terminalId);
|
|
2064
2397
|
}
|
|
2398
|
+
// Close PTY
|
|
2399
|
+
const ptyManager = getPTYManager();
|
|
2400
|
+
ptyManager.close(terminalId);
|
|
2065
2401
|
// Clean up subscribers
|
|
2066
2402
|
this.terminalSubscribers.delete(terminalId);
|
|
2067
2403
|
// Broadcast to other clients
|
|
@@ -2070,8 +2406,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2070
2406
|
data: { terminalId }
|
|
2071
2407
|
});
|
|
2072
2408
|
}
|
|
2073
|
-
// Persistent terminal handlers for tmux-backed sessions
|
|
2074
|
-
persistentHandlers = new Map();
|
|
2075
2409
|
// Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
|
|
2076
2410
|
terminalListenerCleanups = new Map();
|
|
2077
2411
|
// Track which WS connections are subscribed to each terminal's output
|
|
@@ -2104,31 +2438,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2104
2438
|
this.terminalListenerCleanups.delete(terminalId);
|
|
2105
2439
|
}
|
|
2106
2440
|
}
|
|
2107
|
-
/**
|
|
2108
|
-
* Attach persistent (tmux) terminal handlers for output/exit broadcasting.
|
|
2109
|
-
*/
|
|
2110
|
-
attachPersistentHandlers(terminalId, ptyManager) {
|
|
2111
|
-
const handlers = ptyManager.attachPersistent(terminalId, (output) => {
|
|
2112
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2113
|
-
if (subs) {
|
|
2114
|
-
for (const sub of subs) {
|
|
2115
|
-
this.send(sub, { type: 'terminalOutput', terminalId, data: { output } });
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
}, (exitCode) => {
|
|
2119
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2120
|
-
if (subs) {
|
|
2121
|
-
for (const sub of subs) {
|
|
2122
|
-
this.send(sub, { type: 'terminalExit', terminalId, data: { exitCode } });
|
|
2123
|
-
}
|
|
2124
|
-
}
|
|
2125
|
-
this.persistentHandlers.delete(terminalId);
|
|
2126
|
-
this.terminalSubscribers.delete(terminalId);
|
|
2127
|
-
});
|
|
2128
|
-
if (handlers) {
|
|
2129
|
-
this.persistentHandlers.set(terminalId, handlers);
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
2441
|
/**
|
|
2133
2442
|
* Set up PTY event listeners that broadcast to all subscribers.
|
|
2134
2443
|
* Only creates listeners once per terminal (idempotent).
|
|
@@ -2183,85 +2492,5 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2183
2492
|
ptyManager.off('error', onError);
|
|
2184
2493
|
});
|
|
2185
2494
|
}
|
|
2186
|
-
/**
|
|
2187
|
-
* Initialize a persistent (tmux-backed) terminal session
|
|
2188
|
-
* These sessions survive server restarts.
|
|
2189
|
-
* Uses subscriber pattern for multi-client output broadcasting.
|
|
2190
|
-
*/
|
|
2191
|
-
handleTerminalInitPersistent(ws, terminalId, workingDir, requestedShell, cols, rows) {
|
|
2192
|
-
const ptyManager = getPTYManager();
|
|
2193
|
-
// Check if tmux is available
|
|
2194
|
-
if (!ptyManager.isTmuxAvailable()) {
|
|
2195
|
-
this.send(ws, {
|
|
2196
|
-
type: 'terminalError',
|
|
2197
|
-
terminalId,
|
|
2198
|
-
data: { error: 'Persistent terminals require tmux, which is not installed' }
|
|
2199
|
-
});
|
|
2200
|
-
return;
|
|
2201
|
-
}
|
|
2202
|
-
// Add this WS as a subscriber for this terminal's output
|
|
2203
|
-
this.addTerminalSubscriber(terminalId, ws);
|
|
2204
|
-
try {
|
|
2205
|
-
// Create or reconnect to the persistent session
|
|
2206
|
-
const { shell, cwd, isReconnect } = ptyManager.createPersistent(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
|
|
2207
|
-
// Only attach if we don't already have handlers (first subscriber)
|
|
2208
|
-
if (!this.persistentHandlers.has(terminalId)) {
|
|
2209
|
-
this.attachPersistentHandlers(terminalId, ptyManager);
|
|
2210
|
-
}
|
|
2211
|
-
// If reconnecting, send scrollback buffer to THIS client only
|
|
2212
|
-
if (isReconnect) {
|
|
2213
|
-
const scrollback = ptyManager.getPersistentScrollback(terminalId);
|
|
2214
|
-
if (scrollback.length > 0) {
|
|
2215
|
-
this.send(ws, {
|
|
2216
|
-
type: 'terminalScrollback',
|
|
2217
|
-
terminalId,
|
|
2218
|
-
data: { lines: scrollback }
|
|
2219
|
-
});
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
else {
|
|
2223
|
-
// New terminal — broadcast to other clients so they can create matching tabs
|
|
2224
|
-
this.broadcastToOthers(ws, {
|
|
2225
|
-
type: 'terminalCreated',
|
|
2226
|
-
data: { terminalId, shell, cwd, persistent: true }
|
|
2227
|
-
});
|
|
2228
|
-
}
|
|
2229
|
-
// Send ready message to THIS client
|
|
2230
|
-
this.send(ws, {
|
|
2231
|
-
type: 'terminalReady',
|
|
2232
|
-
terminalId,
|
|
2233
|
-
data: { shell, cwd, isReconnect, persistent: true }
|
|
2234
|
-
});
|
|
2235
|
-
}
|
|
2236
|
-
catch (error) {
|
|
2237
|
-
console.error(`[WebSocketImproviseHandler] Failed to create persistent terminal:`, error);
|
|
2238
|
-
this.send(ws, {
|
|
2239
|
-
type: 'terminalError',
|
|
2240
|
-
terminalId,
|
|
2241
|
-
data: { error: error.message || 'Failed to create persistent terminal' }
|
|
2242
|
-
});
|
|
2243
|
-
this.removeTerminalSubscriber(terminalId, ws);
|
|
2244
|
-
}
|
|
2245
|
-
}
|
|
2246
|
-
/**
|
|
2247
|
-
* List all persistent terminal sessions (including those from previous server runs)
|
|
2248
|
-
*/
|
|
2249
|
-
handleTerminalListPersistent(ws) {
|
|
2250
|
-
const ptyManager = getPTYManager();
|
|
2251
|
-
const sessions = ptyManager.getPersistentSessions();
|
|
2252
|
-
this.send(ws, {
|
|
2253
|
-
type: 'terminalListPersistent',
|
|
2254
|
-
data: {
|
|
2255
|
-
available: ptyManager.isTmuxAvailable(),
|
|
2256
|
-
terminals: sessions.map(s => ({
|
|
2257
|
-
id: s.terminalId,
|
|
2258
|
-
shell: s.shell,
|
|
2259
|
-
cwd: s.cwd,
|
|
2260
|
-
createdAt: s.createdAt,
|
|
2261
|
-
lastAttachedAt: s.lastAttachedAt,
|
|
2262
|
-
}))
|
|
2263
|
-
}
|
|
2264
|
-
});
|
|
2265
|
-
}
|
|
2266
2495
|
}
|
|
2267
2496
|
//# sourceMappingURL=handler.js.map
|