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
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
renameFile,
|
|
23
23
|
writeFile
|
|
24
24
|
} from '../files.js';
|
|
25
|
+
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
25
26
|
import { captureException } from '../sentry.js';
|
|
26
|
-
import { getModel, getSettings, setModel } from '../settings.js';
|
|
27
|
+
import { getModel, getPrBaseBranch, getSettings, setModel, setPrBaseBranch } from '../settings.js';
|
|
27
28
|
import { getPTYManager } from '../terminal/pty-manager.js';
|
|
28
29
|
import { AutocompleteService } from './autocomplete.js';
|
|
29
30
|
import { readFileContent } from './file-utils.js';
|
|
@@ -38,24 +39,31 @@ export interface UsageReport {
|
|
|
38
39
|
|
|
39
40
|
export type UsageReporter = (report: UsageReport) => void;
|
|
40
41
|
|
|
42
|
+
/** Convert tool history entries into OutputLine-compatible lines */
|
|
43
|
+
function convertToolHistoryToLines(tools: any[], ts: number): any[] {
|
|
44
|
+
const lines: any[] = [];
|
|
45
|
+
for (const tool of tools) {
|
|
46
|
+
lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
|
|
47
|
+
if (tool.result !== undefined) {
|
|
48
|
+
lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/** Convert a single movement record into OutputLine-compatible entries */
|
|
42
|
-
function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: any[]; assistantResponse?: string; errorOutput?: string; tokensUsed: number }): any[] {
|
|
55
|
+
function convertMovementToLines(movement: { userPrompt: string; timestamp: string; thinkingOutput?: string; toolUseHistory?: any[]; assistantResponse?: string; errorOutput?: string; tokensUsed: number; durationMs?: number }): any[] {
|
|
43
56
|
const lines: any[] = [];
|
|
44
57
|
const ts = new Date(movement.timestamp).getTime();
|
|
45
58
|
|
|
46
|
-
lines.push({ type: 'user', text:
|
|
59
|
+
lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
|
|
47
60
|
|
|
48
61
|
if (movement.thinkingOutput) {
|
|
49
62
|
lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
if (movement.toolUseHistory) {
|
|
53
|
-
|
|
54
|
-
lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
|
|
55
|
-
if (tool.result !== undefined) {
|
|
56
|
-
lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
|
|
57
|
-
}
|
|
58
|
-
}
|
|
66
|
+
lines.push(...convertToolHistoryToLines(movement.toolUseHistory, ts));
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
if (movement.assistantResponse) {
|
|
@@ -66,10 +74,20 @@ function convertMovementToLines(movement: { userPrompt: string; timestamp: strin
|
|
|
66
74
|
lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
const durationText = movement.durationMs
|
|
78
|
+
? `Completed in ${(movement.durationMs / 1000).toFixed(2)}s`
|
|
79
|
+
: 'Completed';
|
|
80
|
+
lines.push({ type: 'system', text: durationText, timestamp: ts });
|
|
70
81
|
return lines;
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
/** Detect git provider from remote URL */
|
|
85
|
+
function detectGitProvider(remoteUrl: string): 'github' | 'gitlab' | 'unknown' {
|
|
86
|
+
if (remoteUrl.includes('github.com')) return 'github';
|
|
87
|
+
if (remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab')) return 'gitlab';
|
|
88
|
+
return 'unknown';
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
export class WebSocketImproviseHandler {
|
|
74
92
|
private sessions: Map<string, ImprovisationSessionManager> = new Map();
|
|
75
93
|
private connections: Map<WSContext, Map<string, string>> = new Map();
|
|
@@ -163,8 +181,11 @@ export class WebSocketImproviseHandler {
|
|
|
163
181
|
try {
|
|
164
182
|
const msg: WebSocketMessage = JSON.parse(message);
|
|
165
183
|
const tabId = msg.tabId || 'default';
|
|
184
|
+
// Extract sandbox permission injected by server relay (for sandboxed shared users)
|
|
185
|
+
const permission = msg._permission;
|
|
186
|
+
delete msg._permission;
|
|
166
187
|
|
|
167
|
-
await this.dispatchMessage(ws, msg, tabId, workingDir);
|
|
188
|
+
await this.dispatchMessage(ws, msg, tabId, workingDir, permission);
|
|
168
189
|
} catch (error: any) {
|
|
169
190
|
console.error('[WebSocketImproviseHandler] Error handling message:', error);
|
|
170
191
|
captureException(error, { context: 'websocket.handleMessage' });
|
|
@@ -178,7 +199,7 @@ export class WebSocketImproviseHandler {
|
|
|
178
199
|
/**
|
|
179
200
|
* Dispatch a parsed message to the appropriate handler
|
|
180
201
|
*/
|
|
181
|
-
private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
202
|
+
private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): Promise<void> {
|
|
182
203
|
switch (msg.type) {
|
|
183
204
|
case 'ping':
|
|
184
205
|
this.send(ws, { type: 'pong', tabId });
|
|
@@ -194,7 +215,7 @@ export class WebSocketImproviseHandler {
|
|
|
194
215
|
case 'new':
|
|
195
216
|
case 'approve':
|
|
196
217
|
case 'reject':
|
|
197
|
-
return this.handleSessionMessage(ws, msg, tabId);
|
|
218
|
+
return this.handleSessionMessage(ws, msg, tabId, permission);
|
|
198
219
|
case 'getSessions':
|
|
199
220
|
case 'getSessionsCount':
|
|
200
221
|
case 'getSessionById':
|
|
@@ -206,16 +227,14 @@ export class WebSocketImproviseHandler {
|
|
|
206
227
|
case 'readFile':
|
|
207
228
|
case 'recordSelection':
|
|
208
229
|
case 'requestNotificationSummary':
|
|
209
|
-
return this.handleFileMessage(ws, msg, tabId, workingDir);
|
|
230
|
+
return this.handleFileMessage(ws, msg, tabId, workingDir, permission);
|
|
210
231
|
case 'terminalInit':
|
|
211
232
|
case 'terminalReconnect':
|
|
212
233
|
case 'terminalList':
|
|
213
|
-
case 'terminalInitPersistent':
|
|
214
|
-
case 'terminalListPersistent':
|
|
215
234
|
case 'terminalInput':
|
|
216
235
|
case 'terminalResize':
|
|
217
236
|
case 'terminalClose':
|
|
218
|
-
return this.handleTerminalMessage(ws, msg, tabId, workingDir);
|
|
237
|
+
return this.handleTerminalMessage(ws, msg, tabId, workingDir, permission);
|
|
219
238
|
case 'listDirectory':
|
|
220
239
|
case 'writeFile':
|
|
221
240
|
case 'createFile':
|
|
@@ -223,7 +242,7 @@ export class WebSocketImproviseHandler {
|
|
|
223
242
|
case 'deleteFile':
|
|
224
243
|
case 'renameFile':
|
|
225
244
|
case 'notifyFileOpened':
|
|
226
|
-
return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
|
|
245
|
+
return this.handleFileExplorerMessage(ws, msg, tabId, workingDir, permission);
|
|
227
246
|
case 'gitStatus':
|
|
228
247
|
case 'gitStage':
|
|
229
248
|
case 'gitUnstage':
|
|
@@ -233,6 +252,9 @@ export class WebSocketImproviseHandler {
|
|
|
233
252
|
case 'gitLog':
|
|
234
253
|
case 'gitDiscoverRepos':
|
|
235
254
|
case 'gitSetDirectory':
|
|
255
|
+
case 'gitGetRemoteInfo':
|
|
256
|
+
case 'gitCreatePR':
|
|
257
|
+
case 'gitGeneratePRDescription':
|
|
236
258
|
return this.handleGitMessage(ws, msg, tabId, workingDir);
|
|
237
259
|
// Session sync messages
|
|
238
260
|
case 'getActiveTabs':
|
|
@@ -262,18 +284,18 @@ export class WebSocketImproviseHandler {
|
|
|
262
284
|
/**
|
|
263
285
|
* Handle session-related messages (execute, cancel, history, new, approve, reject)
|
|
264
286
|
*/
|
|
265
|
-
private handleSessionMessage(ws: WSContext, msg: WebSocketMessage, tabId: string): void {
|
|
287
|
+
private handleSessionMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'control' | 'view'): void {
|
|
266
288
|
switch (msg.type) {
|
|
267
289
|
case 'execute': {
|
|
268
290
|
if (!msg.data?.prompt) throw new Error('Prompt is required');
|
|
269
291
|
const session = this.requireSession(ws, tabId);
|
|
270
|
-
|
|
292
|
+
const sandboxed = permission === 'control' || permission === 'view';
|
|
293
|
+
session.executePrompt(msg.data.prompt, msg.data.attachments, { sandboxed });
|
|
271
294
|
break;
|
|
272
295
|
}
|
|
273
296
|
case 'cancel': {
|
|
274
297
|
const session = this.requireSession(ws, tabId);
|
|
275
298
|
session.cancel();
|
|
276
|
-
this.send(ws, { type: 'output', tabId, data: { text: '\n⚠️ Operation cancelled\n' } });
|
|
277
299
|
break;
|
|
278
300
|
}
|
|
279
301
|
case 'getHistory': {
|
|
@@ -347,15 +369,14 @@ export class WebSocketImproviseHandler {
|
|
|
347
369
|
/**
|
|
348
370
|
* Handle file-related messages (autocomplete, readFile, recordSelection, notifications)
|
|
349
371
|
*/
|
|
350
|
-
private handleFileMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
372
|
+
private handleFileMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
351
373
|
switch (msg.type) {
|
|
352
374
|
case 'autocomplete':
|
|
353
375
|
if (!msg.data?.partialPath) throw new Error('Partial path is required');
|
|
354
376
|
this.send(ws, { type: 'autocomplete', tabId, data: { completions: this.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
|
|
355
377
|
break;
|
|
356
378
|
case 'readFile':
|
|
357
|
-
|
|
358
|
-
this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
|
|
379
|
+
this.handleReadFile(ws, msg, tabId, workingDir, permission);
|
|
359
380
|
break;
|
|
360
381
|
case 'recordSelection':
|
|
361
382
|
if (msg.data?.filePath) this.recordFileSelection(msg.data.filePath);
|
|
@@ -367,14 +388,27 @@ export class WebSocketImproviseHandler {
|
|
|
367
388
|
}
|
|
368
389
|
}
|
|
369
390
|
|
|
391
|
+
private handleReadFile(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
392
|
+
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
393
|
+
const isSandboxed = permission === 'control' || permission === 'view';
|
|
394
|
+
if (isSandboxed) {
|
|
395
|
+
const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
|
|
396
|
+
if (!validation.valid) {
|
|
397
|
+
this.send(ws, { type: 'fileContent', tabId, data: { path: msg.data.filePath, fileName: msg.data.filePath.split('/').pop() || '', content: '', error: 'Sandboxed: path outside project directory' } });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
|
|
402
|
+
}
|
|
403
|
+
|
|
370
404
|
/**
|
|
371
405
|
* Handle terminal messages
|
|
372
406
|
*/
|
|
373
|
-
private handleTerminalMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
407
|
+
private handleTerminalMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
374
408
|
const termId = msg.terminalId || tabId;
|
|
375
409
|
switch (msg.type) {
|
|
376
410
|
case 'terminalInit':
|
|
377
|
-
this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
|
|
411
|
+
this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows, permission);
|
|
378
412
|
break;
|
|
379
413
|
case 'terminalReconnect':
|
|
380
414
|
this.handleTerminalReconnect(ws, termId);
|
|
@@ -382,12 +416,6 @@ export class WebSocketImproviseHandler {
|
|
|
382
416
|
case 'terminalList':
|
|
383
417
|
this.handleTerminalList(ws);
|
|
384
418
|
break;
|
|
385
|
-
case 'terminalInitPersistent':
|
|
386
|
-
this.handleTerminalInitPersistent(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
|
|
387
|
-
break;
|
|
388
|
-
case 'terminalListPersistent':
|
|
389
|
-
this.handleTerminalListPersistent(ws);
|
|
390
|
-
break;
|
|
391
419
|
case 'terminalInput':
|
|
392
420
|
this.handleTerminalInput(ws, termId, msg.data?.input);
|
|
393
421
|
break;
|
|
@@ -496,9 +524,20 @@ export class WebSocketImproviseHandler {
|
|
|
496
524
|
}
|
|
497
525
|
}
|
|
498
526
|
|
|
499
|
-
private handleFileExplorerMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
527
|
+
private handleFileExplorerMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
528
|
+
const isSandboxed = permission === 'control' || permission === 'view';
|
|
500
529
|
const handlers: Record<string, () => void> = {
|
|
501
|
-
listDirectory: () =>
|
|
530
|
+
listDirectory: () => {
|
|
531
|
+
// Sandboxed users can only list directories within the project
|
|
532
|
+
if (isSandboxed && msg.data?.dirPath) {
|
|
533
|
+
const validation = validatePathWithinWorkingDir(msg.data.dirPath, workingDir);
|
|
534
|
+
if (!validation.valid) {
|
|
535
|
+
this.send(ws, { type: 'directoryListing', tabId, data: { success: false, path: msg.data.dirPath, error: 'Sandboxed: path outside project directory' } });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
this.handleListDirectory(ws, msg, tabId, workingDir);
|
|
540
|
+
},
|
|
502
541
|
writeFile: () => this.handleWriteFile(ws, msg, tabId, workingDir),
|
|
503
542
|
createFile: () => this.handleCreateFile(ws, msg, tabId, workingDir),
|
|
504
543
|
createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
|
|
@@ -534,10 +573,10 @@ export class WebSocketImproviseHandler {
|
|
|
534
573
|
});
|
|
535
574
|
|
|
536
575
|
session.on('onMovementStart', (sequenceNumber: number, prompt: string) => {
|
|
537
|
-
this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
|
|
576
|
+
this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp } });
|
|
538
577
|
// Broadcast execution state to ALL clients so tab indicators update
|
|
539
578
|
// even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
|
|
540
|
-
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true } });
|
|
579
|
+
this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
|
|
541
580
|
});
|
|
542
581
|
|
|
543
582
|
session.on('onMovementComplete', (movement: any) => {
|
|
@@ -755,6 +794,7 @@ export class WebSocketImproviseHandler {
|
|
|
755
794
|
outputHistory,
|
|
756
795
|
isExecuting: session.isExecuting,
|
|
757
796
|
executionEvents,
|
|
797
|
+
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
758
798
|
}
|
|
759
799
|
});
|
|
760
800
|
}
|
|
@@ -851,14 +891,14 @@ export class WebSocketImproviseHandler {
|
|
|
851
891
|
* Get count of all historical sessions without reading file contents
|
|
852
892
|
*/
|
|
853
893
|
private getSessionsCount(workingDir: string): number {
|
|
854
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
894
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
855
895
|
|
|
856
896
|
if (!existsSync(sessionsDir)) {
|
|
857
897
|
return 0;
|
|
858
898
|
}
|
|
859
899
|
|
|
860
900
|
return readdirSync(sessionsDir)
|
|
861
|
-
.filter((name: string) => name.
|
|
901
|
+
.filter((name: string) => name.endsWith('.json'))
|
|
862
902
|
.length;
|
|
863
903
|
}
|
|
864
904
|
|
|
@@ -867,7 +907,7 @@ export class WebSocketImproviseHandler {
|
|
|
867
907
|
* Returns minimal metadata - movements are stripped to just userPrompt preview
|
|
868
908
|
*/
|
|
869
909
|
private getSessionsList(workingDir: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
|
|
870
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
910
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
871
911
|
|
|
872
912
|
if (!existsSync(sessionsDir)) {
|
|
873
913
|
return { sessions: [], total: 0, hasMore: false };
|
|
@@ -875,10 +915,10 @@ export class WebSocketImproviseHandler {
|
|
|
875
915
|
|
|
876
916
|
// Get sorted file list (newest first) without reading contents
|
|
877
917
|
const historyFiles = readdirSync(sessionsDir)
|
|
878
|
-
.filter((name: string) => name.
|
|
918
|
+
.filter((name: string) => name.endsWith('.json'))
|
|
879
919
|
.sort((a: string, b: string) => {
|
|
880
|
-
const timestampA = parseInt(a.replace('
|
|
881
|
-
const timestampB = parseInt(b.replace('
|
|
920
|
+
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
921
|
+
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
882
922
|
return timestampB - timestampA;
|
|
883
923
|
});
|
|
884
924
|
|
|
@@ -923,14 +963,14 @@ export class WebSocketImproviseHandler {
|
|
|
923
963
|
* Get a full session by ID (includes all movement data)
|
|
924
964
|
*/
|
|
925
965
|
private getSessionById(workingDir: string, sessionId: string): any {
|
|
926
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
966
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
927
967
|
|
|
928
968
|
if (!existsSync(sessionsDir)) {
|
|
929
969
|
return null;
|
|
930
970
|
}
|
|
931
971
|
|
|
932
972
|
const historyFiles = readdirSync(sessionsDir)
|
|
933
|
-
.filter((name: string) => name.
|
|
973
|
+
.filter((name: string) => name.endsWith('.json'));
|
|
934
974
|
|
|
935
975
|
for (const filename of historyFiles) {
|
|
936
976
|
const historyPath = join(sessionsDir, filename);
|
|
@@ -960,7 +1000,7 @@ export class WebSocketImproviseHandler {
|
|
|
960
1000
|
* Delete a single session from disk
|
|
961
1001
|
*/
|
|
962
1002
|
private deleteSession(workingDir: string, sessionId: string): { sessionId: string; success: boolean } {
|
|
963
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
1003
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
964
1004
|
|
|
965
1005
|
if (!existsSync(sessionsDir)) {
|
|
966
1006
|
return { sessionId, success: false };
|
|
@@ -968,7 +1008,7 @@ export class WebSocketImproviseHandler {
|
|
|
968
1008
|
|
|
969
1009
|
try {
|
|
970
1010
|
const historyFiles = readdirSync(sessionsDir)
|
|
971
|
-
.filter((name: string) => name.
|
|
1011
|
+
.filter((name: string) => name.endsWith('.json'));
|
|
972
1012
|
|
|
973
1013
|
for (const filename of historyFiles) {
|
|
974
1014
|
const historyPath = join(sessionsDir, filename);
|
|
@@ -994,7 +1034,7 @@ export class WebSocketImproviseHandler {
|
|
|
994
1034
|
* Clear all sessions from disk
|
|
995
1035
|
*/
|
|
996
1036
|
private clearAllSessions(workingDir: string): { success: boolean; deletedCount: number } {
|
|
997
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
1037
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
998
1038
|
|
|
999
1039
|
if (!existsSync(sessionsDir)) {
|
|
1000
1040
|
return { success: true, deletedCount: 0 };
|
|
@@ -1002,7 +1042,7 @@ export class WebSocketImproviseHandler {
|
|
|
1002
1042
|
|
|
1003
1043
|
try {
|
|
1004
1044
|
const historyFiles = readdirSync(sessionsDir)
|
|
1005
|
-
.filter((name: string) => name.
|
|
1045
|
+
.filter((name: string) => name.endsWith('.json'));
|
|
1006
1046
|
|
|
1007
1047
|
let deletedCount = 0;
|
|
1008
1048
|
for (const filename of historyFiles) {
|
|
@@ -1053,7 +1093,7 @@ export class WebSocketImproviseHandler {
|
|
|
1053
1093
|
}
|
|
1054
1094
|
|
|
1055
1095
|
private searchSessions(workingDir: string, query: string, limit: number = 20, offset: number = 0): { sessions: any[]; total: number; hasMore: boolean } {
|
|
1056
|
-
const sessionsDir = join(workingDir, '.mstro', '
|
|
1096
|
+
const sessionsDir = join(workingDir, '.mstro', 'history');
|
|
1057
1097
|
|
|
1058
1098
|
if (!existsSync(sessionsDir)) {
|
|
1059
1099
|
return { sessions: [], total: 0, hasMore: false };
|
|
@@ -1063,10 +1103,10 @@ export class WebSocketImproviseHandler {
|
|
|
1063
1103
|
|
|
1064
1104
|
try {
|
|
1065
1105
|
const historyFiles = readdirSync(sessionsDir)
|
|
1066
|
-
.filter((name: string) => name.
|
|
1106
|
+
.filter((name: string) => name.endsWith('.json'))
|
|
1067
1107
|
.sort((a: string, b: string) => {
|
|
1068
|
-
const timestampA = parseInt(a.replace('
|
|
1069
|
-
const timestampB = parseInt(b.replace('
|
|
1108
|
+
const timestampA = parseInt(a.replace('.json', ''), 10);
|
|
1109
|
+
const timestampB = parseInt(b.replace('.json', ''), 10);
|
|
1070
1110
|
return timestampB - timestampA;
|
|
1071
1111
|
});
|
|
1072
1112
|
|
|
@@ -1133,6 +1173,7 @@ export class WebSocketImproviseHandler {
|
|
|
1133
1173
|
isExecuting: session.isExecuting,
|
|
1134
1174
|
outputHistory: this.buildOutputHistory(session),
|
|
1135
1175
|
executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
|
|
1176
|
+
...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
|
|
1136
1177
|
};
|
|
1137
1178
|
} else {
|
|
1138
1179
|
// Session not in memory — try to provide basic info from registry
|
|
@@ -1466,6 +1507,9 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1466
1507
|
gitLog: () => this.handleGitLog(ws, msg, tabId, gitDir),
|
|
1467
1508
|
gitDiscoverRepos: () => this.handleGitDiscoverRepos(ws, tabId, workingDir),
|
|
1468
1509
|
gitSetDirectory: () => this.handleGitSetDirectory(ws, msg, tabId, workingDir),
|
|
1510
|
+
gitGetRemoteInfo: () => this.handleGitGetRemoteInfo(ws, tabId, gitDir),
|
|
1511
|
+
gitCreatePR: () => this.handleGitCreatePR(ws, msg, tabId, gitDir),
|
|
1512
|
+
gitGeneratePRDescription: () => this.handleGitGeneratePRDescription(ws, msg, tabId, gitDir),
|
|
1469
1513
|
};
|
|
1470
1514
|
handlers[msg.type]?.();
|
|
1471
1515
|
}
|
|
@@ -1636,14 +1680,22 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1636
1680
|
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
1637
1681
|
const branch = branchResult.stdout.trim() || 'HEAD';
|
|
1638
1682
|
|
|
1639
|
-
// Get ahead/behind counts
|
|
1683
|
+
// Get ahead/behind counts and upstream tracking info
|
|
1640
1684
|
let ahead = 0;
|
|
1641
1685
|
let behind = 0;
|
|
1686
|
+
let hasUpstream = false;
|
|
1642
1687
|
const trackingResult = await this.executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
|
|
1643
1688
|
if (trackingResult.exitCode === 0) {
|
|
1689
|
+
hasUpstream = true;
|
|
1644
1690
|
const parts = trackingResult.stdout.trim().split(/\s+/);
|
|
1645
1691
|
ahead = parseInt(parts[0], 10) || 0;
|
|
1646
1692
|
behind = parseInt(parts[1], 10) || 0;
|
|
1693
|
+
} else {
|
|
1694
|
+
// No upstream - count local commits as ahead
|
|
1695
|
+
const localResult = await this.executeGitCommand(['rev-list', '--count', 'HEAD'], workingDir);
|
|
1696
|
+
if (localResult.exitCode === 0) {
|
|
1697
|
+
ahead = parseInt(localResult.stdout.trim(), 10) || 0;
|
|
1698
|
+
}
|
|
1647
1699
|
}
|
|
1648
1700
|
|
|
1649
1701
|
const { staged, unstaged, untracked } = this.parseGitStatus(statusResult.stdout);
|
|
@@ -1656,6 +1708,7 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1656
1708
|
untracked,
|
|
1657
1709
|
ahead,
|
|
1658
1710
|
behind,
|
|
1711
|
+
hasUpstream,
|
|
1659
1712
|
};
|
|
1660
1713
|
|
|
1661
1714
|
this.send(ws, { type: 'gitStatus', tabId, data: response });
|
|
@@ -1744,6 +1797,8 @@ Respond with ONLY the summary text, nothing else.`;
|
|
|
1744
1797
|
const hash = hashResult.stdout.trim();
|
|
1745
1798
|
|
|
1746
1799
|
this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message } });
|
|
1800
|
+
// Proactively send updated status so the UI reflects new ahead/behind counts
|
|
1801
|
+
this.handleGitStatus(ws, tabId, workingDir);
|
|
1747
1802
|
} catch (error: any) {
|
|
1748
1803
|
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
1749
1804
|
}
|
|
@@ -1869,6 +1924,8 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1869
1924
|
const hash = hashResult.stdout.trim();
|
|
1870
1925
|
|
|
1871
1926
|
this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message: commitMessage } });
|
|
1927
|
+
// Proactively send updated status so the UI reflects new ahead/behind counts
|
|
1928
|
+
this.handleGitStatus(ws, tabId, workingDir);
|
|
1872
1929
|
}
|
|
1873
1930
|
});
|
|
1874
1931
|
|
|
@@ -1902,7 +1959,7 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1902
1959
|
for (const pattern of patterns) {
|
|
1903
1960
|
const match = output.match(pattern);
|
|
1904
1961
|
if (match?.[1]) {
|
|
1905
|
-
return match[1].trim();
|
|
1962
|
+
return this.stripCoauthorLines(match[1].trim());
|
|
1906
1963
|
}
|
|
1907
1964
|
}
|
|
1908
1965
|
|
|
@@ -1911,7 +1968,7 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1911
1968
|
|
|
1912
1969
|
// If only one paragraph, return it as-is
|
|
1913
1970
|
if (paragraphs.length <= 1) {
|
|
1914
|
-
return output.trim();
|
|
1971
|
+
return this.stripCoauthorLines(output.trim());
|
|
1915
1972
|
}
|
|
1916
1973
|
|
|
1917
1974
|
const firstParagraph = paragraphs[0].trim();
|
|
@@ -1938,7 +1995,7 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1938
1995
|
// Validate the extracted message has a reasonable first line
|
|
1939
1996
|
const extractedFirstLine = commitMessage.split('\n')[0].trim();
|
|
1940
1997
|
if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
|
|
1941
|
-
return commitMessage;
|
|
1998
|
+
return this.stripCoauthorLines(commitMessage);
|
|
1942
1999
|
}
|
|
1943
2000
|
}
|
|
1944
2001
|
|
|
@@ -1953,12 +2010,37 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1953
2010
|
/^[A-Z][a-z]/.test(secondFirstLine) &&
|
|
1954
2011
|
!secondFirstLine.endsWith('.')) {
|
|
1955
2012
|
// Return from second paragraph onwards
|
|
1956
|
-
return paragraphs.slice(1).join('\n\n').trim();
|
|
2013
|
+
return this.stripCoauthorLines(paragraphs.slice(1).join('\n\n').trim());
|
|
1957
2014
|
}
|
|
1958
2015
|
}
|
|
1959
2016
|
|
|
1960
2017
|
// Fall back to original output if we can't identify a better message
|
|
1961
|
-
return output.trim();
|
|
2018
|
+
return this.stripCoauthorLines(output.trim());
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Strip injected coauthor/attribution lines from a commit message.
|
|
2023
|
+
* The Claude Code CLI appends "Co-Authored-By" lines to LLM output.
|
|
2024
|
+
* We detect and remove them by matching known marker strings.
|
|
2025
|
+
*/
|
|
2026
|
+
private stripCoauthorLines(message: string): string {
|
|
2027
|
+
const lines = message.split('\n');
|
|
2028
|
+
const markers = ['co-authored', 'authored-by', 'haiku', 'noreply@anthropic.com'];
|
|
2029
|
+
const result: string[] = [];
|
|
2030
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2031
|
+
const lower = lines[i].toLowerCase();
|
|
2032
|
+
if (markers.some(m => lower.includes(m))) {
|
|
2033
|
+
// Also remove a blank line immediately before this one
|
|
2034
|
+
if (result.length > 0 && result[result.length - 1].trim() === '') {
|
|
2035
|
+
result.pop();
|
|
2036
|
+
}
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
2039
|
+
result.push(lines[i]);
|
|
2040
|
+
}
|
|
2041
|
+
// Don't return empty - keep at least the first line of the original
|
|
2042
|
+
if (result.length === 0) return lines[0]?.trim() || message;
|
|
2043
|
+
return result.join('\n').trimEnd();
|
|
1962
2044
|
}
|
|
1963
2045
|
|
|
1964
2046
|
/**
|
|
@@ -1966,7 +2048,15 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
1966
2048
|
*/
|
|
1967
2049
|
private async handleGitPush(ws: WSContext, tabId: string, workingDir: string): Promise<void> {
|
|
1968
2050
|
try {
|
|
1969
|
-
|
|
2051
|
+
// Check if branch has an upstream, if not use --set-upstream
|
|
2052
|
+
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
2053
|
+
const branch = branchResult.stdout.trim();
|
|
2054
|
+
|
|
2055
|
+
const upstreamCheck = await this.executeGitCommand(['rev-parse', '--abbrev-ref', `${branch}@{u}`], workingDir);
|
|
2056
|
+
const hasUpstream = upstreamCheck.exitCode === 0;
|
|
2057
|
+
|
|
2058
|
+
const pushArgs = hasUpstream ? ['push'] : ['push', '-u', 'origin', branch];
|
|
2059
|
+
const result = await this.executeGitCommand(pushArgs, workingDir);
|
|
1970
2060
|
if (result.exitCode !== 0) {
|
|
1971
2061
|
this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
|
|
1972
2062
|
return;
|
|
@@ -2117,6 +2207,356 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2117
2207
|
}
|
|
2118
2208
|
}
|
|
2119
2209
|
|
|
2210
|
+
/**
|
|
2211
|
+
* Get remote info for PR creation (remote URL, provider, default branch)
|
|
2212
|
+
*/
|
|
2213
|
+
private async handleGitGetRemoteInfo(ws: WSContext, tabId: string, workingDir: string): Promise<void> {
|
|
2214
|
+
try {
|
|
2215
|
+
const remoteResult = await this.executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
|
|
2216
|
+
if (remoteResult.exitCode !== 0) {
|
|
2217
|
+
this.send(ws, { type: 'gitRemoteInfo', tabId, data: { hasRemote: false } });
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const remoteUrl = remoteResult.stdout.trim();
|
|
2222
|
+
const provider = detectGitProvider(remoteUrl);
|
|
2223
|
+
const defaultBranch = await this.getDefaultBranch(workingDir);
|
|
2224
|
+
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
2225
|
+
const currentBranch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : '';
|
|
2226
|
+
const cliStatus = await this.checkGitCliStatus(provider);
|
|
2227
|
+
const remoteBranches = await this.listRemoteBranches(workingDir);
|
|
2228
|
+
const preferredBaseBranch = getPrBaseBranch(remoteUrl) ?? undefined;
|
|
2229
|
+
|
|
2230
|
+
this.send(ws, {
|
|
2231
|
+
type: 'gitRemoteInfo',
|
|
2232
|
+
tabId,
|
|
2233
|
+
data: {
|
|
2234
|
+
hasRemote: true,
|
|
2235
|
+
remoteUrl,
|
|
2236
|
+
provider,
|
|
2237
|
+
defaultBranch,
|
|
2238
|
+
currentBranch,
|
|
2239
|
+
...cliStatus,
|
|
2240
|
+
remoteBranches,
|
|
2241
|
+
preferredBaseBranch,
|
|
2242
|
+
},
|
|
2243
|
+
});
|
|
2244
|
+
} catch (error: any) {
|
|
2245
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
private async getDefaultBranch(workingDir: string): Promise<string> {
|
|
2250
|
+
const result = await this.executeGitCommand(
|
|
2251
|
+
['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
|
|
2252
|
+
workingDir
|
|
2253
|
+
);
|
|
2254
|
+
return result.exitCode === 0 ? result.stdout.trim().replace('origin/', '') : 'main';
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
private async checkGitCliStatus(provider: 'github' | 'gitlab' | 'unknown'): Promise<{ hasGhCli: boolean; ghCliAuthenticated: boolean; ghCliBinary?: 'gh' | 'glab' }> {
|
|
2258
|
+
const cliBin = provider === 'github' ? 'gh' : provider === 'gitlab' ? 'glab' : null;
|
|
2259
|
+
if (!cliBin) return { hasGhCli: false, ghCliAuthenticated: false };
|
|
2260
|
+
|
|
2261
|
+
const installed = await this.spawnCheck(cliBin, ['--version']);
|
|
2262
|
+
if (!installed) return { hasGhCli: false, ghCliAuthenticated: false };
|
|
2263
|
+
|
|
2264
|
+
const authenticated = await this.spawnCheck(cliBin, ['auth', 'status']);
|
|
2265
|
+
return { hasGhCli: true, ghCliAuthenticated: authenticated, ghCliBinary: cliBin };
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
private async listRemoteBranches(workingDir: string): Promise<string[]> {
|
|
2269
|
+
const result = await this.executeGitCommand(['branch', '-r', '--list', 'origin/*'], workingDir);
|
|
2270
|
+
if (result.exitCode !== 0) return [];
|
|
2271
|
+
|
|
2272
|
+
return result.stdout.split('\n')
|
|
2273
|
+
.map(line => line.trim())
|
|
2274
|
+
.filter(line => line && !line.includes('->'))
|
|
2275
|
+
.map(line => line.replace('origin/', ''))
|
|
2276
|
+
.filter(Boolean)
|
|
2277
|
+
.sort();
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/** Check if a binary runs successfully (exit code 0) */
|
|
2281
|
+
private spawnCheck(bin: string, args: string[]): Promise<boolean> {
|
|
2282
|
+
return new Promise((resolve) => {
|
|
2283
|
+
const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2284
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
2285
|
+
proc.on('error', () => resolve(false));
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** Detect which CLI binary to use for PR creation based on remote URL */
|
|
2290
|
+
private detectPRCliBin(remoteUrl: string): { cliBin: 'gh' | 'glab' | null; isGitHub: boolean; isGitLab: boolean } {
|
|
2291
|
+
const isGitHub = remoteUrl.includes('github.com');
|
|
2292
|
+
const isGitLab = remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab');
|
|
2293
|
+
const cliBin = isGitHub ? 'gh' as const : isGitLab ? 'glab' as const : null;
|
|
2294
|
+
return { cliBin, isGitHub, isGitLab };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/** Send PR success and optionally persist base branch */
|
|
2298
|
+
private sendPRCreated(
|
|
2299
|
+
ws: WSContext, tabId: string, url: string, method: string,
|
|
2300
|
+
remoteUrl: string, baseBranch?: string,
|
|
2301
|
+
): void {
|
|
2302
|
+
if (baseBranch) setPrBaseBranch(remoteUrl, baseBranch);
|
|
2303
|
+
this.send(ws, { type: 'gitPRCreated', tabId, data: { url, method } });
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Create a pull/merge request using gh CLI (GitHub) or open browser URL (fallback)
|
|
2308
|
+
*/
|
|
2309
|
+
private async handleGitCreatePR(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
2310
|
+
const { title, body, baseBranch, draft } = msg.data ?? {};
|
|
2311
|
+
|
|
2312
|
+
if (!title) {
|
|
2313
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: 'PR title is required' } });
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
try {
|
|
2318
|
+
const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
|
|
2319
|
+
if (branchResult.exitCode !== 0) {
|
|
2320
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to detect current branch' } });
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
const remoteResult = await this.executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
|
|
2325
|
+
if (remoteResult.exitCode !== 0) {
|
|
2326
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: 'No remote origin configured' } });
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
const headBranch = branchResult.stdout.trim();
|
|
2331
|
+
const remoteUrl = remoteResult.stdout.trim();
|
|
2332
|
+
const { cliBin, isGitHub, isGitLab } = this.detectPRCliBin(remoteUrl);
|
|
2333
|
+
|
|
2334
|
+
const cliResult = await this.tryCliPRCreate(cliBin, { title, body, baseBranch, draft, headBranch }, workingDir);
|
|
2335
|
+
|
|
2336
|
+
if (cliResult.created) {
|
|
2337
|
+
this.sendPRCreated(ws, tabId, cliResult.url!, isGitHub ? 'gh' : 'glab', remoteUrl, baseBranch);
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
if (cliResult.error) {
|
|
2341
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: cliResult.error } });
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
const prUrl = this.buildBrowserPRUrl(remoteUrl, headBranch, baseBranch, title, body, isGitHub, isGitLab);
|
|
2346
|
+
if (prUrl) {
|
|
2347
|
+
this.sendPRCreated(ws, tabId, prUrl, 'browser', remoteUrl, baseBranch);
|
|
2348
|
+
} else {
|
|
2349
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: 'Could not determine remote URL format for PR creation' } });
|
|
2350
|
+
}
|
|
2351
|
+
} catch (error: any) {
|
|
2352
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/** Attempt to create a PR/MR via CLI. Returns { created, url, error } */
|
|
2357
|
+
private async tryCliPRCreate(
|
|
2358
|
+
cliBin: 'gh' | 'glab' | null,
|
|
2359
|
+
opts: { title: string; body?: string; baseBranch?: string; draft?: boolean; headBranch: string },
|
|
2360
|
+
workingDir: string,
|
|
2361
|
+
): Promise<{ created: boolean; url?: string; error?: string }> {
|
|
2362
|
+
if (!cliBin) return { created: false }; // No CLI for this provider
|
|
2363
|
+
|
|
2364
|
+
// Check if CLI is installed
|
|
2365
|
+
const installed = await this.spawnCheck(cliBin, ['--version']);
|
|
2366
|
+
if (!installed) return { created: false }; // Not installed, fall through to browser
|
|
2367
|
+
|
|
2368
|
+
// Build CLI args
|
|
2369
|
+
const args = cliBin === 'gh'
|
|
2370
|
+
? ['pr', 'create', '--title', opts.title]
|
|
2371
|
+
: ['mr', 'create', '--title', opts.title, '--yes']; // glab mr create
|
|
2372
|
+
|
|
2373
|
+
if (opts.body) args.push('--body', opts.body);
|
|
2374
|
+
if (opts.baseBranch) {
|
|
2375
|
+
args.push(cliBin === 'gh' ? '--base' : '--target-branch', opts.baseBranch);
|
|
2376
|
+
}
|
|
2377
|
+
if (opts.draft) args.push('--draft');
|
|
2378
|
+
|
|
2379
|
+
const result = await this.spawnWithOutput(cliBin, args, workingDir);
|
|
2380
|
+
|
|
2381
|
+
if (result.exitCode === 0) {
|
|
2382
|
+
const urlMatch = result.stdout.match(/https?:\/\/\S+/);
|
|
2383
|
+
return { created: true, url: urlMatch ? urlMatch[0] : result.stdout.trim() };
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
return { created: false, error: this.classifyCliPRError(cliBin, result, opts.headBranch) };
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/** Classify a CLI PR creation error into a user-facing message */
|
|
2390
|
+
private classifyCliPRError(
|
|
2391
|
+
cliBin: string,
|
|
2392
|
+
result: { stdout: string; stderr: string },
|
|
2393
|
+
headBranch: string,
|
|
2394
|
+
): string {
|
|
2395
|
+
const combined = result.stderr + result.stdout;
|
|
2396
|
+
const lower = combined.toLowerCase();
|
|
2397
|
+
|
|
2398
|
+
if (lower.includes('already exists')) {
|
|
2399
|
+
const existingUrl = combined.match(/https?:\/\/\S+/);
|
|
2400
|
+
return existingUrl
|
|
2401
|
+
? `A pull request already exists for ${headBranch}: ${existingUrl[0]}`
|
|
2402
|
+
: `A pull request already exists for ${headBranch}`;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
if (lower.includes('auth') || lower.includes('401') || lower.includes('token') || lower.includes('log in')) {
|
|
2406
|
+
return `${cliBin} is not authenticated. Run: ${cliBin} auth login`;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if (lower.includes('must first push') || lower.includes('failed to push') || lower.includes('no upstream')) {
|
|
2410
|
+
return `Branch "${headBranch}" has not been pushed to remote. Push first, then create the PR.`;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
return `${cliBin} failed: ${(result.stderr || result.stdout).trim()}`;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/** Spawn a process and capture stdout/stderr */
|
|
2417
|
+
private spawnWithOutput(bin: string, args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
2418
|
+
return new Promise((resolve) => {
|
|
2419
|
+
const proc = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2420
|
+
let stdout = '';
|
|
2421
|
+
let stderr = '';
|
|
2422
|
+
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
|
|
2423
|
+
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
2424
|
+
proc.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
|
|
2425
|
+
proc.on('error', (err: Error) => resolve({ stdout: '', stderr: err.message, exitCode: 1 }));
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
/** Build a browser URL for PR creation (fallback when no CLI) */
|
|
2430
|
+
private buildBrowserPRUrl(
|
|
2431
|
+
remoteUrl: string, headBranch: string, baseBranch: string | undefined,
|
|
2432
|
+
title: string, body: string | undefined, isGitHub: boolean, isGitLab: boolean,
|
|
2433
|
+
): string {
|
|
2434
|
+
const sshMatch = remoteUrl.match(/[:/]([^/]+)\/([^/.]+)(?:\.git)?$/);
|
|
2435
|
+
if (!sshMatch) return '';
|
|
2436
|
+
|
|
2437
|
+
const [, owner, repo] = sshMatch;
|
|
2438
|
+
const base = baseBranch || 'main';
|
|
2439
|
+
|
|
2440
|
+
if (isGitHub) {
|
|
2441
|
+
return `https://github.com/${owner}/${repo}/compare/${base}...${headBranch}?expand=1&title=${encodeURIComponent(title)}${body ? `&body=${encodeURIComponent(body)}` : ''}`;
|
|
2442
|
+
}
|
|
2443
|
+
if (isGitLab) {
|
|
2444
|
+
return `https://gitlab.com/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${headBranch}&merge_request[target_branch]=${base}&merge_request[title]=${encodeURIComponent(title)}`;
|
|
2445
|
+
}
|
|
2446
|
+
return '';
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
/**
|
|
2450
|
+
* Generate a PR title and description using Haiku, based on the diff against the base branch.
|
|
2451
|
+
*/
|
|
2452
|
+
private async handleGitGeneratePRDescription(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
2453
|
+
const baseBranch = msg.data?.baseBranch || 'main';
|
|
2454
|
+
|
|
2455
|
+
try {
|
|
2456
|
+
// Get commit list for context
|
|
2457
|
+
const logResult = await this.executeGitCommand(['log', `${baseBranch}..HEAD`, '--oneline'], workingDir);
|
|
2458
|
+
const commits = logResult.exitCode === 0 ? logResult.stdout.trim() : '';
|
|
2459
|
+
|
|
2460
|
+
if (!commits) {
|
|
2461
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: `No commits found between ${baseBranch} and HEAD` } });
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// Get diff against base
|
|
2466
|
+
const diffResult = await this.executeGitCommand(['diff', `${baseBranch}...HEAD`], workingDir);
|
|
2467
|
+
const diff = diffResult.exitCode === 0 ? diffResult.stdout : '';
|
|
2468
|
+
|
|
2469
|
+
// Get changed files summary
|
|
2470
|
+
const statResult = await this.executeGitCommand(['diff', `${baseBranch}...HEAD`, '--stat'], workingDir);
|
|
2471
|
+
const stat = statResult.exitCode === 0 ? statResult.stdout.trim() : '';
|
|
2472
|
+
|
|
2473
|
+
// Truncate diff if too long (same pattern as commit message generation)
|
|
2474
|
+
let truncatedDiff = diff;
|
|
2475
|
+
if (diff.length > 8000) {
|
|
2476
|
+
truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// Build prompt
|
|
2480
|
+
const tempDir = join(workingDir, '.mstro', 'tmp');
|
|
2481
|
+
if (!existsSync(tempDir)) {
|
|
2482
|
+
mkdirSync(tempDir, { recursive: true });
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
const prompt = `You are generating a pull request title and description for the following changes.
|
|
2486
|
+
|
|
2487
|
+
COMMITS (${baseBranch}..HEAD):
|
|
2488
|
+
${commits}
|
|
2489
|
+
|
|
2490
|
+
FILES CHANGED:
|
|
2491
|
+
${stat}
|
|
2492
|
+
|
|
2493
|
+
DIFF:
|
|
2494
|
+
${truncatedDiff}
|
|
2495
|
+
|
|
2496
|
+
Generate a pull request title and description following these rules:
|
|
2497
|
+
1. TITLE: First line must be the PR title — imperative mood, under 70 characters
|
|
2498
|
+
2. Leave a blank line after the title
|
|
2499
|
+
3. BODY: Write a concise description in markdown with:
|
|
2500
|
+
- A "## Summary" section with 1-3 bullet points explaining what changed and why
|
|
2501
|
+
- Optionally a "## Details" section if the changes are complex
|
|
2502
|
+
4. Focus on the "why" not just the "what"
|
|
2503
|
+
5. No emojis
|
|
2504
|
+
|
|
2505
|
+
Respond with ONLY the title and description, nothing else.`;
|
|
2506
|
+
|
|
2507
|
+
const promptFile = join(tempDir, `pr-desc-${Date.now()}.txt`);
|
|
2508
|
+
writeFileSync(promptFile, prompt);
|
|
2509
|
+
|
|
2510
|
+
const systemPrompt = 'You are a pull request description assistant. Respond with only the PR title and description, no preamble or explanation.';
|
|
2511
|
+
|
|
2512
|
+
const args = [
|
|
2513
|
+
'--print',
|
|
2514
|
+
'--model', 'haiku',
|
|
2515
|
+
'--system-prompt', systemPrompt,
|
|
2516
|
+
promptFile
|
|
2517
|
+
];
|
|
2518
|
+
|
|
2519
|
+
const claude = spawn('claude', args, {
|
|
2520
|
+
cwd: workingDir,
|
|
2521
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
2522
|
+
});
|
|
2523
|
+
|
|
2524
|
+
let stdout = '';
|
|
2525
|
+
let stderr = '';
|
|
2526
|
+
|
|
2527
|
+
claude.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
2528
|
+
claude.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
2529
|
+
|
|
2530
|
+
claude.on('close', (code: number | null) => {
|
|
2531
|
+
try { unlinkSync(promptFile); } catch { /* ignore */ }
|
|
2532
|
+
|
|
2533
|
+
if (code !== 0 || !stdout.trim()) {
|
|
2534
|
+
console.error('[WebSocketImproviseHandler] Claude PR description error:', stderr || 'No output');
|
|
2535
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// Parse: first line = title, rest = body
|
|
2540
|
+
const output = this.stripCoauthorLines(stdout.trim());
|
|
2541
|
+
const lines = output.split('\n');
|
|
2542
|
+
const title = lines[0].trim();
|
|
2543
|
+
const body = lines.slice(1).join('\n').trim();
|
|
2544
|
+
|
|
2545
|
+
this.send(ws, { type: 'gitPRDescription', tabId, data: { title, body } });
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
claude.on('error', (err: Error) => {
|
|
2549
|
+
console.error('[WebSocketImproviseHandler] Failed to spawn Claude for PR description:', err);
|
|
2550
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
setTimeout(() => { claude.kill(); }, 30000);
|
|
2554
|
+
|
|
2555
|
+
} catch (error: any) {
|
|
2556
|
+
this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2120
2560
|
// ============================================
|
|
2121
2561
|
// Terminal handling methods
|
|
2122
2562
|
// ============================================
|
|
@@ -2130,7 +2570,8 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2130
2570
|
workingDir: string,
|
|
2131
2571
|
requestedShell?: string,
|
|
2132
2572
|
cols?: number,
|
|
2133
|
-
rows?: number
|
|
2573
|
+
rows?: number,
|
|
2574
|
+
permission?: 'control' | 'view'
|
|
2134
2575
|
): void {
|
|
2135
2576
|
|
|
2136
2577
|
const ptyManager = getPTYManager();
|
|
@@ -2156,29 +2597,21 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2156
2597
|
|
|
2157
2598
|
try {
|
|
2158
2599
|
// Create or reconnect to the PTY process
|
|
2600
|
+
// Both 'control' and 'view' users get sandboxed terminals
|
|
2159
2601
|
const { shell, cwd, isReconnect } = ptyManager.create(
|
|
2160
2602
|
terminalId,
|
|
2161
2603
|
workingDir,
|
|
2162
2604
|
cols || 80,
|
|
2163
2605
|
rows || 24,
|
|
2164
|
-
requestedShell
|
|
2606
|
+
requestedShell,
|
|
2607
|
+
{ sandboxed: permission === 'control' || permission === 'view' }
|
|
2165
2608
|
);
|
|
2166
2609
|
|
|
2167
|
-
|
|
2168
|
-
if (isReconnect) {
|
|
2169
|
-
const scrollback = ptyManager.getScrollback(terminalId);
|
|
2170
|
-
if (scrollback.length > 0) {
|
|
2171
|
-
this.send(ws, {
|
|
2172
|
-
type: 'terminalScrollback',
|
|
2173
|
-
terminalId,
|
|
2174
|
-
data: { lines: scrollback }
|
|
2175
|
-
});
|
|
2176
|
-
}
|
|
2177
|
-
} else {
|
|
2610
|
+
if (!isReconnect) {
|
|
2178
2611
|
// New terminal — broadcast to other clients so they can create matching tabs
|
|
2179
2612
|
this.broadcastToOthers(ws, {
|
|
2180
2613
|
type: 'terminalCreated',
|
|
2181
|
-
data: { terminalId, shell, cwd
|
|
2614
|
+
data: { terminalId, shell, cwd }
|
|
2182
2615
|
});
|
|
2183
2616
|
}
|
|
2184
2617
|
|
|
@@ -2227,16 +2660,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2227
2660
|
// Set up broadcast listeners (idempotent — only creates once per terminal)
|
|
2228
2661
|
this.setupTerminalBroadcastListeners(terminalId);
|
|
2229
2662
|
|
|
2230
|
-
// Send scrollback buffer to THIS client only
|
|
2231
|
-
const scrollback = ptyManager.getScrollback(terminalId);
|
|
2232
|
-
if (scrollback.length > 0) {
|
|
2233
|
-
this.send(ws, {
|
|
2234
|
-
type: 'terminalScrollback',
|
|
2235
|
-
terminalId,
|
|
2236
|
-
data: { lines: scrollback }
|
|
2237
|
-
});
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
2663
|
// Send ready message indicating reconnection
|
|
2241
2664
|
this.send(ws, {
|
|
2242
2665
|
type: 'terminalReady',
|
|
@@ -2282,14 +2705,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2282
2705
|
return;
|
|
2283
2706
|
}
|
|
2284
2707
|
|
|
2285
|
-
// Check if this is a persistent terminal first
|
|
2286
|
-
const persistentHandler = this.persistentHandlers.get(terminalId);
|
|
2287
|
-
if (persistentHandler) {
|
|
2288
|
-
persistentHandler.write(input);
|
|
2289
|
-
return;
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
// Otherwise use regular PTY
|
|
2293
2708
|
const ptyManager = getPTYManager();
|
|
2294
2709
|
const success = ptyManager.write(terminalId, input);
|
|
2295
2710
|
|
|
@@ -2315,14 +2730,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2315
2730
|
return;
|
|
2316
2731
|
}
|
|
2317
2732
|
|
|
2318
|
-
// Check if this is a persistent terminal first
|
|
2319
|
-
const persistentHandler = this.persistentHandlers.get(terminalId);
|
|
2320
|
-
if (persistentHandler) {
|
|
2321
|
-
persistentHandler.resize(cols, rows);
|
|
2322
|
-
return;
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
// Otherwise use regular PTY
|
|
2326
2733
|
const ptyManager = getPTYManager();
|
|
2327
2734
|
ptyManager.resize(terminalId, cols, rows);
|
|
2328
2735
|
}
|
|
@@ -2333,27 +2740,17 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2333
2740
|
private handleTerminalClose(ws: WSContext, terminalId: string): void {
|
|
2334
2741
|
trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
|
|
2335
2742
|
|
|
2336
|
-
//
|
|
2337
|
-
const
|
|
2338
|
-
if (
|
|
2339
|
-
|
|
2340
|
-
this.
|
|
2341
|
-
// For persistent terminals, close actually kills the tmux session
|
|
2342
|
-
const ptyManager = getPTYManager();
|
|
2343
|
-
ptyManager.closePersistent(terminalId);
|
|
2344
|
-
} else {
|
|
2345
|
-
// Clean up event listeners
|
|
2346
|
-
const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
|
|
2347
|
-
if (listenerCleanup) {
|
|
2348
|
-
listenerCleanup();
|
|
2349
|
-
this.terminalListenerCleanups.delete(terminalId);
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
// Close regular PTY
|
|
2353
|
-
const ptyManager = getPTYManager();
|
|
2354
|
-
ptyManager.close(terminalId);
|
|
2743
|
+
// Clean up event listeners
|
|
2744
|
+
const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
|
|
2745
|
+
if (listenerCleanup) {
|
|
2746
|
+
listenerCleanup();
|
|
2747
|
+
this.terminalListenerCleanups.delete(terminalId);
|
|
2355
2748
|
}
|
|
2356
2749
|
|
|
2750
|
+
// Close PTY
|
|
2751
|
+
const ptyManager = getPTYManager();
|
|
2752
|
+
ptyManager.close(terminalId);
|
|
2753
|
+
|
|
2357
2754
|
// Clean up subscribers
|
|
2358
2755
|
this.terminalSubscribers.delete(terminalId);
|
|
2359
2756
|
|
|
@@ -2364,9 +2761,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2364
2761
|
});
|
|
2365
2762
|
}
|
|
2366
2763
|
|
|
2367
|
-
// Persistent terminal handlers for tmux-backed sessions
|
|
2368
|
-
private persistentHandlers: Map<string, { write: (data: string) => void; resize: (cols: number, rows: number) => void; detach: () => void }> = new Map();
|
|
2369
|
-
|
|
2370
2764
|
// Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
|
|
2371
2765
|
private terminalListenerCleanups: Map<string, () => void> = new Map();
|
|
2372
2766
|
|
|
@@ -2401,36 +2795,6 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2401
2795
|
}
|
|
2402
2796
|
}
|
|
2403
2797
|
|
|
2404
|
-
/**
|
|
2405
|
-
* Attach persistent (tmux) terminal handlers for output/exit broadcasting.
|
|
2406
|
-
*/
|
|
2407
|
-
private attachPersistentHandlers(terminalId: string, ptyManager: ReturnType<typeof getPTYManager>): void {
|
|
2408
|
-
const handlers = ptyManager.attachPersistent(
|
|
2409
|
-
terminalId,
|
|
2410
|
-
(output: string) => {
|
|
2411
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2412
|
-
if (subs) {
|
|
2413
|
-
for (const sub of subs) {
|
|
2414
|
-
this.send(sub, { type: 'terminalOutput', terminalId, data: { output } });
|
|
2415
|
-
}
|
|
2416
|
-
}
|
|
2417
|
-
},
|
|
2418
|
-
(exitCode: number) => {
|
|
2419
|
-
const subs = this.terminalSubscribers.get(terminalId);
|
|
2420
|
-
if (subs) {
|
|
2421
|
-
for (const sub of subs) {
|
|
2422
|
-
this.send(sub, { type: 'terminalExit', terminalId, data: { exitCode } });
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
this.persistentHandlers.delete(terminalId);
|
|
2426
|
-
this.terminalSubscribers.delete(terminalId);
|
|
2427
|
-
}
|
|
2428
|
-
);
|
|
2429
|
-
if (handlers) {
|
|
2430
|
-
this.persistentHandlers.set(terminalId, handlers);
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
2798
|
/**
|
|
2435
2799
|
* Set up PTY event listeners that broadcast to all subscribers.
|
|
2436
2800
|
* Only creates listeners once per terminal (idempotent).
|
|
@@ -2491,104 +2855,4 @@ Respond with ONLY the commit message, nothing else.`;
|
|
|
2491
2855
|
});
|
|
2492
2856
|
}
|
|
2493
2857
|
|
|
2494
|
-
/**
|
|
2495
|
-
* Initialize a persistent (tmux-backed) terminal session
|
|
2496
|
-
* These sessions survive server restarts.
|
|
2497
|
-
* Uses subscriber pattern for multi-client output broadcasting.
|
|
2498
|
-
*/
|
|
2499
|
-
private handleTerminalInitPersistent(
|
|
2500
|
-
ws: WSContext,
|
|
2501
|
-
terminalId: string,
|
|
2502
|
-
workingDir: string,
|
|
2503
|
-
requestedShell?: string,
|
|
2504
|
-
cols?: number,
|
|
2505
|
-
rows?: number
|
|
2506
|
-
): void {
|
|
2507
|
-
|
|
2508
|
-
const ptyManager = getPTYManager();
|
|
2509
|
-
|
|
2510
|
-
// Check if tmux is available
|
|
2511
|
-
if (!ptyManager.isTmuxAvailable()) {
|
|
2512
|
-
this.send(ws, {
|
|
2513
|
-
type: 'terminalError',
|
|
2514
|
-
terminalId,
|
|
2515
|
-
data: { error: 'Persistent terminals require tmux, which is not installed' }
|
|
2516
|
-
});
|
|
2517
|
-
return;
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
// Add this WS as a subscriber for this terminal's output
|
|
2521
|
-
this.addTerminalSubscriber(terminalId, ws);
|
|
2522
|
-
|
|
2523
|
-
try {
|
|
2524
|
-
// Create or reconnect to the persistent session
|
|
2525
|
-
const { shell, cwd, isReconnect } = ptyManager.createPersistent(
|
|
2526
|
-
terminalId,
|
|
2527
|
-
workingDir,
|
|
2528
|
-
cols || 80,
|
|
2529
|
-
rows || 24,
|
|
2530
|
-
requestedShell
|
|
2531
|
-
);
|
|
2532
|
-
|
|
2533
|
-
// Only attach if we don't already have handlers (first subscriber)
|
|
2534
|
-
if (!this.persistentHandlers.has(terminalId)) {
|
|
2535
|
-
this.attachPersistentHandlers(terminalId, ptyManager);
|
|
2536
|
-
}
|
|
2537
|
-
|
|
2538
|
-
// If reconnecting, send scrollback buffer to THIS client only
|
|
2539
|
-
if (isReconnect) {
|
|
2540
|
-
const scrollback = ptyManager.getPersistentScrollback(terminalId);
|
|
2541
|
-
if (scrollback.length > 0) {
|
|
2542
|
-
this.send(ws, {
|
|
2543
|
-
type: 'terminalScrollback',
|
|
2544
|
-
terminalId,
|
|
2545
|
-
data: { lines: scrollback }
|
|
2546
|
-
});
|
|
2547
|
-
}
|
|
2548
|
-
} else {
|
|
2549
|
-
// New terminal — broadcast to other clients so they can create matching tabs
|
|
2550
|
-
this.broadcastToOthers(ws, {
|
|
2551
|
-
type: 'terminalCreated',
|
|
2552
|
-
data: { terminalId, shell, cwd, persistent: true }
|
|
2553
|
-
});
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2556
|
-
// Send ready message to THIS client
|
|
2557
|
-
this.send(ws, {
|
|
2558
|
-
type: 'terminalReady',
|
|
2559
|
-
terminalId,
|
|
2560
|
-
data: { shell, cwd, isReconnect, persistent: true }
|
|
2561
|
-
});
|
|
2562
|
-
} catch (error: any) {
|
|
2563
|
-
console.error(`[WebSocketImproviseHandler] Failed to create persistent terminal:`, error);
|
|
2564
|
-
this.send(ws, {
|
|
2565
|
-
type: 'terminalError',
|
|
2566
|
-
terminalId,
|
|
2567
|
-
data: { error: error.message || 'Failed to create persistent terminal' }
|
|
2568
|
-
});
|
|
2569
|
-
this.removeTerminalSubscriber(terminalId, ws);
|
|
2570
|
-
}
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
/**
|
|
2574
|
-
* List all persistent terminal sessions (including those from previous server runs)
|
|
2575
|
-
*/
|
|
2576
|
-
private handleTerminalListPersistent(ws: WSContext): void {
|
|
2577
|
-
const ptyManager = getPTYManager();
|
|
2578
|
-
const sessions = ptyManager.getPersistentSessions();
|
|
2579
|
-
|
|
2580
|
-
this.send(ws, {
|
|
2581
|
-
type: 'terminalListPersistent',
|
|
2582
|
-
data: {
|
|
2583
|
-
available: ptyManager.isTmuxAvailable(),
|
|
2584
|
-
terminals: sessions.map(s => ({
|
|
2585
|
-
id: s.terminalId,
|
|
2586
|
-
shell: s.shell,
|
|
2587
|
-
cwd: s.cwd,
|
|
2588
|
-
createdAt: s.createdAt,
|
|
2589
|
-
lastAttachedAt: s.lastAttachedAt,
|
|
2590
|
-
}))
|
|
2591
|
-
}
|
|
2592
|
-
});
|
|
2593
|
-
}
|
|
2594
2858
|
}
|