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
@@ -13,27 +13,34 @@ import { dirname, join } from 'node:path';
13
13
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
14
14
  import { AnalyticsEvents, trackEvent } from '../analytics.js';
15
15
  import { createDirectory, createFile, deleteFile, listDirectory, renameFile, writeFile } from '../files.js';
16
+ import { validatePathWithinWorkingDir } from '../pathUtils.js';
16
17
  import { captureException } from '../sentry.js';
17
- import { getModel, getSettings, setModel } from '../settings.js';
18
+ import { getModel, getPrBaseBranch, getSettings, setModel, setPrBaseBranch } from '../settings.js';
18
19
  import { getPTYManager } from '../terminal/pty-manager.js';
19
20
  import { AutocompleteService } from './autocomplete.js';
20
21
  import { readFileContent } from './file-utils.js';
21
22
  import { SessionRegistry } from './session-registry.js';
23
+ /** Convert tool history entries into OutputLine-compatible lines */
24
+ function convertToolHistoryToLines(tools, ts) {
25
+ const lines = [];
26
+ for (const tool of tools) {
27
+ lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
28
+ if (tool.result !== undefined) {
29
+ lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
30
+ }
31
+ }
32
+ return lines;
33
+ }
22
34
  /** Convert a single movement record into OutputLine-compatible entries */
