mstro-app 0.2.0 → 0.3.1

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 (153) hide show
  1. package/PRIVACY.md +126 -0
  2. package/README.md +24 -23
  3. package/bin/commands/login.js +79 -49
  4. package/bin/mstro.js +305 -39
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +137 -30
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +2 -2
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts +6 -1
  11. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  12. package/dist/server/cli/headless/runner.js +59 -4
  13. package/dist/server/cli/headless/runner.js.map +1 -1
  14. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  15. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.js +20 -1
  17. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  18. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  19. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  21. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  22. package/dist/server/cli/headless/types.d.ts +20 -2
  23. package/dist/server/cli/headless/types.d.ts.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +30 -3
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +224 -31
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/index.js +6 -4
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/mcp/bouncer-cli.js +53 -14
  31. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.d.ts +1 -1
  33. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  34. package/dist/server/mcp/bouncer-integration.js +70 -7
  35. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  36. package/dist/server/mcp/security-audit.d.ts +3 -3
  37. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  38. package/dist/server/mcp/security-audit.js.map +1 -1
  39. package/dist/server/mcp/server.js +3 -2
  40. package/dist/server/mcp/server.js.map +1 -1
  41. package/dist/server/services/analytics.d.ts +2 -2
  42. package/dist/server/services/analytics.d.ts.map +1 -1
  43. package/dist/server/services/analytics.js +13 -1
  44. package/dist/server/services/analytics.js.map +1 -1
  45. package/dist/server/services/files.js +7 -7
  46. package/dist/server/services/files.js.map +1 -1
  47. package/dist/server/services/pathUtils.js +1 -1
  48. package/dist/server/services/pathUtils.js.map +1 -1
  49. package/dist/server/services/platform.d.ts +2 -2
  50. package/dist/server/services/platform.d.ts.map +1 -1
  51. package/dist/server/services/platform.js +13 -1
  52. package/dist/server/services/platform.js.map +1 -1
  53. package/dist/server/services/sentry.d.ts +1 -1
  54. package/dist/server/services/sentry.d.ts.map +1 -1
  55. package/dist/server/services/sentry.js.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.d.ts +12 -0
  57. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  58. package/dist/server/services/terminal/pty-manager.js +81 -6
  59. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  60. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  61. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  62. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  63. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  64. package/dist/server/services/websocket/file-utils.d.ts +4 -0
  65. package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
  66. package/dist/server/services/websocket/file-utils.js +27 -8
  67. package/dist/server/services/websocket/file-utils.js.map +1 -1
  68. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  69. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/git-handlers.js +797 -0
  71. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  73. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  75. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  77. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  79. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  81. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  82. package/dist/server/services/websocket/handler-context.js +4 -0
  83. package/dist/server/services/websocket/handler-context.js.map +1 -0
  84. package/dist/server/services/websocket/handler.d.ts +27 -359
  85. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  86. package/dist/server/services/websocket/handler.js +68 -2329
  87. package/dist/server/services/websocket/handler.js.map +1 -1
  88. package/dist/server/services/websocket/index.d.ts +1 -1
  89. package/dist/server/services/websocket/index.d.ts.map +1 -1
  90. package/dist/server/services/websocket/index.js.map +1 -1
  91. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  92. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  93. package/dist/server/services/websocket/session-handlers.js +508 -0
  94. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  95. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  96. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  97. package/dist/server/services/websocket/settings-handlers.js +125 -0
  98. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  99. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  100. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  101. package/dist/server/services/websocket/tab-handlers.js +131 -0
  102. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  103. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  104. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  105. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  106. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  107. package/dist/server/services/websocket/types.d.ts +63 -2
  108. package/dist/server/services/websocket/types.d.ts.map +1 -1
  109. package/dist/server/utils/agent-manager.d.ts +22 -2
  110. package/dist/server/utils/agent-manager.d.ts.map +1 -1
  111. package/dist/server/utils/agent-manager.js +2 -2
  112. package/dist/server/utils/agent-manager.js.map +1 -1
  113. package/dist/server/utils/port-manager.js.map +1 -1
  114. package/hooks/bouncer.sh +17 -3
  115. package/package.json +7 -3
  116. package/server/README.md +176 -159
  117. package/server/cli/headless/claude-invoker.ts +172 -43
  118. package/server/cli/headless/mcp-config.ts +8 -8
  119. package/server/cli/headless/runner.ts +57 -4
  120. package/server/cli/headless/stall-assessor.ts +25 -0
  121. package/server/cli/headless/tool-watchdog.ts +33 -25
  122. package/server/cli/headless/types.ts +11 -2
  123. package/server/cli/improvisation-session-manager.ts +285 -37
  124. package/server/index.ts +15 -13
  125. package/server/mcp/README.md +59 -67
  126. package/server/mcp/bouncer-cli.ts +73 -20
  127. package/server/mcp/bouncer-integration.ts +99 -16
  128. package/server/mcp/security-audit.ts +4 -4
  129. package/server/mcp/server.ts +6 -5
  130. package/server/services/analytics.ts +16 -4
  131. package/server/services/files.ts +13 -13
  132. package/server/services/pathUtils.ts +2 -2
  133. package/server/services/platform.ts +17 -6
  134. package/server/services/sentry.ts +1 -1
  135. package/server/services/terminal/pty-manager.ts +88 -11
  136. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  137. package/server/services/websocket/file-utils.ts +28 -9
  138. package/server/services/websocket/git-handlers.ts +924 -0
  139. package/server/services/websocket/git-pr-handlers.ts +363 -0
  140. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  141. package/server/services/websocket/handler-context.ts +44 -0
  142. package/server/services/websocket/handler.ts +85 -2680
  143. package/server/services/websocket/index.ts +1 -1
  144. package/server/services/websocket/session-handlers.ts +575 -0
  145. package/server/services/websocket/settings-handlers.ts +150 -0
  146. package/server/services/websocket/tab-handlers.ts +150 -0
  147. package/server/services/websocket/terminal-handlers.ts +277 -0
  148. package/server/services/websocket/types.ts +137 -0
  149. package/server/utils/agent-manager.ts +6 -6
  150. package/server/utils/port-manager.ts +1 -1
  151. package/bin/release.sh +0 -110
  152. package/server/services/platform.test.ts +0 -1304
  153. package/server/services/websocket/handler.test.ts +0 -20
