mstro-app 0.2.0 → 0.3.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 (114) 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 +240 -37
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +133 -27
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +23 -0
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
  12. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  13. package/dist/server/cli/headless/stall-assessor.js +20 -1
  14. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  15. package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
  16. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  17. package/dist/server/cli/headless/tool-watchdog.js +30 -24
  18. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  19. package/dist/server/cli/headless/types.d.ts +19 -1
  20. package/dist/server/cli/headless/types.d.ts.map +1 -1
  21. package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
  22. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  23. package/dist/server/cli/improvisation-session-manager.js +221 -29
  24. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  25. package/dist/server/index.js +0 -3
  26. package/dist/server/index.js.map +1 -1
  27. package/dist/server/services/analytics.d.ts.map +1 -1
  28. package/dist/server/services/analytics.js +13 -1
  29. package/dist/server/services/analytics.js.map +1 -1
  30. package/dist/server/services/platform.d.ts.map +1 -1
  31. package/dist/server/services/platform.js +13 -1
  32. package/dist/server/services/platform.js.map +1 -1
  33. package/dist/server/services/terminal/pty-manager.d.ts +2 -0
  34. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  35. package/dist/server/services/terminal/pty-manager.js +50 -3
  36. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  37. package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
  38. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
  39. package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
  40. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
  41. package/dist/server/services/websocket/git-handlers.d.ts +36 -0
  42. package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
  43. package/dist/server/services/websocket/git-handlers.js +797 -0
  44. package/dist/server/services/websocket/git-handlers.js.map +1 -0
  45. package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
  46. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
  47. package/dist/server/services/websocket/git-pr-handlers.js +299 -0
  48. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
  49. package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
  50. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
  51. package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
  52. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
  53. package/dist/server/services/websocket/handler-context.d.ts +32 -0
  54. package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
  55. package/dist/server/services/websocket/handler-context.js +4 -0
  56. package/dist/server/services/websocket/handler-context.js.map +1 -0
  57. package/dist/server/services/websocket/handler.d.ts +27 -359
  58. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  59. package/dist/server/services/websocket/handler.js +67 -2328
  60. package/dist/server/services/websocket/handler.js.map +1 -1
  61. package/dist/server/services/websocket/index.d.ts +1 -1
  62. package/dist/server/services/websocket/index.d.ts.map +1 -1
  63. package/dist/server/services/websocket/index.js.map +1 -1
  64. package/dist/server/services/websocket/session-handlers.d.ts +10 -0
  65. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
  66. package/dist/server/services/websocket/session-handlers.js +507 -0
  67. package/dist/server/services/websocket/session-handlers.js.map +1 -0
  68. package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
  69. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
  70. package/dist/server/services/websocket/settings-handlers.js +125 -0
  71. package/dist/server/services/websocket/settings-handlers.js.map +1 -0
  72. package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
  73. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
  74. package/dist/server/services/websocket/tab-handlers.js +131 -0
  75. package/dist/server/services/websocket/tab-handlers.js.map +1 -0
  76. package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
  77. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
  78. package/dist/server/services/websocket/terminal-handlers.js +220 -0
  79. package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
  80. package/dist/server/services/websocket/types.d.ts +63 -2
  81. package/dist/server/services/websocket/types.d.ts.map +1 -1
  82. package/package.json +4 -2
  83. package/server/README.md +176 -159
  84. package/server/cli/headless/claude-invoker.ts +155 -31
  85. package/server/cli/headless/output-utils.test.ts +225 -0
  86. package/server/cli/headless/runner.ts +25 -0
  87. package/server/cli/headless/stall-assessor.test.ts +165 -0
  88. package/server/cli/headless/stall-assessor.ts +25 -0
  89. package/server/cli/headless/tool-watchdog.test.ts +429 -0
  90. package/server/cli/headless/tool-watchdog.ts +33 -25
  91. package/server/cli/headless/types.ts +10 -1
  92. package/server/cli/improvisation-session-manager.ts +277 -30
  93. package/server/index.ts +0 -4
  94. package/server/mcp/README.md +59 -67
  95. package/server/mcp/bouncer-integration.test.ts +161 -0
  96. package/server/mcp/security-patterns.test.ts +258 -0
  97. package/server/services/analytics.ts +13 -1
  98. package/server/services/platform.ts +12 -1
  99. package/server/services/terminal/pty-manager.ts +53 -3
  100. package/server/services/websocket/autocomplete.test.ts +194 -0
  101. package/server/services/websocket/file-explorer-handlers.ts +587 -0
  102. package/server/services/websocket/git-handlers.ts +924 -0
  103. package/server/services/websocket/git-pr-handlers.ts +363 -0
  104. package/server/services/websocket/git-worktree-handlers.ts +403 -0
  105. package/server/services/websocket/handler-context.ts +44 -0
  106. package/server/services/websocket/handler.test.ts +1 -1
  107. package/server/services/websocket/handler.ts +83 -2678
  108. package/server/services/websocket/index.ts +1 -1
  109. package/server/services/websocket/session-handlers.ts +574 -0
  110. package/server/services/websocket/settings-handlers.ts +150 -0
  111. package/server/services/websocket/tab-handlers.ts +150 -0
  112. package/server/services/websocket/terminal-handlers.ts +277 -0
  113. package/server/services/websocket/types.ts +135 -0
  114. package/bin/release.sh +0 -110
