@link-assistant/agent 0.26.15 → 0.27.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 (65) hide show
  1. package/README.md +49 -7
  2. package/package.json +51 -42
  3. package/src/agent/agent.ts +12 -7
  4. package/src/auth/azure-login.ts +241 -0
  5. package/src/auth/plugins.ts +9 -0
  6. package/src/auth/provider-logins.ts +804 -0
  7. package/src/cli/argv.ts +1 -1
  8. package/src/cli/cmd/auth.ts +4 -0
  9. package/src/cli/cmd/models.ts +68 -13
  10. package/src/cli/cmd/run.ts +7 -1
  11. package/src/cli/continuous-mode.js +38 -12
  12. package/src/cli/event-handler.js +3 -1
  13. package/src/cli/model-config.js +27 -8
  14. package/src/cli/run-options.js +144 -78
  15. package/src/config/config.ts +306 -105
  16. package/src/config/defaults.ts +13 -5
  17. package/src/config/file-config.ts +16 -0
  18. package/src/file/ripgrep.ts +38 -5
  19. package/src/file/time.ts +86 -9
  20. package/src/index.js +42 -33
  21. package/src/mcp/index.ts +370 -106
  22. package/src/permission/index.ts +22 -0
  23. package/src/project/instance.ts +5 -1
  24. package/src/project/project.ts +35 -2
  25. package/src/provider/env-template.ts +115 -0
  26. package/src/provider/extra-providers.ts +347 -0
  27. package/src/provider/free-tier-error.ts +1 -1
  28. package/src/provider/kilo-deprecation.ts +30 -0
  29. package/src/provider/kilo.ts +44 -7
  30. package/src/provider/model-discovery.ts +690 -0
  31. package/src/provider/models.ts +11 -1
  32. package/src/provider/parity-loaders.ts +595 -0
  33. package/src/provider/provider.ts +309 -203
  34. package/src/provider/retry-fetch.ts +5 -0
  35. package/src/provider/transform.ts +168 -17
  36. package/src/session/compaction.ts +18 -2
  37. package/src/session/index.ts +30 -3
  38. package/src/session/message-v2.ts +318 -32
  39. package/src/session/processor.ts +274 -140
  40. package/src/session/prompt.ts +154 -128
  41. package/src/session/retry.ts +26 -13
  42. package/src/session/summary.ts +4 -1
  43. package/src/tool/bash.ts +232 -45
  44. package/src/tool/bash.txt +1 -1
  45. package/src/tool/codesearch.ts +15 -78
  46. package/src/tool/edit.ts +111 -30
  47. package/src/tool/glob.ts +2 -1
  48. package/src/tool/grep.ts +175 -57
  49. package/src/tool/ls.ts +2 -1
  50. package/src/tool/multiedit.ts +3 -1
  51. package/src/tool/read.ts +169 -41
  52. package/src/tool/task.ts +26 -18
  53. package/src/tool/tool.ts +4 -1
  54. package/src/tool/webfetch.ts +68 -56
  55. package/src/tool/webfetch.txt +4 -4
  56. package/src/tool/websearch.ts +20 -75
  57. package/src/tool/websearch.txt +2 -0
  58. package/src/tool/write.ts +23 -6
  59. package/src/util/bom.ts +32 -0
  60. package/src/util/exa-mcp.ts +82 -0
  61. package/src/util/legacy-error-names.ts +39 -0
  62. package/src/util/read-bounded.ts +32 -0
  63. package/src/util/sse-usage-extractor.ts +67 -13
  64. package/src/util/timeout.ts +3 -4
  65. package/src/util/verbose-fetch.ts +40 -1
package/src/tool/edit.ts CHANGED
@@ -14,11 +14,29 @@ import { FileTime } from '../file/time';
14
14
  import { Instance } from '../project/instance';
15
15
  import { Snapshot } from '../snapshot';
16
16
  import { Permission } from '../permission';
17
+ import { Bom } from '../util/bom';
18
+ import { config } from '../config/config';
17
19
 
18
20
  function normalizeLineEndings(text: string): string {
19
21
  return text.replaceAll('\r\n', '\n');
20
22
  }
21
23
 
