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,924 @@
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, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { handleGitPRMessage } from './git-pr-handlers.js';
8
+ import { handleGitWorktreeMessage } from './git-worktree-handlers.js';
9
+ import type { HandlerContext } from './handler-context.js';
10
+ import type { GitBranchEntry, GitDirectorySetResponse, GitFileStatus, GitLogEntry, GitRepoInfo, GitReposDiscoveredResponse, GitStatusResponse, GitTagEntry, WebSocketMessage, WSContext } from './types.js';
11
+
12
+ /** Detect git provider from remote URL */
13
+ export function detectGitProvider(remoteUrl: string): 'github' | 'gitlab' | 'unknown' {
14
+ if (remoteUrl.includes('github.com')) return 'github';
15
+ if (remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab')) return 'gitlab';
16
+ return 'unknown';
17
+ }
18
+
19
+ /** Execute a git command and return stdout */
20
+ export function executeGitCommand(args: string[], workingDir: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
21
+ return new Promise((resolve) => {
22
+ const git = spawn('git', args, {
23
+ cwd: workingDir,
24
+ stdio: ['ignore', 'pipe', 'pipe']
25
+ });
26
+
27
+ let stdout = '';
28
+ let stderr = '';
29
+
30
+ git.stdout?.on('data', (data: Buffer) => {
31
+ stdout += data.toString();
32
+ });
33
+
34
+ git.stderr?.on('data', (data: Buffer) => {
35
+ stderr += data.toString();
36
+ });
37
+
38
+ git.on('close', (code: number | null) => {
39
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
40
+ });
41
+
42
+ git.on('error', (err: Error) => {
43
+ resolve({ stdout: '', stderr: err.message, exitCode: 1 });
44
+ });
45
+ });
46
+ }
47
+
48
+ /** Map of simple escape sequences to their character values */
49
+ const ESCAPE_CHARS: Record<string, string> = {
50
+ '\\': '\\',
51
+ '"': '"',
52
+ 'n': '\n',
53
+ 't': '\t',
54
+ 'r': '\r',
55
+ };
56
+
57
+ /** Check if position i starts an octal escape sequence (\nnn) */
58
+ function isOctalEscape(str: string, i: number): boolean {
59
+ return i + 3 < str.length &&
60
+ /[0-7]/.test(str[i + 1]) &&
61
+ /[0-7]{2}/.test(str.slice(i + 2, i + 4));
62
+ }
63
+
64
+ /**
65
+ * Unquote a git-quoted path (C-style quoting)
66
+ */
67
+ export function unquoteGitPath(path: string): string {
68
+ if (!path.startsWith('"') || !path.endsWith('"')) {
69
+ return path;
70
+ }
71
+
72
+ const inner = path.slice(1, -1);
73
+ let result = '';
74
+ let i = 0;
75
+
76
+ while (i < inner.length) {
77
+ if (inner[i] !== '\\' || i + 1 >= inner.length) {
78
+ result += inner[i];
79
+ i++;
80
+ continue;
81
+ }
82
+
83
+ const next = inner[i + 1];
84
+ const escaped = ESCAPE_CHARS[next];
85
+
86
+ if (escaped !== undefined) {
87
+ result += escaped;
88
+ i += 2;
89
+ } else if (isOctalEscape(inner, i)) {
90
+ result += String.fromCharCode(parseInt(inner.slice(i + 1, i + 4), 8));
91
+ i += 4;
92
+ } else {
93
+ result += inner[i];
94
+ i++;
95
+ }
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ /** Parse git status --porcelain output into structured format */
102
+ export function parseGitStatus(porcelainOutput: string): { staged: GitFileStatus[]; unstaged: GitFileStatus[]; untracked: GitFileStatus[] } {
103
+ const staged: GitFileStatus[] = [];
104
+ const unstaged: GitFileStatus[] = [];
105
+ const untracked: GitFileStatus[] = [];
106
+
107
+ const lines = porcelainOutput.split('\n').filter(line => line.length >= 4);
108
+
109
+ for (const line of lines) {
110
+ const indexStatus = line[0];
111
+ const workTreeStatus = line[1];
112
+ const rawPath = line.slice(3);
113
+
114
+ const path = unquoteGitPath(rawPath);
115
+
116
+ let filePath = path;
117
+ let originalPath: string | undefined;
118
+ if (rawPath.includes(' -> ')) {
119
+ const parts = rawPath.split(' -> ');
120
+ originalPath = unquoteGitPath(parts[0]);
121
+ filePath = unquoteGitPath(parts[1]);
122
+ }
123
+
124
+ if (indexStatus === '?' && workTreeStatus === '?') {
125
+ untracked.push({ path: filePath, status: '?', staged: false });
126
+ continue;
127
+ }
128
+
129
+ if (indexStatus !== ' ' && indexStatus !== '?') {
130
+ staged.push({ path: filePath, status: indexStatus as GitFileStatus['status'], staged: true, originalPath });
131
+ }
132
+
133
+ if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
134
+ unstaged.push({ path: filePath, status: workTreeStatus as GitFileStatus['status'], staged: false, originalPath });
135
+ }
136
+ }
137
+
138
+ return { staged, unstaged, untracked };
139
+ }
140
+
141
+ /** Check if a binary runs successfully (exit code 0) */
142
+ export function spawnCheck(bin: string, args: string[]): Promise<boolean> {
143
+ return new Promise((resolve) => {
144
+ const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
145
+ proc.on('close', (code) => resolve(code === 0));
146
+ proc.on('error', () => resolve(false));
147
+ });
148
+ }
149
+
150
+ /** Spawn a process and capture stdout/stderr */
151
+ export function spawnWithOutput(bin: string, args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
152
+ return new Promise((resolve) => {
153
+ const proc = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
154
+ let stdout = '';
155
+ let stderr = '';
156
+ proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
157
+ proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
158
+ proc.on('close', (code) => resolve({ stdout, stderr, exitCode: code ?? 1 }));
159
+ proc.on('error', (err: Error) => resolve({ stdout: '', stderr: err.message, exitCode: 1 }));
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Strip injected coauthor/attribution lines from a commit message.
165
+ */
166
+ export function stripCoauthorLines(message: string): string {
167
+ const lines = message.split('\n');
168
+ const markers = ['co-authored', 'authored-by', 'haiku', 'noreply@anthropic.com'];
169
+ const result: string[] = [];
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const lower = lines[i].toLowerCase();
172
+ if (markers.some(m => lower.includes(m))) {
173
+ if (result.length > 0 && result[result.length - 1].trim() === '') {
174
+ result.pop();
175
+ }
176
+ continue;
177
+ }
178
+ result.push(lines[i]);
179
+ }
180
+ if (result.length === 0) return '';
181
+ return result.join('\n').trimEnd();
182
+ }
183
+
184
+ // PR message types that route to git-pr-handlers
185
+ const GIT_PR_TYPES = new Set([
186
+ 'gitGetRemoteInfo', 'gitCreatePR', 'gitGeneratePRDescription',
187
+ ]);
188
+
189
+ // Worktree/merge message types that route to git-worktree-handlers
190
+ const GIT_WORKTREE_TYPES = new Set([
191
+ 'gitWorktreeList', 'gitWorktreeCreate', 'gitWorktreeRemove',
192
+ 'tabWorktreeSwitch', 'gitWorktreePush', 'gitWorktreeCreatePR',
193
+ 'gitMergePreview', 'gitWorktreeMerge', 'gitMergeAbort', 'gitMergeComplete',
194
+ ]);
195
+
196
+ /** Route git messages to appropriate sub-handler */
197
+ export function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
198
+ const gitDir = ctx.gitDirectories.get(tabId) || workingDir;
199
+
200
+ if (GIT_PR_TYPES.has(msg.type)) {
201
+ handleGitPRMessage(ctx, ws, msg, tabId, gitDir, workingDir);
202
+ return;
203
+ }
204
+ if (GIT_WORKTREE_TYPES.has(msg.type)) {
205
+ handleGitWorktreeMessage(ctx, ws, msg, tabId, gitDir, workingDir);
206
+ return;
207
+ }
208
+
209
+ const handlers: Record<string, () => void> = {
210
+ gitStatus: () => handleGitStatus(ctx, ws, tabId, gitDir),
211
+ gitStage: () => handleGitStage(ctx, ws, msg, tabId, gitDir),
212
+ gitUnstage: () => handleGitUnstage(ctx, ws, msg, tabId, gitDir),
213
+ gitCommit: () => handleGitCommit(ctx, ws, msg, tabId, gitDir),
214
+ gitCommitWithAI: () => handleGitCommitWithAI(ctx, ws, msg, tabId, gitDir),
215
+ gitPush: () => handleGitPush(ctx, ws, tabId, gitDir),
216
+ gitPull: () => handleGitPull(ctx, ws, tabId, gitDir),
217
+ gitLog: () => handleGitLog(ctx, ws, msg, tabId, gitDir),
218
+ gitDiscoverRepos: () => handleGitDiscoverRepos(ctx, ws, tabId, workingDir),
219
+ gitSetDirectory: () => handleGitSetDirectory(ctx, ws, msg, tabId, workingDir),
220
+ gitListBranches: () => handleGitListBranches(ctx, ws, tabId, gitDir),
221
+ gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir),
222
+ gitCreateBranch: () => handleGitCreateBranch(ctx, ws, msg, tabId, gitDir),
223
+ gitDeleteBranch: () => handleGitDeleteBranch(ctx, ws, msg, tabId, gitDir),
224
+ gitDiff: () => handleGitDiff(ctx, ws, msg, tabId, gitDir),
225
+ gitListTags: () => handleGitListTags(ctx, ws, tabId, gitDir),
226
+ gitCreateTag: () => handleGitCreateTag(ctx, ws, msg, tabId, gitDir),
227
+ gitPushTag: () => handleGitPushTag(ctx, ws, msg, tabId, gitDir),
228
+ };
229
+ handlers[msg.type]?.();
230
+ }
231
+
232
+ export async function handleGitStatus(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
233
+ try {
234
+ const statusResult = await executeGitCommand(['status', '--porcelain=v1'], workingDir);
235
+ if (statusResult.exitCode !== 0) {
236
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: statusResult.stderr || statusResult.stdout || 'Failed to get git status' } });
237
+ return;
238
+ }
239
+
240
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
241
+ const branch = branchResult.stdout.trim() || 'HEAD';
242
+
243
+ let ahead = 0;
244
+ let behind = 0;
245
+ let hasUpstream = false;
246
+ const trackingResult = await executeGitCommand(['rev-list', '--left-right', '--count', `${branch}...@{u}`], workingDir);
247
+ if (trackingResult.exitCode === 0) {
248
+ hasUpstream = true;
249
+ const parts = trackingResult.stdout.trim().split(/\s+/);
250
+ ahead = parseInt(parts[0], 10) || 0;
251
+ behind = parseInt(parts[1], 10) || 0;
252
+ } else {
253
+ const localResult = await executeGitCommand(['rev-list', '--count', 'HEAD'], workingDir);
254
+ if (localResult.exitCode === 0) {
255
+ ahead = parseInt(localResult.stdout.trim(), 10) || 0;
256
+ }
257
+ }
258
+
259
+ const { staged, unstaged, untracked } = parseGitStatus(statusResult.stdout);
260
+
261
+ const response: GitStatusResponse = {
262
+ branch,
263
+ isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0,
264
+ staged,
265
+ unstaged,
266
+ untracked,
267
+ ahead,
268
+ behind,
269
+ hasUpstream,
270
+ };
271
+
272
+ ctx.send(ws, { type: 'gitStatus', tabId, data: response });
273
+ } catch (error: any) {
274
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
275
+ }
276
+ }
277
+
278
+ async function handleGitStage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
279
+ const stageAll = !!msg.data?.stageAll;
280
+ const paths = msg.data?.paths as string[] | undefined;
281
+
282
+ if (!stageAll && (!paths || paths.length === 0)) {
283
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for staging' } });
284
+ return;
285
+ }
286
+
287
+ try {
288
+ const args = stageAll ? ['add', '-A'] : ['add', '--', ...paths!];
289
+ const result = await executeGitCommand(args, workingDir);
290
+ if (result.exitCode !== 0) {
291
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to stage files' } });
292
+ return;
293
+ }
294
+
295
+ ctx.send(ws, { type: 'gitStaged', tabId, data: { paths: paths || [] } });
296
+ } catch (error: any) {
297
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
298
+ }
299
+ }
300
+
301
+ async function handleGitUnstage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
302
+ const paths = msg.data?.paths as string[] | undefined;
303
+ if (!paths || paths.length === 0) {
304
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'No paths specified for unstaging' } });
305
+ return;
306
+ }
307
+
308
+ try {
309
+ const result = await executeGitCommand(['reset', 'HEAD', '--', ...paths], workingDir);
310
+ if (result.exitCode !== 0) {
311
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to unstage files' } });
312
+ return;
313
+ }
314
+
315
+ ctx.send(ws, { type: 'gitUnstaged', tabId, data: { paths } });
316
+ } catch (error: any) {
317
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
318
+ }
319
+ }
320
+
321
+ async function handleGitCommit(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
322
+ const message = msg.data?.message as string | undefined;
323
+ if (!message) {
324
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit message is required' } });
325
+ return;
326
+ }
327
+
328
+ try {
329
+ const result = await executeGitCommand(['commit', '-m', message], workingDir);
330
+ if (result.exitCode !== 0) {
331
+ let errorMsg = result.stderr || result.stdout || 'Failed to commit';
332
+ if (errorMsg.includes('nothing to commit') || errorMsg.includes('no changes added')) {
333
+ errorMsg = 'No changes staged for commit. Use "Stage" to add files before committing.';
334
+ handleGitStatus(ctx, ws, tabId, workingDir);
335
+ }
336
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: errorMsg } });
337
+ return;
338
+ }
339
+
340
+ const hashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
341
+ const hash = hashResult.stdout.trim();
342
+
343
+ ctx.send(ws, { type: 'gitCommitted', tabId, data: { hash, message } });
344
+ handleGitStatus(ctx, ws, tabId, workingDir);
345
+ } catch (error: any) {
346
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
347
+ }
348
+ }
349
+
350
+ async function handleGitCommitWithAI(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
351
+ try {
352
+ const statusResult = await executeGitCommand(['status', '--porcelain=v1'], workingDir);
353
+ const { staged } = parseGitStatus(statusResult.stdout);
354
+
355
+ if (staged.length === 0) {
356
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'No staged changes to commit' } });
357
+ return;
358
+ }
359
+
360
+ const diffResult = await executeGitCommand(['diff', '--cached'], workingDir);
361
+ const diff = diffResult.stdout;
362
+
363
+ const logResult = await executeGitCommand(['log', '--oneline', '-5'], workingDir);
364
+ const recentCommits = logResult.stdout.trim();
365
+
366
+ const tempDir = join(workingDir, '.mstro', 'tmp');
367
+ if (!existsSync(tempDir)) {
368
+ mkdirSync(tempDir, { recursive: true });
369
+ }
370
+
371
+ let truncatedDiff = diff;
372
+ if (diff.length > 8000) {
373
+ truncatedDiff = `${diff.slice(0, 4000)}\n\n... [diff truncated] ...\n\n${diff.slice(-3500)}`;
374
+ }
375
+
376
+ const prompt = `You are generating a git commit message for the following staged changes.
377
+
378
+ RECENT COMMIT MESSAGES (for style reference):
379
+ ${recentCommits || 'No recent commits'}
380
+
381
+ STAGED FILES:
382
+ ${staged.map(f => `${f.status} ${f.path}`).join('\n')}
383
+
384
+ DIFF OF STAGED CHANGES:
385
+ ${truncatedDiff}
386
+
387
+ Generate a commit message following these rules:
388
+ 1. First line: imperative mood, max 72 characters (e.g., "Add user authentication", "Fix memory leak in parser")
389
+ 2. If the changes are complex, add a blank line then bullet points explaining the key changes
390
+ 3. Focus on the "why" not just the "what"
391
+ 4. Match the style of recent commits if possible
392
+ 5. No emojis unless the repo already uses them
393
+
394
+ Respond with ONLY the commit message, nothing else.`;
395
+
396
+ const promptFile = join(tempDir, `commit-msg-${Date.now()}.txt`);
397
+ writeFileSync(promptFile, prompt);
398
+
399
+ const systemPrompt = 'You are a commit message assistant. Respond with only the commit message, no preamble or explanation.';
400
+
401
+ const args = [
402
+ '--print',
403
+ '--model', 'haiku',
404
+ '--system-prompt', systemPrompt,
405
+ promptFile
406
+ ];
407
+
408
+ const claude = spawn('claude', args, {
409
+ cwd: workingDir,
410
+ stdio: ['ignore', 'pipe', 'pipe']
411
+ });
412
+
413
+ let stdout = '';
414
+ let stderr = '';
415
+
416
+ claude.stdout?.on('data', (data: Buffer) => {
417
+ stdout += data.toString();
418
+ });
419
+
420
+ claude.stderr?.on('data', (data: Buffer) => {
421
+ stderr += data.toString();
422
+ });
423
+
424
+ claude.on('close', async (code: number | null) => {
425
+ try {
426
+ unlinkSync(promptFile);
427
+ } catch {
428
+ // Ignore cleanup errors
429
+ }
430
+
431
+ if (code !== 0 || !stdout.trim()) {
432
+ console.error('[WebSocketImproviseHandler] Claude commit message error:', stderr || 'No output');
433
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
434
+ return;
435
+ }
436
+
437
+ const commitMessage = extractCommitMessage(stdout.trim());
438
+ const autoCommit = !!msg.data?.autoCommit;
439
+
440
+ ctx.send(ws, { type: 'gitCommitMessage', tabId, data: { message: commitMessage, autoCommit } });
441
+
442
+ if (msg.data?.autoCommit) {
443
+ const commitResult = await executeGitCommand(['commit', '-m', commitMessage], workingDir);
444
+ if (commitResult.exitCode !== 0) {
445
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: commitResult.stderr || commitResult.stdout || 'Failed to commit' } });
446
+ return;
447
+ }
448
+
449
+ const hashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], workingDir);
450
+ const hash = hashResult.stdout.trim();
451
+
452
+ ctx.send(ws, { type: 'gitCommitted', tabId, data: { hash, message: commitMessage } });
453
+ handleGitStatus(ctx, ws, tabId, workingDir);
454
+ }
455
+ });
456
+
457
+ claude.on('error', (err: Error) => {
458
+ console.error('[WebSocketImproviseHandler] Failed to spawn Claude for commit:', err);
459
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Failed to generate commit message' } });
460
+ });
461
+
462
+ setTimeout(() => {
463
+ claude.kill();
464
+ }, 30000);
465
+
466
+ } catch (error: any) {
467
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
468
+ }
469
+ }
470
+
471
+ function extractCommitMessage(output: string): string {
472
+ const patterns = [
473
+ /(?:here'?s?\s+(?:the\s+)?commit\s+message:?\s*\n+)([\s\S]+)/i,
474
+ /(?:commit\s+message:?\s*\n+)([\s\S]+)/i,
475
+ /(?:suggested\s+commit\s+message:?\s*\n+)([\s\S]+)/i,
476
+ ];
477
+
478
+ for (const pattern of patterns) {
479
+ const match = output.match(pattern);
480
+ if (match?.[1]) {
481
+ return stripCoauthorLines(match[1].trim());
482
+ }
483
+ }
484
+
485
+ const paragraphs = output.split(/\n\n+/).filter(p => p.trim());
486
+
487
+ if (paragraphs.length <= 1) {
488
+ return stripCoauthorLines(output.trim());
489
+ }
490
+
491
+ const firstParagraph = paragraphs[0].trim();
492
+ const firstLine = firstParagraph.split('\n')[0].trim();
493
+
494
+ const reasoningPatterns = [
495
+ /^(Now|Based|Looking|After|Here|Let me|I\s+(can|will|see|notice|'ll|would))/i,
496
+ /^The\s+\w+\s+(file|changes?|commit|diff)/i,
497
+ /\b(I can|I will|I'll|let me|analyzing|looking at)\b/i,
498
+ ];
499
+
500
+ const looksLikeReasoning = reasoningPatterns.some(p => p.test(firstParagraph));
501
+ const firstLineTooLong = firstLine.length > 80;
502
+ const endsWithPeriod = firstLine.endsWith('.');
503
+
504
+ if (looksLikeReasoning || (firstLineTooLong && endsWithPeriod)) {
505
+ const commitMessage = paragraphs.slice(1).join('\n\n').trim();
506
+ const extractedFirstLine = commitMessage.split('\n')[0].trim();
507
+ if (extractedFirstLine.length > 0 && extractedFirstLine.length <= 100) {
508
+ return stripCoauthorLines(commitMessage);
509
+ }
510
+ }
511
+
512
+ if (paragraphs.length >= 2) {
513
+ const secondParagraph = paragraphs[1].trim();
514
+ const secondFirstLine = secondParagraph.split('\n')[0].trim();
515
+
516
+ if (secondFirstLine.length <= 72 &&
517
+ /^[A-Z][a-z]/.test(secondFirstLine) &&
518
+ !secondFirstLine.endsWith('.')) {
519
+ return stripCoauthorLines(paragraphs.slice(1).join('\n\n').trim());
520
+ }
521
+ }
522
+
523
+ return stripCoauthorLines(output.trim());
524
+ }
525
+
526
+ async function handleGitPush(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
527
+ try {
528
+ const branchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
529
+ const branch = branchResult.stdout.trim();
530
+
531
+ const upstreamCheck = await executeGitCommand(['rev-parse', '--abbrev-ref', `${branch}@{u}`], workingDir);
532
+ const hasUpstream = upstreamCheck.exitCode === 0;
533
+
534
+ const pushArgs = hasUpstream ? ['push'] : ['push', '-u', 'origin', branch];
535
+ const result = await executeGitCommand(pushArgs, workingDir);
536
+ if (result.exitCode !== 0) {
537
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to push' } });
538
+ return;
539
+ }
540
+
541
+ ctx.send(ws, { type: 'gitPushed', tabId, data: { output: result.stdout || result.stderr } });
542
+ } catch (error: any) {
543
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
544
+ }
545
+ }
546
+
547
+ async function handleGitPull(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
548
+ try {
549
+ const result = await executeGitCommand(['pull'], workingDir);
550
+ if (result.exitCode !== 0) {
551
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to pull' } });
552
+ return;
553
+ }
554
+
555
+ ctx.send(ws, { type: 'gitPulled', tabId, data: { output: result.stdout || result.stderr } });
556
+ } catch (error: any) {
557
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
558
+ }
559
+ }
560
+
561
+ async function handleGitLog(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
562
+ const limit = msg.data?.limit ?? 10;
563
+
564
+ try {
565
+ const result = await executeGitCommand([
566
+ 'log',
567
+ `-${limit}`,
568
+ '--format=%H|%h|%s|%an|%aI'
569
+ ], workingDir);
570
+
571
+ if (result.exitCode !== 0) {
572
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || result.stdout || 'Failed to get log' } });
573
+ return;
574
+ }
575
+
576
+ const entries: GitLogEntry[] = result.stdout.trim().split('\n').filter(Boolean).map(line => {
577
+ const [hash, shortHash, subject, author, date] = line.split('|');
578
+ const cleanSubject = stripCoauthorLines(subject || '') || subject || '';
579
+ return { hash, shortHash, subject: cleanSubject, author, date };
580
+ });
581
+
582
+ ctx.send(ws, { type: 'gitLog', tabId, data: { entries } });
583
+ } catch (error: any) {
584
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
585
+ }
586
+ }
587
+
588
+ /** Directories to skip when scanning for git repos */
589
+ const SKIP_DIRS = ['node_modules', 'vendor', '.git'];
590
+
591
+ function shouldSkipDir(name: string): boolean {
592
+ return name.startsWith('.') || SKIP_DIRS.includes(name);
593
+ }
594
+
595
+ async function getRepoBranch(repoPath: string): Promise<string | undefined> {
596
+ const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
597
+ return result.exitCode === 0 ? result.stdout.trim() : undefined;
598
+ }
599
+
600
+ async function scanForGitRepos(dir: string, depth: number, maxDepth: number, repos: GitRepoInfo[]): Promise<void> {
601
+ if (depth > maxDepth) return;
602
+
603
+ let entries: string[];
604
+ try {
605
+ entries = readdirSync(dir);
606
+ } catch {
607
+ return;
608
+ }
609
+
610
+ for (const name of entries) {
611
+ if (shouldSkipDir(name)) continue;
612
+
613
+ const fullPath = join(dir, name);
614
+ const gitPath = join(fullPath, '.git');
615
+
616
+ if (existsSync(gitPath)) {
617
+ repos.push({ path: fullPath, name, branch: await getRepoBranch(fullPath) });
618
+ } else {
619
+ await scanForGitRepos(fullPath, depth + 1, maxDepth, repos);
620
+ }
621
+ }
622
+ }
623
+
624
+ async function handleGitDiscoverRepos(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
625
+ try {
626
+ const repos: GitRepoInfo[] = [];
627
+ const rootIsGitRepo = existsSync(join(workingDir, '.git'));
628
+
629
+ if (rootIsGitRepo) {
630
+ repos.push({
631
+ path: workingDir,
632
+ name: workingDir.split('/').pop() || workingDir,
633
+ branch: await getRepoBranch(workingDir),
634
+ });
635
+ } else {
636
+ await scanForGitRepos(workingDir, 1, 3, repos);
637
+ }
638
+
639
+ const response: GitReposDiscoveredResponse = {
640
+ repos,
641
+ rootIsGitRepo,
642
+ selectedDirectory: ctx.gitDirectories.get(tabId) || null,
643
+ };
644
+
645
+ ctx.send(ws, { type: 'gitReposDiscovered', tabId, data: response });
646
+ } catch (error: any) {
647
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
648
+ }
649
+ }
650
+
651
+ async function handleGitSetDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
652
+ const directory = msg.data?.directory as string | undefined;
653
+
654
+ if (!directory) {
655
+ ctx.gitDirectories.delete(tabId);
656
+ const response: GitDirectorySetResponse = {
657
+ directory: workingDir,
658
+ isValid: existsSync(join(workingDir, '.git')),
659
+ };
660
+ ctx.send(ws, { type: 'gitDirectorySet', tabId, data: response });
661
+ handleGitStatus(ctx, ws, tabId, workingDir);
662
+ return;
663
+ }
664
+
665
+ const gitPath = join(directory, '.git');
666
+ const isValid = existsSync(gitPath);
667
+
668
+ if (isValid) {
669
+ ctx.gitDirectories.set(tabId, directory);
670
+ }
671
+
672
+ const response: GitDirectorySetResponse = {
673
+ directory,
674
+ isValid,
675
+ };
676
+
677
+ ctx.send(ws, { type: 'gitDirectorySet', tabId, data: response });
678
+
679
+ if (isValid) {
680
+ handleGitStatus(ctx, ws, tabId, directory);
681
+ handleGitLog(ctx, ws, { type: 'gitLog', data: { limit: 5 } }, tabId, directory);
682
+ }
683
+ }
684
+
685
+ async function handleGitListBranches(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
686
+ try {
687
+ const result = await executeGitCommand(
688
+ ['branch', '-a', '--format=%(refname:short)|%(objectname:short)|%(upstream:short)|%(HEAD)'],
689
+ workingDir
690
+ );
691
+ if (result.exitCode !== 0) {
692
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to list branches' } });
693
+ return;
694
+ }
695
+
696
+ const currentBranchResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
697
+ const currentBranch = currentBranchResult.stdout.trim() || 'HEAD';
698
+
699
+ const branches: GitBranchEntry[] = result.stdout.trim().split('\n')
700
+ .filter(line => line.trim())
701
+ .map(line => {
702
+ const [name, shortHash, upstream, head] = line.split('|');
703
+ const isRemote = name.includes('/') && (name.startsWith('origin/') || name.includes('remotes/'));
704
+ return {
705
+ name: name.trim(),
706
+ shortHash: shortHash?.trim() || '',
707
+ isRemote,
708
+ isCurrent: head?.trim() === '*',
709
+ upstream: upstream?.trim() || undefined,
710
+ };
711
+ })
712
+ .filter(b => b.name !== 'origin/HEAD');
713
+
714
+ ctx.send(ws, { type: 'gitBranchList', tabId, data: { branches, current: currentBranch } });
715
+ } catch (error: any) {
716
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
717
+ }
718
+ }
719
+
720
+ async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
721
+ try {
722
+ const { branch, create, startPoint } = msg.data || {};
723
+ if (!branch) {
724
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
725
+ return;
726
+ }
727
+
728
+ const statusResult = await executeGitCommand(['status', '--porcelain'], workingDir);
729
+ if (statusResult.stdout.trim()) {
730
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit or stash changes before switching branches' } });
731
+ return;
732
+ }
733
+
734
+ const prevResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
735
+ const previous = prevResult.stdout.trim();
736
+
737
+ const args = create
738
+ ? ['checkout', '-b', branch, ...(startPoint ? [startPoint] : [])]
739
+ : ['checkout', branch];
740
+
741
+ const result = await executeGitCommand(args, workingDir);
742
+ if (result.exitCode !== 0) {
743
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to checkout branch' } });
744
+ return;
745
+ }
746
+
747
+ ctx.send(ws, { type: 'gitCheckedOut', tabId, data: { branch, previous } });
748
+ handleGitStatus(ctx, ws, tabId, workingDir);
749
+ } catch (error: any) {
750
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
751
+ }
752
+ }
753
+
754
+ async function handleGitCreateBranch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
755
+ try {
756
+ const { name, startPoint, checkout } = msg.data || {};
757
+ if (!name) {
758
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
759
+ return;
760
+ }
761
+
762
+ const args = ['branch', name, ...(startPoint ? [startPoint] : [])];
763
+ const result = await executeGitCommand(args, workingDir);
764
+ if (result.exitCode !== 0) {
765
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to create branch' } });
766
+ return;
767
+ }
768
+
769
+ const hashResult = await executeGitCommand(['rev-parse', '--short', name], workingDir);
770
+
771
+ if (checkout) {
772
+ await executeGitCommand(['checkout', name], workingDir);
773
+ }
774
+
775
+ ctx.send(ws, { type: 'gitBranchCreated', tabId, data: { name, hash: hashResult.stdout.trim() } });
776
+ } catch (error: any) {
777
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
778
+ }
779
+ }
780
+
781
+ async function handleGitDeleteBranch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
782
+ try {
783
+ const { name, force } = msg.data || {};
784
+ if (!name) {
785
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
786
+ return;
787
+ }
788
+
789
+ const currentResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
790
+ if (currentResult.stdout.trim() === name) {
791
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Cannot delete the currently checked out branch' } });
792
+ return;
793
+ }
794
+
795
+ const result = await executeGitCommand(['branch', force ? '-D' : '-d', name], workingDir);
796
+ if (result.exitCode !== 0) {
797
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to delete branch' } });
798
+ return;
799
+ }
800
+
801
+ ctx.send(ws, { type: 'gitBranchDeleted', tabId, data: { name } });
802
+ } catch (error: any) {
803
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
804
+ }
805
+ }
806
+
807
+ async function handleGitDiff(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
808
+ try {
809
+ const { path, staged } = msg.data || {};
810
+ if (!path) {
811
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'File path is required' } });
812
+ return;
813
+ }
814
+
815
+ const originalResult = await executeGitCommand(['show', `HEAD:${path}`], workingDir);
816
+ const original = originalResult.exitCode === 0 ? originalResult.stdout : '';
817
+
818
+ let modified: string;
819
+ if (staged) {
820
+ const indexResult = await executeGitCommand(['show', `:${path}`], workingDir);
821
+ modified = indexResult.exitCode === 0 ? indexResult.stdout : '';
822
+ } else {
823
+ const fullPath = join(workingDir, path);
824
+ try {
825
+ modified = readFileSync(fullPath, 'utf-8');
826
+ } catch {
827
+ modified = '';
828
+ }
829
+ }
830
+
831
+ ctx.send(ws, {
832
+ type: 'gitDiffResult',
833
+ tabId,
834
+ data: { path, original, modified, staged: !!staged },
835
+ });
836
+ } catch (error: any) {
837
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
838
+ }
839
+ }
840
+
841
+ async function handleGitListTags(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
842
+ try {
843
+ const result = await executeGitCommand(
844
+ ['tag', '-l', '--sort=-creatordate', '--format=%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)'],
845
+ workingDir
846
+ );
847
+ if (result.exitCode !== 0) {
848
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to list tags' } });
849
+ return;
850
+ }
851
+
852
+ const tags: GitTagEntry[] = result.stdout.trim().split('\n')
853
+ .filter(line => line.trim())
854
+ .slice(0, 50)
855
+ .map(line => {
856
+ const parts = line.split('|');
857
+ return {
858
+ name: parts[0]?.trim() || '',
859
+ shortHash: parts[1]?.trim() || '',
860
+ date: parts[2]?.trim() || '',
861
+ message: parts[3]?.trim() || '',
862
+ };
863
+ });
864
+
865
+ ctx.send(ws, { type: 'gitTagList', tabId, data: { tags } });
866
+ } catch (error: any) {
867
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
868
+ }
869
+ }
870
+
871
+ async function handleGitCreateTag(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
872
+ try {
873
+ const { name, message, commit } = msg.data || {};
874
+ if (!name) {
875
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Tag name is required' } });
876
+ return;
877
+ }
878
+
879
+ if (/\s/.test(name) || name.includes('..')) {
880
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Invalid tag name: no spaces or ".." allowed' } });
881
+ return;
882
+ }
883
+
884
+ const args = message
885
+ ? ['tag', '-a', name, '-m', message, ...(commit ? [commit] : [])]
886
+ : ['tag', name, ...(commit ? [commit] : [])];
887
+
888
+ const result = await executeGitCommand(args, workingDir);
889
+ if (result.exitCode !== 0) {
890
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to create tag' } });
891
+ return;
892
+ }
893
+
894
+ const hashResult = await executeGitCommand(['rev-parse', '--short', name], workingDir);
895
+ ctx.send(ws, { type: 'gitTagCreated', tabId, data: { name, hash: hashResult.stdout.trim() } });
896
+ } catch (error: any) {
897
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
898
+ }
899
+ }
900
+
901
+ async function handleGitPushTag(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
902
+ try {
903
+ const { name, all } = msg.data || {};
904
+
905
+ const args = all
906
+ ? ['push', 'origin', '--tags']
907
+ : ['push', 'origin', name];
908
+
909
+ if (!all && !name) {
910
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Tag name is required' } });
911
+ return;
912
+ }
913
+
914
+ const result = await executeGitCommand(args, workingDir);
915
+ if (result.exitCode !== 0) {
916
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to push tag' } });
917
+ return;
918
+ }
919
+
920
+ ctx.send(ws, { type: 'gitTagPushed', tabId, data: { name: name || 'all', output: result.stderr || result.stdout } });
921
+ } catch (error: any) {
922
+ ctx.send(ws, { type: 'gitError', tabId, data: { error: error.message } });
923
+ }
924
+ }