@@ -0,0 +1,363 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { getPrBaseBranch, setPrBaseBranch } from '../settings.js';
8
+ import { detectGitProvider, executeGitCommand, spawnCheck, spawnWithOutput, stripCoauthorLines } from './git-handlers.js';
9
+ import type { HandlerContext } from './handler-context.js';
10
+ import type { WebSocketMessage, WSContext } from './types.js';
11
+
12
+ export function handleGitPRMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, _workingDir: string): void {
13
+ const handlers: Record<string, () => void> = {
14
+ gitGetRemoteInfo: () => handleGitGetRemoteInfo(ctx, ws, tabId, gitDir),
15
+ gitCreatePR: () => handleGitCreatePR(ctx, ws, msg, tabId, gitDir),
16
+ gitGeneratePRDescription: () => handleGitGeneratePRDescription(ctx, ws, msg, tabId, gitDir),
17
+ };
18
+ handlers[msg.type]?.();
19
+ }
20
+
21
+ async function handleGitGetRemoteInfo(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
22
+ try {
23
+ const remoteResult = await executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
24
+ if (remoteResult.exitCode !== 0) {
25
+ ctx.send(ws, { type: 'gitRemoteInfo', tabId, data: { hasRemote: false } });
26
+ return;
27
+ }
28
+
29
+ const remoteUrl = remoteResult.stdout.trim();
30
+ const provider = detectGitProvider(remoteUrl);
31
+ const defaultBranch = await getDefaultBranch(workingDir);
32
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
33
+ const currentBranch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : '';
34
+ const cliStatus = await checkGitCliStatus(provider);
35
+ const remoteBranches = await listRemoteBranches(workingDir);
36
+ const preferredBaseBranch = getPrBaseBranch(remoteUrl) ?? undefined;
37
+
38
+ ctx.send(ws, {
39
+ type: 'gitRemoteInfo',
40
+ tabId,
41
+ data: {
42
+ hasRemote: true,
43
+ remoteUrl,
44
+ provider,
45
+ defaultBranch,
46
+ currentBranch,
47
+ ...cliStatus,
48
+ remoteBranches,
49
+ preferredBaseBranch,
50
+ },
51
+ });
52
+ } catch (error: unknown) {
53
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
54
+ }
55
+ }
56
+
57
+ async function getDefaultBranch(workingDir: string): Promise<string> {
58
+ const result = await executeGitCommand(
59
+ ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
60
+ workingDir
61
+ );
62
+ return result.exitCode === 0 ? result.stdout.trim().replace('origin/', '') : 'main';
63
+ }
64
+
65
+ async function checkGitCliStatus(provider: 'github' | 'gitlab' | 'unknown'): Promise<{ hasGhCli: boolean; ghCliAuthenticated: boolean; ghCliBinary?: 'gh' | 'glab' }> {
66
+ const cliBin = provider === 'github' ? 'gh' : provider === 'gitlab' ? 'glab' : null;
67
+ if (!cliBin) return { hasGhCli: false, ghCliAuthenticated: false };
68
+
69
+ const installed = await spawnCheck(cliBin, ['--version']);
70
+ if (!installed) return { hasGhCli: false, ghCliAuthenticated: false };
71
+
72
+ const authenticated = await spawnCheck(cliBin, ['auth', 'status']);
73
+ return { hasGhCli: true, ghCliAuthenticated: authenticated, ghCliBinary: cliBin };
74
+ }
75
+
76
+ async function listRemoteBranches(workingDir: string): Promise<string[]> {
77
+ const result = await executeGitCommand(['branch', '-r', '--list', 'origin/*'], workingDir);
78
+ if (result.exitCode !== 0) return [];
79
+
80
+ return result.stdout.split('\n')
81
+ .map(line => line.trim())
82
+ .filter(line => line && !line.includes('->'))
83
+ .map(line => line.replace('origin/', ''))
84
+ .filter(Boolean)
85
+ .sort();
86
+ }
87
+
88
+ /** Detect which CLI binary to use for PR creation based on remote URL */
89
+ function detectPRCliBin(remoteUrl: string): { cliBin: 'gh' | 'glab' | null; isGitHub: boolean; isGitLab: boolean } {
90
+ const isGitHub = remoteUrl.includes('github.com');
91
+ const isGitLab = remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab');
92
+ const cliBin = isGitHub ? 'gh' as const : isGitLab ? 'glab' as const : null;
93
+ return { cliBin, isGitHub, isGitLab };
94
+ }
95
+
96
+ /** Send PR success and optionally persist base branch */
97
+ function sendPRCreated(
98
+ ctx: HandlerContext, ws: WSContext, tabId: string, url: string, method: string,
99
+ remoteUrl: string, baseBranch?: string,
100
+ ): void {
101
+ if (baseBranch) setPrBaseBranch(remoteUrl, baseBranch);
102
+ ctx.send(ws, { type: 'gitPRCreated', tabId, data: { url, method } });
103
+ }
104
+
105
+ /** Auto-push branch if it has unpushed commits or no upstream. Returns error string on failure. */
106
+ async function ensureBranchPushed(headBranch: string, workingDir: string): Promise<string | null> {
107
+ const upstreamCheck = await executeGitCommand(['rev-parse', '--abbrev-ref', `${headBranch}@{u}`], workingDir);
108
+ const hasUpstream = upstreamCheck.exitCode === 0;
109
+ let needsPush = !hasUpstream;
110
+
111
+ if (hasUpstream) {
112
+ const aheadCheck = await executeGitCommand(['rev-list', '--count', `@{u}..HEAD`], workingDir);
113
+ needsPush = aheadCheck.exitCode === 0 && parseInt(aheadCheck.stdout.trim(), 10) > 0;
114
+ }
115
+
116
+ if (!needsPush) return null;
117
+
118
+ const pushArgs = hasUpstream ? ['push'] : ['push', '-u', 'origin', headBranch];
119
+ const pushResult = await executeGitCommand(pushArgs, workingDir);
120
+ if (pushResult.exitCode !== 0) {
121
+ return `Failed to push branch before creating PR: ${pushResult.stderr || pushResult.stdout}`;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ async function handleGitCreatePR(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
127
+ const { title, body, baseBranch, draft } = msg.data ?? {};
128
+
129
+ if (!title) {
130
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'PR title is required' } });
131
+ return;
132
+ }
133
+
134
+ try {
135
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
136
+ if (branchResult.exitCode !== 0) {
137
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to detect current branch' } });
138
+ return;
139
+ }
140
+
141
+ const remoteResult = await executeGitCommand(['remote', 'get-url', 'origin'], workingDir);
142
+ if (remoteResult.exitCode !== 0) {
143
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'No remote origin configured' } });
144
+ return;
145
+ }
146
+
147
+ const headBranch = branchResult.stdout.trim();
148
+ const remoteUrl = remoteResult.stdout.trim();
149
+ const { cliBin, isGitHub, isGitLab } = detectPRCliBin(remoteUrl);
150
+
151
+ const pushError = await ensureBranchPushed(headBranch, workingDir);
152
+ if (pushError) {
153
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: pushError } });
154
+ return;
155
+ }
156
+
157
+ const cliResult = await tryCliPRCreate(cliBin, { title, body, baseBranch, draft, headBranch }, workingDir);
158
+
159
+ if (cliResult.created) {
160
+ sendPRCreated(ctx, ws, tabId, cliResult.url!, isGitHub ? 'gh' : 'glab', remoteUrl, baseBranch);
161
+ return;
162
+ }
163
+ if (cliResult.error) {
164
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: cliResult.error } });
165
+ return;
166
+ }
167
+
168
+ const prUrl = buildBrowserPRUrl(remoteUrl, headBranch, baseBranch, title, body, isGitHub, isGitLab);
169
+ if (prUrl) {
170
+ sendPRCreated(ctx, ws, tabId, prUrl, 'browser', remoteUrl, baseBranch);
171
+ } else {
172
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Could not determine remote URL format for PR creation' } });
173
+ }
174
+ } catch (error: unknown) {
175
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
176
+ }
177
+ }
178
+
179
+ /** Attempt to create a PR/MR via CLI. Returns { created, url, error } */
180
+ async function tryCliPRCreate(
181
+ cliBin: 'gh' | 'glab' | null,
182
+ opts: { title: string; body?: string; baseBranch?: string; draft?: boolean; headBranch: string },
183
+ workingDir: string,
184
+ ): Promise<{ created: boolean; url?: string; error?: string }> {
185
+ if (!cliBin) return { created: false };
186
+
187
+ const installed = await spawnCheck(cliBin, ['--version']);
188
+ if (!installed) return { created: false };
189
+
190
+ const args = cliBin === 'gh'
191
+ ? ['pr', 'create', '--title', opts.title]
192
+ : ['mr', 'create', '--title', opts.title, '--yes'];
193
+
194
+ if (opts.body) args.push('--body', opts.body);
195
+ if (opts.baseBranch) {
196
+ args.push(cliBin === 'gh' ? '--base' : '--target-branch', opts.baseBranch);
197
+ }
198
+ if (opts.draft) args.push('--draft');
199
+
200
+ const result = await spawnWithOutput(cliBin, args, workingDir);
201
+
202
+ if (result.exitCode === 0) {
203
+ const urlMatch = result.stdout.match(/https?:\/\/\S+/);
204
+ return { created: true, url: urlMatch ? urlMatch[0] : result.stdout.trim() };
205
+ }
206
+
207
+ return { created: false, error: classifyCliPRError(cliBin, result, opts.headBranch) };
208
+ }
209
+
210
+ /** Classify a CLI PR creation error into a user-facing message */
211
+ function classifyCliPRError(
212
+ cliBin: string,
213
+ result: { stdout: string; stderr: string },
214
+ headBranch: string,
215
+ ): string {
216
+ const combined = result.stderr + result.stdout;
217
+ const lower = combined.toLowerCase();
218
+
219
+ if (lower.includes('already exists')) {
220
+ const existingUrl = combined.match(/https?:\/\/\S+/);
221
+ return existingUrl
222
+ ? `A pull request already exists for ${headBranch}: ${existingUrl[0]}`
223
+ : `A pull request already exists for ${headBranch}`;
224
+ }
225
+
226
+ if (lower.includes('auth') || lower.includes('401') || lower.includes('token') || lower.includes('log in')) {
227
+ return `${cliBin} is not authenticated. Run: ${cliBin} auth login`;
228
+ }
229
+
230
+ if (lower.includes('must first push') || lower.includes('failed to push') || lower.includes('no upstream')) {
231
+ return `Branch "${headBranch}" has not been pushed to remote. Push first, then create the PR.`;
232
+ }
233
+
234
+ return `${cliBin} failed: ${(result.stderr || result.stdout).trim()}`;
235
+ }
236
+
237
+ /** Build a browser URL for PR creation (fallback when no CLI) */
238
+ function buildBrowserPRUrl(
239
+ remoteUrl: string, headBranch: string, baseBranch: string | undefined,
240
+ title: string, body: string | undefined, isGitHub: boolean, isGitLab: boolean,
241
+ ): string {
242
+ const sshMatch = remoteUrl.match(/[:/]([^/]+)\/([^/.]+)(?:\.git)?$/);
243
+ if (!sshMatch) return '';
244
+
245
+ const [, owner, repo] = sshMatch;
246
+ const base = baseBranch || 'main';
247
+
248
+ if (isGitHub) {
249
+ return `https://github.com/${owner}/${repo}/compare/${base}...${headBranch}?expand=1&title=${encodeURIComponent(title)}${body ? `&body=${encodeURIComponent(body)}` : ''}`;
250
+ }
251
+ if (isGitLab) {
252
+ return `https://gitlab.com/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${headBranch}&merge_request[target_branch]=${base}&merge_request[title]=${encodeURIComponent(title)}`;
253
+ }
254
+ return '';
255
+ }
256
+
257
+ async function handleGitGeneratePRDescription(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
258
+ const baseBranch = msg.data?.baseBranch || 'main';
259
+
260
+ try {
261
+ // Use origin/ prefix to compare against remote base branch (what a PR actually compares against).
262
+ // Fall back to local branch name if the remote ref doesn't exist.
263
+ const remoteBase = `origin/${baseBranch}`;
264
+ const remoteRefCheck = await executeGitCommand(['rev-parse', '--verify', remoteBase], workingDir);
265
+ const compareRef = remoteRefCheck.exitCode === 0 ? remoteBase : baseBranch;
266
+
267
+ const logResult = await executeGitCommand(['log', `${compareRef}..HEAD`, '--oneline'], workingDir);
268
+ const commits = logResult.exitCode === 0 ? logResult.stdout.trim() : '';
269
+
270
+ if (!commits) {
271
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: `No commits found between ${baseBranch} and HEAD` } });
272
+ return;
273
+ }
274
+
275
+ const diffResult = await executeGitCommand(['diff', `${compareRef}...HEAD`], workingDir);
276
+ const diff = diffResult.exitCode === 0 ? diffResult.stdout : '';
277
+
278
+ const statResult = await executeGitCommand(['diff', `${compareRef}...HEAD`, '--stat'], workingDir);
279
+ const stat = statResult.exitCode === 0 ? statResult.stdout.trim() : '';
280
+
281
+ let truncatedDiff = diff;
282
+ if (diff.length > 8000) {
283
+ truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
284
+ }
285
+
286
+ const tempDir = join(workingDir, '.mstro', 'tmp');
287
+ if (!existsSync(tempDir)) {
288
+ mkdirSync(tempDir, { recursive: true });
289
+ }
290
+
291
+ const prompt = `You are generating a pull request title and description for the following changes.
292
+
293
+ COMMITS (${baseBranch}..HEAD):
294
+ ${commits}
295
+
296
+ FILES CHANGED:
297
+ ${stat}
298
+
299
+ DIFF:
300
+ ${truncatedDiff}
301
+
302
+ Generate a pull request title and description following these rules:
303
+ 1. TITLE: First line must be the PR title — imperative mood, under 70 characters
304
+ 2. Leave a blank line after the title
305
+ 3. BODY: Write a concise description in markdown with:
306
+ - A "## Summary" section with 1-3 bullet points explaining what changed and why
307
+ - Optionally a "## Details" section if the changes are complex
308
+ 4. Focus on the "why" not just the "what"
309
+ 5. No emojis
310
+
311
+ Respond with ONLY the title and description, nothing else.`;
312
+
313
+ const promptFile = join(tempDir, `pr-desc-${Date.now()}.txt`);
314
+ writeFileSync(promptFile, prompt);
315
+
316
+ const systemPrompt = 'You are a pull request description assistant. Respond with only the PR title and description, no preamble or explanation.';
317
+
318
+ const args = [
319
+ '--print',
320
+ '--model', 'haiku',
321
+ '--system-prompt', systemPrompt,
322
+ promptFile
323
+ ];
324
+
325
+ const claude = spawn('claude', args, {
326
+ cwd: workingDir,
327
+ stdio: ['ignore', 'pipe', 'pipe']
328
+ });
329
+
330
+ let stdout = '';
331
+ let stderr = '';
332
+
333
+ claude.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); });
334
+ claude.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); });
335
+
336
+ claude.on('close', (code: number | null) => {
337
+ try { unlinkSync(promptFile); } catch { /* ignore */ }
338
+
339
+ if (code !== 0 || !stdout.trim()) {
340
+ console.error('[WebSocketImproviseHandler] Claude PR description error:', stderr || 'No output');
341
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
342
+ return;
343
+ }
344
+
345
+ const output = stripCoauthorLines(stdout.trim());
346
+ const lines = output.split('\n');
347
+ const title = lines[0].trim();
348
+ const body = lines.slice(1).join('\n').trim();
349
+
350
+ ctx.send(ws, { type: 'gitPRDescription', tabId, data: { title, body } });
351
+ });
352
+
353
+ claude.on('error', (err: Error) => {
354
+ console.error('[WebSocketImproviseHandler] Failed to spawn Claude for PR description:', err);
355
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate PR description' } });
356
+ });
357
+
358
+ setTimeout(() => { claude.kill(); }, 30000);
359
+
360
+ } catch (error: unknown) {
361
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
362
+ }
363
+ }