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.
Files changed (100) hide show
  1. package/bin/commands/login.js +27 -14
  2. package/bin/commands/logout.js +35 -1
  3. package/bin/commands/status.js +1 -1
  4. package/bin/mstro.js +5 -108
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +432 -103
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/index.d.ts +2 -1
  9. package/dist/server/cli/headless/index.d.ts.map +1 -1
  10. package/dist/server/cli/headless/index.js +2 -0
  11. package/dist/server/cli/headless/index.js.map +1 -1
  12. package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
  13. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
  14. package/dist/server/cli/headless/prompt-utils.js +40 -5
  15. package/dist/server/cli/headless/prompt-utils.js.map +1 -1
  16. package/dist/server/cli/headless/runner.d.ts +1 -1
  17. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  18. package/dist/server/cli/headless/runner.js +29 -7
  19. package/dist/server/cli/headless/runner.js.map +1 -1
  20. package/dist/server/cli/headless/stall-assessor.d.ts +77 -1
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +336 -20
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +67 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
  26. package/dist/server/cli/headless/tool-watchdog.js +296 -0
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
  28. package/dist/server/cli/headless/types.d.ts +80 -1
  29. package/dist/server/cli/headless/types.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +109 -2
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +737 -132
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/index.js +5 -10
  35. package/dist/server/index.js.map +1 -1
  36. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  37. package/dist/server/mcp/bouncer-integration.js +18 -0
  38. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  39. package/dist/server/mcp/security-audit.d.ts +2 -2
  40. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  41. package/dist/server/mcp/security-audit.js +12 -8
  42. package/dist/server/mcp/security-audit.js.map +1 -1
  43. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  44. package/dist/server/mcp/security-patterns.js +9 -4
  45. package/dist/server/mcp/security-patterns.js.map +1 -1
  46. package/dist/server/routes/improvise.js +6 -6
  47. package/dist/server/routes/improvise.js.map +1 -1
  48. package/dist/server/services/analytics.d.ts +2 -0
  49. package/dist/server/services/analytics.d.ts.map +1 -1
  50. package/dist/server/services/analytics.js +13 -3
  51. package/dist/server/services/analytics.js.map +1 -1
  52. package/dist/server/services/platform.d.ts.map +1 -1
  53. package/dist/server/services/platform.js +4 -9
  54. package/dist/server/services/platform.js.map +1 -1
  55. package/dist/server/services/sandbox-utils.d.ts +6 -0
  56. package/dist/server/services/sandbox-utils.d.ts.map +1 -0
  57. package/dist/server/services/sandbox-utils.js +72 -0
  58. package/dist/server/services/sandbox-utils.js.map +1 -0
  59. package/dist/server/services/settings.d.ts +6 -0
  60. package/dist/server/services/settings.d.ts.map +1 -1
  61. package/dist/server/services/settings.js +21 -0
  62. package/dist/server/services/settings.js.map +1 -1
  63. package/dist/server/services/terminal/pty-manager.d.ts +3 -51
  64. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.js +14 -100
  66. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  67. package/dist/server/services/websocket/handler.d.ts +36 -15
  68. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  69. package/dist/server/services/websocket/handler.js +452 -223
  70. package/dist/server/services/websocket/handler.js.map +1 -1
  71. package/dist/server/services/websocket/types.d.ts +6 -2
  72. package/dist/server/services/websocket/types.d.ts.map +1 -1
  73. package/hooks/bouncer.sh +11 -4
  74. package/package.json +4 -1
  75. package/server/cli/headless/claude-invoker.ts +602 -119
  76. package/server/cli/headless/index.ts +7 -1
  77. package/server/cli/headless/prompt-utils.ts +37 -5
  78. package/server/cli/headless/runner.ts +30 -8
  79. package/server/cli/headless/stall-assessor.ts +453 -22
  80. package/server/cli/headless/tool-watchdog.ts +390 -0
  81. package/server/cli/headless/types.ts +84 -1
  82. package/server/cli/improvisation-session-manager.ts +884 -143
  83. package/server/index.ts +5 -10
  84. package/server/mcp/bouncer-integration.ts +28 -0
  85. package/server/mcp/security-audit.ts +12 -8
  86. package/server/mcp/security-patterns.ts +8 -2
  87. package/server/routes/improvise.ts +6 -6
  88. package/server/services/analytics.ts +13 -3
  89. package/server/services/platform.test.ts +0 -10
  90. package/server/services/platform.ts +4 -10
  91. package/server/services/sandbox-utils.ts +78 -0
  92. package/server/services/settings.ts +25 -0
  93. package/server/services/terminal/pty-manager.ts +16 -127
  94. package/server/services/websocket/handler.ts +515 -251
  95. package/server/services/websocket/types.ts +10 -4
  96. package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
  97. package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
  98. package/dist/server/services/terminal/tmux-manager.js +0 -352
  99. package/dist/server/services/terminal/tmux-manager.js.map +0 -1
  100. 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: `> ${movement.userPrompt}`, timestamp: ts });
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
- for (const tool of movement.toolUseHistory) {
54
- lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
55
- if (tool.result !== undefined) {
56
- lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
57
- }
58
- }
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
- lines.push({ type: 'system', text: `Command completed (tokens: ${movement.tokensUsed.toLocaleString()})`, timestamp: ts });
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
- session.executePrompt(msg.data.prompt, msg.data.attachments);
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
- if (!msg.data?.filePath) throw new Error('File path is required');
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: () => this.handleListDirectory(ws, msg, tabId, workingDir),
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', 'improvise');
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.startsWith('history-') && name.endsWith('.json'))
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', 'improvise');
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.startsWith('history-') && name.endsWith('.json'))
918
+ .filter((name: string) => name.endsWith('.json'))
879
919
  .sort((a: string, b: string) => {
880
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
881
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
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', 'improvise');
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.startsWith('history-') && name.endsWith('.json'));
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', 'improvise');
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.startsWith('history-') && name.endsWith('.json'));
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', 'improvise');
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.startsWith('history-') && name.endsWith('.json'));
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', 'improvise');
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.startsWith('history-') && name.endsWith('.json'))
1106
+ .filter((name: string) => name.endsWith('.json'))
1067
1107
  .sort((a: string, b: string) => {
1068
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
1069
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
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
- const result = await this.executeGitCommand(['push'], workingDir);
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
- // If reconnecting, send scrollback buffer to THIS client only
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, persistent: false }
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
- // Check if this is a persistent terminal first
2337
- const persistentHandler = this.persistentHandlers.get(terminalId);
2338
- if (persistentHandler) {
2339
- persistentHandler.detach();
2340
- this.persistentHandlers.delete(terminalId);
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
  }