@wonderwhy-er/desktop-commander 0.2.42 → 0.2.44
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.
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +22 -0
- package/dist/config-manager.d.ts +30 -1
- package/dist/config-manager.js +55 -8
- package/dist/handlers/filesystem-handlers.js +86 -52
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -0
- package/dist/remote-device/desktop-commander-integration.js +8 -2
- package/dist/remote-device/remote-channel.d.ts +15 -0
- package/dist/remote-device/remote-channel.js +136 -27
- package/dist/server.d.ts +5 -1
- package/dist/server.js +118 -27
- package/dist/terminal-manager.d.ts +18 -0
- package/dist/terminal-manager.js +84 -18
- package/dist/tools/edit.d.ts +1 -1
- package/dist/tools/edit.js +34 -26
- package/dist/tools/filesystem.d.ts +1 -0
- package/dist/tools/filesystem.js +23 -13
- package/dist/tools/fuzzySearch.d.ts +10 -20
- package/dist/tools/fuzzySearch.js +66 -105
- package/dist/tools/fuzzySearchCore.d.ts +52 -0
- package/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/tools/improved-process-tools.js +8 -1
- package/dist/tools/schemas.d.ts +33 -1
- package/dist/tools/schemas.js +61 -3
- package/dist/types.d.ts +3 -1
- package/dist/ui/config-editor/config-editor-runtime.js +49 -49
- package/dist/ui/config-editor/src/app.js +2 -11
- package/dist/ui/file-preview/preview-runtime.js +147 -146
- package/dist/ui/file-preview/src/app.js +172 -53
- package/dist/ui/file-preview/src/directory-controller.js +4 -4
- package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
- package/dist/ui/file-preview/src/markdown/controller.js +4 -1
- package/dist/ui/file-preview/src/panel-actions.js +4 -4
- package/dist/ui/file-preview/src/payload-utils.js +23 -7
- package/dist/utils/capture.d.ts +2 -0
- package/dist/utils/capture.js +32 -7
- package/dist/utils/files/base.d.ts +2 -0
- package/dist/utils/files/image.js +1 -1
- package/dist/utils/files/text.js +24 -19
- package/dist/utils/open-browser.d.ts +1 -1
- package/dist/utils/open-browser.js +5 -2
- package/dist/utils/unsupportedParams.d.ts +24 -0
- package/dist/utils/unsupportedParams.js +66 -0
- package/dist/utils/usageTracker.d.ts +5 -1
- package/dist/utils/usageTracker.js +14 -3
- package/dist/utils/welcome-onboarding.d.ts +1 -1
- package/dist/utils/welcome-onboarding.js +2 -2
- package/dist/utils/withTimeout.d.ts +17 -0
- package/dist/utils/withTimeout.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
package/dist/terminal-manager.js
CHANGED
|
@@ -32,6 +32,17 @@ function getRepairedPathExt() {
|
|
|
32
32
|
}
|
|
33
33
|
return current;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Output buffering caps. Without a cap, a process emitting enough output makes
|
|
37
|
+
* string concatenation throw "RangeError: Invalid string length" at V8's max
|
|
38
|
+
* string size (~536M chars) inside a stdout 'data' handler — an uncaught
|
|
39
|
+
* exception that kills the whole server (index.ts exits on uncaughtException).
|
|
40
|
+
* The cap also bounds the join() cost in snapshot reads and the periodic
|
|
41
|
+
* process-state scan, both of which are O(total output).
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_BUFFERED_OUTPUT_CHARS = 50 * 1024 * 1024; // per session; oldest lines evicted first
|
|
44
|
+
const MAX_LINE_CHARS = 1024 * 1024; // force-split longer lines so eviction can work
|
|
45
|
+
const MAX_WAIT_OUTPUT_CHARS = 2 * 1024 * 1024; // start_process wait buffer (prompt/state detection)
|
|
35
46
|
/**
|
|
36
47
|
* Get the appropriate spawn configuration for a given shell
|
|
37
48
|
* This handles login shell flags for different shell types
|
|
@@ -207,7 +218,10 @@ export class TerminalManager {
|
|
|
207
218
|
outputLines: [], // Line-based buffer
|
|
208
219
|
lastReadIndex: 0, // Track where "new" output starts
|
|
209
220
|
isBlocked: false,
|
|
210
|
-
startTime: new Date()
|
|
221
|
+
startTime: new Date(),
|
|
222
|
+
bufferedChars: 0,
|
|
223
|
+
evictedLines: 0,
|
|
224
|
+
evictedChars: 0
|
|
211
225
|
};
|
|
212
226
|
this.sessions.set(childProcess.pid, session);
|
|
213
227
|
// Timing telemetry
|
|
@@ -249,7 +263,14 @@ export class TerminalManager {
|
|
|
249
263
|
if (!firstOutputTime)
|
|
250
264
|
firstOutputTime = now;
|
|
251
265
|
lastOutputTime = now;
|
|
252
|
-
output
|
|
266
|
+
// `output` only feeds the wait-phase result and prompt/state detection,
|
|
267
|
+
// so stop growing it once resolved and keep only a bounded tail.
|
|
268
|
+
if (!resolved) {
|
|
269
|
+
output += text;
|
|
270
|
+
if (output.length > MAX_WAIT_OUTPUT_CHARS) {
|
|
271
|
+
output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
253
274
|
// Append to line-based buffer
|
|
254
275
|
this.appendToLineBuffer(session, text);
|
|
255
276
|
// Record output event if collecting timing
|
|
@@ -282,7 +303,12 @@ export class TerminalManager {
|
|
|
282
303
|
if (!firstOutputTime)
|
|
283
304
|
firstOutputTime = now;
|
|
284
305
|
lastOutputTime = now;
|
|
285
|
-
|
|
306
|
+
if (!resolved) {
|
|
307
|
+
output += text;
|
|
308
|
+
if (output.length > MAX_WAIT_OUTPUT_CHARS) {
|
|
309
|
+
output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
286
312
|
// Append to line-based buffer
|
|
287
313
|
this.appendToLineBuffer(session, text);
|
|
288
314
|
// Record output event if collecting timing
|
|
@@ -329,7 +355,9 @@ export class TerminalManager {
|
|
|
329
355
|
outputLines: [...session.outputLines], // Copy line buffer
|
|
330
356
|
exitCode: code,
|
|
331
357
|
startTime: session.startTime,
|
|
332
|
-
endTime: new Date()
|
|
358
|
+
endTime: new Date(),
|
|
359
|
+
evictedLines: session.evictedLines,
|
|
360
|
+
evictedChars: session.evictedChars
|
|
333
361
|
});
|
|
334
362
|
// Keep only last 100 completed sessions
|
|
335
363
|
if (this.completedSessions.size > 100) {
|
|
@@ -373,6 +401,32 @@ export class TerminalManager {
|
|
|
373
401
|
session.outputLines.push(line);
|
|
374
402
|
}
|
|
375
403
|
}
|
|
404
|
+
// Appended text contributes exactly its length to the joined buffer
|
|
405
|
+
// (its newlines become the join separators).
|
|
406
|
+
session.bufferedChars += text.length;
|
|
407
|
+
// A process printing without newlines grows a single line forever, which
|
|
408
|
+
// eviction can't bound — force-split so no line exceeds MAX_LINE_CHARS.
|
|
409
|
+
// Each inserted break adds one separator to the joined length.
|
|
410
|
+
let lastIndex = session.outputLines.length - 1;
|
|
411
|
+
while (session.outputLines[lastIndex].length > MAX_LINE_CHARS) {
|
|
412
|
+
const overlong = session.outputLines[lastIndex];
|
|
413
|
+
session.outputLines[lastIndex] = overlong.slice(0, MAX_LINE_CHARS);
|
|
414
|
+
session.outputLines.push(overlong.slice(MAX_LINE_CHARS));
|
|
415
|
+
session.bufferedChars += 1;
|
|
416
|
+
lastIndex++;
|
|
417
|
+
}
|
|
418
|
+
// Enforce the per-session cap by evicting the oldest lines. Keeps the
|
|
419
|
+
// buffer far below V8's max string length so concatenation and join()
|
|
420
|
+
// can never throw "Invalid string length" and kill the server.
|
|
421
|
+
while (session.bufferedChars > MAX_BUFFERED_OUTPUT_CHARS && session.outputLines.length > 1) {
|
|
422
|
+
const dropped = session.outputLines.shift();
|
|
423
|
+
const droppedJoinedChars = dropped.length + 1; // +1 for its join separator
|
|
424
|
+
session.bufferedChars -= droppedJoinedChars;
|
|
425
|
+
session.evictedChars += droppedJoinedChars;
|
|
426
|
+
session.evictedLines++;
|
|
427
|
+
if (session.lastReadIndex > 0)
|
|
428
|
+
session.lastReadIndex--;
|
|
429
|
+
}
|
|
376
430
|
}
|
|
377
431
|
/**
|
|
378
432
|
* Read process output with pagination (like file reading)
|
|
@@ -385,15 +439,19 @@ export class TerminalManager {
|
|
|
385
439
|
// First check active sessions
|
|
386
440
|
const session = this.sessions.get(pid);
|
|
387
441
|
if (session) {
|
|
388
|
-
|
|
442
|
+
const result = this.readFromLineBuffer(session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined);
|
|
443
|
+
result.evictedLines = session.evictedLines;
|
|
444
|
+
return result;
|
|
389
445
|
}
|
|
390
446
|
// Then check completed sessions
|
|
391
447
|
const completedSession = this.completedSessions.get(pid);
|
|
392
448
|
if (completedSession) {
|
|
393
449
|
const runtimeMs = completedSession.endTime.getTime() - completedSession.startTime.getTime();
|
|
394
|
-
|
|
450
|
+
const result = this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, // Completed sessions don't track read position
|
|
395
451
|
() => { }, // No-op for completed sessions
|
|
396
452
|
true, completedSession.exitCode, runtimeMs);
|
|
453
|
+
result.evictedLines = completedSession.evictedLines;
|
|
454
|
+
return result;
|
|
397
455
|
}
|
|
398
456
|
return null;
|
|
399
457
|
}
|
|
@@ -491,8 +549,11 @@ export class TerminalManager {
|
|
|
491
549
|
if (session) {
|
|
492
550
|
const fullOutput = session.outputLines.join('\n');
|
|
493
551
|
return {
|
|
494
|
-
|
|
495
|
-
|
|
552
|
+
// Absolute since process start (includes evicted output), so the
|
|
553
|
+
// offset stays valid even if the cap evicts lines between
|
|
554
|
+
// snapshot and read.
|
|
555
|
+
totalChars: session.evictedChars + fullOutput.length,
|
|
556
|
+
lineCount: session.evictedLines + session.outputLines.length
|
|
496
557
|
};
|
|
497
558
|
}
|
|
498
559
|
return null;
|
|
@@ -506,23 +567,28 @@ export class TerminalManager {
|
|
|
506
567
|
// Check active session first
|
|
507
568
|
const session = this.sessions.get(pid);
|
|
508
569
|
if (session) {
|
|
509
|
-
|
|
510
|
-
if (fullOutput.length <= snapshot.totalChars) {
|
|
511
|
-
return ''; // No new output
|
|
512
|
-
}
|
|
513
|
-
return fullOutput.substring(snapshot.totalChars);
|
|
570
|
+
return TerminalManager.outputSinceSnapshot(session.outputLines, session.evictedChars, snapshot.totalChars);
|
|
514
571
|
}
|
|
515
572
|
// Fallback to completed sessions - process may have finished between snapshot and poll
|
|
516
573
|
const completedSession = this.completedSessions.get(pid);
|
|
517
574
|
if (completedSession) {
|
|
518
|
-
|
|
519
|
-
if (fullOutput.length <= snapshot.totalChars) {
|
|
520
|
-
return ''; // No new output
|
|
521
|
-
}
|
|
522
|
-
return fullOutput.substring(snapshot.totalChars);
|
|
575
|
+
return TerminalManager.outputSinceSnapshot(completedSession.outputLines, completedSession.evictedChars, snapshot.totalChars);
|
|
523
576
|
}
|
|
524
577
|
return null;
|
|
525
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* New output since a snapshot, in absolute (since process start) offsets.
|
|
581
|
+
* If eviction dropped part of the unseen output, returns what the buffer
|
|
582
|
+
* still holds — the oldest unseen chars are lost to the cap.
|
|
583
|
+
*/
|
|
584
|
+
static outputSinceSnapshot(outputLines, evictedChars, snapshotTotalChars) {
|
|
585
|
+
const fullOutput = outputLines.join('\n');
|
|
586
|
+
const newChars = evictedChars + fullOutput.length - snapshotTotalChars;
|
|
587
|
+
if (newChars <= 0) {
|
|
588
|
+
return ''; // No new output
|
|
589
|
+
}
|
|
590
|
+
return fullOutput.substring(Math.max(0, fullOutput.length - newChars));
|
|
591
|
+
}
|
|
526
592
|
/**
|
|
527
593
|
* Get a session by PID
|
|
528
594
|
* @param pid Process ID
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface SearchReplace {
|
|
|
19
19
|
search: string;
|
|
20
20
|
replace: string;
|
|
21
21
|
}
|
|
22
|
-
export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number): Promise<ServerResult>;
|
|
22
|
+
export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number, origin?: 'ui' | 'llm'): Promise<ServerResult>;
|
|
23
23
|
/**
|
|
24
24
|
* Handle edit_block command
|
|
25
25
|
*
|
package/dist/tools/edit.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 3. Unify the editRange() signature to handle both text search/replace and structured edits
|
|
16
16
|
*/
|
|
17
17
|
import { getDefaultEditorMetadata, writeFile, readFileInternal, validatePath } from './filesystem.js';
|
|
18
|
-
import {
|
|
18
|
+
import { runFuzzySearchInWorker, getSimilarityRatio } from './fuzzySearch.js';
|
|
19
19
|
import { capture } from '../utils/capture.js';
|
|
20
20
|
import { createErrorResponse } from '../error-handlers.js';
|
|
21
21
|
import { EditBlockArgsSchema } from "./schemas.js";
|
|
@@ -82,7 +82,7 @@ function getCharacterCodeData(expected, actual) {
|
|
|
82
82
|
diffLength: fullDiff.length
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
|
-
export async function performSearchReplace(filePath, block, expectedReplacements = 1) {
|
|
85
|
+
export async function performSearchReplace(filePath, block, expectedReplacements = 1, origin) {
|
|
86
86
|
// Get file extension for telemetry using path module
|
|
87
87
|
const fileExtension = path.extname(filePath).toLowerCase();
|
|
88
88
|
// Capture file extension and string sizes in telemetry without capturing the file path
|
|
@@ -176,13 +176,18 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
|
|
|
176
176
|
type: "text",
|
|
177
177
|
text: `${statusLine}${previewContent}`
|
|
178
178
|
}],
|
|
179
|
-
structuredContent:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
179
|
+
// structuredContent only travels on widget-initiated calls: it is the
|
|
180
|
+
// widget's save-success signal (assertSuccessfulEditBlockResult) and
|
|
181
|
+
// metadata source. LLM-facing responses don't need it — nothing there
|
|
182
|
+
// consumes it, and the widget re-reads by path instead.
|
|
183
|
+
...(origin === 'ui' ? {
|
|
184
|
+
structuredContent: {
|
|
185
|
+
fileName: path.basename(resolvedEditPath),
|
|
186
|
+
filePath: resolvedEditPath,
|
|
187
|
+
fileType: resolvePreviewFileType(resolvedEditPath),
|
|
188
|
+
...await getDefaultEditorMetadata(resolvedEditPath),
|
|
189
|
+
},
|
|
190
|
+
} : {}),
|
|
186
191
|
};
|
|
187
192
|
}
|
|
188
193
|
// If exact match found but count doesn't match expected, inform the user
|
|
@@ -202,8 +207,9 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
|
|
|
202
207
|
if (count === 0) {
|
|
203
208
|
// Track fuzzy search time
|
|
204
209
|
const startTime = performance.now();
|
|
205
|
-
// Perform fuzzy search
|
|
206
|
-
|
|
210
|
+
// Perform fuzzy search in a worker thread so the main event loop stays
|
|
211
|
+
// responsive to pings and parallel tool calls during the scan
|
|
212
|
+
const fuzzyResult = await runFuzzySearchInWorker(content, block.search);
|
|
207
213
|
const similarity = getSimilarityRatio(block.search, fuzzyResult.value);
|
|
208
214
|
// Calculate execution time in milliseconds
|
|
209
215
|
const executionTime = performance.now() - startTime;
|
|
@@ -366,13 +372,14 @@ export async function handleEditBlock(args) {
|
|
|
366
372
|
type: "text",
|
|
367
373
|
text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
|
|
368
374
|
}],
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
375
|
+
...(parsed.origin === 'ui' ? {
|
|
376
|
+
structuredContent: {
|
|
377
|
+
fileName: path.basename(resolvedRangePath),
|
|
378
|
+
filePath: resolvedRangePath,
|
|
379
|
+
fileType: resolvePreviewFileType(resolvedRangePath),
|
|
380
|
+
...await getDefaultEditorMetadata(resolvedRangePath),
|
|
381
|
+
},
|
|
382
|
+
} : {}),
|
|
376
383
|
};
|
|
377
384
|
}
|
|
378
385
|
catch (error) {
|
|
@@ -403,13 +410,14 @@ export async function handleEditBlock(args) {
|
|
|
403
410
|
type: "text",
|
|
404
411
|
text: `Successfully applied ${result.editsApplied} edit(s) to ${parsed.file_path}`
|
|
405
412
|
}],
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
+
...(parsed.origin === 'ui' ? {
|
|
414
|
+
structuredContent: {
|
|
415
|
+
fileName: path.basename(resolvedEditRangePath),
|
|
416
|
+
filePath: resolvedEditRangePath,
|
|
417
|
+
fileType: resolvePreviewFileType(resolvedEditRangePath),
|
|
418
|
+
...await getDefaultEditorMetadata(resolvedEditRangePath),
|
|
419
|
+
},
|
|
420
|
+
} : {}),
|
|
413
421
|
};
|
|
414
422
|
}
|
|
415
423
|
const errorMsg = result.errors?.map(e => e.error).join('; ') || 'Unknown error';
|
|
@@ -423,5 +431,5 @@ export async function handleEditBlock(args) {
|
|
|
423
431
|
return performSearchReplace(parsed.file_path, {
|
|
424
432
|
search: parsed.old_string,
|
|
425
433
|
replace: parsed.new_string
|
|
426
|
-
}, parsed.expected_replacements);
|
|
434
|
+
}, parsed.expected_replacements, parsed.origin);
|
|
427
435
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReadOptions, FileResult, PdfPageItem } from '../utils/files/base.js';
|
|
2
2
|
import { PdfOperations, PdfMetadata } from './pdf/index.js';
|
|
3
|
+
export declare const READ_OPERATION_TIMEOUT_MS: number;
|
|
3
4
|
/**
|
|
4
5
|
* Validates a path to ensure it can be accessed or created.
|
|
5
6
|
* For existing paths, returns the real path (resolving symlinks).
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -5,7 +5,7 @@ import fetch from 'cross-fetch';
|
|
|
5
5
|
import { execFile } from 'child_process';
|
|
6
6
|
import { promisify } from 'util';
|
|
7
7
|
import { capture } from '../utils/capture.js';
|
|
8
|
-
import { withTimeout } from '../utils/withTimeout.js';
|
|
8
|
+
import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
|
|
9
9
|
import { configManager } from '../config-manager.js';
|
|
10
10
|
import { getFileHandler, TextFileHandler } from '../utils/files/index.js';
|
|
11
11
|
import { isPdfFile } from "./mime-types.js";
|
|
@@ -16,6 +16,13 @@ const FILE_OPERATION_TIMEOUTS = {
|
|
|
16
16
|
URL_FETCH: 30000, // 30 seconds
|
|
17
17
|
FILE_READ: 30000, // 30 seconds
|
|
18
18
|
};
|
|
19
|
+
// Cap file read operations at 3 minutes. The MCP client's hard per-call limit
|
|
20
|
+
// is ~4 minutes; timing out at 3m lets us abort the underlying fs op and return
|
|
21
|
+
// a useful error (e.g. the cloud-storage guidance in buildPermissionError)
|
|
22
|
+
// BEFORE the client gives up with an opaque "No result received after 4
|
|
23
|
+
// minutes". Paired with runWithAbortableTimeout so the read is actually
|
|
24
|
+
// cancelled (fd/thread released), not just abandoned.
|
|
25
|
+
export const READ_OPERATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
|
|
19
26
|
const FILE_SIZE_LIMITS = {
|
|
20
27
|
LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
|
|
21
28
|
};
|
|
@@ -426,8 +433,9 @@ export async function readFileFromDisk(filePath, options) {
|
|
|
426
433
|
capture('server_read_file_error', { error: errorMessage, fileExtension: fileExtension });
|
|
427
434
|
// If we can't stat the file, continue anyway and let the read operation handle errors
|
|
428
435
|
}
|
|
429
|
-
//
|
|
430
|
-
|
|
436
|
+
// Read under an abortable timeout so a hung/stalled read is cancelled
|
|
437
|
+
// (fd/thread freed) rather than leaked until the OS call returns.
|
|
438
|
+
const readOperation = async (signal) => {
|
|
431
439
|
// Get appropriate handler for this file type (async - includes binary detection)
|
|
432
440
|
const handler = await getFileHandler(validPath);
|
|
433
441
|
// Use handler to read the file
|
|
@@ -436,7 +444,8 @@ export async function readFileFromDisk(filePath, options) {
|
|
|
436
444
|
length,
|
|
437
445
|
sheet,
|
|
438
446
|
range,
|
|
439
|
-
includeStatusMessage: true
|
|
447
|
+
includeStatusMessage: true,
|
|
448
|
+
signal
|
|
440
449
|
});
|
|
441
450
|
// Return with content as string
|
|
442
451
|
// For images: content is already base64-encoded string from handler
|
|
@@ -458,18 +467,17 @@ export async function readFileFromDisk(filePath, options) {
|
|
|
458
467
|
metadata: result.metadata
|
|
459
468
|
};
|
|
460
469
|
};
|
|
461
|
-
// Execute with timeout
|
|
470
|
+
// Execute with a 3-minute, cancellable timeout
|
|
462
471
|
let result;
|
|
463
472
|
try {
|
|
464
|
-
result = await
|
|
473
|
+
result = await runWithAbortableTimeout((signal) => readOperation(signal), READ_OPERATION_TIMEOUT_MS, `Read file operation for ${filePath}`);
|
|
465
474
|
}
|
|
466
475
|
catch (error) {
|
|
467
476
|
const err = error;
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
throw buildPermissionError(filePath, isWithTimeoutString ? 'ETIMEDOUT' : err.code);
|
|
477
|
+
// runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
|
|
478
|
+
// on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
|
|
479
|
+
if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
|
|
480
|
+
throw buildPermissionError(filePath, err.code);
|
|
473
481
|
}
|
|
474
482
|
throw error;
|
|
475
483
|
}
|
|
@@ -515,8 +523,10 @@ export async function readFileInternal(filePath, offset = 0, length) {
|
|
|
515
523
|
// IMPORTANT: For internal operations (especially edit operations), we must
|
|
516
524
|
// preserve exact file content including original line endings.
|
|
517
525
|
// We cannot use readline-based reading as it strips line endings.
|
|
518
|
-
// Read entire file content preserving line endings
|
|
519
|
-
|
|
526
|
+
// Read entire file content preserving line endings, under a 3-minute,
|
|
527
|
+
// cancellable timeout so an edit on a stalled/cloud path can't hang forever
|
|
528
|
+
// (previously this read had no timeout at all).
|
|
529
|
+
const content = await runWithAbortableTimeout((signal) => fs.readFile(validPath, { encoding: 'utf8', signal }), READ_OPERATION_TIMEOUT_MS, `Internal read for ${filePath}`);
|
|
520
530
|
// If we need to apply offset/length, do it while preserving line endings
|
|
521
531
|
if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
|
|
522
532
|
// Most common case for edit operations: read entire file
|
|
@@ -1,22 +1,12 @@
|
|
|
1
|
+
import type { FuzzyMatch } from './fuzzySearchCore.js';
|
|
2
|
+
export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js';
|
|
3
|
+
/** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */
|
|
4
|
+
export declare const FUZZY_SEARCH_TIMEOUT_MS = 30000;
|
|
1
5
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* @param parentDistance Best distance found so far (default: Infinity)
|
|
8
|
-
* @returns Object with start and end indices, matched value, and Levenshtein distance
|
|
6
|
+
* Runs the fuzzy search in a Worker thread so the main MCP event loop stays
|
|
7
|
+
* responsive to pings and other tool calls during heavy scans. Rejects if the
|
|
8
|
+
* scan exceeds timeoutMs, terminating the worker so it doesn't linger in the
|
|
9
|
+
* background. Search metrics come back with the result and are captured here,
|
|
10
|
+
* on the main thread, where the client identity is initialized.
|
|
9
11
|
*/
|
|
10
|
-
export declare function
|
|
11
|
-
start: number;
|
|
12
|
-
end: number;
|
|
13
|
-
value: string;
|
|
14
|
-
distance: number;
|
|
15
|
-
};
|
|
16
|
-
/**
|
|
17
|
-
* Calculates the similarity ratio between two strings
|
|
18
|
-
* @param a First string
|
|
19
|
-
* @param b Second string
|
|
20
|
-
* @returns Similarity ratio (0-1)
|
|
21
|
-
*/
|
|
22
|
-
export declare function getSimilarityRatio(a: string, b: string): number;
|
|
12
|
+
export declare function runFuzzySearchInWorker(text: string, query: string, timeoutMs?: number): Promise<FuzzyMatch>;
|
|
@@ -1,113 +1,74 @@
|
|
|
1
|
-
import { distance } from 'fastest-levenshtein';
|
|
2
1
|
import { capture } from '../utils/capture.js';
|
|
2
|
+
import { Worker } from 'worker_threads';
|
|
3
|
+
// Re-export so existing callers keep importing from this module.
|
|
4
|
+
export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js';
|
|
5
|
+
/** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */
|
|
6
|
+
export const FUZZY_SEARCH_TIMEOUT_MS = 30000;
|
|
3
7
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @param parentDistance Best distance found so far (default: Infinity)
|
|
10
|
-
* @returns Object with start and end indices, matched value, and Levenshtein distance
|
|
8
|
+
* Inline worker entry: imports the dependency-free core module (passed as
|
|
9
|
+
* moduleUrl) and runs the search off the main thread. The core module is
|
|
10
|
+
* deliberately a leaf — importing this module (or anything app-level) from the
|
|
11
|
+
* worker would boot the whole server per search. Kept as an eval'd snippet so
|
|
12
|
+
* the worker needs no separate file to ship alongside the compiled output.
|
|
11
13
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
result_distance: result.distance
|
|
24
|
-
});
|
|
25
|
-
return result;
|
|
26
|
-
}
|
|
27
|
-
if (end === null)
|
|
28
|
-
end = text.length;
|
|
29
|
-
// For small text segments, use iterative approach
|
|
30
|
-
if (end - start <= 2 * query.length) {
|
|
31
|
-
return iterativeReduction(text, query, start, end, parentDistance);
|
|
32
|
-
}
|
|
33
|
-
let midPoint = start + Math.floor((end - start) / 2);
|
|
34
|
-
let leftEnd = Math.min(end, midPoint + query.length); // Include query length to cover overlaps
|
|
35
|
-
let rightStart = Math.max(start, midPoint - query.length); // Include query length to cover overlaps
|
|
36
|
-
// Calculate distance for current segments
|
|
37
|
-
let leftDistance = distance(text.substring(start, leftEnd), query);
|
|
38
|
-
let rightDistance = distance(text.substring(rightStart, end), query);
|
|
39
|
-
let bestDistance = Math.min(leftDistance, parentDistance, rightDistance);
|
|
40
|
-
// If parent distance is already the best, use iterative approach
|
|
41
|
-
if (parentDistance === bestDistance) {
|
|
42
|
-
return iterativeReduction(text, query, start, end, parentDistance);
|
|
43
|
-
}
|
|
44
|
-
// Recursively search the better half
|
|
45
|
-
if (leftDistance < rightDistance) {
|
|
46
|
-
return recursiveFuzzyIndexOf(text, query, start, leftEnd, bestDistance, depth + 1);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
return recursiveFuzzyIndexOf(text, query, rightStart, end, bestDistance, depth + 1);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
14
|
+
const WORKER_CODE = `
|
|
15
|
+
const { workerData, parentPort } = require('worker_threads');
|
|
16
|
+
import(workerData.moduleUrl)
|
|
17
|
+
.then((m) => {
|
|
18
|
+
parentPort.postMessage({ ok: true, ...m.runFuzzySearch(workerData.text, workerData.query) });
|
|
19
|
+
})
|
|
20
|
+
.catch((err) => {
|
|
21
|
+
parentPort.postMessage({ ok: false, error: String(err && err.stack || err) });
|
|
22
|
+
});
|
|
23
|
+
`;
|
|
24
|
+
const CORE_MODULE_URL = new URL('./fuzzySearchCore.js', import.meta.url).href;
|
|
52
25
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* @param parentDistance Best distance found so far
|
|
59
|
-
* @returns Object with start and end indices, matched value, and Levenshtein distance
|
|
26
|
+
* Runs the fuzzy search in a Worker thread so the main MCP event loop stays
|
|
27
|
+
* responsive to pings and other tool calls during heavy scans. Rejects if the
|
|
28
|
+
* scan exceeds timeoutMs, terminating the worker so it doesn't linger in the
|
|
29
|
+
* background. Search metrics come back with the result and are captured here,
|
|
30
|
+
* on the main thread, where the client identity is initialized.
|
|
60
31
|
*/
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
32
|
+
export function runFuzzySearchInWorker(text, query, timeoutMs = FUZZY_SEARCH_TIMEOUT_MS) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: CORE_MODULE_URL, text, query } });
|
|
35
|
+
// Never let a scan keep the server process alive during shutdown.
|
|
36
|
+
worker.unref();
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
worker.terminate();
|
|
39
|
+
reject(new Error(`Fuzzy search timed out after ${timeoutMs}ms`));
|
|
40
|
+
}, timeoutMs);
|
|
41
|
+
timer.unref();
|
|
42
|
+
worker.on('message', (msg) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
if (msg.ok) {
|
|
45
|
+
captureFuzzySearchMetrics(msg.metrics);
|
|
46
|
+
resolve(msg.result);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
reject(new Error(`Fuzzy search worker failed: ${msg.error}`));
|
|
50
|
+
}
|
|
51
|
+
// Don't let the worker wind down on its own; the answer is already
|
|
52
|
+
// here, and a lingering worker holds its copy of the file text.
|
|
53
|
+
// The promise is settled, so the exit-code rejection below is a no-op.
|
|
54
|
+
worker.terminate();
|
|
55
|
+
});
|
|
56
|
+
worker.on('error', (err) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
reject(err);
|
|
59
|
+
});
|
|
60
|
+
worker.on('exit', (code) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
if (code !== 0) {
|
|
63
|
+
reject(new Error(`Fuzzy search worker exited with code ${code}`));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
93
66
|
});
|
|
94
|
-
return {
|
|
95
|
-
start: bestStart,
|
|
96
|
-
end: bestEnd,
|
|
97
|
-
value: text.substring(bestStart, bestEnd),
|
|
98
|
-
distance: bestDistance
|
|
99
|
-
};
|
|
100
67
|
}
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
export function getSimilarityRatio(a, b) {
|
|
108
|
-
const maxLength = Math.max(a.length, b.length);
|
|
109
|
-
if (maxLength === 0)
|
|
110
|
-
return 1; // Both strings are empty
|
|
111
|
-
const levenshteinDistance = distance(a, b);
|
|
112
|
-
return 1 - (levenshteinDistance / maxLength);
|
|
68
|
+
/** Same telemetry events the search used to emit inline, now sent from the main thread. */
|
|
69
|
+
function captureFuzzySearchMetrics(metrics) {
|
|
70
|
+
capture('fuzzy_search_recursive_metrics', metrics.recursive);
|
|
71
|
+
if (metrics.iterative) {
|
|
72
|
+
capture('fuzzy_search_iterative_metrics', metrics.iterative);
|
|
73
|
+
}
|
|
113
74
|
}
|