24
+ // The dominant line ending, so one stray CRLF line does not turn every
25
+ // inserted line of an LF file into CRLF (upstream #53166). Ties keep LF.
26
+ function detectLineEnding(text: string): '\n' | '\r\n' {
27
+ const crlf = text.split('\r\n').length - 1;
28
+ const lf = text.split('\n').length - 1 - crlf;
29
+ return crlf > lf ? '\r\n' : '\n';
30
+ }
31
+
32
+ function convertToLineEnding(text: string, ending: '\n' | '\r\n'): string {
33
+ if (ending === '\n') return text;
34
+ return text.replaceAll('\n', '\r\n');
35
+ }
36
+
37
+ const EMPTY_OLD_STRING_ERROR =
38
+ 'oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.';
39
+
22
40
  export const EditTool = Tool.define('edit', {
23
41
  description: DESCRIPTION,
24
42
  parameters: z.object({
@@ -61,16 +79,24 @@ export const EditTool = Tool.define('edit', {
61
79
  let diff = '';
62
80
  let contentOld = '';
63
81
  let contentNew = '';
64
- await (async () => {
82
+ // Tool calls of one step run concurrently; serialize read-modify-write of
83
+ // the same file so parallel edits cannot drop each other's changes.
84
+ await FileTime.withLock(filePath, async () => {
65
85
  if (params.oldString === '') {
66
- contentNew = params.newString;
86
+ // --legacy-edit-matching: an empty oldString overwrote the file.
87
+ if (!config.legacyEditMatching && (await Bun.file(filePath).exists())) {
88
+ throw new Error(EMPTY_OLD_STRING_ERROR);
89
+ }
90
+ const next = Bom.split(params.newString);
91
+ contentNew = next.text;
67
92
  diff = trimDiff(
68
93
  createTwoFilesPatch(filePath, filePath, contentOld, contentNew)
69
94
  );
70
- await Bun.write(filePath, params.newString);
95
+ await Bun.write(filePath, Bom.join(contentNew, next.bom));
71
96
  await Bus.publish(File.Event.Edited, {
72
97
  file: filePath,
73
98
  });
99
+ FileTime.read(ctx.sessionID, filePath);
74
100
  return;
75
101
  }
76
102
 
@@ -80,28 +106,30 @@ export const EditTool = Tool.define('edit', {
80
106
  if (stats.isDirectory())
81
107
  throw new Error(`Path is a directory, not a file: ${filePath}`);
82
108
  await FileTime.assert(ctx.sessionID, filePath);
83
- contentOld = await file.text();
84
- contentNew = replace(
85
- contentOld,
86
- params.oldString,
87
- params.newString,
88
- params.replaceAll
109
+ const source = await Bom.readFile(filePath);
110
+ contentOld = source.text;
111
+
112
+ // Models usually send LF; keep the file's own line endings so a CRLF
113
+ // file is neither left unmatched nor partially converted to LF.
114
+ const ending = detectLineEnding(contentOld);
115
+ const oldString = convertToLineEnding(
116
+ normalizeLineEndings(params.oldString),
117
+ ending
89
118
  );
90
-
91
- diff = trimDiff(
92
- createTwoFilesPatch(
93
- filePath,
94
- filePath,
95
- normalizeLineEndings(contentOld),
96
- normalizeLineEndings(contentNew)
97
- )
119
+ const newString = convertToLineEnding(
120
+ normalizeLineEndings(params.newString),
121
+ ending
122
+ );
123
+ const next = Bom.split(
124
+ replace(contentOld, oldString, newString, params.replaceAll)
98
125
  );
126
+ contentNew = next.text;
99
127
 
100
- await file.write(contentNew);
128
+ await Bun.write(filePath, Bom.join(contentNew, source.bom || next.bom));
101
129
  await Bus.publish(File.Event.Edited, {
102
130
  file: filePath,
103
131
  });
104
- contentNew = await file.text();
132
+ FileTime.read(ctx.sessionID, filePath);
105
133
  diff = trimDiff(
106
134
  createTwoFilesPatch(
107
135
  filePath,
@@ -110,11 +138,10 @@ export const EditTool = Tool.define('edit', {
110
138
  normalizeLineEndings(contentNew)
111
139
  )
112
140
  );
113
- })();
114
-
115
- FileTime.read(ctx.sessionID, filePath);
141
+ });
116
142
 
117
- let output = '';
143
+ // --legacy-tool-output: edit used to return an empty output.
144
+ const output = config.legacyToolOutput ? '' : 'Edit applied successfully.';
118
145
  const diagnostics = {};
119
146
 
120
147
  const filediff: Snapshot.FileDiff = {
@@ -147,8 +174,29 @@ export type Replacer = (
147
174
  ) => Generator<string, void, unknown>;
148
175
 
149
176
  // Similarity thresholds for block anchor fallback matching
150
- const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
151
- const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;
177
+ // (raised from 0.0 / 0.3 in upstream #30932: with 0.0 any block whose first
178
+ // and last lines matched was replaced, even when the middle was unrelated)
179
+ // --legacy-edit-matching / LINK_ASSISTANT_AGENT_LEGACY_EDIT_MATCHING restores
180
+ // the old thresholds and drops the block-size and disproportionate-match
181
+ // guards.
182
+ const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65;
183
+ const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65;
184
+ const LEGACY_SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
185
+ const LEGACY_MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;
186
+
187
+ function thresholds() {
188
+ return config.legacyEditMatching
189
+ ? {
190
+ single: LEGACY_SINGLE_CANDIDATE_SIMILARITY_THRESHOLD,
191
+ multiple: LEGACY_MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD,
192
+ maxLineDelta: (_size: number) => Infinity,
193
+ }
194
+ : {
195
+ single: SINGLE_CANDIDATE_SIMILARITY_THRESHOLD,
196
+ multiple: MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD,
197
+ maxLineDelta: (size: number) => Math.max(1, Math.floor(size * 0.25)),
198
+ };
199
+ }
152
200
 
153
201
  /**
154
202
  * Levenshtein distance algorithm implementation
@@ -236,6 +284,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
236
284
  const firstLineSearch = searchLines[0].trim();
237
285
  const lastLineSearch = searchLines[searchLines.length - 1].trim();
238
286
  const searchBlockSize = searchLines.length;
287
+ const limits = thresholds();
288
+ const maxLineDelta = limits.maxLineDelta(searchBlockSize);
239
289
 
240
290
  // Collect all candidate positions where both anchors match
241
291
  const candidates: Array<{ startLine: number; endLine: number }> = [];
@@ -247,7 +297,10 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
247
297
  // Look for the matching last line after this first line
248
298
  for (let j = i + 2; j < originalLines.length; j++) {
249
299
  if (originalLines[j].trim() === lastLineSearch) {
250
- candidates.push({ startLine: i, endLine: j });
300
+ const actualBlockSize = j - i + 1;
301
+ if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
302
+ candidates.push({ startLine: i, endLine: j });
303
+ }
251
304
  break; // Only match the first occurrence of the last line
252
305
  }
253
306
  }
@@ -278,7 +331,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
278
331
  similarity += (1 - distance / maxLen) / linesToCheck;
279
332
 
280
333
  // Exit early when threshold is reached
281
- if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
334
+ if (similarity >= limits.single) {
282
335
  break;
283
336
  }
284
337
  }
@@ -287,7 +340,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
287
340
  similarity = 1.0;
288
341
  }
289
342
 
290
- if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
343
+ if (similarity >= limits.single) {
291
344
  let matchStartIndex = 0;
292
345
  for (let k = 0; k < startLine; k++) {
293
346
  matchStartIndex += originalLines[k].length + 1;
@@ -339,7 +392,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
339
392
  }
340
393
 
341
394
  // Threshold judgment
342
- if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
395
+ if (maxSimilarity >= limits.multiple && bestMatch) {
343
396
  const { startLine, endLine } = bestMatch;
344
397
  let matchStartIndex = 0;
345
398
  for (let k = 0; k < startLine; k++) {
@@ -631,6 +684,11 @@ export function replace(
631
684
  if (oldString === newString) {
632
685
  throw new Error('oldString and newString must be different');
633
686
  }
687
+ if (oldString === '') {
688
+ // Every replacer would "match" the empty string (MultiOccurrenceReplacer
689
+ // never terminates on it), so reject it explicitly.
690
+ throw new Error(EMPTY_OLD_STRING_ERROR);
691
+ }
634
692
 
635
693
  let notFound = true;
636
694
 
@@ -649,8 +707,18 @@ export function replace(
649
707
  const index = content.indexOf(search);
650
708
  if (index === -1) continue;
651
709
  notFound = false;
710
+ if (
711
+ !config.legacyEditMatching &&
712
+ isDisproportionateMatch(search, oldString)
713
+ ) {
714
+ throw new Error(
715
+ 'Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.'
716
+ );
717
+ }
652
718
  if (replaceAll) {
653
- return content.replaceAll(search, newString);
719
+ // split/join instead of String#replaceAll so `$&`, `$1`, `$'` in
720
+ // newString are inserted literally.
721
+ return content.split(search).join(newString);
654
722
  }
655
723
  const lastIndex = content.lastIndexOf(search);
656
724
  if (index !== lastIndex) continue;
@@ -669,3 +737,16 @@ export function replace(
669
737
  'Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.'
670
738
  );
671
739
  }
740
+
741
+ // A fuzzy replacer matched far more text than the model asked to replace
742
+ // (upstream #30932): refuse instead of deleting unrelated code.
743
+ function isDisproportionateMatch(search: string, oldString: string) {
744
+ const oldLines = oldString.split('\n').length;
745
+ const searchLines = search.split('\n').length;
746
+ if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
747
+ if (oldLines === 1) return false;
748
+ return (
749
+ search.trim().length >
750
+ Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
751
+ );
752
+ }
package/src/tool/glob.ts CHANGED
@@ -16,7 +16,7 @@ export const GlobTool = Tool.define('glob', {
16
16
  `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`
17
17
  ),
18
18
  }),
19
- async execute(params) {
19
+ async execute(params, ctx) {
20
20
  let search = params.path ?? Instance.directory;
21
21
  search = path.isAbsolute(search)
22
22
  ? search
@@ -28,6 +28,7 @@ export const GlobTool = Tool.define('glob', {
28
28
  for await (const file of Ripgrep.files({
29
29
  cwd: search,
30
30
  glob: [params.pattern],
31
+ signal: ctx.abort,
31
32
  })) {
32
33
  if (files.length >= limit) {
33
34
  truncated = true;
package/src/tool/grep.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  import z from 'zod';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
2
4
  import { Tool } from './tool';
3
5
  import { Ripgrep } from '../file/ripgrep';
4
6
 
5
7
  import DESCRIPTION from './grep.txt';
6
8
  import { Instance } from '../project/instance';
9
+ import { config } from '../config/config';
7
10
  import {
8
11
  byteOffsetToColumnIndex,
9
12
  formatFocusedLineWindow,
10
13
  } from './text-window';
11
14
 
12
15
  const MAX_MATCHING_LINE_LENGTH = 2000;
16
+ const RESULT_LIMIT = 100;
17
+ // rg output is parsed while it streams in, so memory is bounded by the
18
+ // matches kept, not by rg's total output. Past this many matches rg is
19
+ // stopped and the newest RESULT_LIMIT of the collected ones are returned.
20
+ const MAX_COLLECTED_MATCHES = 10_000;
13
21
 
14
22
  export const GrepTool = Tool.define('grep', {
15
23
  description: DESCRIPTION,
@@ -30,28 +38,94 @@ export const GrepTool = Tool.define('grep', {
30
38
  'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'
31
39
  ),
32
40
  }),
33
- async execute(params) {
41
+ async execute(params, ctx) {
34
42
  if (!params.pattern) {
35
43
  throw new Error('pattern is required');
36
44
  }
37
45
 
38
- const searchPath = params.path || Instance.directory;
46
+ // Relative paths are relative to the session directory, not to the
47
+ // process cwd that rg would otherwise use.
48
+ const searchPath = path.resolve(Instance.directory, params.path || '.');
49
+ // upstream 8f4b62eb49 (#35337): say so instead of passing rg's exit 2 on
50
+ if (!(await fs.stat(searchPath).catch(() => undefined))) {
51
+ throw new Error(`Search path does not exist: ${searchPath}`);
52
+ }
39
53
 
40
54
  const rgPath = await Ripgrep.filepath();
41
- const args = ['--json', '--regexp', params.pattern];
55
+ // `--hidden` finds dotfiles such as `.github/` like glob/ls do (upstream
56
+ // d5738f542c #7501); --no-grep-hidden restores the previous behaviour.
57
+ // `--no-messages` keeps per-file errors out of stderr (upstream
58
+ // 4edb4fa4fa #8612); `--no-config` ignores RIPGREP_CONFIG_PATH.
59
+ const args = ['--json'];
60
+ if (config.grepHidden) args.push('--hidden');
61
+ args.push('--no-messages', '--no-config');
62
+ if (config.grepHidden) args.push('--glob=!**/.git/**');
63
+ args.push('--regexp', params.pattern);
42
64
  if (params.include) {
43
65
  args.push('--glob', params.include);
44
66
  }
45
67
  args.push(searchPath);
46
68
 
69
+ // upstream 4d2696e027 / 3bda0ce123 (#39238): honor the tool call's abort
70
+ // signal and do not let a huge search run forever
71
+ // (--grep-timeout-ms, 0 = no timeout).
72
+ ctx.abort.throwIfAborted();
73
+ const timeoutMs = config.grepTimeoutMs;
74
+ // rg gets its own controller: a listener on the long-lived ctx.abort
75
+ // would keep the process object reachable after the call returns.
76
+ const stop = new AbortController();
77
+ let timedOut = false;
78
+ const onAbort = () => stop.abort();
79
+ ctx.abort.addEventListener('abort', onAbort, { once: true });
80
+ const timer =
81
+ timeoutMs > 0
82
+ ? setTimeout(() => {
83
+ timedOut = true;
84
+ stop.abort();
85
+ }, timeoutMs)
86
+ : undefined;
87
+
47
88
  const proc = Bun.spawn([rgPath, ...args], {
48
89
  stdout: 'pipe',
49
90
  stderr: 'pipe',
91
+ signal: stop.signal,
50
92
  });
51
93
 
52
- const output = await new Response(proc.stdout).text();
53
- const errorOutput = await new Response(proc.stderr).text();
54
- const exitCode = await proc.exited;
94
+ const matches: Match[] = [];
95
+ let collectedAll = true;
96
+ let exitCode: number;
97
+ let errorOutput: string;
98
+ try {
99
+ // Drain both pipes concurrently: reading stdout to the end first
100
+ // deadlocks once rg fills the stderr pipe.
101
+ const stderr = new Response(proc.stderr).text();
102
+ const mtimes = new Map<string, number | null>();
103
+ for await (const line of lines(proc.stdout)) {
104
+ const match = await parseMatch(line, mtimes);
105
+ if (!match) continue;
106
+ matches.push(match);
107
+ if (matches.length >= MAX_COLLECTED_MATCHES) {
108
+ collectedAll = false;
109
+ proc.kill();
110
+ break;
111
+ }
112
+ }
113
+ [errorOutput, exitCode] = await Promise.all([stderr, proc.exited]);
114
+ } finally {
115
+ if (timer) clearTimeout(timer);
116
+ ctx.abort.removeEventListener('abort', onAbort);
117
+ // A parse error must not leave rg running.
118
+ if (proc.exitCode === null) proc.kill();
119
+ }
120
+
121
+ ctx.abort.throwIfAborted();
122
+ if (timedOut) {
123
+ throw new Error(
124
+ `Search timed out after ${timeoutMs / 1000} seconds. Consider using a more specific path or pattern.`
125
+ );
126
+ }
127
+ // rg was stopped on purpose after enough matches.
128
+ if (!collectedAll) exitCode = 0;
55
129
 
56
130
  if (exitCode === 1) {
57
131
  return {
@@ -61,59 +135,36 @@ export const GrepTool = Tool.define('grep', {
61
135
  };
62
136
  }
63
137
 
64
- if (exitCode !== 0) {
65
- throw new Error(`Exit code ${exitCode}\nripgrep failed: ${errorOutput}`);
138
+ // Exit codes: 0 = matches, 1 = no matches, 2 = an error occurred. With
139
+ // `--no-messages`, exit 2 without a regex error means some paths could
140
+ // not be read; the matches from readable files are still valid.
141
+ if (
142
+ exitCode === 2 &&
143
+ /regex parse error|error parsing regex/i.test(errorOutput)
144
+ ) {
145
+ throw new Error(`Invalid regex pattern: ${errorOutput.trim()}`);
66
146
  }
67
147
 
68
- const matches = [];
69
-
70
- for (const line of output.trim().split('\n')) {
71
- if (!line) continue;
72
-
73
- const event = JSON.parse(line);
74
- if (event.type !== 'match') continue;
75
-
76
- const filePath = event.data?.path?.text;
77
- const lineNum = event.data?.line_number;
78
- const rawLineText = event.data?.lines?.text;
79
- const submatch = event.data?.submatches?.[0];
80
- if (!filePath || !lineNum || typeof rawLineText !== 'string') continue;
81
-
82
- const lineText = stripTrailingLineEnding(rawLineText);
83
- const matchStart = byteOffsetToColumnIndex(
84
- lineText,
85
- submatch?.start ?? 0
86
- );
87
- const matchEnd = byteOffsetToColumnIndex(
88
- lineText,
89
- submatch?.end ?? submatch?.start ?? 0
90
- );
91
-
92
- const file = Bun.file(filePath);
93
- const stats = await file.stat().catch(() => null);
94
- if (!stats) continue;
95
-
96
- matches.push({
97
- path: filePath,
98
- modTime: stats.mtime.getTime(),
99
- lineNum,
100
- lineText,
101
- matchStart,
102
- matchEnd,
103
- });
148
+ if (exitCode !== 0 && exitCode !== 2) {
149
+ throw new Error(`Exit code ${exitCode}\nripgrep failed: ${errorOutput}`);
104
150
  }
105
151
 
152
+ const hasErrors = exitCode === 2;
153
+
106
154
  matches.sort((a, b) => b.modTime - a.modTime);
107
155
 
108
- const limit = 100;
109
- const truncated = matches.length > limit;
110
- const finalMatches = truncated ? matches.slice(0, limit) : matches;
156
+ const limit = RESULT_LIMIT;
157
+ const truncated = matches.length > limit || !collectedAll;
158
+ const finalMatches =
159
+ matches.length > limit ? matches.slice(0, limit) : matches;
111
160
 
112
161
  if (finalMatches.length === 0) {
113
162
  return {
114
163
  title: params.pattern,
115
164
  metadata: { matches: 0, truncated: false },
116
- output: 'No files found',
165
+ output: hasErrors
166
+ ? 'No files found\n\n(Some paths were inaccessible and skipped)'
167
+ : 'No files found',
117
168
  };
118
169
  }
119
170
 
@@ -128,15 +179,7 @@ export const GrepTool = Tool.define('grep', {
128
179
  currentFile = match.path;
129
180
  outputLines.push(`${match.path}:`);
130
181
  }
131
- outputLines.push(
132
- ` Line ${match.lineNum}: ${formatFocusedLineWindow({
133
- line: match.lineText,
134
- lineNumber: match.lineNum,
135
- focusStart: match.matchStart,
136
- focusEnd: match.matchEnd,
137
- limit: MAX_MATCHING_LINE_LENGTH,
138
- })}`
139
- );
182
+ outputLines.push(` Line ${match.lineNum}: ${match.text}`);
140
183
  }
141
184
 
142
185
  if (truncated) {
@@ -146,6 +189,11 @@ export const GrepTool = Tool.define('grep', {
146
189
  );
147
190
  }
148
191
 
192
+ if (hasErrors) {
193
+ outputLines.push('');
194
+ outputLines.push('(Some paths were inaccessible and skipped)');
195
+ }
196
+
149
197
  return {
150
198
  title: params.pattern,
151
199
  metadata: {
@@ -157,6 +205,76 @@ export const GrepTool = Tool.define('grep', {
157
205
  },
158
206
  });
159
207
 
208
+ type Match = {
209
+ path: string;
210
+ modTime: number;
211
+ lineNum: number;
212
+ // Already windowed to MAX_MATCHING_LINE_LENGTH, so a minified file does not
213
+ // keep megabyte-long lines in memory.
214
+ text: string;
215
+ };
216
+
217
+ // Split a byte stream into lines without buffering the whole stream.
218
+ async function* lines(stream: ReadableStream<Uint8Array>) {
219
+ const decoder = new TextDecoder();
220
+ let pending = '';
221
+ for await (const chunk of stream) {
222
+ pending += decoder.decode(chunk, { stream: true });
223
+ let index: number;
224
+ while ((index = pending.indexOf('\n')) !== -1) {
225
+ yield pending.slice(0, index).replace(/\r$/, '');
226
+ pending = pending.slice(index + 1);
227
+ }
228
+ }
229
+ pending += decoder.decode();
230
+ if (pending) yield pending;
231
+ }
232
+
233
+ async function parseMatch(
234
+ line: string,
235
+ mtimes: Map<string, number | null>
236
+ ): Promise<Match | undefined> {
237
+ if (!line) return undefined;
238
+ const event = JSON.parse(line);
239
+ if (event.type !== 'match') return undefined;
240
+
241
+ const filePath = event.data?.path?.text;
242
+ const lineNum = event.data?.line_number;
243
+ const rawLineText = event.data?.lines?.text;
244
+ const submatch = event.data?.submatches?.[0];
245
+ if (!filePath || !lineNum || typeof rawLineText !== 'string')
246
+ return undefined;
247
+
248
+ // One stat per file, not per matching line.
249
+ if (!mtimes.has(filePath)) {
250
+ const stats = await Bun.file(filePath)
251
+ .stat()
252
+ .catch(() => null);
253
+ mtimes.set(filePath, stats ? stats.mtime.getTime() : null);
254
+ }
255
+ const modTime = mtimes.get(filePath);
256
+ if (modTime === null || modTime === undefined) return undefined;
257
+
258
+ const lineText = stripTrailingLineEnding(rawLineText);
259
+ const matchStart = byteOffsetToColumnIndex(lineText, submatch?.start ?? 0);
260
+ const matchEnd = byteOffsetToColumnIndex(
261
+ lineText,
262
+ submatch?.end ?? submatch?.start ?? 0
263
+ );
264
+ return {
265
+ path: filePath,
266
+ modTime,
267
+ lineNum,
268
+ text: formatFocusedLineWindow({
269
+ line: lineText,
270
+ lineNumber: lineNum,
271
+ focusStart: matchStart,
272
+ focusEnd: matchEnd,
273
+ limit: MAX_MATCHING_LINE_LENGTH,
274
+ }),
275
+ };
276
+ }
277
+
160
278
  function stripTrailingLineEnding(line: string) {
161
279
  return line.replace(/\r?\n$/, '');
162
280
  }
package/src/tool/ls.ts CHANGED
@@ -48,7 +48,7 @@ export const ListTool = Tool.define('list', {
48
48
  .describe('List of glob patterns to ignore')
49
49
  .optional(),
50
50
  }),
51
- async execute(params) {
51
+ async execute(params, ctx) {
52
52
  const searchPath = path.resolve(Instance.directory, params.path || '.');
53
53
 
54
54
  const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(
@@ -58,6 +58,7 @@ export const ListTool = Tool.define('list', {
58
58
  for await (const file of Ripgrep.files({
59
59
  cwd: searchPath,
60
60
  glob: ignoreGlobs,
61
+ signal: ctx.abort,
61
62
  })) {
62
63
  files.push(file);
63
64
  if (files.length >= LIMIT) break;
@@ -49,7 +49,9 @@ export const MultiEditTool = Tool.define('multiedit', {
49
49
  metadata: {
50
50
  results: results.map((r) => r.metadata),
51
51
  },
52
- output: results.at(-1)!.output,
52
+ // `edits: []` applies nothing; return an empty output like the Rust
53
+ // tool instead of throwing a TypeError (issue #335 parity audit).
54
+ output: results.at(-1)?.output ?? '',
53
55
  };
54
56
  },
55
57
  });