@@ -0,0 +1,403 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import { dirname, join } from 'node:path';
5
+ import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handlers.js';
6
+ import type { HandlerContext } from './handler-context.js';
7
+ import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
8
+
9
+ export function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): void {
10
+ const handlers: Record<string, () => void> = {
11
+ gitWorktreeList: () => handleGitWorktreeList(ctx, ws, tabId, gitDir),
12
+ gitWorktreeCreate: () => handleGitWorktreeCreate(ctx, ws, msg, tabId, gitDir),
13
+ gitWorktreeRemove: () => handleGitWorktreeRemove(ctx, ws, msg, tabId, gitDir),
14
+ tabWorktreeSwitch: () => handleTabWorktreeSwitch(ctx, ws, msg, tabId, workingDir),
15
+ gitWorktreePush: () => handleGitWorktreePush(ctx, ws, msg, tabId, gitDir),
16
+ gitWorktreeCreatePR: () => handleGitWorktreeCreatePR(ctx, ws, msg, tabId, gitDir),
17
+ gitMergePreview: () => handleGitMergePreview(ctx, ws, msg, tabId, gitDir),
18
+ gitWorktreeMerge: () => handleGitWorktreeMerge(ctx, ws, msg, tabId, gitDir),
19
+ gitMergeAbort: () => handleGitMergeAbort(ctx, ws, tabId, gitDir),
20
+ gitMergeComplete: () => handleGitMergeComplete(ctx, ws, msg, tabId, gitDir),
21
+ };
22
+ handlers[msg.type]?.();
23
+ }
24
+
25
+ function applyWorktreePorcelainLine(line: string, worktrees: WorktreeInfo[], current: Partial<WorktreeInfo>): Partial<WorktreeInfo> {
26
+ if (line.startsWith('worktree ')) {
27
+ if (current.path) worktrees.push(current as WorktreeInfo);
28
+ return { path: line.slice(9).trim(), isMain: false, isBare: false };
29
+ }
30
+ if (line.startsWith('HEAD ')) current.head = line.slice(5).trim();
31
+ else if (line.startsWith('branch ')) current.branch = line.slice(7).trim().replace('refs/heads/', '');
32
+ else if (line === 'bare') current.isBare = true;
33
+ else if (line === 'prunable') current.prunable = true;
34
+ return current;
35
+ }
36
+
37
+ function parseWorktreePorcelain(stdout: string): WorktreeInfo[] {
38
+ const worktrees: WorktreeInfo[] = [];
39
+ let current: Partial<WorktreeInfo> = {};
40
+
41
+ for (const line of stdout.split('\n')) {
42
+ current = applyWorktreePorcelainLine(line, worktrees, current);
43
+ }
44
+ if (current.path) worktrees.push(current as WorktreeInfo);
45
+ if (worktrees.length > 0) worktrees[0].isMain = true;
46
+ return worktrees;
47
+ }
48
+
49
+ async function handleGitWorktreeList(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
50
+ try {
51
+ const result = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
52
+ if (result.exitCode !== 0) {
53
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to list worktrees' } });
54
+ return;
55
+ }
56
+ const worktrees = parseWorktreePorcelain(result.stdout);
57
+ ctx.send(ws, { type: 'gitWorktreeListResult', tabId, data: { worktrees } });
58
+ } catch (error: any) {
59
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
60
+ }
61
+ }
62
+
63
+ async function handleGitWorktreeCreate(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
64
+ try {
65
+ const { branchName, baseBranch, path: worktreePath } = msg.data || {};
66
+ if (!branchName) {
67
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
68
+ return;
69
+ }
70
+
71
+ const repoName = workingDir.split('/').pop() || 'repo';
72
+ const wtPath = worktreePath || join(dirname(workingDir), `${repoName}-worktrees`, branchName);
73
+
74
+ const args = ['worktree', 'add', wtPath, '-b', branchName, ...(baseBranch ? [baseBranch] : [])];
75
+ const result = await executeGitCommand(args, workingDir);
76
+ if (result.exitCode !== 0) {
77
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to create worktree' } });
78
+ return;
79
+ }
80
+
81
+ const headResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], wtPath);
82
+
83
+ ctx.send(ws, {
84
+ type: 'gitWorktreeCreated',
85
+ tabId,
86
+ data: { path: wtPath, branch: branchName, head: headResult.stdout.trim() },
87
+ });
88
+ } catch (error: any) {
89
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
90
+ }
91
+ }
92
+
93
+ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
94
+ try {
95
+ const { path: wtPath, force, deleteBranch } = msg.data || {};
96
+ if (!wtPath) {
97
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Worktree path is required' } });
98
+ return;
99
+ }
100
+
101
+ let branchToDelete: string | undefined;
102
+ if (deleteBranch) {
103
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], wtPath);
104
+ branchToDelete = branchResult.stdout.trim();
105
+ }
106
+
107
+ const args = force ? ['worktree', 'remove', '--force', wtPath] : ['worktree', 'remove', wtPath];
108
+ const result = await executeGitCommand(args, workingDir);
109
+ if (result.exitCode !== 0) {
110
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to remove worktree' } });
111
+ return;
112
+ }
113
+
114
+ if (branchToDelete && deleteBranch) {
115
+ await executeGitCommand(['branch', '-d', branchToDelete], workingDir);
116
+ }
117
+
118
+ await executeGitCommand(['worktree', 'prune'], workingDir);
119
+
120
+ ctx.send(ws, { type: 'gitWorktreeRemoved', tabId, data: { path: wtPath } });
121
+ } catch (error: any) {
122
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
123
+ }
124
+ }
125
+
126
+ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
127
+ try {
128
+ const { tabId: targetTabId, worktreePath } = msg.data || {};
129
+ const resolvedTabId = targetTabId || tabId;
130
+ if (!worktreePath) {
131
+ ctx.gitDirectories.delete(resolvedTabId);
132
+ ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: workingDir, branch: '' } });
133
+ handleGitStatus(ctx, ws, resolvedTabId, workingDir);
134
+ return;
135
+ }
136
+
137
+ ctx.gitDirectories.set(resolvedTabId, worktreePath);
138
+
139
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
140
+ const branch = branchResult.stdout.trim();
141
+
142
+ ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
143
+ handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
144
+ } catch (error: any) {
145
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
146
+ }
147
+ }
148
+
149
+ async function pushWithUpstreamRetry(
150
+ worktreePath: string,
151
+ pushRemote: string,
152
+ pushBranch: string,
153
+ setUpstream: boolean,
154
+ ): Promise<{ exitCode: number; output: string; error: string }> {
155
+ const args = setUpstream
156
+ ? ['push', '--set-upstream', pushRemote, pushBranch]
157
+ : ['push', pushRemote, pushBranch];
158
+
159
+ const result = await executeGitCommand(args, worktreePath);
160
+ if (result.exitCode === 0) return { exitCode: 0, output: result.stderr || result.stdout, error: '' };
161
+
162
+ const needsUpstream = result.stderr.includes('no upstream') || result.stderr.includes('has no upstream');
163
+ if (!needsUpstream) return { exitCode: result.exitCode, output: '', error: result.stderr || 'Failed to push' };
164
+
165
+ const retry = await executeGitCommand(['push', '--set-upstream', pushRemote, pushBranch], worktreePath);
166
+ if (retry.exitCode !== 0) return { exitCode: retry.exitCode, output: '', error: retry.stderr || 'Failed to push' };
167
+ return { exitCode: 0, output: retry.stderr || retry.stdout, error: '' };
168
+ }
169
+
170
+ async function handleGitWorktreePush(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, _workingDir: string): Promise<void> {
171
+ try {
172
+ const { worktreePath, remote, branch, setUpstream } = msg.data || {};
173
+ if (!worktreePath) {
174
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Worktree path is required' } });
175
+ return;
176
+ }
177
+
178
+ const pushRemote = remote || 'origin';
179
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
180
+ const pushBranch = branch || branchResult.stdout.trim();
181
+
182
+ const pushResult = await pushWithUpstreamRetry(worktreePath, pushRemote, pushBranch, !!setUpstream);
183
+ if (pushResult.exitCode !== 0) {
184
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: pushResult.error } });
185
+ return;
186
+ }
187
+ ctx.send(ws, { type: 'gitWorktreePushed', tabId, data: { output: pushResult.output, upstream: `${pushRemote}/${pushBranch}` } });
188
+ } catch (error: any) {
189
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
190
+ }
191
+ }
192
+
193
+ async function handleGitWorktreeCreatePR(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, _workingDir: string): Promise<void> {
194
+ try {
195
+ const { worktreePath, title, body, baseBranch, draft } = msg.data || {};
196
+ if (!worktreePath) {
197
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Worktree path is required' } });
198
+ return;
199
+ }
200
+
201
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
202
+ const branchName = branchResult.stdout.trim();
203
+ const prTitle = title || branchName.replace(/[-_/]/g, ' ').replace(/^\w/, c => c.toUpperCase());
204
+
205
+ const args = ['pr', 'create', '--title', prTitle];
206
+ if (body) args.push('--body', body);
207
+ if (baseBranch) args.push('--base', baseBranch);
208
+ if (draft) args.push('--draft');
209
+
210
+ const result = await spawnWithOutput('gh', args, worktreePath);
211
+ if (result.exitCode !== 0) {
212
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to create PR' } });
213
+ return;
214
+ }
215
+
216
+ const prUrl = result.stdout.trim();
217
+ const prNumberMatch = prUrl.match(/\/(\d+)$/);
218
+ const prNumber = prNumberMatch ? parseInt(prNumberMatch[1], 10) : 0;
219
+
220
+ ctx.send(ws, { type: 'gitWorktreePRCreated', tabId, data: { prUrl, prNumber } });
221
+ } catch (error: any) {
222
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
223
+ }
224
+ }
225
+
226
+ async function handleGitMergePreview(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
227
+ try {
228
+ const { sourceBranch, targetBranch } = msg.data || {};
229
+ if (!sourceBranch || !targetBranch) {
230
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Source and target branches are required' } });
231
+ return;
232
+ }
233
+
234
+ let clean = true;
235
+ let conflicts: string[] = [];
236
+ const mergeTreeResult = await executeGitCommand(['merge-tree', '--write-tree', targetBranch, sourceBranch], workingDir);
237
+ if (mergeTreeResult.exitCode !== 0) {
238
+ clean = false;
239
+ conflicts = mergeTreeResult.stdout.split('\n')
240
+ .filter(line => line.includes('CONFLICT'))
241
+ .map(line => {
242
+ const match = line.match(/CONFLICT.*:\s+(.+)/);
243
+ return match?.[1]?.trim() || line;
244
+ });
245
+ }
246
+
247
+ const statResult = await executeGitCommand(['diff', `${targetBranch}...${sourceBranch}`, '--stat'], workingDir);
248
+ const stat = statResult.stdout.trim();
249
+
250
+ const logResult = await executeGitCommand(
251
+ ['log', `${targetBranch}..${sourceBranch}`, '--oneline', '--format=%h|%s'],
252
+ workingDir
253
+ );
254
+ const commits = logResult.stdout.trim().split('\n')
255
+ .filter(line => line.trim())
256
+ .map(line => {
257
+ const [hash, ...rest] = line.split('|');
258
+ return { hash: hash.trim(), message: rest.join('|').trim() };
259
+ });
260
+
261
+ ctx.send(ws, {
262
+ type: 'gitMergePreviewResult',
263
+ tabId,
264
+ data: { clean, conflicts, stat, commits, ahead: commits.length },
265
+ });
266
+ } catch (error: any) {
267
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
268
+ }
269
+ }
270
+
271
+ async function resolveMainWorktreePath(workingDir: string): Promise<string> {
272
+ const wtListResult = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
273
+ const firstLine = wtListResult.stdout.split('\n').find(l => l.startsWith('worktree '));
274
+ return firstLine ? firstLine.slice(9).trim() : workingDir;
275
+ }
276
+
277
+ async function executeMergeStrategy(
278
+ strategy: string,
279
+ sourceBranch: string,
280
+ commitMessage: string | undefined,
281
+ mainPath: string,
282
+ ): Promise<{ exitCode: number; error?: string }> {
283
+ if (strategy === 'squash') {
284
+ const squashResult = await executeGitCommand(['merge', '--squash', sourceBranch], mainPath);
285
+ if (squashResult.exitCode !== 0) return { exitCode: squashResult.exitCode, error: squashResult.stderr };
286
+ const msg2 = commitMessage || `Squash merge branch '${sourceBranch}'`;
287
+ const commitResult = await executeGitCommand(['commit', '-m', msg2], mainPath);
288
+ if (commitResult.exitCode !== 0) return { exitCode: commitResult.exitCode, error: commitResult.stderr || 'Failed to commit squash merge' };
289
+ return { exitCode: 0 };
290
+ }
291
+ if (strategy === 'rebase') {
292
+ const result = await executeGitCommand(['merge', '--ff-only', sourceBranch], mainPath);
293
+ return { exitCode: result.exitCode, error: result.stderr };
294
+ }
295
+ const mergeArgs = commitMessage ? ['merge', sourceBranch, '-m', commitMessage] : ['merge', sourceBranch];
296
+ const result = await executeGitCommand(mergeArgs, mainPath);
297
+ return { exitCode: result.exitCode, error: result.stderr };
298
+ }
299
+
300
+ async function detectMergeConflicts(mainPath: string): Promise<string[]> {
301
+ const result = await executeGitCommand(['diff', '--name-only', '--diff-filter=U'], mainPath);
302
+ return result.stdout.trim().split('\n').filter(f => f.trim());
303
+ }
304
+
305
+ async function cleanupAfterMerge(
306
+ mainPath: string,
307
+ sourceBranch: string,
308
+ strategy: string,
309
+ deleteWorktree: boolean,
310
+ deleteBranch: boolean,
311
+ ): Promise<void> {
312
+ if (deleteWorktree) {
313
+ const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
314
+ const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
315
+ if (worktreePath && worktreePath !== mainPath) {
316
+ await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
317
+ }
318
+ }
319
+ if (deleteBranch) {
320
+ const deleteFlag = strategy === 'squash' ? '-D' : '-d';
321
+ await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
322
+ }
323
+ await executeGitCommand(['worktree', 'prune'], mainPath);
324
+ }
325
+
326
+ function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
327
+ let currentWtPath = '';
328
+ for (const line of porcelainOutput.split('\n')) {
329
+ if (line.startsWith('worktree ')) currentWtPath = line.slice(9).trim();
330
+ if (line.startsWith('branch ') && line.includes(branchName)) return currentWtPath;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
336
+ try {
337
+ const { sourceBranch, targetBranch, strategy, commitMessage, deleteWorktree, deleteBranch } = msg.data || {};
338
+ if (!sourceBranch || !targetBranch || !strategy) {
339
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Source branch, target branch, and strategy are required' } });
340
+ return;
341
+ }
342
+
343
+ const mainPath = await resolveMainWorktreePath(workingDir);
344
+
345
+ const mainBranchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], mainPath);
346
+ if (mainBranchResult.stdout.trim() !== targetBranch) {
347
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: false, error: `Switch the main worktree to "${targetBranch}" before merging` } });
348
+ return;
349
+ }
350
+
351
+ const mergeResult = await executeMergeStrategy(strategy, sourceBranch, commitMessage, mainPath);
352
+ if (mergeResult.exitCode !== 0) {
353
+ const conflictFiles = await detectMergeConflicts(mainPath);
354
+ const data = conflictFiles.length > 0
355
+ ? { success: false, conflictFiles }
356
+ : { success: false, error: mergeResult.error || 'Merge failed' };
357
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data });
358
+ return;
359
+ }
360
+
361
+ const commitHashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], mainPath);
362
+ await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
363
+
364
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: true, mergeCommit: commitHashResult.stdout.trim() } });
365
+ } catch (error: any) {
366
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
367
+ }
368
+ }
369
+
370
+ async function handleGitMergeAbort(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
371
+ try {
372
+ const mainPath = await resolveMainWorktreePath(workingDir);
373
+
374
+ const result = await executeGitCommand(['merge', '--abort'], mainPath);
375
+ if (result.exitCode !== 0) {
376
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to abort merge' } });
377
+ return;
378
+ }
379
+
380
+ ctx.send(ws, { type: 'gitMergeAborted', tabId, data: { aborted: true } });
381
+ } catch (error: any) {
382
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
383
+ }
384
+ }
385
+
386
+ async function handleGitMergeComplete(ctx: HandlerContext, ws: WSContext, _msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
387
+ try {
388
+ const mainPath = await resolveMainWorktreePath(workingDir);
389
+
390
+ const result = await executeGitCommand(['commit', '--no-edit'], mainPath);
391
+ if (result.exitCode !== 0) {
392
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to complete merge' } });
393
+ return;
394
+ }
395
+
396
+ const hashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], mainPath);
397
+ const mergeCommit = hashResult.stdout.trim();
398
+
399
+ ctx.send(ws, { type: 'gitMergeCompleted', tabId, data: { success: true, mergeCommit } });
400
+ } catch (error: any) {
401
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
402
+ }
403
+ }
@@ -0,0 +1,44 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import type { ChildProcess } from 'node:child_process';
5
+ import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
6
+ import type { AutocompleteService } from './autocomplete.js';
7
+ import type { SessionRegistry } from './session-registry.js';
8
+ import type { WebSocketResponse, WSContext } from './types.js';
9
+
10
+ export interface UsageReport {
11
+ tokensUsed: number;
12
+ sessionId?: string;
13
+ movementId?: string;
14
+ }
15
+
16
+ export type UsageReporter = (report: UsageReport) => void;
17
+
18
+ /**
19
+ * Shared context passed to all domain handler functions.
20
+ * The WebSocketImproviseHandler class satisfies this interface directly.
21
+ */
22
+ export interface HandlerContext {
23
+ // Shared state
24
+ sessions: Map<string, ImprovisationSessionManager>;
25
+ connections: Map<WSContext, Map<string, string>>;
26
+ allConnections: Set<WSContext>;
27
+ gitDirectories: Map<string, string>;
28
+ activeSearches: Map<string, ChildProcess>;
29
+ terminalSubscribers: Map<string, Set<WSContext>>;
30
+ terminalListenerCleanups: Map<string, () => void>;
31
+ autocompleteService: AutocompleteService;
32
+ usageReporter: UsageReporter | null;
33
+
34
+ // Registry access
35
+ getRegistry(workingDir: string): SessionRegistry;
36
+
37
+ // Communication utilities
38
+ send(ws: WSContext, response: WebSocketResponse): void;
39
+ broadcastToOthers(sender: WSContext, response: WebSocketResponse): void;
40
+ broadcastToAll(response: WebSocketResponse): void;
41
+
42
+ // Frecency
43
+ recordFileSelection(filePath: string): void;
44
+ }
@@ -15,6 +15,6 @@ describe('WebSocket handler code quality', () => {
15
15
  })
16
16
 
17
17
  it('imports mkdirSync from fs at the top level', () => {
18
- expect(handlerSource).toContain("import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'")
18
+ expect(handlerSource).toContain("import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'")
19
19
  })
20
20
  })