starmux 0.12.0 → 0.13.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 (93) hide show
  1. package/dist/src/cli/mac-mcp-gateway.js +4 -3
  2. package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
  3. package/dist/src/cli/starmux.js +1 -1
  4. package/dist/src/cli/starmux.js.map +1 -1
  5. package/dist/src/runtime-paths.js +3 -3
  6. package/dist/src/runtime-paths.js.map +1 -1
  7. package/dist/vendor/desktop-commander/LICENSE +21 -0
  8. package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
  9. package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
  10. package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
  11. package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
  12. package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
  13. package/dist/vendor/desktop-commander/dist/config.js +19 -0
  14. package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
  15. package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
  16. package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
  17. package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
  18. package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
  19. package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
  20. package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
  21. package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
  22. package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
  23. package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
  24. package/dist/vendor/desktop-commander/dist/index.js +88 -0
  25. package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
  26. package/dist/vendor/desktop-commander/dist/server.js +1414 -0
  27. package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
  28. package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
  29. package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
  30. package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
  31. package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
  32. package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
  33. package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
  34. package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
  35. package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
  36. package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
  37. package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
  38. package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
  39. package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
  40. package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
  41. package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
  42. package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
  43. package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
  44. package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
  45. package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
  46. package/dist/vendor/desktop-commander/dist/types.js +1 -0
  47. package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
  48. package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
  49. package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
  50. package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
  51. package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
  52. package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
  53. package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  54. package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
  55. package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
  56. package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
  57. package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
  58. package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
  59. package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
  60. package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
  61. package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
  62. package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
  63. package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
  64. package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
  65. package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
  66. package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
  67. package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
  68. package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
  69. package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
  70. package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
  71. package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
  72. package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
  73. package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
  74. package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
  75. package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
  76. package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
  77. package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
  78. package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
  79. package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
  80. package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
  81. package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
  82. package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
  83. package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
  84. package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
  85. package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
  86. package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
  87. package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
  88. package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
  89. package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
  90. package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
  91. package/dist/vendor/desktop-commander/dist/version.js +1 -0
  92. package/dist/vendor/desktop-commander/package.json +15 -0
  93. package/package.json +14 -2