23
35
  function convertMovementToLines(movement) {
24
36
  const lines = [];
25
37
  const ts = new Date(movement.timestamp).getTime();
26
- lines.push({ type: 'user', text: `> ${movement.userPrompt}`, timestamp: ts });
38
+ lines.push({ type: 'user', text: movement.userPrompt, timestamp: ts });
27
39
  if (movement.thinkingOutput) {
28
40
  lines.push({ type: 'thinking', text: '', thinking: movement.thinkingOutput, timestamp: ts });
29
41
  }
30
42
  if (movement.toolUseHistory) {
31
- for (const tool of movement.toolUseHistory) {
32
- lines.push({ type: 'tool-call', text: '', toolName: tool.toolName, toolInput: tool.toolInput || {}, timestamp: ts });
33
- if (tool.result !== undefined) {
34
- lines.push({ type: 'tool-result', text: '', toolResult: tool.result || 'No output', toolStatus: tool.isError ? 'error' : 'success', timestamp: ts });
35
- }
36
- }
43
+ lines.push(...convertToolHistoryToLines(movement.toolUseHistory, ts));
37
44
  }
38
45
  if (movement.assistantResponse) {
39
46
  lines.push({ type: 'assistant', text: movement.assistantResponse, timestamp: ts });
@@ -41,9 +48,20 @@ function convertMovementToLines(movement) {
41
48
  if (movement.errorOutput) {
42
49
  lines.push({ type: 'error', text: `Error: ${movement.errorOutput}`, timestamp: ts });
43
50
  }
44
- lines.push({ type: 'system', text: `Command completed (tokens: ${movement.tokensUsed.toLocaleString()})`, timestamp: ts });
51
+ const durationText = movement.durationMs
52
+ ? `Completed in ${(movement.durationMs / 1000).toFixed(2)}s`
53
+ : 'Completed';
54
+ lines.push({ type: 'system', text: durationText, timestamp: ts });
45
55
  return lines;
46
56
  }
57
+ /** Detect git provider from remote URL */
58
+ function detectGitProvider(remoteUrl) {
59
+ if (remoteUrl.includes('github.com'))
60
+ return 'github';
61
+ if (remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab'))
62
+ return 'gitlab';
63
+ return 'unknown';
64
+ }
47
65
  export class WebSocketImproviseHandler {
48
66
  sessions = new Map();
49
67
  connections = new Map();
@@ -127,7 +145,10 @@ export class WebSocketImproviseHandler {
127
145
  try {
128
146
  const msg = JSON.parse(message);
129
147
  const tabId = msg.tabId || 'default';
130
- await this.dispatchMessage(ws, msg, tabId, workingDir);
148
+ // Extract sandbox permission injected by server relay (for sandboxed shared users)
149
+ const permission = msg._permission;
150
+ delete msg._permission;
151
+ await this.dispatchMessage(ws, msg, tabId, workingDir, permission);
131
152
  }
132
153
  catch (error) {
133
154
  console.error('[WebSocketImproviseHandler] Error handling message:', error);
@@ -141,7 +162,7 @@ export class WebSocketImproviseHandler {
141
162
  /**
142
163
  * Dispatch a parsed message to the appropriate handler
143
164
  */
144
- async dispatchMessage(ws, msg, tabId, workingDir) {
165
+ async dispatchMessage(ws, msg, tabId, workingDir, permission) {
145
166
  switch (msg.type) {
146
167
  case 'ping':
147
168
  this.send(ws, { type: 'pong', tabId });
@@ -158,7 +179,7 @@ export class WebSocketImproviseHandler {
158
179
  case 'new':
159
180
  case 'approve':
160
181
  case 'reject':
161
- return this.handleSessionMessage(ws, msg, tabId);
182
+ return this.handleSessionMessage(ws, msg, tabId, permission);
162
183
  case 'getSessions':
163
184
  case 'getSessionsCount':
164
185
  case 'getSessionById':
@@ -170,16 +191,14 @@ export class WebSocketImproviseHandler {
170
191
  case 'readFile':
171
192
  case 'recordSelection':
172
193
  case 'requestNotificationSummary':
173
- return this.handleFileMessage(ws, msg, tabId, workingDir);
194
+ return this.handleFileMessage(ws, msg, tabId, workingDir, permission);
174
195
  case 'terminalInit':
175
196
  case 'terminalReconnect':
176
197
  case 'terminalList':
177
- case 'terminalInitPersistent':
178
- case 'terminalListPersistent':
179
198
  case 'terminalInput':
180
199
  case 'terminalResize':
181
200
  case 'terminalClose':
182
- return this.handleTerminalMessage(ws, msg, tabId, workingDir);
201
+ return this.handleTerminalMessage(ws, msg, tabId, workingDir, permission);
183
202
  case 'listDirectory':
184
203
  case 'writeFile':
185
204
  case 'createFile':
@@ -187,7 +206,7 @@ export class WebSocketImproviseHandler {
187
206
  case 'deleteFile':
188
207
  case 'renameFile':
189
208
  case 'notifyFileOpened':
190
- return this.handleFileExplorerMessage(ws, msg, tabId, workingDir);
209
+ return this.handleFileExplorerMessage(ws, msg, tabId, workingDir, permission);
191
210
  case 'gitStatus':
192
211
  case 'gitStage':
193
212
  case 'gitUnstage':
@@ -197,6 +216,9 @@ export class WebSocketImproviseHandler {
197
216
  case 'gitLog':
198
217
  case 'gitDiscoverRepos':
199
218
  case 'gitSetDirectory':
219
+ case 'gitGetRemoteInfo':
220
+ case 'gitCreatePR':
221
+ case 'gitGeneratePRDescription':
200
222
  return this.handleGitMessage(ws, msg, tabId, workingDir);
201
223
  // Session sync messages
202
224
  case 'getActiveTabs':
@@ -225,19 +247,19 @@ export class WebSocketImproviseHandler {
225
247
  /**
226
248
  * Handle session-related messages (execute, cancel, history, new, approve, reject)
227
249
  */
228
- handleSessionMessage(ws, msg, tabId) {
250
+ handleSessionMessage(ws, msg, tabId, permission) {
229
251
  switch (msg.type) {
230
252
  case 'execute': {
231
253
  if (!msg.data?.prompt)
232
254
  throw new Error('Prompt is required');
233
255
  const session = this.requireSession(ws, tabId);
234
- session.executePrompt(msg.data.prompt, msg.data.attachments);
256
+ const sandboxed = permission === 'control' || permission === 'view';
257
+ session.executePrompt(msg.data.prompt, msg.data.attachments, { sandboxed });
235
258
  break;
236
259
  }
237
260
  case 'cancel': {
238
261
  const session = this.requireSession(ws, tabId);
239
262
  session.cancel();
240
- this.send(ws, { type: 'output', tabId, data: { text: '\n⚠️ Operation cancelled\n' } });
241
263
  break;
242
264
  }
243
265
  case 'getHistory': {
@@ -313,7 +335,7 @@ export class WebSocketImproviseHandler {
313
335
  /**
314
336
  * Handle file-related messages (autocomplete, readFile, recordSelection, notifications)
315
337
  */
316
- handleFileMessage(ws, msg, tabId, workingDir) {
338
+ handleFileMessage(ws, msg, tabId, workingDir, permission) {
317
339
  switch (msg.type) {
318
340
  case 'autocomplete':
319
341
  if (!msg.data?.partialPath)
@@ -321,9 +343,7 @@ export class WebSocketImproviseHandler {
321
343
  this.send(ws, { type: 'autocomplete', tabId, data: { completions: this.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
322
344
  break;
323
345
  case 'readFile':
324
- if (!msg.data?.filePath)
325
- throw new Error('File path is required');
326
- this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
346
+ this.handleReadFile(ws, msg, tabId, workingDir, permission);
327
347
  break;
328
348
  case 'recordSelection':
329
349
  if (msg.data?.filePath)
@@ -336,14 +356,27 @@ export class WebSocketImproviseHandler {
336
356
  break;
337
357
  }
338
358
  }
359
+ handleReadFile(ws, msg, tabId, workingDir, permission) {
360
+ if (!msg.data?.filePath)
361
+ throw new Error('File path is required');
362
+ const isSandboxed = permission === 'control' || permission === 'view';
363
+ if (isSandboxed) {
364
+ const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
365
+ if (!validation.valid) {
366
+ this.send(ws, { type: 'fileContent', tabId, data: { path: msg.data.filePath, fileName: msg.data.filePath.split('/').pop() || '', content: '', error: 'Sandboxed: path outside project directory' } });
367
+ return;
368
+ }
369
+ }
370
+ this.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
371
+ }
339
372
  /**
340
373
  * Handle terminal messages
341
374
  */
342
- handleTerminalMessage(ws, msg, tabId, workingDir) {
375
+ handleTerminalMessage(ws, msg, tabId, workingDir, permission) {
343
376
  const termId = msg.terminalId || tabId;
344
377
  switch (msg.type) {
345
378
  case 'terminalInit':
346
- this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
379
+ this.handleTerminalInit(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows, permission);
347
380
  break;
348
381
  case 'terminalReconnect':
349
382
  this.handleTerminalReconnect(ws, termId);
@@ -351,12 +384,6 @@ export class WebSocketImproviseHandler {
351
384
  case 'terminalList':
352
385
  this.handleTerminalList(ws);
353
386
  break;
354
- case 'terminalInitPersistent':
355
- this.handleTerminalInitPersistent(ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
356
- break;
357
- case 'terminalListPersistent':
358
- this.handleTerminalListPersistent(ws);
359
- break;
360
387
  case 'terminalInput':
361
388
  this.handleTerminalInput(ws, termId, msg.data?.input);
362
389
  break;
@@ -465,9 +492,20 @@ export class WebSocketImproviseHandler {
465
492
  });
466
493
  }
467
494
  }
468
- handleFileExplorerMessage(ws, msg, tabId, workingDir) {
495
+ handleFileExplorerMessage(ws, msg, tabId, workingDir, permission) {
496
+ const isSandboxed = permission === 'control' || permission === 'view';
469
497
  const handlers = {
470
- listDirectory: () => this.handleListDirectory(ws, msg, tabId, workingDir),
498
+ listDirectory: () => {
499
+ // Sandboxed users can only list directories within the project
500
+ if (isSandboxed && msg.data?.dirPath) {
501
+ const validation = validatePathWithinWorkingDir(msg.data.dirPath, workingDir);
502
+ if (!validation.valid) {
503
+ this.send(ws, { type: 'directoryListing', tabId, data: { success: false, path: msg.data.dirPath, error: 'Sandboxed: path outside project directory' } });
504
+ return;
505
+ }
506
+ }
507
+ this.handleListDirectory(ws, msg, tabId, workingDir);
508
+ },
471
509
  writeFile: () => this.handleWriteFile(ws, msg, tabId, workingDir),
472
510
  createFile: () => this.handleCreateFile(ws, msg, tabId, workingDir),
473
511
  createDirectory: () => this.handleCreateDirectory(ws, msg, tabId, workingDir),
@@ -499,10 +537,10 @@ export class WebSocketImproviseHandler {
499
537
  this.send(ws, { type: 'thinking', tabId, data: { text } });
500
538
  });
501
539
  session.on('onMovementStart', (sequenceNumber, prompt) => {
502
- this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now() } });
540
+ this.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp } });
503
541
  // Broadcast execution state to ALL clients so tab indicators update
504
542
  // even if per-tab event subscriptions aren't ready yet (e.g., newly discovered tabs)
505
- this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true } });
543
+ this.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
506
544
  });
507
545
  session.on('onMovementComplete', (movement) => {
508
546
  this.send(ws, { type: 'movementComplete', tabId, data: movement });
@@ -681,6 +719,7 @@ export class WebSocketImproviseHandler {
681
719
  outputHistory,
682
720
  isExecuting: session.isExecuting,
683
721
  executionEvents,
722
+ ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
684
723
  }
685
724
  });
686
725
  }
@@ -767,12 +806,12 @@ export class WebSocketImproviseHandler {
767
806
  * Get count of all historical sessions without reading file contents
768
807
  */
769
808
  getSessionsCount(workingDir) {
770
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
809
+ const sessionsDir = join(workingDir, '.mstro', 'history');
771
810
  if (!existsSync(sessionsDir)) {
772
811
  return 0;
773
812
  }
774
813
  return readdirSync(sessionsDir)
775
- .filter((name) => name.startsWith('history-') && name.endsWith('.json'))
814
+ .filter((name) => name.endsWith('.json'))
776
815
  .length;
777
816
  }
778
817
  /**
@@ -780,16 +819,16 @@ export class WebSocketImproviseHandler {
780
819
  * Returns minimal metadata - movements are stripped to just userPrompt preview
781
820
  */
782
821
  getSessionsList(workingDir, limit = 20, offset = 0) {
783
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
822
+ const sessionsDir = join(workingDir, '.mstro', 'history');
784
823
  if (!existsSync(sessionsDir)) {
785
824
  return { sessions: [], total: 0, hasMore: false };
786
825
  }
787
826
  // Get sorted file list (newest first) without reading contents
788
827
  const historyFiles = readdirSync(sessionsDir)
789
- .filter((name) => name.startsWith('history-') && name.endsWith('.json'))
828
+ .filter((name) => name.endsWith('.json'))
790
829
  .sort((a, b) => {
791
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
792
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
830
+ const timestampA = parseInt(a.replace('.json', ''), 10);
831
+ const timestampB = parseInt(b.replace('.json', ''), 10);
793
832
  return timestampB - timestampA;
794
833
  });
795
834
  const total = historyFiles.length;
@@ -828,12 +867,12 @@ export class WebSocketImproviseHandler {
828
867
  * Get a full session by ID (includes all movement data)
829
868
  */
830
869
  getSessionById(workingDir, sessionId) {
831
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
870
+ const sessionsDir = join(workingDir, '.mstro', 'history');
832
871
  if (!existsSync(sessionsDir)) {
833
872
  return null;
834
873
  }
835
874
  const historyFiles = readdirSync(sessionsDir)
836
- .filter((name) => name.startsWith('history-') && name.endsWith('.json'));
875
+ .filter((name) => name.endsWith('.json'));
837
876
  for (const filename of historyFiles) {
838
877
  const historyPath = join(sessionsDir, filename);
839
878
  try {
@@ -861,13 +900,13 @@ export class WebSocketImproviseHandler {
861
900
  * Delete a single session from disk
862
901
  */
863
902
  deleteSession(workingDir, sessionId) {
864
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
903
+ const sessionsDir = join(workingDir, '.mstro', 'history');
865
904
  if (!existsSync(sessionsDir)) {
866
905
  return { sessionId, success: false };
867
906
  }
868
907
  try {
869
908
  const historyFiles = readdirSync(sessionsDir)
870
- .filter((name) => name.startsWith('history-') && name.endsWith('.json'));
909
+ .filter((name) => name.endsWith('.json'));
871
910
  for (const filename of historyFiles) {
872
911
  const historyPath = join(sessionsDir, filename);
873
912
  try {
@@ -892,13 +931,13 @@ export class WebSocketImproviseHandler {
892
931
  * Clear all sessions from disk
893
932
  */
894
933
  clearAllSessions(workingDir) {
895
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
934
+ const sessionsDir = join(workingDir, '.mstro', 'history');
896
935
  if (!existsSync(sessionsDir)) {
897
936
  return { success: true, deletedCount: 0 };
898
937
  }
899
938
  try {
900
939
  const historyFiles = readdirSync(sessionsDir)
901
- .filter((name) => name.startsWith('history-') && name.endsWith('.json'));
940
+ .filter((name) => name.endsWith('.json'));
902
941
  let deletedCount = 0;
903
942
  for (const filename of historyFiles) {
904
943
  const historyPath = join(sessionsDir, filename);
@@ -945,17 +984,17 @@ export class WebSocketImproviseHandler {
945
984
  };
946
985
  }
947
986
  searchSessions(workingDir, query, limit = 20, offset = 0) {
948
- const sessionsDir = join(workingDir, '.mstro', 'improvise');
987
+ const sessionsDir = join(workingDir, '.mstro', 'history');
949
988
  if (!existsSync(sessionsDir)) {
950
989
  return { sessions: [], total: 0, hasMore: false };
951
990
  }
952
991
  const lowerQuery = query.toLowerCase();
953
992
  try {
954
993
  const historyFiles = readdirSync(sessionsDir)
955
- .filter((name) => name.startsWith('history-') && name.endsWith('.json'))
994
+ .filter((name) => name.endsWith('.json'))
956
995
  .sort((a, b) => {
957
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10);
958
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10);
996
+ const timestampA = parseInt(a.replace('.json', ''), 10);
997
+ const timestampB = parseInt(b.replace('.json', ''), 10);
959
998
  return timestampB - timestampA;
960
999
  });
961
1000
  const allMatches = [];
@@ -1017,6 +1056,7 @@ export class WebSocketImproviseHandler {
1017
1056
  isExecuting: session.isExecuting,
1018
1057
  outputHistory: this.buildOutputHistory(session),
1019
1058
  executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
1059
+ ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
1020
1060
  };
1021
1061
  }
1022
1062
  else {
@@ -1312,6 +1352,9 @@ Respond with ONLY the summary text, nothing else.`;
1312
1352
  gitLog: () => this.handleGitLog(ws, msg, tabId, gitDir),
1313
1353
  gitDiscoverRepos: () => this.handleGitDiscoverRepos(ws, tabId, workingDir),
1314
1354
  gitSetDirectory: () => this.handleGitSetDirectory(ws, msg, tabId, workingDir),
1355
+ gitGetRemoteInfo: () => this.handleGitGetRemoteInfo(ws, tabId, gitDir),
1356
+ gitCreatePR: () => this.handleGitCreatePR(ws, msg, tabId, gitDir),
1357
+ gitGeneratePRDescription: () => this.handleGitGeneratePRDescription(ws, msg, tabId, gitDir),
1315
1358
  };
1316
1359
  handlers[msg.type]?.();
1317
1360
  }
@@ -1457,15 +1500,24 @@ Respond with ONLY the summary text, nothing else.`;
1457
1500
  // Get current branch
1458
1501
  const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
1459
1502
  const branch = branchResult.stdout.trim() || 'HEAD';
1460
- // Get ahead/behind counts
1503
+ // Get ahead/behind counts and upstream tracking info
1461
1504
  let ahead = 0;
1462
1505
  let behind = 0;
1506
+ let hasUpstream = false;
1463
1507
  const trackingResult = await this.executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
1464
1508
  if (trackingResult.exitCode === 0) {
1509
+ hasUpstream = true;
1465
1510
  const parts = trackingResult.stdout.trim().split(/\s+/);
1466
1511
  ahead = parseInt(parts[0], 10) || 0;
1467
1512
  behind = parseInt(parts[1], 10) || 0;
1468
1513
  }
1514
+ else {
1515
+ // No upstream - count local commits as ahead
1516
+ const localResult = await this.executeGitCommand(['rev-list', '--count', 'HEAD'], workingDir);
1517
+ if (localResult.exitCode === 0) {
1518
+ ahead = parseInt(localResult.stdout.trim(), 10) || 0;
1519
+ }
1520
+ }
1469
1521
  const { staged, unstaged, untracked } = this.parseGitStatus(statusResult.stdout);
1470
1522
  const response = {
1471
1523
  branch,
@@ -1475,6 +1527,7 @@ Respond with ONLY the summary text, nothing else.`;
1475
1527
  untracked,
1476
1528
  ahead,
1477
1529
  behind,
1530
+ hasUpstream,
1478
1531
  };
1479
1532
  this.send(ws, { type: 'gitStatus', tabId, data: response });
1480
1533
  }
@@ -1554,6 +1607,8 @@ Respond with ONLY the summary text, nothing else.`;
1554
1607
  const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
1555
1608
  const hash = hashResult.stdout.trim();
1556
1609
  this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message } });
1610
+ // Proactively send updated status so the UI reflects new ahead/behind counts
1611
+ this.handleGitStatus(ws, tabId, workingDir);
1557
1612
  }
1558
1613
  catch (error) {
1559
1614
  this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
@@ -1660,6 +1715,8 @@ Respond with ONLY the commit message, nothing else.`;
1660
1715
  const hashResult = await this.executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
1661
1716
  const hash = hashResult.stdout.trim();
1662
1717
  this.send(ws, { type: 'gitCommitted', tabId, data: { hash, message: commitMessage } });
1718
+ // Proactively send updated status so the UI reflects new ahead/behind counts
1719
+ this.handleGitStatus(ws, tabId, workingDir);
1663
1720
  }
1664
1721
  });
1665
1722
  claude.on('error', (err) => {
@@ -1689,14 +1746,14 @@ Respond with ONLY the commit message, nothing else.`;
1689
1746
  for (const pattern of patterns) {
1690
1747
  const match = output.match(pattern);
1691
1748
  if (match?.[1]) {
1692
- return match[1].trim();
1749
+ return this.stripCoauthorLines(match[1].trim());
1693
1750
  }
1694
1751
  }
1695
1752
  // Split into paragraphs for analysis
1696
1753
  const paragraphs = output.split(/\n\n+/).filter(p => p.trim());
1697
1754
  // If only one paragraph, return it as-is
1698
1755
  if (paragraphs.length <= 1) {
1699
- return output.trim();
1756
+ return this.stripCoauthorLines(output.trim());
1700
1757
  }
1701
1758
  const firstParagraph = paragraphs[0].trim();
1702
1759
  const firstLine = firstParagraph.split('\n')[0].trim();
@@ -1717,7 +1774,7 @@ Respond with ONLY the commit message, nothing else.`;
1717
1774
  // Validate the extracted message has a reasonable first line
1718
1775
  const extractedFirstLine = commitMessage.split('\n')[0].trim();
1719
1776
  if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
1720
- return commitMessage;
1777
+ return this.stripCoauthorLines(commitMessage);
1721
1778
  }
1722
1779
  }
1723
1780
  // Check if the second paragraph looks like a proper commit title
@@ -1730,18 +1787,49 @@ Respond with ONLY the commit message, nothing else.`;
1730
1787
  /^[A-Z][a-z]/.test(secondFirstLine) &&
1731
1788
  !secondFirstLine.endsWith('.')) {
1732
1789
  // Return from second paragraph onwards
1733
- return paragraphs.slice(1).join('\n\n').trim();
1790
+ return this.stripCoauthorLines(paragraphs.slice(1).join('\n\n').trim());
1734
1791
  }
1735
1792
  }
1736
1793
  // Fall back to original output if we can't identify a better message
1737
- return output.trim();
1794
+ return this.stripCoauthorLines(output.trim());
1795
+ }
1796
+ /**
1797
+ * Strip injected coauthor/attribution lines from a commit message.
1798
+ * The Claude Code CLI appends "Co-Authored-By" lines to LLM output.
1799
+ * We detect and remove them by matching known marker strings.
1800
+ */
1801
+ stripCoauthorLines(message) {
1802
+ const lines = message.split('\n');
1803
+ const markers = ['co-authored', 'authored-by', 'haiku', 'noreply@anthropic.com'];
1804
+ const result = [];
1805
+ for (let i = 0; i < lines.length; i++) {
1806
+ const lower = lines[i].toLowerCase();
1807
+ if (markers.some(m => lower.includes(m))) {
1808
+ // Also remove a blank line immediately before this one
1809
+ if (result.length > 0 && result[result.length - 1].trim() === '') {
1810
+ result.pop();
1811
+ }
1812
+ continue;
1813
+ }
1814
+ result.push(lines[i]);
1815
+ }
1816
+ // Don't return empty - keep at least the first line of the original
1817
+ if (result.length === 0)
1818
+ return lines[0]?.trim() || message;
1819
+ return result.join('\n').trimEnd();
1738
1820
  }
1739
1821
  /**
1740
1822
  * Handle git push request
1741
1823
  */
1742
1824
  async handleGitPush(ws, tabId, workingDir) {
1743
1825
  try {
1744
- const result = await this.executeGitCommand(['push'], workingDir);
1826
+ // Check if branch has an upstream, if not use --set-upstream
1827
+ const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
1828
+ const branch = branchResult.stdout.trim();
1829
+ const upstreamCheck = await this.executeGitCommand(['rev-parse', '--abbrev-ref', `${branch}@{u}`], workingDir);
1830
+ const hasUpstream = upstreamCheck.exitCode === 0;
1831
+ const pushArgs = hasUpstream ? ['push'] : ['push', '-u', 'origin', branch];
1832
+ const result = await this.executeGitCommand(pushArgs, workingDir);
1745
1833
  if (result.exitCode !== 0) {
1746
1834
  this.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
1747
1835
  return;
@@ -1874,13 +1962,305 @@ Respond with ONLY the commit message, nothing else.`;
1874
1962
  this.handleGitLog(ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
1875
1963
  }
1876
1964
  }
1965
+ /**
1966
+ * Get remote info for PR creation (remote URL, provider, default branch)
1967
+ */
1968
+ async handleGitGetRemoteInfo(ws, tabId, workingDir) {
1969
+ try {
1970
+ const remoteResult = await this.executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
1971
+ if (remoteResult.exitCode !== 0) {
1972
+ this.send(ws, { type: 'gitRemoteInfo', tabId, data: { hasRemote: false } });
1973
+ return;
1974
+ }
1975
+ const remoteUrl = remoteResult.stdout.trim();
1976
+ const provider = detectGitProvider(remoteUrl);
1977
+ const defaultBranch = await this.getDefaultBranch(workingDir);
1978
+ const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
1979
+ const currentBranch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : '';
1980
+ const cliStatus = await this.checkGitCliStatus(provider);
1981
+ const remoteBranches = await this.listRemoteBranches(workingDir);
1982
+ const preferredBaseBranch = getPrBaseBranch(remoteUrl) ?? undefined;
1983
+ this.send(ws, {
1984
+ type: 'gitRemoteInfo',
1985
+ tabId,
1986
+ data: {
1987
+ hasRemote: true,
1988
+ remoteUrl,
1989
+ provider,
1990
+ defaultBranch,
1991
+ currentBranch,
1992
+ ...cliStatus,
1993
+ remoteBranches,
1994
+ preferredBaseBranch,
1995
+ },
1996
+ });
1997
+ }
1998
+ catch (error) {
1999
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
2000
+ }
2001
+ }
2002
+ async getDefaultBranch(workingDir) {
2003
+ const result = await this.executeGitCommand(['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], workingDir);
2004
+ return result.exitCode === 0 ? result.stdout.trim().replace('origin/', '') : 'main';
2005
+ }
2006
+ async checkGitCliStatus(provider) {
2007
+ const cliBin = provider === 'github' ? 'gh' : provider === 'gitlab' ? 'glab' : null;
2008
+ if (!cliBin)
2009
+ return { hasGhCli: false, ghCliAuthenticated: false };
2010
+ const installed = await this.spawnCheck(cliBin, ['--version']);
2011
+ if (!installed)
2012
+ return { hasGhCli: false, ghCliAuthenticated: false };
2013
+ const authenticated = await this.spawnCheck(cliBin, ['auth', 'status']);
2014
+ return { hasGhCli: true, ghCliAuthenticated: authenticated, ghCliBinary: cliBin };
2015
+ }
2016
+ async listRemoteBranches(workingDir) {
2017
+ const result = await this.executeGitCommand(['branch', '-r', '--list', 'origin/*'], workingDir);
2018
+ if (result.exitCode !== 0)
2019
+ return [];
2020
+ return result.stdout.split('\n')
2021
+ .map(line => line.trim())
2022
+ .filter(line => line && !line.includes('->'))
2023
+ .map(line => line.replace('origin/', ''))
2024
+ .filter(Boolean)
2025
+ .sort();
2026
+ }
2027
+ /** Check if a binary runs successfully (exit code 0) */
2028
+ spawnCheck(bin, args) {
2029
+ return new Promise((resolve) => {
2030
+ const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
2031
+ proc.on('close', (code) => resolve(code === 0));
2032
+ proc.on('error', () => resolve(false));
2033
+ });
2034
+ }
2035
+ /** Detect which CLI binary to use for PR creation based on remote URL */
2036
+ detectPRCliBin(remoteUrl) {
2037
+ const isGitHub = remoteUrl.includes('github.com');
2038
+ const isGitLab = remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab');
2039
+ const cliBin = isGitHub ? 'gh' : isGitLab ? 'glab' : null;
2040
+ return { cliBin, isGitHub, isGitLab };
2041
+ }
2042
+ /** Send PR success and optionally persist base branch */
2043
+ sendPRCreated(ws, tabId, url, method, remoteUrl, baseBranch) {
2044
+ if (baseBranch)
2045
+ setPrBaseBranch(remoteUrl, baseBranch);
2046
+ this.send(ws, { type: 'gitPRCreated', tabId, data: { url, method } });
2047
+ }
2048
+ /**
2049
+ * Create a pull/merge request using gh CLI (GitHub) or open browser URL (fallback)
2050
+ */
2051
+ async handleGitCreatePR(ws, msg, tabId, workingDir) {
2052
+ const { title, body, baseBranch, draft } = msg.data ?? {};
2053
+ if (!title) {
2054
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'PR title is required' } });
2055
+ return;
2056
+ }
2057
+ try {
2058
+ const branchResult = await this.executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
2059
+ if (branchResult.exitCode !== 0) {
2060
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to detect current branch' } });
2061
+ return;
2062
+ }
2063
+ const remoteResult = await this.executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
2064
+ if (remoteResult.exitCode !== 0) {
2065
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'No remote origin configured' } });
2066
+ return;
2067
+ }
2068
+ const headBranch = branchResult.stdout.trim();
2069
+ const remoteUrl = remoteResult.stdout.trim();
2070
+ const { cliBin, isGitHub, isGitLab } = this.detectPRCliBin(remoteUrl);
2071
+ const cliResult = await this.tryCliPRCreate(cliBin, { title, body, baseBranch, draft, headBranch }, workingDir);
2072
+ if (cliResult.created) {
2073
+ this.sendPRCreated(ws, tabId, cliResult.url, isGitHub ? 'gh' : 'glab', remoteUrl, baseBranch);
2074
+ return;
2075
+ }
2076
+ if (cliResult.error) {
2077
+ this.send(ws, { type: 'gitError', tabId, data: { error: cliResult.error } });
2078
+ return;
2079
+ }
2080
+ const prUrl = this.buildBrowserPRUrl(remoteUrl, headBranch, baseBranch, title, body, isGitHub, isGitLab);
2081
+ if (prUrl) {
2082
+ this.sendPRCreated(ws, tabId, prUrl, 'browser', remoteUrl, baseBranch);
2083
+ }
2084
+ else {
2085
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Could not determine remote URL format for PR creation' } });
2086
+ }
2087
+ }
2088
+ catch (error) {
2089
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
2090
+ }
2091
+ }
2092
+ /** Attempt to create a PR/MR via CLI. Returns { created, url, error } */
2093
+ async tryCliPRCreate(cliBin, opts, workingDir) {
2094
+ if (!cliBin)
2095
+ return { created: false }; // No CLI for this provider
2096
+ // Check if CLI is installed
2097
+ const installed = await this.spawnCheck(cliBin, ['--version']);
2098
+ if (!installed)
2099
+ return { created: false }; // Not installed, fall through to browser
2100
+ // Build CLI args
2101
+ const args = cliBin === 'gh'
2102
+ ? ['pr', 'create', '--title', opts.title]
2103
+ : ['mr', 'create', '--title', opts.title, '--yes']; // glab mr create
2104
+ if (opts.body)
2105
+ args.push('--body', opts.body);
2106
+ if (opts.baseBranch) {
2107
+ args.push(cliBin === 'gh' ? '--base' : '--target-branch', opts.baseBranch);
2108
+ }
2109
+ if (opts.draft)
2110
+ args.push('--draft');
2111
+ const result = await this.spawnWithOutput(cliBin, args, workingDir);
2112
+ if (result.exitCode === 0) {
2113
+ const urlMatch = result.stdout.match(/https?:\/\/\S+/);
2114
+ return { created: true, url: urlMatch ? urlMatch[0] : result.stdout.trim() };
2115
+ }
2116
+ return { created: false, error: this.classifyCliPRError(cliBin, result, opts.headBranch) };
2117
+ }
2118
+ /** Classify a CLI PR creation error into a user-facing message */
2119
+ classifyCliPRError(cliBin, result, headBranch) {
2120
+ const combined = result.stderr + result.stdout;
2121
+ const lower = combined.toLowerCase();
2122
+ if (lower.includes('already exists')) {
2123
+ const existingUrl = combined.match(/https?:\/\/\S+/);
2124
+ return existingUrl
2125
+ ? `A pull request already exists for ${headBranch}: ${existingUrl[0]}`
2126
+ : `A pull request already exists for ${headBranch}`;
2127
+ }
2128
+ if (lower.includes('auth') || lower.includes('401') || lower.includes('token') || lower.includes('log in')) {
2129
+ return `${cliBin} is not authenticated. Run: ${cliBin} auth login`;
2130
+ }
2131
+ if (lower.includes('must first push') || lower.includes('failed to push') || lower.includes('no upstream')) {
2132
+ return `Branch "${headBranch}" has not been pushed to remote. Push first, then create the PR.`;
2133
+ }
2134
+ return `${cliBin} failed: ${(result.stderr || result.stdout).trim()}`;
2135
+ }
2136
+ /** Spawn a process and capture stdout/stderr */
2137
+ spawnWithOutput(bin, args, cwd) {
2138
+ return new Promise((resolve) => {
2139
+ const proc = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
2140
+ let stdout = '';
2141
+ let stderr = '';
2142
+ proc.stdout?.on('data', (d) => { stdout += d.toString(); });
2143
+ proc.stderr?.on('data', (d) => { stderr += d.toString(); });
2144
+ proc.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
2145
+ proc.on('error', (err) => resolve({ stdout: '', stderr: err.message, exitCode: 1 }));
2146
+ });
2147
+ }
2148
+ /** Build a browser URL for PR creation (fallback when no CLI) */
2149
+ buildBrowserPRUrl(remoteUrl, headBranch, baseBranch, title, body, isGitHub, isGitLab) {
2150
+ const sshMatch = remoteUrl.match(/[:/]([^/]+)\/([^/.]+)(?:\.git)?$/);
2151
+ if (!sshMatch)
2152
+ return '';
2153
+ const [, owner, repo] = sshMatch;
2154
+ const base = baseBranch || 'main';
2155
+ if (isGitHub) {
2156
+ return `https://github.com/${owner}/${repo}/compare/${base}...${headBranch}?expand=1&title=${encodeURIComponent(title)}${body ? `&body=${encodeURIComponent(body)}` : ''}`;
2157
+ }
2158
+ if (isGitLab) {
2159
+ return `https://gitlab.com/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${headBranch}&merge_request[target_branch]=${base}&merge_request[title]=${encodeURIComponent(title)}`;
2160
+ }
2161
+ return '';
2162
+ }
2163
+ /**
2164
+ * Generate a PR title and description using Haiku, based on the diff against the base branch.
2165
+ */
2166
+ async handleGitGeneratePRDescription(ws, msg, tabId, workingDir) {
2167
+ const baseBranch = msg.data?.baseBranch || 'main';
2168
+ try {
2169
+ // Get commit list for context
2170
+ const logResult = await this.executeGitCommand(['log', `${baseBranch}..HEAD`, '--oneline'], workingDir);
2171
+ const commits = logResult.exitCode === 0 ? logResult.stdout.trim() : '';
2172
+ if (!commits) {
2173
+ this.send(ws, { type: 'gitError', tabId, data: { error: `No commits found between ${baseBranch} and HEAD` } });
2174
+ return;
2175
+ }
2176
+ // Get diff against base
2177
+ const diffResult = await this.executeGitCommand(['diff', `${baseBranch}...HEAD`], workingDir);
2178
+ const diff = diffResult.exitCode === 0 ? diffResult.stdout : '';
2179
+ // Get changed files summary
2180
+ const statResult = await this.executeGitCommand(['diff', `${baseBranch}...HEAD`, '--stat'], workingDir);
2181
+ const stat = statResult.exitCode === 0 ? statResult.stdout.trim() : '';
2182
+ // Truncate diff if too long (same pattern as commit message generation)
2183
+ let truncatedDiff = diff;
2184
+ if (diff.length > 8000) {
2185
+ truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
2186
+ }
2187
+ // Build prompt
2188
+ const tempDir = join(workingDir, '.mstro', 'tmp');
2189
+ if (!existsSync(tempDir)) {
2190
+ mkdirSync(tempDir, { recursive: true });
2191
+ }
2192
+ const prompt = `You are generating a pull request title and description for the following changes.
2193
+
2194
+ COMMITS (${baseBranch}..HEAD):
2195
+ ${commits}
2196
+
2197
+ FILES CHANGED:
2198
+ ${stat}
2199
+
2200
+ DIFF:
2201
+ ${truncatedDiff}
2202
+
2203
+ Generate a pull request title and description following these rules:
2204
+ 1. TITLE: First line must be the PR title — imperative mood, under 70 characters
2205
+ 2. Leave a blank line after the title
2206
+ 3. BODY: Write a concise description in markdown with:
2207
+ - A "## Summary" section with 1-3 bullet points explaining what changed and why
2208
+ - Optionally a "## Details" section if the changes are complex
2209
+ 4. Focus on the "why" not just the "what"
2210
+ 5. No emojis
2211
+
2212
+ Respond with ONLY the title and description, nothing else.`;
2213
+ const promptFile = join(tempDir, `pr-desc-${Date.now()}.txt`);
2214
+ writeFileSync(promptFile, prompt);
2215
+ const systemPrompt = 'You are a pull request description assistant. Respond with only the PR title and description, no preamble or explanation.';
2216
+ const args = [
2217
+ '--print',
2218
+ '--model', 'haiku',
2219
+ '--system-prompt', systemPrompt,
2220
+ promptFile
2221
+ ];
2222
+ const claude = spawn('claude', args, {
2223
+ cwd: workingDir,
2224
+ stdio: ['ignore', 'pipe', 'pipe']
2225
+ });
2226
+ let stdout = '';
2227
+ let stderr = '';
2228
+ claude.stdout?.on('data', (data) => { stdout += data.toString(); });
2229
+ claude.stderr?.on('data', (data) => { stderr += data.toString(); });
2230
+ claude.on('close', (code) => {
2231
+ try {
2232
+ unlinkSync(promptFile);
2233
+ }
2234
+ catch { /* ignore */ }
2235
+ if (code !== 0 || !stdout.trim()) {
2236
+ console.error('[WebSocketImproviseHandler] Claude PR description error:', stderr || 'No output');
2237
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
2238
+ return;
2239
+ }
2240
+ // Parse: first line = title, rest = body
2241
+ const output = this.stripCoauthorLines(stdout.trim());
2242
+ const lines = output.split('\n');
2243
+ const title = lines[0].trim();
2244
+ const body = lines.slice(1).join('\n').trim();
2245
+ this.send(ws, { type: 'gitPRDescription', tabId, data: { title, body } });
2246
+ });
2247
+ claude.on('error', (err) => {
2248
+ console.error('[WebSocketImproviseHandler] Failed to spawn Claude for PR description:', err);
2249
+ this.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
2250
+ });
2251
+ setTimeout(() => { claude.kill(); }, 30000);
2252
+ }
2253
+ catch (error) {
2254
+ this.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
2255
+ }
2256
+ }
1877
2257
  // ============================================
1878
2258
  // Terminal handling methods
1879
2259
  // ============================================
1880
2260
  /**
1881
2261
  * Initialize a new terminal session or reconnect to existing one
1882
2262
  */
1883
- handleTerminalInit(ws, terminalId, workingDir, requestedShell, cols, rows) {
2263
+ handleTerminalInit(ws, terminalId, workingDir, requestedShell, cols, rows, permission) {
1884
2264
  const ptyManager = getPTYManager();
1885
2265
  // Check if PTY is available (node-pty requires native compilation)
1886
2266
  if (!ptyManager.isPtyAvailable()) {
@@ -1900,23 +2280,13 @@ Respond with ONLY the commit message, nothing else.`;
1900
2280
  this.setupTerminalBroadcastListeners(terminalId);
1901
2281
  try {
1902
2282
  // Create or reconnect to the PTY process
1903
- const { shell, cwd, isReconnect } = ptyManager.create(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
1904
- // If reconnecting, send scrollback buffer to THIS client only
1905
- if (isReconnect) {
1906
- const scrollback = ptyManager.getScrollback(terminalId);
1907
- if (scrollback.length > 0) {
1908
- this.send(ws, {
1909
- type: 'terminalScrollback',
1910
- terminalId,
1911
- data: { lines: scrollback }
1912
- });
1913
- }
1914
- }
1915
- else {
2283
+ // Both 'control' and 'view' users get sandboxed terminals
2284
+ const { shell, cwd, isReconnect } = ptyManager.create(terminalId, workingDir, cols || 80, rows || 24, requestedShell, { sandboxed: permission === 'control' || permission === 'view' });
2285
+ if (!isReconnect) {
1916
2286
  // New terminal — broadcast to other clients so they can create matching tabs
1917
2287
  this.broadcastToOthers(ws, {
1918
2288
  type: 'terminalCreated',
1919
- data: { terminalId, shell, cwd, persistent: false }
2289
+ data: { terminalId, shell, cwd }
1920
2290
  });
1921
2291
  }
1922
2292
  // Send ready message to THIS client
@@ -1959,15 +2329,6 @@ Respond with ONLY the commit message, nothing else.`;
1959
2329
  this.addTerminalSubscriber(terminalId, ws);
1960
2330
  // Set up broadcast listeners (idempotent — only creates once per terminal)
1961
2331
  this.setupTerminalBroadcastListeners(terminalId);
1962
- // Send scrollback buffer to THIS client only
1963
- const scrollback = ptyManager.getScrollback(terminalId);
1964
- if (scrollback.length > 0) {
1965
- this.send(ws, {
1966
- type: 'terminalScrollback',
1967
- terminalId,
1968
- data: { lines: scrollback }
1969
- });
1970
- }
1971
2332
  // Send ready message indicating reconnection
1972
2333
  this.send(ws, {
1973
2334
  type: 'terminalReady',
@@ -2003,13 +2364,6 @@ Respond with ONLY the commit message, nothing else.`;
2003
2364
  if (!input) {
2004
2365
  return;
2005
2366
  }
2006
- // Check if this is a persistent terminal first
2007
- const persistentHandler = this.persistentHandlers.get(terminalId);
2008
- if (persistentHandler) {
2009
- persistentHandler.write(input);
2010
- return;
2011
- }
2012
- // Otherwise use regular PTY
2013
2367
  const ptyManager = getPTYManager();
2014
2368
  const success = ptyManager.write(terminalId, input);
2015
2369
  if (!success) {
@@ -2027,13 +2381,6 @@ Respond with ONLY the commit message, nothing else.`;
2027
2381
  if (!cols || !rows) {
2028
2382
  return;
2029
2383
  }
2030
- // Check if this is a persistent terminal first
2031
- const persistentHandler = this.persistentHandlers.get(terminalId);
2032
- if (persistentHandler) {
2033
- persistentHandler.resize(cols, rows);
2034
- return;
2035
- }
2036
- // Otherwise use regular PTY
2037
2384
  const ptyManager = getPTYManager();
2038
2385
  ptyManager.resize(terminalId, cols, rows);
2039
2386
  }
@@ -2042,26 +2389,15 @@ Respond with ONLY the commit message, nothing else.`;
2042
2389
  */
2043
2390
  handleTerminalClose(ws, terminalId) {
2044
2391
  trackEvent(AnalyticsEvents.TERMINAL_SESSION_CLOSED);
2045
- // Check if this is a persistent terminal first
2046
- const persistentHandler = this.persistentHandlers.get(terminalId);
2047
- if (persistentHandler) {
2048
- persistentHandler.detach();
2049
- this.persistentHandlers.delete(terminalId);
2050
- // For persistent terminals, close actually kills the tmux session
2051
- const ptyManager = getPTYManager();
2052
- ptyManager.closePersistent(terminalId);
2053
- }
2054
- else {
2055
- // Clean up event listeners
2056
- const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
2057
- if (listenerCleanup) {
2058
- listenerCleanup();
2059
- this.terminalListenerCleanups.delete(terminalId);
2060
- }
2061
- // Close regular PTY
2062
- const ptyManager = getPTYManager();
2063
- ptyManager.close(terminalId);
2392
+ // Clean up event listeners
2393
+ const listenerCleanup = this.terminalListenerCleanups.get(terminalId);
2394
+ if (listenerCleanup) {
2395
+ listenerCleanup();
2396
+ this.terminalListenerCleanups.delete(terminalId);
2064
2397
  }
2398
+ // Close PTY
2399
+ const ptyManager = getPTYManager();
2400
+ ptyManager.close(terminalId);
2065
2401
  // Clean up subscribers
2066
2402
  this.terminalSubscribers.delete(terminalId);
2067
2403
  // Broadcast to other clients
@@ -2070,8 +2406,6 @@ Respond with ONLY the commit message, nothing else.`;
2070
2406
  data: { terminalId }
2071
2407
  });
2072
2408
  }
2073
- // Persistent terminal handlers for tmux-backed sessions
2074
- persistentHandlers = new Map();
2075
2409
  // Track PTY event listener cleanup functions per terminal to prevent duplicate listeners
2076
2410
  terminalListenerCleanups = new Map();
2077
2411
  // Track which WS connections are subscribed to each terminal's output
@@ -2104,31 +2438,6 @@ Respond with ONLY the commit message, nothing else.`;
2104
2438
  this.terminalListenerCleanups.delete(terminalId);
2105
2439
  }
2106
2440
  }
2107
- /**
2108
- * Attach persistent (tmux) terminal handlers for output/exit broadcasting.
2109
- */
2110
- attachPersistentHandlers(terminalId, ptyManager) {
2111
- const handlers = ptyManager.attachPersistent(terminalId, (output) => {
2112
- const subs = this.terminalSubscribers.get(terminalId);
2113
- if (subs) {
2114
- for (const sub of subs) {
2115
- this.send(sub, { type: 'terminalOutput', terminalId, data: { output } });
2116
- }
2117
- }
2118
- }, (exitCode) => {
2119
- const subs = this.terminalSubscribers.get(terminalId);
2120
- if (subs) {
2121
- for (const sub of subs) {
2122
- this.send(sub, { type: 'terminalExit', terminalId, data: { exitCode } });
2123
- }
2124
- }
2125
- this.persistentHandlers.delete(terminalId);
2126
- this.terminalSubscribers.delete(terminalId);
2127
- });
2128
- if (handlers) {
2129
- this.persistentHandlers.set(terminalId, handlers);
2130
- }
2131
- }
2132
2441
  /**
2133
2442
  * Set up PTY event listeners that broadcast to all subscribers.
2134
2443
  * Only creates listeners once per terminal (idempotent).
@@ -2183,85 +2492,5 @@ Respond with ONLY the commit message, nothing else.`;
2183
2492
  ptyManager.off('error', onError);
2184
2493
  });
2185
2494
  }
2186
- /**
2187
- * Initialize a persistent (tmux-backed) terminal session
2188
- * These sessions survive server restarts.
2189
- * Uses subscriber pattern for multi-client output broadcasting.
2190
- */
2191
- handleTerminalInitPersistent(ws, terminalId, workingDir, requestedShell, cols, rows) {
2192
- const ptyManager = getPTYManager();
2193
- // Check if tmux is available
2194
- if (!ptyManager.isTmuxAvailable()) {
2195
- this.send(ws, {
2196
- type: 'terminalError',
2197
- terminalId,
2198
- data: { error: 'Persistent terminals require tmux, which is not installed' }
2199
- });
2200
- return;
2201
- }
2202
- // Add this WS as a subscriber for this terminal's output
2203
- this.addTerminalSubscriber(terminalId, ws);
2204
- try {
2205
- // Create or reconnect to the persistent session
2206
- const { shell, cwd, isReconnect } = ptyManager.createPersistent(terminalId, workingDir, cols || 80, rows || 24, requestedShell);
2207
- // Only attach if we don't already have handlers (first subscriber)
2208
- if (!this.persistentHandlers.has(terminalId)) {
2209
- this.attachPersistentHandlers(terminalId, ptyManager);
2210
- }
2211
- // If reconnecting, send scrollback buffer to THIS client only
2212
- if (isReconnect) {
2213
- const scrollback = ptyManager.getPersistentScrollback(terminalId);
2214
- if (scrollback.length > 0) {
2215
- this.send(ws, {
2216
- type: 'terminalScrollback',
2217
- terminalId,
2218
- data: { lines: scrollback }
2219
- });
2220
- }
2221
- }
2222
- else {
2223
- // New terminal — broadcast to other clients so they can create matching tabs
2224
- this.broadcastToOthers(ws, {
2225
- type: 'terminalCreated',
2226
- data: { terminalId, shell, cwd, persistent: true }
2227
- });
2228
- }
2229
- // Send ready message to THIS client
2230
- this.send(ws, {
2231
- type: 'terminalReady',
2232
- terminalId,
2233
- data: { shell, cwd, isReconnect, persistent: true }
2234
- });
2235
- }
2236
- catch (error) {
2237
- console.error(`[WebSocketImproviseHandler] Failed to create persistent terminal:`, error);
2238
- this.send(ws, {
2239
- type: 'terminalError',
2240
- terminalId,
2241
- data: { error: error.message || 'Failed to create persistent terminal' }
2242
- });
2243
- this.removeTerminalSubscriber(terminalId, ws);
2244
- }
2245
- }
2246
- /**
2247
- * List all persistent terminal sessions (including those from previous server runs)
2248
- */
2249
- handleTerminalListPersistent(ws) {
2250
- const ptyManager = getPTYManager();
2251
- const sessions = ptyManager.getPersistentSessions();
2252
- this.send(ws, {
2253
- type: 'terminalListPersistent',
2254
- data: {
2255
- available: ptyManager.isTmuxAvailable(),
2256
- terminals: sessions.map(s => ({
2257
- id: s.terminalId,
2258
- shell: s.shell,
2259
- cwd: s.cwd,
2260
- createdAt: s.createdAt,
2261
- lastAttachedAt: s.lastAttachedAt,
2262
- }))
2263
- }
2264
- });
2265
- }
2266
2495
  }
2267
2496
  //# sourceMappingURL=handler.js.map