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,587 @@
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 { join, relative } from 'node:path';
6
+ import {
7
+ createDirectory,
8
+ createFile,
9
+ deleteFile,
10
+ listDirectory,
11
+ renameFile,
12
+ writeFile
13
+ } from '../files.js';
14
+ import { validatePathWithinWorkingDir } from '../pathUtils.js';
15
+ import { readFileContent } from './file-utils.js';
16
+ import type { HandlerContext } from './handler-context.js';
17
+ import type { WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
18
+
19
+ export function handleFileMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
20
+ switch (msg.type) {
21
+ case 'autocomplete':
22
+ if (!msg.data?.partialPath) throw new Error('Partial path is required');
23
+ ctx.send(ws, { type: 'autocomplete', tabId, data: { completions: ctx.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
24
+ break;
25
+ case 'readFile':
26
+ handleReadFile(ctx, ws, msg, tabId, workingDir, permission);
27
+ break;
28
+ case 'recordSelection':
29
+ if (msg.data?.filePath) ctx.recordFileSelection(msg.data.filePath);
30
+ break;
31
+ }
32
+ }
33
+
34
+ function handleReadFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
35
+ if (!msg.data?.filePath) throw new Error('File path is required');
36
+ const isSandboxed = permission === 'control' || permission === 'view';
37
+ if (isSandboxed) {
38
+ const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
39
+ if (!validation.valid) {
40
+ ctx.send(ws, { type: 'fileContent', tabId, data: { path: msg.data.filePath, fileName: msg.data.filePath.split('/').pop() || '', content: '', error: 'Sandboxed: path outside project directory' } });
41
+ return;
42
+ }
43
+ }
44
+ ctx.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
45
+ }
46
+
47
+ function sendFileResult(ctx: HandlerContext, ws: WSContext, type: WebSocketResponse['type'], tabId: string, result: { success: boolean; path?: string; error?: string }, successData?: Record<string, unknown>): void {
48
+ const data = result.success
49
+ ? { success: true, path: result.path, ...successData }
50
+ : { success: false, path: result.path, error: result.error };
51
+ ctx.send(ws, { type, tabId, data });
52
+ }
53
+
54
+ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
55
+ const isSandboxed = permission === 'control' || permission === 'view';
56
+ const handlers: Record<string, () => void> = {
57
+ listDirectory: () => {
58
+ if (isSandboxed && msg.data?.dirPath) {
59
+ const validation = validatePathWithinWorkingDir(msg.data.dirPath, workingDir);
60
+ if (!validation.valid) {
61
+ ctx.send(ws, { type: 'directoryListing', tabId, data: { success: false, path: msg.data.dirPath, error: 'Sandboxed: path outside project directory' } });
62
+ return;
63
+ }
64
+ }
65
+ handleListDirectory(ctx, ws, msg, tabId, workingDir);
66
+ },
67
+ writeFile: () => handleWriteFile(ctx, ws, msg, tabId, workingDir),
68
+ createFile: () => handleCreateFile(ctx, ws, msg, tabId, workingDir),
69
+ createDirectory: () => handleCreateDirectory(ctx, ws, msg, tabId, workingDir),
70
+ deleteFile: () => handleDeleteFile(ctx, ws, msg, tabId, workingDir),
71
+ renameFile: () => handleRenameFile(ctx, ws, msg, tabId, workingDir),
72
+ notifyFileOpened: () => handleNotifyFileOpened(ctx, ws, msg, workingDir),
73
+ searchFileContents: () => handleSearchFileContents(ctx, ws, msg, tabId, workingDir),
74
+ cancelSearch: () => handleCancelSearch(ctx, tabId),
75
+ findDefinition: () => handleFindDefinition(ctx, ws, msg, tabId, workingDir),
76
+ };
77
+ handlers[msg.type]?.();
78
+ }
79
+
80
+ function handleListDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
81
+ if (msg.data?.dirPath === undefined) throw new Error('Directory path is required');
82
+ const result = listDirectory(msg.data.dirPath, workingDir, msg.data.showHidden ?? false);
83
+ ctx.send(ws, { type: 'directoryListing', tabId, data: result.success ? { success: true, path: msg.data.dirPath, entries: result.entries } : { success: false, path: msg.data.dirPath, error: result.error } });
84
+ }
85
+
86
+ function handleWriteFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
87
+ if (!msg.data?.filePath) throw new Error('File path is required');
88
+ if (msg.data.content === undefined) throw new Error('Content is required');
89
+ const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
90
+ sendFileResult(ctx, ws, 'fileWritten', tabId, result);
91
+ if (result.success) {
92
+ ctx.broadcastToOthers(ws, {
93
+ type: 'fileContentChanged',
94
+ data: { path: result.path, content: msg.data.content }
95
+ });
96
+ }
97
+ }
98
+
99
+ function handleCreateFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
100
+ if (!msg.data?.filePath) throw new Error('File path is required');
101
+ const result = createFile(msg.data.filePath, workingDir);
102
+ sendFileResult(ctx, ws, 'fileCreated', tabId, result);
103
+ if (result.success && result.path) {
104
+ const name = result.path.split('/').pop() || 'unknown';
105
+ ctx.broadcastToOthers(ws, {
106
+ type: 'fileCreated',
107
+ data: { path: result.path, name, size: 0, modifiedAt: new Date().toISOString() }
108
+ });
109
+ }
110
+ }
111
+
112
+ function handleCreateDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
113
+ if (!msg.data?.dirPath) throw new Error('Directory path is required');
114
+ const result = createDirectory(msg.data.dirPath, workingDir);
115
+ sendFileResult(ctx, ws, 'directoryCreated', tabId, result);
116
+ if (result.success && result.path) {
117
+ const name = result.path.split('/').pop() || 'unknown';
118
+ ctx.broadcastToOthers(ws, {
119
+ type: 'directoryCreated',
120
+ data: { path: result.path, name }
121
+ });
122
+ }
123
+ }
124
+
125
+ function handleDeleteFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
126
+ if (!msg.data?.filePath) throw new Error('File path is required');
127
+ const result = deleteFile(msg.data.filePath, workingDir);
128
+ sendFileResult(ctx, ws, 'fileDeleted', tabId, result);
129
+ if (result.success && result.path) {
130
+ ctx.broadcastToOthers(ws, {
131
+ type: 'fileDeleted',
132
+ data: { path: result.path }
133
+ });
134
+ }
135
+ }
136
+
137
+ function handleRenameFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
138
+ if (!msg.data?.oldPath) throw new Error('Old path is required');
139
+ if (!msg.data?.newPath) throw new Error('New path is required');
140
+ const result = renameFile(msg.data.oldPath, msg.data.newPath, workingDir);
141
+ const renamedName = result.path?.split('/').pop() || 'unknown';
142
+ sendFileResult(ctx, ws, 'fileRenamed', tabId, result, { oldPath: msg.data.oldPath, newPath: result.path, name: renamedName });
143
+ if (result.success && result.path) {
144
+ const name = result.path.split('/').pop() || 'unknown';
145
+ ctx.broadcastToOthers(ws, {
146
+ type: 'fileRenamed',
147
+ data: { oldPath: msg.data.oldPath, newPath: result.path, name }
148
+ });
149
+ }
150
+ }
151
+
152
+ function handleNotifyFileOpened(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
153
+ if (!msg.data?.filePath) return;
154
+ const fileData = readFileContent(msg.data.filePath, workingDir);
155
+ if (!fileData.error) {
156
+ ctx.broadcastToOthers(ws, {
157
+ type: 'fileOpened',
158
+ data: {
159
+ path: msg.data.filePath,
160
+ fileName: fileData.fileName,
161
+ content: fileData.content
162
+ }
163
+ });
164
+ }
165
+ }
166
+
167
+ function appendGlobArgs(args: string[], globStr: string, prefix: string): void {
168
+ for (const glob of globStr.split(',')) {
169
+ const trimmed = glob.trim();
170
+ if (trimmed) args.push('--glob', `${prefix}${trimmed}`);
171
+ }
172
+ }
173
+
174
+ function buildRgArgs(query: string, options: Record<string, unknown>): string[] {
175
+ const args: string[] = ['--json', '--no-heading'];
176
+ if (!options.caseSensitive) args.push('-i');
177
+ if (options.wholeWord) args.push('-w');
178
+ if (!options.regex) args.push('-F');
179
+ args.push('-C', options.contextLines !== undefined ? String(options.contextLines) : '1');
180
+ if (options.includeGlob) appendGlobArgs(args, options.includeGlob as string, '');
181
+ if (options.excludeGlob) appendGlobArgs(args, options.excludeGlob as string, '!');
182
+ args.push('--', query, '.');
183
+ return args;
184
+ }
185
+
186
+ type SearchMatch = { filePath: string; line: number; column: number; lineContent: string; contextBefore: string[]; contextAfter: string[] };
187
+
188
+ /** Process a single JSON line from rg output. Returns true if search should stop (maxResults reached). */
189
+ function processRgSearchLine(
190
+ line: string,
191
+ workingDir: string,
192
+ batch: SearchMatch[],
193
+ seenFiles: Set<string>,
194
+ contextMap: Map<string, { before: string[]; after: string[] }>,
195
+ counters: { totalMatches: number; fileCount: number },
196
+ maxResults: number,
197
+ flushBatch: () => void,
198
+ ): boolean {
199
+ try {
200
+ const parsed = JSON.parse(line);
201
+ if (parsed.type === 'match') {
202
+ return processRgMatch(parsed, workingDir, batch, seenFiles, contextMap, counters, maxResults, flushBatch);
203
+ }
204
+ if (parsed.type === 'context') {
205
+ appendRgContext(parsed, workingDir, batch);
206
+ }
207
+ } catch {
208
+ // Skip malformed JSON lines
209
+ }
210
+ return false;
211
+ }
212
+
213
+ function processRgMatch(
214
+ parsed: { data: { path: { text: string }; line_number: number; lines: { text: string }; submatches?: Array<{ start: number }> } },
215
+ workingDir: string,
216
+ batch: SearchMatch[],
217
+ seenFiles: Set<string>,
218
+ contextMap: Map<string, { before: string[]; after: string[] }>,
219
+ counters: { totalMatches: number; fileCount: number },
220
+ maxResults: number,
221
+ flushBatch: () => void,
222
+ ): boolean {
223
+ const filePath = relative(workingDir, parsed.data.path.text);
224
+ const lineNumber = parsed.data.line_number;
225
+ const lineContent = parsed.data.lines.text.replace(/\n$/, '');
226
+ const column = parsed.data.submatches?.[0]?.start ?? 0;
227
+
228
+ if (!seenFiles.has(filePath)) {
229
+ seenFiles.add(filePath);
230
+ counters.fileCount++;
231
+ }
232
+ counters.totalMatches++;
233
+
234
+ const key = `${filePath}:${lineNumber}`;
235
+ const ctxLines = contextMap.get(key) || { before: [], after: [] };
236
+ batch.push({ filePath, line: lineNumber, column: column + 1, lineContent, contextBefore: ctxLines.before, contextAfter: [] });
237
+
238
+ if (counters.totalMatches >= maxResults) {
239
+ flushBatch();
240
+ return true;
241
+ }
242
+ if (batch.length >= 50) flushBatch();
243
+ return false;
244
+ }
245
+
246
+ function appendRgContext(
247
+ parsed: { data: { path: { text: string }; line_number: number; lines: { text: string } } },
248
+ workingDir: string,
249
+ batch: SearchMatch[],
250
+ ): void {
251
+ const filePath = relative(workingDir, parsed.data.path.text);
252
+ const lineNumber = parsed.data.line_number;
253
+ const lineContent = parsed.data.lines.text.replace(/\n$/, '');
254
+
255
+ const lastMatch = batch[batch.length - 1];
256
+ if (!lastMatch || lastMatch.filePath !== filePath) return;
257
+ if (lineNumber < lastMatch.line) {
258
+ lastMatch.contextBefore.push(lineContent);
259
+ } else {
260
+ lastMatch.contextAfter.push(lineContent);
261
+ }
262
+ }
263
+
264
+ function handleSearchFileContents(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
265
+ const query = msg.data?.query;
266
+ if (!query) {
267
+ ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: 'Search query is required' } });
268
+ return;
269
+ }
270
+
271
+ handleCancelSearch(ctx, tabId);
272
+
273
+ const options = msg.data.options || {};
274
+ const startTime = Date.now();
275
+ let totalMatches = 0;
276
+ let fileCount = 0;
277
+ const seenFiles = new Set<string>();
278
+ const maxResults = options.maxResults || 5000;
279
+ let batch: SearchMatch[] = [];
280
+
281
+ const args = buildRgArgs(query, options);
282
+
283
+ const rgProcess = spawn('rg', args, { cwd: workingDir, stdio: ['ignore', 'pipe', 'pipe'] });
284
+ ctx.activeSearches.set(tabId, rgProcess);
285
+
286
+ let buffer = '';
287
+ const contextMap = new Map<string, { before: string[]; after: string[] }>();
288
+
289
+ const flushBatch = () => {
290
+ if (batch.length > 0) {
291
+ ctx.send(ws, { type: 'contentSearchResults', tabId, data: { matches: batch, partial: true } });
292
+ batch = [];
293
+ }
294
+ };
295
+
296
+ const searchState = { totalMatches, fileCount };
297
+
298
+ rgProcess.stdout?.on('data', (chunk: Buffer) => {
299
+ buffer += chunk.toString();
300
+ const lines = buffer.split('\n');
301
+ buffer = lines.pop() || '';
302
+
303
+ for (const line of lines) {
304
+ if (!line.trim()) continue;
305
+ if (processRgSearchLine(line, workingDir, batch, seenFiles, contextMap, searchState, maxResults, flushBatch)) {
306
+ rgProcess.kill();
307
+ return;
308
+ }
309
+ }
310
+ totalMatches = searchState.totalMatches;
311
+ fileCount = searchState.fileCount;
312
+ });
313
+
314
+ rgProcess.stderr?.on('data', (chunk: Buffer) => {
315
+ const errText = chunk.toString().trim();
316
+ if (errText && !errText.includes('No files were searched')) {
317
+ console.error(`[Search] rg stderr: ${errText}`);
318
+ }
319
+ });
320
+
321
+ rgProcess.on('close', (_code) => {
322
+ ctx.activeSearches.delete(tabId);
323
+ flushBatch();
324
+
325
+ ctx.send(ws, {
326
+ type: 'contentSearchComplete',
327
+ tabId,
328
+ data: {
329
+ totalMatches,
330
+ fileCount,
331
+ truncated: totalMatches >= maxResults,
332
+ durationMs: Date.now() - startTime,
333
+ },
334
+ });
335
+ });
336
+
337
+ rgProcess.on('error', (err) => {
338
+ ctx.activeSearches.delete(tabId);
339
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
340
+ handleSearchFallback(ctx, ws, query, options, tabId, workingDir);
341
+ } else {
342
+ ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: err.message } });
343
+ }
344
+ });
345
+ }
346
+
347
+ /** Process a single grep output line. Returns true if search should stop. */
348
+ function processGrepLine(
349
+ line: string,
350
+ batch: SearchMatch[],
351
+ seenFiles: Set<string>,
352
+ counters: { totalMatches: number; fileCount: number },
353
+ maxResults: number,
354
+ flushBatch: () => void,
355
+ ): boolean {
356
+ const match = line.match(/^\.\/(.+?):(\d+):(.*)$/);
357
+ if (!match) return false;
358
+
359
+ const filePath = match[1];
360
+ const lineNumber = parseInt(match[2], 10);
361
+ const lineContent = match[3];
362
+
363
+ if (!seenFiles.has(filePath)) {
364
+ seenFiles.add(filePath);
365
+ counters.fileCount++;
366
+ }
367
+ counters.totalMatches++;
368
+
369
+ batch.push({ filePath, line: lineNumber, column: 1, lineContent, contextBefore: [], contextAfter: [] });
370
+
371
+ if (counters.totalMatches >= maxResults) {
372
+ flushBatch();
373
+ return true;
374
+ }
375
+ if (batch.length >= 50) flushBatch();
376
+ return false;
377
+ }
378
+
379
+ function handleSearchFallback(ctx: HandlerContext, ws: WSContext, query: string, options: Record<string, unknown>, tabId: string, workingDir: string): void {
380
+ const startTime = Date.now();
381
+ const args: string[] = ['-rn'];
382
+ if (!options.caseSensitive) args.push('-i');
383
+ if (options.includeGlob) {
384
+ for (const glob of String(options.includeGlob).split(',')) {
385
+ const trimmed = glob.trim();
386
+ if (trimmed) args.push(`--include=${trimmed}`);
387
+ }
388
+ }
389
+ args.push('--', query, '.');
390
+
391
+ const grepProcess = spawn('grep', args, { cwd: workingDir, stdio: ['ignore', 'pipe', 'pipe'] });
392
+ ctx.activeSearches.set(tabId, grepProcess);
393
+
394
+ let buffer = '';
395
+ let totalMatches = 0;
396
+ let fileCount = 0;
397
+ const seenFiles = new Set<string>();
398
+ const maxResults = (options.maxResults as number) || 5000;
399
+ let batch: SearchMatch[] = [];
400
+ const grepState = { totalMatches, fileCount };
401
+
402
+ const flushGrepBatch = () => {
403
+ if (batch.length > 0) {
404
+ ctx.send(ws, { type: 'contentSearchResults', tabId, data: { matches: batch, partial: true } });
405
+ batch = [];
406
+ }
407
+ };
408
+
409
+ grepProcess.stdout?.on('data', (chunk: Buffer) => {
410
+ buffer += chunk.toString();
411
+ const lines = buffer.split('\n');
412
+ buffer = lines.pop() || '';
413
+
414
+ for (const line of lines) {
415
+ if (!line.trim()) continue;
416
+ if (processGrepLine(line, batch, seenFiles, grepState, maxResults, flushGrepBatch)) {
417
+ grepProcess.kill();
418
+ return;
419
+ }
420
+ }
421
+ totalMatches = grepState.totalMatches;
422
+ fileCount = grepState.fileCount;
423
+ });
424
+
425
+ grepProcess.on('close', () => {
426
+ ctx.activeSearches.delete(tabId);
427
+ if (batch.length > 0) {
428
+ ctx.send(ws, { type: 'contentSearchResults', tabId, data: { matches: batch, partial: true } });
429
+ }
430
+ ctx.send(ws, {
431
+ type: 'contentSearchComplete',
432
+ tabId,
433
+ data: { totalMatches, fileCount, truncated: totalMatches >= maxResults, durationMs: Date.now() - startTime },
434
+ });
435
+ });
436
+
437
+ grepProcess.on('error', (err) => {
438
+ ctx.activeSearches.delete(tabId);
439
+ ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: `Search unavailable: ${err.message}` } });
440
+ });
441
+ }
442
+
443
+ function handleCancelSearch(ctx: HandlerContext, tabId: string): void {
444
+ const process = ctx.activeSearches.get(tabId);
445
+ if (process) {
446
+ process.kill();
447
+ ctx.activeSearches.delete(tabId);
448
+ }
449
+ }
450
+
451
+ type DefinitionEntry = { filePath: string; line: number; column: number; lineContent: string; kind: string };
452
+
453
+ function classifyDefinitionKind(lineContent: string): string {
454
+ if (/\b(function|def|func|fn)\b/.test(lineContent)) return 'function';
455
+ if (/\bclass\b/.test(lineContent)) return 'class';
456
+ if (/\binterface\b/.test(lineContent)) return 'interface';
457
+ if (/\btype\b/.test(lineContent)) return 'type';
458
+ if (/\b(enum|struct|trait)\b/.test(lineContent)) return 'enum';
459
+ return 'variable';
460
+ }
461
+
462
+ /** Parse a single JSON line from rg definition search. Returns true if max definitions reached. */
463
+ function parseDefinitionLine(line: string, workingDir: string, definitions: DefinitionEntry[]): boolean {
464
+ try {
465
+ const parsed = JSON.parse(line);
466
+ if (parsed.type !== 'match') return false;
467
+
468
+ const filePath = relative(workingDir, join(workingDir, parsed.data.path.text));
469
+ const lineContent = parsed.data.lines.text.replace(/\n$/, '');
470
+ const column = parsed.data.submatches?.[0]?.start ?? 0;
471
+
472
+ definitions.push({
473
+ filePath,
474
+ line: parsed.data.line_number,
475
+ column: column + 1,
476
+ lineContent,
477
+ kind: classifyDefinitionKind(lineContent),
478
+ });
479
+ return definitions.length >= 20;
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ function sortDefinitionsByProximity(definitions: DefinitionEntry[], currentFile: string): void {
486
+ const currentDir = currentFile ? currentFile.substring(0, currentFile.lastIndexOf('/')) : '';
487
+ definitions.sort((a, b) => {
488
+ const exactDiff = (a.filePath === currentFile ? 0 : 1) - (b.filePath === currentFile ? 0 : 1);
489
+ if (exactDiff !== 0) return exactDiff;
490
+ const dirDiff = (a.filePath.startsWith(`${currentDir}/`) ? 0 : 1) - (b.filePath.startsWith(`${currentDir}/`) ? 0 : 1);
491
+ if (dirDiff !== 0) return dirDiff;
492
+ return a.filePath.split('/').length - b.filePath.split('/').length;
493
+ });
494
+ }
495
+
496
+ function handleFindDefinition(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
497
+ const symbol = msg.data?.symbol;
498
+ const language = msg.data?.language || 'typescript';
499
+ const currentFile = msg.data?.currentFile || '';
500
+
501
+ if (!symbol) {
502
+ ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol: '' } });
503
+ return;
504
+ }
505
+
506
+ const escapedSymbol = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
507
+
508
+ const DEFINITION_PATTERNS: Record<string, (s: string) => string[]> = {
509
+ typescript: (s) => [
510
+ `(function|const|let|var|class|interface|type|enum)\\s+${s}\\b`,
511
+ `export\\s+(default\\s+)?(function|const|let|var|class|interface|type|enum)\\s+${s}\\b`,
512
+ ],
513
+ javascript: (s) => [
514
+ `(function|const|let|var|class)\\s+${s}\\b`,
515
+ `export\\s+(default\\s+)?(function|const|let|var|class)\\s+${s}\\b`,
516
+ ],
517
+ python: (s) => [
518
+ `(def|class)\\s+${s}\\b`,
519
+ `${s}\\s*=`,
520
+ ],
521
+ go: (s) => [
522
+ `func\\s+(\\(\\w+\\s+\\*?\\w+\\)\\s+)?${s}\\b`,
523
+ `type\\s+${s}\\b`,
524
+ `var\\s+${s}\\b`,
525
+ ],
526
+ rust: (s) => [
527
+ `(fn|struct|enum|trait|type|const|static|mod)\\s+${s}\\b`,
528
+ `impl\\s+${s}\\b`,
529
+ ],
530
+ };
531
+
532
+ const LANGUAGE_GLOBS: Record<string, string> = {
533
+ typescript: '*.{ts,tsx}',
534
+ javascript: '*.{js,jsx,mjs,cjs}',
535
+ python: '*.py',
536
+ go: '*.go',
537
+ rust: '*.rs',
538
+ };
539
+
540
+ const patterns = DEFINITION_PATTERNS[language] || DEFINITION_PATTERNS.typescript;
541
+ const combinedPattern = patterns(escapedSymbol).join('|');
542
+ const fileGlob = LANGUAGE_GLOBS[language] || LANGUAGE_GLOBS.typescript;
543
+
544
+ const args = [
545
+ '--json', '-n',
546
+ '--glob', fileGlob,
547
+ '--glob', '!node_modules/**',
548
+ '--glob', '!dist/**',
549
+ '--glob', '!build/**',
550
+ '--glob', '!.git/**',
551
+ '--glob', '!*.min.*',
552
+ '--glob', '!*.bundle.*',
553
+ '-e', combinedPattern, '.',
554
+ ];
555
+
556
+ const rgProcess = spawn('rg', args, { cwd: workingDir, stdio: ['ignore', 'pipe', 'pipe'] });
557
+ let rgBuffer = '';
558
+ const definitions: Array<{ filePath: string; line: number; column: number; lineContent: string; kind: string }> = [];
559
+
560
+ rgProcess.stdout?.on('data', (chunk: Buffer) => {
561
+ rgBuffer += chunk.toString();
562
+ const lines = rgBuffer.split('\n');
563
+ rgBuffer = lines.pop() || '';
564
+
565
+ for (const line of lines) {
566
+ if (!line.trim()) continue;
567
+ if (parseDefinitionLine(line, workingDir, definitions)) {
568
+ rgProcess.kill();
569
+ return;
570
+ }
571
+ }
572
+ });
573
+
574
+ rgProcess.on('close', () => {
575
+ sortDefinitionsByProximity(definitions, currentFile);
576
+
577
+ ctx.send(ws, {
578
+ type: 'definitionResult',
579
+ tabId,
580
+ data: { definitions: definitions.slice(0, 10), symbol },
581
+ });
582
+ });
583
+
584
+ rgProcess.on('error', (_err) => {
585
+ ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol } });
586
+ });
587
+ }
@@ -226,6 +226,18 @@ export function isImageFile(filePath: string): boolean {
226
226
  return ext ? imageExtensions.includes(`.${ext}`) : false;
227
227
  }