@@ -0,0 +1,1262 @@
1
+ import { spawn } from 'child_process';
2
+ import { constants } from 'buffer';
3
+ import { once } from 'events';
4
+ import path from 'path';
5
+ import fs from 'fs/promises';
6
+ import { validatePath } from './tools/filesystem.js';
7
+ import { capture } from './utils/capture.js';
8
+ import { logger } from './utils/logger.js';
9
+ import { getRipgrepPath } from './utils/ripgrep-resolver.js';
10
+ import { isExcelFile } from './utils/files/index.js';
11
+ import { exceljsPackage } from './utils/files/excel.js';
12
+ import { pizzipPackage } from './utils/files/docx.js';
13
+ /**
14
+ * Default time limit of a file search for an exact filename ("package.json"):
15
+ * a quick lookup of one known file, which gives up rather than walk a huge tree.
16
+ * A content search for the same text looks for every reference to that file,
17
+ * so it has no default limit.
18
+ */
19
+ const EXACT_FILENAME_SEARCH_TIMEOUT_MS = 1500;
20
+ /** The longest a timer waits (2^31 - 1 ms, ~24.8 days): setTimeout fires a longer delay after 1 ms */
21
+ const LONGEST_TIMER_MS = 2 ** 31 - 1;
22
+ /** The extensions of the files the Excel and DOCX searches are for (see targetsOfficeFiles) */
23
+ const EXCEL_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.xlsb'];
24
+ const DOCX_EXTENSIONS = ['.docx'];
25
+ /**
26
+ * The OS errors ripgrep reports for a path it may not read ("rg: <path>: ...
27
+ * (os error N)"): on Windows ERROR_ACCESS_DENIED (5); elsewhere EACCES (13) and
28
+ * EPERM (1: on macOS, a folder its privacy settings protect). On Windows, 1 is
29
+ * an error of another kind.
30
+ */
31
+ const PERMISSION_OS_ERRORS = process.platform === 'win32' ? [5] : [1, 13];
32
+ /** Whether an error of the Excel/DOCX search is about permissions */
33
+ const isPermissionError = (error) => ['EACCES', 'EPERM'].includes(error?.code ?? '');
34
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
35
+ /** The alternatives of a filePattern ("*.js|*.ts") */
36
+ const filePatternAlternatives = (filePattern) => (filePattern ?? '').split('|').map(p => p.trim()).filter(Boolean);
37
+ /** Answers show this many characters of a result's text, then '...' if there is more */
38
+ export const SHOWN_TEXT_CHARS = 100;
39
+ /**
40
+ * What a session keeps of a line's text: what answers show, and one character
41
+ * more so they still know to add '...'. ripgrep sends each line whole (--json
42
+ * ignores --max-columns), and a line can be megabytes long. Copied, because a
43
+ * V8 substring keeps the whole string it was cut from alive. No text (ripgrep
44
+ * sends text that is not valid UTF-8 as "bytes") stays no text, as it always was.
45
+ */
46
+ const keptText = (text) => text === undefined ? undefined : Buffer.from(text.slice(0, SHOWN_TEXT_CHARS + 1), 'utf16le').toString('utf16le');
47
+ /**
48
+ * A glob as ripgrep matches its -g globs (gitignore style), for the files
49
+ * ripgrep doesn't select itself: a file search's files, and the Excel and DOCX
50
+ * files. Without a '/' it is matched against the file's name, with one against
51
+ * its path below the search path ('/'-separated). '*' and '?' stop at '/', "**"
52
+ * as a whole path segment spans folders, "[...]" is a character class ("[!...]"
53
+ * negated) and "{a,b}" a choice.
54
+ */
55
+ function ripgrepGlobMatcher(glob, ignoreCase) {
56
+ const byPath = glob.includes('/'); // "/x.ts" too: x.ts in the search path itself
57
+ let regex;
58
+ try {
59
+ regex = new RegExp(`^${globRegexSource(glob.replace(/^\//, ''))}$`, ignoreCase ? 'i' : '');
60
+ }
61
+ catch {
62
+ // A glob ripgrep rejects too ("[z-a]"): every glob a search uses reaches
63
+ // ripgrep (see buildRipgrepArgs), and the search answers with its error
64
+ return () => false;
65
+ }
66
+ return relativePath => regex.test(byPath ? relativePath : relativePath.slice(relativePath.lastIndexOf('/') + 1));
67
+ }
68
+ /** The regular expression source of a glob (see ripgrepGlobMatcher) */
69
+ function globRegexSource(glob) {
70
+ let source = '';
71
+ for (let i = 0; i < glob.length; i++) {
72
+ const c = glob[i];
73
+ const classEnd = c === '[' ? characterClassEnd(glob, i) : -1;
74
+ const choiceEnd = c === '{' ? glob.indexOf('}', i) : -1;
75
+ if (c === '*' && glob[i + 1] === '*' && (i === 0 || glob[i - 1] === '/') && (i + 2 === glob.length || glob[i + 2] === '/')) {
76
+ // "**" as a whole path segment: any number of folders ("**/": none too)
77
+ source += i + 2 === glob.length ? '.*' : '(?:.*/)?';
78
+ i += i + 2 === glob.length ? 1 : 2;
79
+ }
80
+ else if (c === '*') {
81
+ source += '[^/]*';
82
+ }
83
+ else if (c === '?') {
84
+ source += '[^/]';
85
+ }
86
+ else if (classEnd > 0) {
87
+ let body = glob.slice(i + 1, classEnd);
88
+ const negated = body.startsWith('!') || body.startsWith('^');
89
+ if (negated)
90
+ body = body.slice(1);
91
+ source += `[${negated ? '^/' : ''}${body.replace(/[\\\]^]/g, '\\$&')}]`;
92
+ i = classEnd;
93
+ }
94
+ else if (choiceEnd > 0) {
95
+ source += `(?:${glob.slice(i + 1, choiceEnd).split(',').map(globRegexSource).join('|')})`;
96
+ i = choiceEnd;
97
+ }
98
+ else {
99
+ source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+ }
101
+ }
102
+ return source;
103
+ }
104
+ /** A file's path below the search path, '/'-separated (just its name when it is the search path) */
105
+ const pathBelow = (root, file) => path.relative(root, file).split(path.sep).join('/') || path.basename(file);
106
+ /** A glob that matches `text` itself: each glob character in a class of its own ("[*]", "[[]") */
107
+ const literalGlob = (text) => text.replace(/[*?[\]{}]/g, '[$&]');
108
+ /** The ']' that closes the character class opening at `start` (one right after "[" or "[!" is part of it), or -1 */
109
+ function characterClassEnd(glob, start) {
110
+ let i = start + 1;
111
+ if (glob[i] === '!' || glob[i] === '^')
112
+ i++;
113
+ if (glob[i] === ']')
114
+ i++;
115
+ return glob.indexOf(']', i);
116
+ }
117
+ /**
118
+ * The longest line of ripgrep output the server assembles, in characters: a
119
+ * match or context line in its JSON. Half of V8's longest string (2^29 - 24
120
+ * characters in Node 24), past which the append throws and the server exits:
121
+ * only a longer line is skipped, and answers name its file. Any shorter line is
122
+ * processed whole.
123
+ */
124
+ export const MAX_OUTPUT_LINE_CHARS = Math.floor(constants.MAX_STRING_LENGTH / 2);
125
+ /** The most of ripgrep's error output (stderr) a session keeps, in characters */
126
+ const MAX_KEPT_ERROR_CHARS = 64 * 1024;
127
+ /**
128
+ * Search Session Manager - handles ripgrep processes like terminal sessions
129
+ * Supports both file search and content search with progressive results
130
+ */ export class SearchManager {
131
+ constructor() {
132
+ this.sessions = new Map();
133
+ this.sessionCounter = 0;
134
+ this.cleanupTimer = null;
135
+ }
136
+ /**
137
+ * Start a new search session (like start_process)
138
+ * Returns immediately with initial state and results
139
+ */
140
+ async startSearch(options) {
141
+ const sessionId = `search_${++this.sessionCounter}_${Date.now()}`;
142
+ // Validate path first
143
+ const validPath = await validatePath(options.rootPath);
144
+ // Build ripgrep arguments
145
+ const args = this.buildRipgrepArgs({ ...options, rootPath: validPath });
146
+ // Get ripgrep path with fallback resolution
147
+ let rgPath;
148
+ try {
149
+ rgPath = await getRipgrepPath();
150
+ }
151
+ catch (err) {
152
+ throw new Error(`Failed to locate ripgrep binary: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ // Start ripgrep process. It matches a glob with a '/' ("src/*.ts") against
155
+ // the path below its working directory: that must be the root path. A root
156
+ // that can't be stat'ed (missing...) can't be searched: start_search answers
157
+ // with why, as for any search that can't start.
158
+ const rootIsDirectory = (await fs.stat(validPath)).isDirectory();
159
+ const rgProcess = spawn(rgPath, args, {
160
+ windowsHide: true, // Prevent visible console windows on Windows
161
+ cwd: rootIsDirectory ? validPath : undefined
162
+ });
163
+ await this.whenStarted(rgProcess);
164
+ // Create session
165
+ let markCompleted;
166
+ const completed = new Promise(resolve => { markCompleted = resolve; });
167
+ const session = {
168
+ id: sessionId,
169
+ process: rgProcess,
170
+ results: [],
171
+ isComplete: false,
172
+ startTime: Date.now(),
173
+ lastReadTime: Date.now(),
174
+ options,
175
+ buffer: '',
176
+ skippedLines: new Map(),
177
+ totalMatches: 0,
178
+ totalContextLines: 0,
179
+ filePatternIncludes: this.filePatternIncludes(options, validPath),
180
+ pendingSources: new Set(['ripgrep']),
181
+ unsearched: new Map(),
182
+ completed,
183
+ markCompleted
184
+ };
185
+ this.sessions.set(sessionId, session);
186
+ // Set up process event handlers
187
+ this.setupProcessHandlers(session);
188
+ // Start cleanup interval now that we have a session
189
+ this.startCleanupIfNeeded();
190
+ // Set up the time limit, if any: it stops every source still running.
191
+ // For exact filename file searches, use a shorter default timeout
192
+ const timeoutMs = options.timeout ??
193
+ (this.isExactFilenameSearch(options) ? EXACT_FILENAME_SEARCH_TIMEOUT_MS : undefined);
194
+ if (timeoutMs) {
195
+ session.timeLimitMs = timeoutMs;
196
+ session.timeoutTimer = setTimeout(() => {
197
+ session.stopReason ?? (session.stopReason = 'timed_out');
198
+ this.stopSources(session);
199
+ }, Math.min(timeoutMs, LONGEST_TIMER_MS));
200
+ }
201
+ capture('search_session_started', {
202
+ sessionId,
203
+ searchType: options.searchType,
204
+ hasTimeout: !!timeoutMs,
205
+ timeoutMs,
206
+ requestedPath: options.rootPath,
207
+ validatedPath: validPath
208
+ });
209
+ // For content searches, only search Excel files when contextually relevant:
210
+ // - filePattern explicitly targets Excel files (*.xlsx, *.xls, etc.)
211
+ // - or rootPath is an Excel file itself
212
+ const shouldSearchExcel = options.searchType === 'content' &&
213
+ this.targetsOfficeFiles(EXCEL_EXTENSIONS, options.filePattern, validPath);
214
+ if (shouldSearchExcel) {
215
+ this.startOfficeSource(session, 'excel', sink => this.searchExcelFiles(validPath, options.pattern, options.ignoreCase !== false, sink, options.filePattern, // Pass filePattern to filter Excel files too
216
+ options.includeHidden === true));
217
+ }
218
+ // For content searches, also search DOCX files
219
+ const shouldSearchDocx = options.searchType === 'content' &&
220
+ this.targetsOfficeFiles(DOCX_EXTENSIONS, options.filePattern, validPath);
221
+ if (shouldSearchDocx) {
222
+ this.startOfficeSource(session, 'docx', sink => this.searchDocxFiles(validPath, options.pattern, options.ignoreCase !== false, sink, options.filePattern, options.includeHidden === true));
223
+ }
224
+ // Wait for first chunk of data or early completion instead of fixed delay
225
+ // Office searches run in background and add their matches to the session as they find them
226
+ const firstChunk = new Promise(resolve => {
227
+ const onData = () => {
228
+ session.process.stdout?.off('data', onData);
229
+ resolve();
230
+ };
231
+ session.process.stdout?.once('data', onData);
232
+ setTimeout(resolve, 40); // cap at 40ms instead of 50-100ms
233
+ });
234
+ // Only wait for ripgrep first chunk - Excel results merge asynchronously
235
+ await firstChunk;
236
+ return { ...this.stateOf(session), results: [...session.results] };
237
+ }
238
+ /**
239
+ * Read search results with offset-based pagination (like read_file)
240
+ * Supports both range reading and tail behavior
241
+ */
242
+ readSearchResults(sessionId, offset = 0, length = 100) {
243
+ const session = this.sessions.get(sessionId);
244
+ if (!session) {
245
+ throw new Error(`Search session ${sessionId} not found`);
246
+ }
247
+ // Get all results (excluding internal markers)
248
+ const allResults = session.results.filter(r => r.file !== '__LAST_READ_MARKER__');
249
+ // Every read keeps the session from cleanup, a read of the last results too
250
+ session.lastReadTime = Date.now();
251
+ // Handle negative offsets (tail behavior) - like file reading
252
+ if (offset < 0) {
253
+ const tailCount = Math.abs(offset);
254
+ const tailResults = allResults.slice(-tailCount);
255
+ return {
256
+ ...this.stateOf(session),
257
+ results: tailResults,
258
+ returnedCount: tailResults.length,
259
+ hasMoreResults: false // Tail always returns what's available
260
+ };
261
+ }
262
+ // Handle positive offsets (range behavior) - like file reading
263
+ const slicedResults = allResults.slice(offset, offset + length);
264
+ const hasMoreResults = offset + length < allResults.length || !session.isComplete;
265
+ return {
266
+ ...this.stateOf(session),
267
+ results: slicedResults,
268
+ returnedCount: slicedResults.length,
269
+ hasMoreResults
270
+ };
271
+ }
272
+ /** What answers say about a session (see handlers/search-answers.ts) */
273
+ stateOf(session) {
274
+ return {
275
+ sessionId: session.id,
276
+ isComplete: session.isComplete,
277
+ outcome: session.outcome,
278
+ failure: session.failure,
279
+ unsearched: [...session.unsearched.values()],
280
+ timeLimitMs: session.timeLimitMs,
281
+ maxResults: session.options.maxResults,
282
+ totalMatches: session.totalMatches,
283
+ totalResults: session.totalMatches + session.totalContextLines,
284
+ runtime: (session.endTime ?? Date.now()) - session.startTime,
285
+ skippedLines: this.skippedLinesOf(session)
286
+ };
287
+ }
288
+ skippedLinesOf(session) {
289
+ return [...session.skippedLines].map(([file, count]) => ({ file, count }));
290
+ }
291
+ /**
292
+ * Keeps ripgrep's error output for the answer (shown when a search fails
293
+ * without results), each piece once and up to MAX_KEPT_ERROR_CHARS: a search
294
+ * over folders it can't read can print an error per entry, and the session
295
+ * keeps this until it is cleaned up. What comes first is kept.
296
+ */
297
+ keepErrorOutput(session, text) {
298
+ const kept = session.error ?? '';
299
+ if (kept.length >= MAX_KEPT_ERROR_CHARS)
300
+ return;
301
+ session.error = kept + text.slice(0, MAX_KEPT_ERROR_CHARS - kept.length);
302
+ }
303
+ /**
304
+ * Resolves with all of a session's results once it is complete: every source
305
+ * has finished, or the search was stopped.
306
+ */
307
+ async waitForCompletion(sessionId) {
308
+ const session = this.sessions.get(sessionId);
309
+ if (!session) {
310
+ throw new Error(`Search session ${sessionId} not found`);
311
+ }
312
+ await session.completed;
313
+ return [...session.results];
314
+ }
315
+ /**
316
+ * Terminate a search session (like force_terminate)
317
+ */
318
+ terminateSearch(sessionId) {
319
+ const session = this.sessions.get(sessionId);
320
+ if (!session) {
321
+ return false;
322
+ }
323
+ if (!session.isComplete)
324
+ session.stopReason ?? (session.stopReason = 'stopped');
325
+ this.stopSources(session);
326
+ // Don't delete session immediately - let user read final results
327
+ // It will be cleaned up by cleanup process
328
+ return true;
329
+ }
330
+ /**
331
+ * Stop every running search and drop all sessions and the cleanup timer.
332
+ * For owners that tear the search manager down (shutdown, tests); a later
333
+ * startSearch() starts afresh.
334
+ */
335
+ dispose() {
336
+ for (const session of this.sessions.values()) {
337
+ this.stopSources(session);
338
+ }
339
+ this.sessions.clear();
340
+ if (this.cleanupTimer) {
341
+ clearInterval(this.cleanupTimer);
342
+ this.cleanupTimer = null;
343
+ }
344
+ }
345
+ /**
346
+ * Start the periodic cleanup of old sessions. It is housekeeping only, so it
347
+ * is unref'd: it must never keep the process alive on its own.
348
+ */
349
+ startCleanupIfNeeded() {
350
+ if (this.cleanupTimer)
351
+ return;
352
+ this.cleanupTimer = setInterval(() => this.cleanupSessions(), 5 * 60 * 1000);
353
+ this.cleanupTimer.unref();
354
+ }
355
+ /**
356
+ * Get list of active search sessions (like list_sessions)
357
+ */
358
+ listSearchSessions() {
359
+ return Array.from(this.sessions.values()).map(session => ({
360
+ id: session.id,
361
+ searchType: session.options.searchType,
362
+ pattern: session.options.pattern,
363
+ isComplete: session.isComplete,
364
+ isError: session.outcome === 'failed',
365
+ runtime: (session.endTime ?? Date.now()) - session.startTime,
366
+ totalResults: session.totalMatches + session.totalContextLines
367
+ }));
368
+ }
369
+ /**
370
+ * Run an Excel or DOCX search as one of the session's sources: its matches are
371
+ * collected as it finds them, until it has searched every file or the session
372
+ * stops it (stop_search, timeout, maxResults).
373
+ */
374
+ startOfficeSource(session, source, search) {
375
+ session.pendingSources.add(source);
376
+ const sink = {
377
+ onMatch: result => {
378
+ if (session.pendingSources.has(source))
379
+ this.collectMatch(session, result);
380
+ },
381
+ isStopped: () => !session.pendingSources.has(source),
382
+ couldNotRead: (what, filePath, error) => {
383
+ if (!session.pendingSources.has(source))
384
+ return;
385
+ const code = error?.code;
386
+ if (isPermissionError(error))
387
+ this.noteUnsearched(session, 'permissions', filePath);
388
+ else
389
+ this.noteUnsearched(session, `${source}_${what}`, what === 'folder' ? `${filePath}: ${code ?? errorText(error)}` : filePath);
390
+ }
391
+ };
392
+ search(sink)
393
+ .catch((err) => {
394
+ // Log Office search errors but don't fail the whole search: its answer
395
+ // says that part couldn't be searched
396
+ const message = errorText(err);
397
+ logger.error(`The ${source} part of search ${session.id} failed; its matches are missing: ${message}`);
398
+ capture(`${source}_search_error`, { error: message });
399
+ if (session.pendingSources.has(source))
400
+ this.noteUnsearched(session, `${source}_search`, message);
401
+ })
402
+ .finally(() => this.finishSource(session, source));
403
+ }
404
+ /**
405
+ * Search Excel files for content matches
406
+ * Called during content search to include Excel files alongside text files
407
+ * Searches ALL sheets in each Excel file (row-wise for cross-column matching)
408
+ *
409
+ * TODO: Refactor - Extract Excel search logic to separate module (src/utils/search/excel-search.ts)
410
+ * and inject into SearchManager, similar to how file handlers are structured in src/utils/files/
411
+ * This would allow adding other file type searches (PDF, etc.) without bloating search-manager.ts
412
+ */
413
+ async searchExcelFiles(rootPath, pattern, ignoreCase, sink, filePattern, includeHidden = false) {
414
+ // Office file search always uses literal matching to prevent ReDoS.
415
+ // Regex patterns are treated as literal strings — this is intentional.
416
+ const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
417
+ // Find Excel files recursively
418
+ let excelFiles = await this.findExcelFiles(rootPath, sink, includeHidden);
419
+ // Filter by filePattern if provided
420
+ if (filePattern) {
421
+ excelFiles = this.filterOfficeFiles(excelFiles, filePattern, rootPath);
422
+ }
423
+ if (excelFiles.length === 0)
424
+ return;
425
+ // ExcelJS, to search all sheets: loaded here if the server hasn't yet
426
+ // (a search runs in the background, so it isn't refused while it loads)
427
+ const ExcelJS = exceljsPackage.load();
428
+ for (const filePath of excelFiles) {
429
+ if (sink.isStopped())
430
+ break;
431
+ try {
432
+ const workbook = new ExcelJS.Workbook();
433
+ await workbook.xlsx.readFile(filePath);
434
+ // Search ALL sheets in the workbook (row-wise for speed and cross-column matching)
435
+ for (const worksheet of workbook.worksheets) {
436
+ if (sink.isStopped())
437
+ break;
438
+ const sheetName = worksheet.name;
439
+ // Iterate through rows (faster than cell-by-cell)
440
+ worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
441
+ if (sink.isStopped())
442
+ return;
443
+ // Build a concatenated string of all cell values in the row
444
+ const rowValues = [];
445
+ row.eachCell({ includeEmpty: false }, (cell) => {
446
+ if (cell.value === null || cell.value === undefined)
447
+ return;
448
+ let cellStr;
449
+ if (typeof cell.value === 'object') {
450
+ if ('result' in cell.value) {
451
+ cellStr = String(cell.value.result ?? '');
452
+ }
453
+ else if ('richText' in cell.value) {
454
+ cellStr = cell.value.richText.map((rt) => rt.text).join('');
455
+ }
456
+ else if ('text' in cell.value) {
457
+ cellStr = String(cell.value.text);
458
+ }
459
+ else {
460
+ cellStr = String(cell.value);
461
+ }
462
+ }
463
+ else {
464
+ cellStr = String(cell.value);
465
+ }
466
+ if (cellStr.trim()) {
467
+ rowValues.push(cellStr);
468
+ }
469
+ });
470
+ // Join all cell values with space for cross-column matching
471
+ const rowText = rowValues.join(' ');
472
+ const textToSearch = ignoreCase ? rowText.toLowerCase() : rowText;
473
+ const matchIndex = textToSearch.indexOf(searchTerm);
474
+ if (matchIndex !== -1) {
475
+ const matchContext = this.getMatchContext(rowText, matchIndex, searchTerm.length);
476
+ sink.onMatch({
477
+ file: `${filePath}:${sheetName}!Row${rowNumber}`,
478
+ line: rowNumber,
479
+ match: matchContext,
480
+ type: 'content'
481
+ });
482
+ }
483
+ });
484
+ }
485
+ }
486
+ catch (error) {
487
+ // Skip files that can't be read (permission issues, corrupted, etc.); the answer says so
488
+ sink.couldNotRead('file', filePath, error);
489
+ continue;
490
+ }
491
+ }
492
+ }
493
+ /**
494
+ * Whether the Excel/DOCX search walks into a folder: never node_modules, and a
495
+ * hidden one (a name starting with '.') only with includeHidden, as ripgrep
496
+ * enters hidden folders only with --hidden.
497
+ */
498
+ officeWalkEnters(name, includeHidden) {
499
+ return name !== 'node_modules' && (includeHidden || !name.startsWith('.'));
500
+ }
501
+ /**
502
+ * Find all Excel files in a directory recursively. Stops walking once the
503
+ * search is stopped; a folder it can't list is reported to the sink.
504
+ */
505
+ async findExcelFiles(rootPath, sink, includeHidden = false) {
506
+ const excelFiles = [];
507
+ const enters = (name) => this.officeWalkEnters(name, includeHidden);
508
+ async function walk(dir) {
509
+ if (sink.isStopped())
510
+ return;
511
+ try {
512
+ const entries = await fs.readdir(dir, { withFileTypes: true });
513
+ for (const entry of entries) {
514
+ const fullPath = path.join(dir, entry.name);
515
+ if (entry.isDirectory()) {
516
+ if (enters(entry.name)) {
517
+ await walk(fullPath);
518
+ }
519
+ }
520
+ else if (entry.isFile() && isExcelFile(entry.name)) {
521
+ excelFiles.push(fullPath);
522
+ }
523
+ }
524
+ }
525
+ catch (error) {
526
+ // Skip directories we can't read; the answer says so
527
+ sink.couldNotRead('folder', dir, error);
528
+ }
529
+ }
530
+ // Check if rootPath is a file or directory
531
+ try {
532
+ const stats = await fs.stat(rootPath);
533
+ if (stats.isFile() && isExcelFile(rootPath)) {
534
+ return [rootPath];
535
+ }
536
+ else if (stats.isDirectory()) {
537
+ await walk(rootPath);
538
+ }
539
+ }
540
+ catch {
541
+ // Path doesn't exist or can't be accessed
542
+ }
543
+ return excelFiles;
544
+ }
545
+ /**
546
+ * The Excel/DOCX files a filePattern selects, as ripgrep's globs select text
547
+ * files (ripgrepGlobMatcher), ignoring case: one of its alternatives matches
548
+ * the file (any file, when all of them are "!" alternatives), and none of its
549
+ * "!" alternatives leaves it out (see officeFileExcluded). A search path that
550
+ * is itself a file is searched whatever the pattern says, as ripgrep searches
551
+ * a file it is given.
552
+ */
553
+ filterOfficeFiles(files, filePattern, rootPath) {
554
+ const patterns = filePatternAlternatives(filePattern);
555
+ const includes = patterns.filter(pat => !pat.startsWith('!')).map(pat => ripgrepGlobMatcher(pat, true));
556
+ const excludes = patterns.filter(pat => pat.startsWith('!')).map(pat => pat.slice(1));
557
+ return files.filter(filePath => {
558
+ if (filePath === rootPath)
559
+ return true;
560
+ const relativePath = pathBelow(rootPath, filePath);
561
+ return (includes.length === 0 || includes.some(matches => matches(relativePath))) &&
562
+ !excludes.some(pat => this.officeFileExcluded(relativePath, pat));
563
+ });
564
+ }
565
+ /**
566
+ * Whether a "!" alternative of a filePattern (given without its "!") leaves an
567
+ * Excel/DOCX file out - as it leaves text files out of ripgrep's search: when
568
+ * it matches the file, or a directory between the search path and the file;
569
+ * ending in '/', only directories.
570
+ */
571
+ officeFileExcluded(relativePath, exclusion) {
572
+ const dirOnly = exclusion.endsWith('/');
573
+ const matches = ripgrepGlobMatcher(exclusion.replace(/\/+$/, ''), true);
574
+ const parts = relativePath.split('/');
575
+ // The directories between the search path and the file, then the file itself
576
+ const candidates = parts.map((_, i) => parts.slice(0, i + 1).join('/')).slice(0, dirOnly ? -1 : undefined);
577
+ return candidates.some(candidate => matches(candidate));
578
+ }
579
+ /**
580
+ * Whether a content search runs the Excel or DOCX search, the one for files
581
+ * with these extensions. Only when contextually relevant: the rootPath is
582
+ * such a file, or a filePattern alternative targets them ("*.xlsx",
583
+ * "budget.xlsx") - which one that leaves files out ("!*.xlsx") does not.
584
+ * Each alternative is checked on its own, so "report.xlsx|memo.docx" runs both.
585
+ */
586
+ targetsOfficeFiles(extensions, filePattern, rootPath) {
587
+ const lowerRootPath = rootPath.toLowerCase();
588
+ const targetingPatterns = filePatternAlternatives(filePattern)
589
+ .filter(pattern => !pattern.startsWith('!'))
590
+ .map(pattern => pattern.toLowerCase());
591
+ return extensions.some(ext => lowerRootPath.endsWith(ext) ||
592
+ targetingPatterns.some(pattern => pattern.includes(`*${ext}`) || pattern.endsWith(ext)));
593
+ }
594
+ /**
595
+ * Search DOCX files for content matches
596
+ * Extracts <w:t> text from document.xml and searches it
597
+ */
598
+ async searchDocxFiles(rootPath, pattern, ignoreCase, sink, filePattern, includeHidden = false) {
599
+ // Office file search always uses literal matching to prevent ReDoS.
600
+ // Regex patterns are treated as literal strings — this is intentional.
601
+ const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
602
+ let docxFiles = await this.findDocxFiles(rootPath, sink, includeHidden);
603
+ if (filePattern) {
604
+ docxFiles = this.filterOfficeFiles(docxFiles, filePattern, rootPath);
605
+ }
606
+ if (docxFiles.length === 0)
607
+ return;
608
+ // PizZip, to open the DOCX files: loaded here if the server hasn't yet
609
+ const PizZip = pizzipPackage.load();
610
+ for (const filePath of docxFiles) {
611
+ if (sink.isStopped())
612
+ break;
613
+ try {
614
+ const buf = await fs.readFile(filePath);
615
+ const zip = new PizZip(buf);
616
+ // Search all XML parts that can contain text
617
+ const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
618
+ 'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
619
+ for (const xmlPath of xmlParts) {
620
+ if (sink.isStopped())
621
+ break;
622
+ const file = zip.file(xmlPath);
623
+ if (!file)
624
+ continue;
625
+ const xml = file.asText();
626
+ // Extract all <w:t> text with position tracking
627
+ const wtRe = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
628
+ let m;
629
+ let lineNum = 0;
630
+ while ((m = wtRe.exec(xml)) !== null) {
631
+ if (sink.isStopped())
632
+ break;
633
+ const text = m[1];
634
+ if (!text || !text.trim())
635
+ continue;
636
+ lineNum++;
637
+ const textToSearch = ignoreCase ? text.toLowerCase() : text;
638
+ const matchIndex = textToSearch.indexOf(searchTerm);
639
+ if (matchIndex !== -1) {
640
+ const matchContext = this.getMatchContext(text, matchIndex, searchTerm.length);
641
+ const partName = xmlPath === 'word/document.xml' ? '' : `:${xmlPath.replace('word/', '')}`;
642
+ sink.onMatch({
643
+ file: `${filePath}${partName}`,
644
+ line: lineNum,
645
+ match: matchContext,
646
+ type: 'content'
647
+ });
648
+ }
649
+ }
650
+ }
651
+ }
652
+ catch (error) {
653
+ // A file that can't be read: the answer says so
654
+ sink.couldNotRead('file', filePath, error);
655
+ continue;
656
+ }
657
+ }
658
+ }
659
+ /**
660
+ * Find all DOCX files in a directory recursively. Stops walking once the
661
+ * search is stopped; a folder it can't list is reported to the sink.
662
+ */
663
+ async findDocxFiles(rootPath, sink, includeHidden = false) {
664
+ const docxFiles = [];
665
+ const enters = (name) => this.officeWalkEnters(name, includeHidden);
666
+ const isDocx = (name) => name.toLowerCase().endsWith('.docx');
667
+ async function walk(dir) {
668
+ if (sink.isStopped())
669
+ return;
670
+ try {
671
+ const entries = await fs.readdir(dir, { withFileTypes: true });
672
+ for (const entry of entries) {
673
+ const fullPath = path.join(dir, entry.name);
674
+ if (entry.isDirectory()) {
675
+ if (enters(entry.name)) {
676
+ await walk(fullPath);
677
+ }
678
+ }
679
+ else if (entry.isFile() && isDocx(entry.name)) {
680
+ docxFiles.push(fullPath);
681
+ }
682
+ }
683
+ }
684
+ catch (error) {
685
+ sink.couldNotRead('folder', dir, error);
686
+ }
687
+ }
688
+ try {
689
+ const stats = await fs.stat(rootPath);
690
+ if (stats.isFile() && isDocx(rootPath)) {
691
+ return [rootPath];
692
+ }
693
+ else if (stats.isDirectory()) {
694
+ await walk(rootPath);
695
+ }
696
+ }
697
+ catch { /* skip */ }
698
+ return docxFiles;
699
+ }
700
+ /**
701
+ * Extract context around a match for display (show surrounding text)
702
+ */
703
+ getMatchContext(text, matchStart, matchLength) {
704
+ const contextChars = 50; // chars before and after match
705
+ const start = Math.max(0, matchStart - contextChars);
706
+ const end = Math.min(text.length, matchStart + matchLength + contextChars);
707
+ let context = text.substring(start, end);
708
+ // Add ellipsis if truncated
709
+ if (start > 0)
710
+ context = '...' + context;
711
+ if (end < text.length)
712
+ context = context + '...';
713
+ return context;
714
+ }
715
+ /**
716
+ * Clean up completed sessions older than specified time
717
+ * Called automatically by cleanup interval
718
+ */
719
+ cleanupSessions(maxAge = 5 * 60 * 1000) {
720
+ const cutoffTime = Date.now() - maxAge;
721
+ for (const [sessionId, session] of this.sessions) {
722
+ if (session.isComplete && session.lastReadTime < cutoffTime) {
723
+ this.sessions.delete(sessionId);
724
+ }
725
+ }
726
+ }
727
+ /**
728
+ * Get total number of active sessions (excluding completed ones)
729
+ */
730
+ getActiveSessionCount() {
731
+ return Array.from(this.sessions.values()).filter(session => !session.isComplete).length;
732
+ }
733
+ /**
734
+ * A file search for one exact filename ("package.json"): a lookup of a known
735
+ * file, unlike a content search for that same text
736
+ */
737
+ isExactFilenameSearch(options) {
738
+ return options.searchType === 'files' && this.isExactFilename(options.pattern, options.literalSearch);
739
+ }
740
+ /**
741
+ * Detect if pattern looks like an exact filename
742
+ * (has file extension and no glob wildcards; a literal pattern has none)
743
+ */
744
+ isExactFilename(pattern, literal = false) {
745
+ return /\.[a-zA-Z0-9]+$/.test(pattern) &&
746
+ (literal || !this.isGlobPattern(pattern));
747
+ }
748
+ /**
749
+ * Detect if pattern contains glob wildcards
750
+ */
751
+ isGlobPattern(pattern) {
752
+ return pattern.includes('*') ||
753
+ pattern.includes('?') ||
754
+ pattern.includes('[') ||
755
+ pattern.includes('{') ||
756
+ pattern.includes(']') ||
757
+ pattern.includes('}');
758
+ }
759
+ /**
760
+ * A file search's filePattern alternatives other than "!", which a file it
761
+ * finds must match one of (see filePatternSelects); undefined when there are none
762
+ */
763
+ filePatternIncludes(options, root) {
764
+ if (options.searchType !== 'files')
765
+ return undefined;
766
+ const includes = filePatternAlternatives(options.filePattern).filter(pattern => !pattern.startsWith('!'));
767
+ if (includes.length === 0)
768
+ return undefined;
769
+ return { root, matchers: includes.map(glob => ripgrepGlobMatcher(glob, options.ignoreCase !== false)) };
770
+ }
771
+ /**
772
+ * Whether a file a file search found is one its filePattern selects: ripgrep
773
+ * checked the "!" alternatives (see buildRipgrepArgs), one of the others must
774
+ * match it. ripgrep can't check those itself: with the pattern's glob in the
775
+ * same list, any glob that matches lets a file in.
776
+ */
777
+ filePatternSelects(session, file) {
778
+ const includes = session.filePatternIncludes;
779
+ if (!includes)
780
+ return true;
781
+ const relativePath = pathBelow(includes.root, file);
782
+ return includes.matchers.some(matches => matches(relativePath));
783
+ }
784
+ buildRipgrepArgs(options) {
785
+ // These arguments are the whole search: the user's ripgrep config file
786
+ // (RIPGREP_CONFIG_PATH) must not add flags such as --hidden, --glob,
787
+ // --max-count or --null that change what is found or the output parsed here
788
+ const args = ['--no-config'];
789
+ if (options.searchType === 'content') {
790
+ // Content search mode
791
+ args.push('--json', '--line-number');
792
+ // Add literal search support for content searches
793
+ if (options.literalSearch) {
794
+ args.push('-F'); // Fixed string matching (literal)
795
+ }
796
+ if (options.contextLines && options.contextLines > 0) {
797
+ args.push('-C', options.contextLines.toString());
798
+ }
799
+ }
800
+ else {
801
+ // File search mode
802
+ args.push('--files');
803
+ }
804
+ // Case-insensitive: content searches use -i flag, file searches use --iglob
805
+ if (options.searchType === 'content' && options.ignoreCase !== false) {
806
+ args.push('-i');
807
+ }
808
+ if (options.includeHidden) {
809
+ args.push('--hidden');
810
+ }
811
+ // maxResults caps the TOTAL number of matches (see collectMatch()), so no
812
+ // file can contribute more than that: -m stops ripgrep there in each file.
813
+ // Without it a folder search keeps a file's whole output in ripgrep's memory
814
+ // until the file is done, however little of it the search can use. (ripgrep
815
+ // still prints a match that falls in the trailing context of its last one;
816
+ // collectRipgrepLine() takes it like any other. --files ignores -m.)
817
+ if (options.maxResults && options.maxResults > 0) {
818
+ args.push('-m', options.maxResults.toString());
819
+ }
820
+ // File pattern filtering (for file type restrictions like *.js, *.d.ts)
821
+ if (options.filePattern && options.searchType === 'content') {
822
+ for (const p of filePatternAlternatives(options.filePattern)) {
823
+ args.push('-g', p);
824
+ }
825
+ }
826
+ // Handle the main search pattern
827
+ if (options.searchType === 'files') {
828
+ // For file search: determine how to treat the pattern
829
+ // (--iglob for case-insensitive or --glob for case-sensitive)
830
+ const globFlag = options.ignoreCase !== false ? '--iglob' : '--glob';
831
+ // literalSearch: the pattern is an exact string, glob characters and all
832
+ const name = options.literalSearch ? literalGlob(options.pattern) : options.pattern;
833
+ if (this.isExactFilename(options.pattern, options.literalSearch)) {
834
+ // Exact filename: use appropriate glob flag with the exact pattern
835
+ args.push(globFlag, name);
836
+ }
837
+ else if (!options.literalSearch && this.isGlobPattern(options.pattern)) {
838
+ // Already a glob pattern: use appropriate glob flag as-is
839
+ args.push(globFlag, options.pattern);
840
+ }
841
+ else {
842
+ // Substring/fuzzy search: wrap with wildcards
843
+ args.push(globFlag, `*${name}*`);
844
+ }
845
+ // filePattern narrows the files the pattern finds. Its "!" alternatives
846
+ // come after the pattern's glob, since ripgrep's last matching glob wins;
847
+ // the others are checked on what ripgrep finds (filePatternSelects), and
848
+ // still reach ripgrep as --pre-globs: it parses them (an invalid one is its
849
+ // error, as for any glob) but without --pre never applies them
850
+ for (const p of filePatternAlternatives(options.filePattern)) {
851
+ args.push(...(p.startsWith('!') ? [globFlag, p] : ['--pre-glob', p]));
852
+ }
853
+ // Add the root path for file mode
854
+ args.push(options.rootPath);
855
+ }
856
+ else {
857
+ // Content search: terminate options before the pattern to prevent
858
+ // patterns starting with '-' being interpreted as flags
859
+ args.push('--', options.pattern, options.rootPath);
860
+ }
861
+ return args;
862
+ }
863
+ /**
864
+ * Resolves once ripgrep has started; else logs why it could not start (not
865
+ * found, not executable...) and rejects with the error start_search always
866
+ * reported. 'spawn' or 'error' comes on the next tick, before any I/O, so
867
+ * the caller still sets up its handlers in time.
868
+ */
869
+ async whenStarted(child) {
870
+ try {
871
+ await once(child, 'spawn');
872
+ }
873
+ catch (error) {
874
+ const reason = error instanceof Error ? error.message : String(error);
875
+ logger.error(`Failed to start ripgrep: ${reason}`);
876
+ throw Object.assign(new Error('Failed to start ripgrep process'), { cause: error });
877
+ }
878
+ }
879
+ setupProcessHandlers(session) {
880
+ const { process } = session;
881
+ // Decoded as streams: a character cut between two chunks stays whole
882
+ // (decoding each chunk on its own made it U+FFFD, in matches and paths)
883
+ process.stdout?.setEncoding('utf8');
884
+ process.stderr?.setEncoding('utf8');
885
+ process.stdout?.on('data', (data) => {
886
+ session.printedOutput = true;
887
+ this.processOutput(session, data);
888
+ });
889
+ process.stderr?.on('data', (data) => {
890
+ const errorText = data.toString();
891
+ // Store error text for potential user display (bounded: see keepErrorOutput)
892
+ this.keepErrorOutput(session, errorText);
893
+ // Filter meaningful errors, for telemetry; they are part of the text kept above
894
+ const filteredErrors = errorText
895
+ .split('\n')
896
+ .filter(line => {
897
+ const trimmed = line.trim();
898
+ // Skip empty lines and lines with just symbols/numbers/colons
899
+ if (!trimmed || trimmed.match(/^[\)\(\s\d:]*$/))
900
+ return false;
901
+ // Skip all ripgrep system errors that start with "rg:"
902
+ if (trimmed.startsWith('rg:'))
903
+ return false;
904
+ return true;
905
+ });
906
+ if (filteredErrors.length > 0) {
907
+ const meaningfulErrors = filteredErrors.join('\n').trim();
908
+ if (meaningfulErrors) {
909
+ capture('search_session_error', {
910
+ sessionId: session.id,
911
+ error: meaningfulErrors.substring(0, 200)
912
+ });
913
+ }
914
+ }
915
+ });
916
+ process.on('close', (code) => {
917
+ // Process the last line, if the output did not end with a newline
918
+ this.processOutput(session, '', true);
919
+ // Exit codes: 0 found, 1 found nothing, 2 an error. No code: a signal
920
+ // ended it, ours (time limit, stop_search, maxResults) or not.
921
+ // A content search's ripgrep prints a JSON line for each file it searches
922
+ // and a summary at the end: exiting 2 with nothing printed, it could not
923
+ // search at all (an invalid pattern or glob), rather than meeting files it
924
+ // couldn't read. A file search prints only the names it finds, so for it
925
+ // the error must say so: a glob it could not parse. Its error is the answer,
926
+ // whatever the Excel/DOCX searches find.
927
+ const couldNotSearch = session.options.searchType === 'content' || /^rg: error parsing glob /m.test(session.error ?? '');
928
+ if (code === 2 && !session.printedOutput && session.error?.trim() && couldNotSearch) {
929
+ session.failure = { error: session.error.trim() };
930
+ }
931
+ else if (code === 2) {
932
+ this.noteRipgrepErrors(session);
933
+ }
934
+ else if (code !== 0 && code !== 1 && !session.stoppedRipgrep) {
935
+ const how = code === null ? `signal ${session.process.signalCode}` : `exit code ${code}`;
936
+ session.ripgrepTrouble ?? (session.ripgrepTrouble = how);
937
+ this.noteUnsearched(session, 'ripgrep_ended', how);
938
+ }
939
+ session.exitCode = code;
940
+ this.finishSource(session, 'ripgrep');
941
+ });
942
+ process.on('error', (error) => {
943
+ session.error = `Process error: ${error.message}`;
944
+ session.ripgrepTrouble ?? (session.ripgrepTrouble = session.error);
945
+ this.noteUnsearched(session, 'ripgrep_ended', session.error);
946
+ this.finishSource(session, 'ripgrep');
947
+ });
948
+ }
949
+ /**
950
+ * ripgrep searched, and ended with exit code 2: some paths couldn't be
951
+ * searched, each named on stderr ("rg: <path>: <why> (os error N)"). Those it
952
+ * may not read are about permissions; any other error is trouble, which fails
953
+ * a search that found nothing (see outcomeOf).
954
+ */
955
+ noteRipgrepErrors(session) {
956
+ const errors = (session.error ?? '').split('\n').filter(line => line.startsWith('rg: ')).map(line => line.slice(4).trim());
957
+ for (const error of errors) {
958
+ const osError = Number(/\(os error (\d+)\)$/.exec(error)?.[1]);
959
+ if (PERMISSION_OS_ERRORS.includes(osError)) {
960
+ this.noteUnsearched(session, 'permissions', error);
961
+ }
962
+ else {
963
+ session.ripgrepTrouble ?? (session.ripgrepTrouble = error);
964
+ this.noteUnsearched(session, 'ripgrep_error', error);
965
+ }
966
+ }
967
+ if (errors.length === 0) {
968
+ session.ripgrepTrouble ?? (session.ripgrepTrouble = 'exit code 2');
969
+ this.noteUnsearched(session, 'ripgrep_ended', 'exit code 2');
970
+ }
971
+ }
972
+ /** Something the search couldn't search: counted by kind, the first one kept as the example */
973
+ noteUnsearched(session, kind, example) {
974
+ const noted = session.unsearched.get(kind);
975
+ if (noted)
976
+ noted.count++;
977
+ else
978
+ session.unsearched.set(kind, { kind, count: 1, example });
979
+ }
980
+ /**
981
+ * The session's one outcome, once every source is done. A failure first:
982
+ * ripgrep couldn't search at all, or had trouble and nothing was found. Then
983
+ * what stopped the search, if anything did; then what couldn't be searched.
984
+ */
985
+ outcomeOf(session) {
986
+ if (!session.failure && session.ripgrepTrouble && session.totalMatches === 0) {
987
+ const error = session.error?.trim();
988
+ session.failure = error ? { error } : { ripgrepEnded: session.ripgrepTrouble };
989
+ }
990
+ if (session.failure)
991
+ return 'failed';
992
+ if (session.stopReason)
993
+ return session.stopReason;
994
+ return session.unsearched.size > 0 ? 'partial' : 'completed';
995
+ }
996
+ /**
997
+ * A source has ended, or was stopped. This is the one place a session
998
+ * completes: when its last source is done, whatever the order they end in.
999
+ */
1000
+ finishSource(session, source) {
1001
+ session.pendingSources.delete(source);
1002
+ if (session.pendingSources.size > 0 || session.isComplete)
1003
+ return;
1004
+ session.outcome = this.outcomeOf(session);
1005
+ session.isComplete = true;
1006
+ session.endTime = Date.now();
1007
+ clearTimeout(session.timeoutTimer);
1008
+ capture('search_session_completed', {
1009
+ sessionId: session.id,
1010
+ exitCode: session.exitCode,
1011
+ totalResults: session.totalMatches + session.totalContextLines,
1012
+ totalMatches: session.totalMatches,
1013
+ runtime: session.endTime - session.startTime,
1014
+ outcome: session.outcome,
1015
+ wasIncomplete: session.outcome === 'partial' // Some files couldn't be searched
1016
+ });
1017
+ session.markCompleted();
1018
+ // Rely on cleanupSessions(maxAge) only; no per-session timer
1019
+ }
1020
+ /**
1021
+ * Stop every source still running (stop_search, timeout, dispose); the
1022
+ * session completes once ripgrep has exited.
1023
+ */
1024
+ stopSources(session) {
1025
+ this.stopOfficeSources(session);
1026
+ this.stopRipgrep(session);
1027
+ }
1028
+ /**
1029
+ * The Excel/DOCX searches are done as of now. They can't be interrupted
1030
+ * mid-file: each stops at its next check, and anything it still finds is dropped.
1031
+ */
1032
+ stopOfficeSources(session) {
1033
+ for (const source of session.pendingSources) {
1034
+ if (source === 'excel' || source === 'docx')
1035
+ this.finishSource(session, source);
1036
+ }
1037
+ }
1038
+ stopRipgrep(session) {
1039
+ if (session.pendingSources.has('ripgrep') && !session.process.killed) {
1040
+ session.stoppedRipgrep = true;
1041
+ session.process.kill('SIGTERM');
1042
+ }
1043
+ }
1044
+ /**
1045
+ * Split ripgrep's output into lines as it arrives. Only the new text is split,
1046
+ * its first piece joined to the line pending from earlier chunks, so a line
1047
+ * costs time linear in its length (splitting the whole pending line again for
1048
+ * every chunk made a long line quadratic). With isFinal, the pending line is
1049
+ * complete as it is.
1050
+ */
1051
+ processOutput(session, text, isFinal = false) {
1052
+ const pieces = text.split('\n');
1053
+ const lines = [];
1054
+ pieces.forEach((piece, i) => {
1055
+ this.appendToLine(session, piece);
1056
+ if (i < pieces.length - 1 || isFinal) {
1057
+ const line = this.takeLine(session);
1058
+ if (line !== undefined)
1059
+ lines.push(line);
1060
+ }
1061
+ });
1062
+ this.processLines(session, lines);
1063
+ }
1064
+ /**
1065
+ * Add text to the line being received, unless that makes it longer than
1066
+ * MAX_OUTPUT_LINE_CHARS: then the line is skipped, noted by its file, and
1067
+ * the rest of it dropped as it arrives.
1068
+ */
1069
+ appendToLine(session, text) {
1070
+ if (session.skippingLine || !text)
1071
+ return;
1072
+ if (session.buffer.length + text.length <= MAX_OUTPUT_LINE_CHARS) {
1073
+ session.buffer += text;
1074
+ return;
1075
+ }
1076
+ const file = this.fileOfOutputLine((session.buffer + text).slice(0, 4096)) ?? '';
1077
+ session.skippedLines.set(file, (session.skippedLines.get(file) ?? 0) + 1);
1078
+ session.skippingLine = true;
1079
+ session.buffer = '';
1080
+ }
1081
+ /** The line being received, now complete; undefined if it was skipped */
1082
+ takeLine(session) {
1083
+ const line = session.buffer;
1084
+ session.buffer = '';
1085
+ if (session.skippingLine) {
1086
+ session.skippingLine = false;
1087
+ return undefined;
1088
+ }
1089
+ return line;
1090
+ }
1091
+ /**
1092
+ * The file a line of ripgrep's --json output is about, read from the line's
1093
+ * start: {"type":"match","data":{"path":{"text":"..."},... (a path that is
1094
+ * not valid UTF-8 comes as "bytes" and is not read)
1095
+ */
1096
+ fileOfOutputLine(start) {
1097
+ const quoted = /^\{"type":"\w+","data":\{"path":\{"text":("(?:[^"\\]|\\.)*")/.exec(start)?.[1];
1098
+ try {
1099
+ return quoted ? JSON.parse(quoted) : undefined;
1100
+ }
1101
+ catch {
1102
+ // A malformed escape in the path: the skipped line is then counted without a file name
1103
+ return undefined;
1104
+ }
1105
+ }
1106
+ processLines(session, lines) {
1107
+ for (const line of lines) {
1108
+ if (!line.trim())
1109
+ continue;
1110
+ const parsed = this.parseLine(line, session.options.searchType);
1111
+ if (parsed?.kind === 'match' && parsed.result.type === 'file' && !this.filePatternSelects(session, parsed.result.file)) {
1112
+ continue;
1113
+ }
1114
+ if (parsed) {
1115
+ this.collectRipgrepLine(session, parsed);
1116
+ // Early termination for exact filename matches (if enabled)
1117
+ if (parsed.kind === 'match' &&
1118
+ session.options.earlyTermination !== false && // Default to true
1119
+ this.isExactFilenameSearch(session.options)) {
1120
+ const pat = path.normalize(session.options.pattern);
1121
+ const filePath = path.normalize(parsed.result.file);
1122
+ const ignoreCase = session.options.ignoreCase !== false;
1123
+ const ends = ignoreCase
1124
+ ? filePath.toLowerCase().endsWith(pat.toLowerCase())
1125
+ : filePath.endsWith(pat);
1126
+ if (ends) {
1127
+ // Found exact match, terminate search early
1128
+ setTimeout(() => this.stopRipgrep(session), 100); // Small delay to allow any remaining results
1129
+ break;
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ /**
1136
+ * Collect one line of ripgrep output. Once maxResults matches are in, only the
1137
+ * last match's trailing context is still collected - a matching line inside it
1138
+ * is kept as context, like grep -m - and ripgrep is stopped when it ends.
1139
+ */
1140
+ collectRipgrepLine(session, line) {
1141
+ if (line.kind !== 'match' && line.kind !== 'context') {
1142
+ // A file ended (or the next began): no trailing context is pending any more
1143
+ session.trailingContext = undefined;
1144
+ }
1145
+ else if (!this.atMaxResults(session)) {
1146
+ if (line.kind === 'context') {
1147
+ session.results.push(line.result);
1148
+ session.totalContextLines++;
1149
+ }
1150
+ else {
1151
+ // Note where this match's trailing context ends, in case it is the last match
1152
+ const contextLines = session.options.contextLines ?? 0;
1153
+ session.trailingContext = line.result.type === 'content' && line.result.line !== undefined && contextLines > 0
1154
+ ? { file: line.result.file, lastLine: line.result.line + contextLines }
1155
+ : undefined;
1156
+ this.collectMatch(session, line.result);
1157
+ }
1158
+ }
1159
+ else {
1160
+ const trailing = session.trailingContext;
1161
+ const lineNumber = line.result.line;
1162
+ if (trailing && line.result.file === trailing.file && lineNumber !== undefined && lineNumber <= trailing.lastLine) {
1163
+ session.results.push({ ...line.result, context: true });
1164
+ session.totalContextLines++;
1165
+ if (lineNumber === trailing.lastLine) {
1166
+ session.trailingContext = undefined;
1167
+ }
1168
+ }
1169
+ else {
1170
+ // Past the last match's trailing context: everything from here on is dropped
1171
+ session.trailingContext = undefined;
1172
+ }
1173
+ }
1174
+ this.stopAtMaxResults(session);
1175
+ }
1176
+ /**
1177
+ * The one place a session counts a match - from ripgrep, Excel or DOCX alike -
1178
+ * so maxResults caps the TOTAL number of matches a search returns.
1179
+ */
1180
+ collectMatch(session, result) {
1181
+ if (this.atMaxResults(session))
1182
+ return;
1183
+ session.results.push(result);
1184
+ session.totalMatches++;
1185
+ if (this.atMaxResults(session)) {
1186
+ session.stopReason ?? (session.stopReason = 'max_results');
1187
+ this.stopAtMaxResults(session);
1188
+ }
1189
+ }
1190
+ /** Whether options.maxResults matches are in: later ones are dropped */
1191
+ atMaxResults(session) {
1192
+ const { maxResults } = session.options;
1193
+ return !!maxResults && maxResults > 0 && session.totalMatches >= maxResults;
1194
+ }
1195
+ /**
1196
+ * Once maxResults matches are in, stop the sources that cannot contribute
1197
+ * anything more: the Excel/DOCX searches at once, ripgrep when the last
1198
+ * match's trailing context is complete.
1199
+ */
1200
+ stopAtMaxResults(session) {
1201
+ if (!this.atMaxResults(session))
1202
+ return;
1203
+ this.stopOfficeSources(session);
1204
+ if (!session.trailingContext) {
1205
+ this.stopRipgrep(session);
1206
+ }
1207
+ }
1208
+ parseLine(line, searchType) {
1209
+ if (searchType === 'content') {
1210
+ // Parse JSON output from content search
1211
+ try {
1212
+ const parsed = JSON.parse(line);
1213
+ if (parsed.type === 'match') {
1214
+ // Handle multiple submatches per line - return first submatch
1215
+ const submatch = parsed.data?.submatches?.[0];
1216
+ return {
1217
+ kind: 'match',
1218
+ result: {
1219
+ file: parsed.data.path.text,
1220
+ line: parsed.data.line_number,
1221
+ match: keptText(submatch?.match?.text || parsed.data.lines.text),
1222
+ type: 'content'
1223
+ }
1224
+ };
1225
+ }
1226
+ if (parsed.type === 'context') {
1227
+ return {
1228
+ kind: 'context',
1229
+ result: {
1230
+ context: true,
1231
+ file: parsed.data.path.text,
1232
+ line: parsed.data.line_number,
1233
+ match: keptText(parsed.data.lines.text.trim()),
1234
+ type: 'content'
1235
+ }
1236
+ };
1237
+ }
1238
+ // begin/end frame each file's lines; summary closes the output
1239
+ if (parsed.type === 'begin' || parsed.type === 'end') {
1240
+ return { kind: parsed.type, file: parsed.data.path.text };
1241
+ }
1242
+ return { kind: 'summary' };
1243
+ }
1244
+ catch (error) {
1245
+ // Skip invalid JSON lines
1246
+ return null;
1247
+ }
1248
+ }
1249
+ else {
1250
+ // File search - each line is a file path
1251
+ return {
1252
+ kind: 'match',
1253
+ result: {
1254
+ file: line.trim(),
1255
+ type: 'file'
1256
+ }
1257
+ };
1258
+ }
1259
+ }
1260
+ }
1261
+ // Global search manager instance
1262
+ export const searchManager = new SearchManager();