228
228
 
229
+ /**
230
+ * Check if a file is a binary file that should be base64-encoded (images + PDFs)
231
+ */
232
+ export function isBinaryFile(filePath: string): boolean {
233
+ return isImageFile(filePath) || isPdfFile(filePath);
234
+ }
235
+
236
+ function isPdfFile(filePath: string): boolean {
237
+ const ext = filePath.toLowerCase().split('.').pop();
238
+ return ext === 'pdf';
239
+ }
240
+
229
241
  type FileContentResult = { path: string; fileName: string; content: string; size?: number; modifiedAt?: string; isImage?: boolean; mimeType?: string; error?: string };
230
242
 
231
243
  function readDirectoryContent(fullPath: string, filePath: string, fileName: string): FileContentResult {
@@ -246,10 +258,17 @@ function readDirectoryContent(fullPath: string, filePath: string, fileName: stri
246
258
  }
247
259
  }
248
260
 
249
- function readImageContent(fullPath: string, filePath: string, fileName: string, stats: { size: number; mtime: Date }): FileContentResult {
261
+ function getBinaryMimeType(ext: string): string {
262
+ if (ext === 'svg') return 'image/svg+xml';
263
+ if (ext === 'jpg') return 'image/jpeg';
264
+ if (ext === 'pdf') return 'application/pdf';
265
+ return `image/${ext}`;
266
+ }
267
+
268
+ function readBinaryContent(fullPath: string, filePath: string, fileName: string, stats: { size: number; mtime: Date }): FileContentResult {
250
269
  const buffer = readFileSync(fullPath);
251
270
  const ext = fullPath.toLowerCase().split('.').pop() || 'png';
252
- const mimeType = ext === 'svg' ? 'image/svg+xml' : ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
271
+ const mimeType = getBinaryMimeType(ext);
253
272
  return {
254
273
  path: filePath, fileName,
255
274
  content: buffer.toString('base64'),
@@ -289,17 +308,17 @@ export function readFileContent(filePath: string, workingDir: string): FileConte
289
308
  return readDirectoryContent(fullPath, filePath, fileName);
290
309
  }
291
310
 
292
- const isImage = isImageFile(fullPath);
293
- const MAX_FILE_SIZE = isImage ? 10 * 1024 * 1024 : 1024 * 1024;
311
+ const isBin = isBinaryFile(fullPath);
312
+ const MAX_FILE_SIZE = isBin ? 10 * 1024 * 1024 : 1024 * 1024;
294
313
  if (stats.size > MAX_FILE_SIZE) {
295
- return { path: filePath, fileName, content: '', size: stats.size, error: `File too large (${Math.round(stats.size / 1024)}KB). Maximum is ${isImage ? '10MB' : '1MB'}.` };
314
+ return { path: filePath, fileName, content: '', size: stats.size, error: `File too large (${Math.round(stats.size / 1024)}KB). Maximum is ${isBin ? '10MB' : '1MB'}.` };
296
315
  }
297
316
 
298
- return isImage
299
- ? readImageContent(fullPath, filePath, fileName, stats)
317
+ return isBin
318
+ ? readBinaryContent(fullPath, filePath, fileName, stats)
300
319
  : readTextContent(fullPath, filePath, fileName, stats);
301
- } catch (error: any) {
320
+ } catch (error: unknown) {
302
321
  console.error('[FileUtils] Error reading file:', error);
303
- return { path: filePath, fileName: filePath.split(sep).pop() || filePath, content: '', error: error.message || 'Failed to read file' };
322
+ return { path: filePath, fileName: filePath.split(sep).pop() || filePath, content: '', error: (error instanceof Error ? error.message : String(error)) || 'Failed to read file' };
304
323
  }
305
324
  }