tinker-agent 2.9.0 → 2.10.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 (38) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +17 -1
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +443 -0
  5. package/src/cli/command-line.ts +26 -2
  6. package/src/cli/connect-runner.tsx +26 -0
  7. package/src/cli/main.ts +26 -0
  8. package/src/cli/output.ts +1 -1
  9. package/src/cli/public-cli-contract.ts +18 -0
  10. package/src/cli/public-config-contract.ts +1 -1
  11. package/src/cli/serve-runner.ts +45 -0
  12. package/src/cli/serve-runtime.ts +100 -0
  13. package/src/context/context-swap-renderer.ts +14 -0
  14. package/src/observation/observation-builder.ts +87 -37
  15. package/src/remote/client.ts +350 -0
  16. package/src/remote/config.ts +95 -0
  17. package/src/remote/http-server.ts +240 -0
  18. package/src/remote/protocol.ts +228 -0
  19. package/src/remote/service-store.ts +175 -0
  20. package/src/remote/service.ts +219 -0
  21. package/src/remote/sync-hub.ts +95 -0
  22. package/src/session/remote-history-reader.ts +143 -0
  23. package/src/tools/bash-task.ts +26 -16
  24. package/src/tools/bash.ts +44 -2
  25. package/src/tools/glob.ts +107 -19
  26. package/src/tools/grep-output.ts +130 -0
  27. package/src/tools/grep-pagination.ts +73 -0
  28. package/src/tools/grep-path.ts +11 -0
  29. package/src/tools/grep-snippets.ts +111 -0
  30. package/src/tools/grep.ts +139 -154
  31. package/src/tools/read.ts +0 -9
  32. package/src/tools/ripgrep.ts +19 -26
  33. package/src/tools/shell-process.ts +30 -4
  34. package/src/tools/task-stop.ts +2 -1
  35. package/src/tools/terminal-screen.ts +11 -2
  36. package/src/tools/types.ts +30 -2
  37. package/src/tui/event-store.ts +15 -2
  38. package/src/tui/remote-app.tsx +210 -0
package/src/tools/grep.ts CHANGED
@@ -4,6 +4,9 @@ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
4
4
  import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
5
5
  import { resolveWorkspacePath, toDisplayPath } from "./path-safety";
6
6
  import { ripGrep } from "./ripgrep";
7
+ import { parseGrepOutput } from "./grep-output";
8
+ import { applyContentHeadLimit, applyHeadLimit } from "./grep-pagination";
9
+ import { formatGrepPath } from "./grep-path";
7
10
  import { defineToolExecutor } from "./types";
8
11
  import type {
9
12
  GrepOutputMode,
@@ -39,7 +42,7 @@ export type GrepToolOptions = {
39
42
  };
40
43
  };
41
44
 
42
- const ignoredDirectories = [
45
+ const defaultExcludedDirectories = [
43
46
  ".git",
44
47
  ".svn",
45
48
  ".hg",
@@ -50,35 +53,37 @@ const ignoredDirectories = [
50
53
  ".tinker",
51
54
  ];
52
55
 
53
- const defaultHeadLimit = 250;
54
-
55
56
  export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
56
57
  return defineToolExecutor("grep", {
57
58
  definition: {
58
59
  name: "Grep",
59
- description: "Search file contents with ripgrep.",
60
+ description:
61
+ "Search file contents with ripgrep. Directory searches include hidden files, respect ignore rules (including .gitignore and .ignore), and exclude .git, .svn, .hg, .bzr, .jj, .sl, node_modules, and .tinker by default. Directory traversal does not follow symlinks; binary detection can stop searching a file. No matches means no matches within the effective search scope. Results use a consistent file-path order. Pagination is not a snapshot: file changes between calls may cause skipped or repeated results. Quoted paths use JSON escaping; pass the decoded path to file tools.",
60
62
  parameters: {
61
63
  type: "object",
62
64
  additionalProperties: false,
63
65
  properties: {
64
66
  pattern: {
65
67
  type: "string",
68
+ minLength: 1,
66
69
  description:
67
70
  "The regular expression pattern to search for in file contents.",
68
71
  },
69
72
  path: {
70
73
  type: "string",
71
74
  description:
72
- "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd.",
75
+ "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely.",
73
76
  },
74
77
  glob: {
75
78
  type: "string",
76
- description: 'Glob pattern to filter files, such as "*.js" or "**/*.tsx".',
79
+ description:
80
+ "Passed unchanged to ripgrep's --glob option after default exclusions. A glob that matches files inside an excluded directory does not necessarily allow traversal into that directory: **/node_modules/** can return no matches because node_modules itself is still excluded. To search an excluded directory, set path to that directory (for example, node_modules/pkg), then use glob to filter files within it. Broad globs such as **/* can also match excluded directory entries and allow traversal. Does not enable symlink traversal or disable binary detection.",
77
81
  },
78
82
  output_mode: {
79
83
  type: "string",
80
- enum: ["content", "files_with_matches", "count"],
81
- description: 'Output mode. Defaults to "files_with_matches".',
84
+ enum: ["content", "files_with_matches", "count", "count-matches"],
85
+ description:
86
+ 'Defaults to "files_with_matches" (matching file paths). "content" returns matching lines. Lines longer than 500 Unicode code points are excerpted: match windows include up to 100 code points on each side, with at most 500 source code points retained per line; later matches or long matches may be omitted. Long context lines retain their first 500 code points. Omission markers report skipped code points; use Read for full lines. "count" returns matching lines per file, counting a line once even with multiple matches; cannot be combined with multiline=true. "count-matches" returns non-overlapping matches per file, including multiple matches on one line. Count summaries cover only the displayed files when paginated.',
82
87
  },
83
88
  "-B": {
84
89
  type: "integer",
@@ -113,24 +118,24 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
113
118
  },
114
119
  type: {
115
120
  type: "string",
116
- description: "File type to search, such as js, ts, py, rust, go, or java.",
121
+ description: "Passed unchanged to ripgrep's --type option.",
117
122
  },
118
123
  head_limit: {
119
124
  type: "integer",
120
125
  minimum: 0,
121
126
  description:
122
- "Limit output to first N lines or entries. Defaults to 250. Pass 0 for unlimited.",
127
+ "Limit selected results. In content mode, counts matching lines (one per line even with multiple matches), or complete ripgrep match events with multiline=true. Each selected result includes its requested context when the search completes; context does not consume the limit. Nearby matches shown within that context are not consumed or expanded and may reappear on later pages. Overlapping context is deduplicated within a page. Other modes count file entries. Defaults to 250. Pass 0 for unlimited.",
123
128
  },
124
129
  offset: {
125
130
  type: "integer",
126
131
  minimum: 0,
127
132
  description:
128
- "Skip first N lines or entries before applying head_limit. Defaults to 0.",
133
+ "Skip N results in the same units as head_limit, excluding context. Defaults to 0. To continue, pass nextOffset with the same search parameters. Pages may repeat context lines.",
129
134
  },
130
135
  multiline: {
131
136
  type: "boolean",
132
137
  description:
133
- "Enable multiline mode where dot matches newlines. Defaults to false.",
138
+ 'Enable multiline mode where dot matches newlines. Defaults to false. For counting, use output_mode="count-matches"; "count" rejects multiline=true.',
134
139
  },
135
140
  },
136
141
  required: ["pattern"],
@@ -178,10 +183,16 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
178
183
  });
179
184
  }
180
185
 
181
- const rgArgs = buildRipgrepArgs(input, mode, absoluteSearchPath);
186
+ const searchCwd = pathCheck.isDirectory ? absoluteSearchPath : undefined;
187
+ const rgArgs = buildRipgrepArgs(
188
+ input,
189
+ mode,
190
+ pathCheck.isDirectory ? "." : absoluteSearchPath,
191
+ );
182
192
  const rg = await ripGrep(rgArgs, {
183
193
  signal: context.signal,
184
194
  ...options.ripgrep,
195
+ cwd: searchCwd,
185
196
  });
186
197
 
187
198
  if (!rg.ok) {
@@ -201,67 +212,111 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
201
212
  searchPath,
202
213
  absoluteSearchPath,
203
214
  mode,
204
- ignored: ignoredDirectories,
205
215
  };
206
216
  const partialWarning = rg.truncated ? rg.error : undefined;
207
217
 
208
- if (mode === "files_with_matches") {
209
- const sorted = await sortFilesForOutput([...new Set(rg.lines)]);
210
- const page = applyHeadLimit(sorted, input.head_limit, input.offset ?? 0);
211
- const filenames = page.items.map((file) =>
212
- toDisplayPath(options.workspaceRoot, file),
218
+ let records;
219
+ try {
220
+ records = parseGrepOutput(
221
+ rg.stdout,
222
+ mode,
223
+ options.workspaceRoot,
224
+ rg.truncated,
225
+ searchCwd,
213
226
  );
214
-
215
- return {
216
- ...base,
217
- filenames,
218
- numFiles: filenames.length,
219
- appliedLimit: page.appliedLimit,
220
- appliedOffset: appliedOffset(input.offset),
221
- truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
222
- error: partialWarning,
223
- };
227
+ } catch (error) {
228
+ return grepFailure({
229
+ pattern: input.pattern,
230
+ searchPath,
231
+ absoluteSearchPath,
232
+ mode,
233
+ error: `Invalid ripgrep output: ${errorMessage(error)}`,
234
+ });
235
+ }
236
+ if (rg.truncated && records.length === 0) {
237
+ return grepFailure({
238
+ pattern: input.pattern,
239
+ searchPath,
240
+ absoluteSearchPath,
241
+ mode,
242
+ truncated: true,
243
+ error:
244
+ partialWarning ??
245
+ "Incomplete ripgrep output contained no complete records.",
246
+ });
224
247
  }
225
248
 
226
- if (mode === "count") {
227
- const sorted = [...rg.lines].sort((left, right) => left.localeCompare(right));
228
- const page = applyHeadLimit(sorted, input.head_limit, input.offset ?? 0);
229
- const entries = page.items.map((line) =>
230
- parseCountLine(line, options.workspaceRoot),
249
+ const requestedContext = resolveGrepContext(input);
250
+ const page =
251
+ mode === "content"
252
+ ? applyContentHeadLimit(
253
+ records,
254
+ input.head_limit,
255
+ input.offset ?? 0,
256
+ requestedContext,
257
+ )
258
+ : applyHeadLimit(records, input.head_limit, input.offset ?? 0);
259
+ const filenames = [...new Set(page.items.map((record) => record.filePath))];
260
+ const pagination = {
261
+ totalResults: page.totalResults,
262
+ returnedResults: page.returnedResults,
263
+ paginationUnit:
264
+ mode === "content"
265
+ ? input.multiline === true
266
+ ? ("match_events" as const)
267
+ : ("matching_lines" as const)
268
+ : ("files" as const),
269
+ hasMore: page.hasMore,
270
+ nextOffset: page.nextOffset,
271
+ appliedLimit: page.appliedLimit,
272
+ appliedOffset: appliedOffset(input.offset),
273
+ searchIncomplete: rg.truncated,
274
+ contextMayBeIncomplete:
275
+ mode === "content" &&
276
+ rg.truncated &&
277
+ (requestedContext.before > 0 || requestedContext.after > 0),
278
+ truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
279
+ error: partialWarning,
280
+ };
281
+ if (mode === "files_with_matches") {
282
+ return { ...base, ...pagination, filenames, numFiles: filenames.length };
283
+ }
284
+ if (mode === "count" || mode === "count-matches") {
285
+ const counts = page.items.flatMap((record) =>
286
+ record.kind === "count"
287
+ ? [{ filePath: record.filePath, count: record.count }]
288
+ : [],
231
289
  );
232
- const filenames = entries.map((entry) => entry.filePath);
233
-
234
290
  return {
235
291
  ...base,
292
+ ...pagination,
236
293
  filenames,
237
294
  numFiles: filenames.length,
238
- content: entries
239
- .map((entry) => `${entry.filePath}:${entry.count}`)
295
+ counts,
296
+ content: counts
297
+ .map((entry) => `${formatGrepPath(entry.filePath)}:${entry.count}`)
240
298
  .join("\n"),
241
- numMatches: entries.reduce((total, entry) => total + entry.count, 0),
242
- appliedLimit: page.appliedLimit,
243
- appliedOffset: appliedOffset(input.offset),
244
- truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
245
- error: partialWarning,
299
+ numMatches: counts.reduce((sum, entry) => sum + entry.count, 0),
246
300
  };
247
301
  }
248
-
249
- const page = applyHeadLimit(rg.lines, input.head_limit, input.offset ?? 0);
250
- const contentLines = page.items.map((line) =>
251
- relativizeContentLine(line, options.workspaceRoot),
252
- );
253
- const filenames = extractContentFilenames(contentLines);
254
-
302
+ const contentLines = page.items.flatMap((record) => {
303
+ if (record.kind !== "match" && record.kind !== "context") return [];
304
+ const separator = record.kind === "match" ? ":" : "-";
305
+ return record.lines.map((text, index) => {
306
+ const position =
307
+ input.lineNumbers === false
308
+ ? ""
309
+ : `${record.lineNumber + index}${separator}`;
310
+ return `${formatGrepPath(record.filePath)}${separator}${position}${text}`;
311
+ });
312
+ });
255
313
  return {
256
314
  ...base,
315
+ ...pagination,
257
316
  filenames,
258
317
  numFiles: filenames.length,
259
318
  content: contentLines.join("\n"),
260
319
  numLines: contentLines.length,
261
- appliedLimit: page.appliedLimit,
262
- appliedOffset: appliedOffset(input.offset),
263
- truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
264
- error: partialWarning,
265
320
  };
266
321
  },
267
322
  });
@@ -270,36 +325,24 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
270
325
  export function buildRipgrepArgs(
271
326
  input: GrepArgs,
272
327
  mode: GrepOutputMode,
273
- absoluteSearchPath: string,
328
+ searchPathArgument: string,
274
329
  ): string[] {
275
- const args = ["--hidden", "--max-columns", "500"];
330
+ // Sorting in rg fixes cross-file order before any pagination, including partial output.
331
+ const args = ["--no-config", "--hidden", "--sort", "path", "--color", "never"];
276
332
 
277
- for (const directory of ignoredDirectories) {
333
+ for (const directory of defaultExcludedDirectories) {
278
334
  args.push("--glob", `!${directory}`);
279
335
  }
280
336
 
281
337
  if (mode === "files_with_matches") {
282
- args.push("-l");
283
- } else if (mode === "count") {
284
- args.push("-c", "--with-filename");
338
+ args.push("-l", "--null");
339
+ } else if (mode === "count" || mode === "count-matches") {
340
+ args.push(mode === "count" ? "-c" : "--count-matches", "--with-filename", "--null");
285
341
  } else {
286
- args.push("--with-filename");
287
- if (input.lineNumbers !== false) {
288
- args.push("-n");
289
- }
342
+ args.push("--json");
290
343
 
291
- if (input.context !== undefined) {
292
- args.push("-C", String(input.context));
293
- } else if (input.contextAlias !== undefined) {
294
- args.push("-C", String(input.contextAlias));
295
- } else {
296
- if (input.before !== undefined) {
297
- args.push("-B", String(input.before));
298
- }
299
- if (input.after !== undefined) {
300
- args.push("-A", String(input.after));
301
- }
302
- }
344
+ const context = resolveGrepContext(input);
345
+ args.push("-B", String(context.before), "-A", String(context.after));
303
346
  }
304
347
 
305
348
  if (input.multiline === true) {
@@ -318,26 +361,15 @@ export function buildRipgrepArgs(
318
361
  args.push("--glob", input.glob);
319
362
  }
320
363
 
321
- args.push("-e", input.pattern, absoluteSearchPath);
364
+ args.push("-e", input.pattern, searchPathArgument);
322
365
  return args;
323
366
  }
324
367
 
325
- export function applyHeadLimit<T>(
326
- items: T[],
327
- limit: number | undefined,
328
- offset = 0,
329
- ): { items: T[]; appliedLimit?: number } {
330
- if (limit === 0) {
331
- return { items: items.slice(offset) };
332
- }
333
-
334
- const effectiveLimit = limit ?? defaultHeadLimit;
335
- const itemsPage = items.slice(offset, offset + effectiveLimit);
336
- const wasTruncated = items.length - offset > effectiveLimit;
337
-
368
+ function resolveGrepContext(input: GrepArgs) {
369
+ const both = input.context ?? input.contextAlias;
338
370
  return {
339
- items: itemsPage,
340
- appliedLimit: wasTruncated ? effectiveLimit : undefined,
371
+ before: both ?? input.before ?? 0,
372
+ after: both ?? input.after ?? 0,
341
373
  };
342
374
  }
343
375
 
@@ -368,18 +400,29 @@ function parseGrepArgs(args: unknown): ParsedGrepArgs {
368
400
  args.output_mode !== undefined &&
369
401
  args.output_mode !== "content" &&
370
402
  args.output_mode !== "files_with_matches" &&
371
- args.output_mode !== "count"
403
+ args.output_mode !== "count" &&
404
+ args.output_mode !== "count-matches"
372
405
  ) {
373
406
  return {
374
407
  ok: false,
375
408
  pattern,
376
409
  error:
377
- 'Grep.output_mode must be one of "content", "files_with_matches", or "count".',
410
+ 'Grep.output_mode must be one of "content", "files_with_matches", "count", or "count-matches".',
378
411
  };
379
412
  }
380
413
 
381
414
  const mode = args.output_mode;
382
415
 
416
+ if (mode === "count" && args.multiline === true) {
417
+ return {
418
+ ok: false,
419
+ pattern,
420
+ mode,
421
+ error:
422
+ 'Grep output_mode="count" counts matching lines and cannot be combined with multiline=true. Use output_mode="count-matches" to count multiline matches.',
423
+ };
424
+ }
425
+
383
426
  for (const name of ["-B", "-A", "-C", "context", "head_limit", "offset"]) {
384
427
  const value = args[name];
385
428
  if (value !== undefined && !isNonNegativeInteger(value)) {
@@ -449,74 +492,17 @@ function resolveSearchPath(options: GrepToolOptions, inputPath?: string): string
449
492
 
450
493
  async function ensureFileOrDirectory(
451
494
  targetPath: string,
452
- ): Promise<{ ok: true } | { ok: false; error: string }> {
495
+ ): Promise<{ ok: true; isDirectory: boolean } | { ok: false; error: string }> {
453
496
  try {
454
497
  const info = await stat(targetPath);
455
498
  return info.isFile() || info.isDirectory()
456
- ? { ok: true }
499
+ ? { ok: true, isDirectory: info.isDirectory() }
457
500
  : { ok: false, error: "Grep.path must be a file or directory." };
458
501
  } catch {
459
502
  return { ok: false, error: "Grep.path does not exist." };
460
503
  }
461
504
  }
462
505
 
463
- async function sortFilesForOutput(files: string[]): Promise<string[]> {
464
- if (process.env.NODE_ENV === "test") {
465
- return [...files].sort((left, right) => left.localeCompare(right));
466
- }
467
-
468
- const withMtime = await Promise.all(
469
- files.map(async (file) => {
470
- try {
471
- const info = await stat(file);
472
- return { file, mtimeMs: info.mtimeMs };
473
- } catch {
474
- return { file, mtimeMs: 0 };
475
- }
476
- }),
477
- );
478
-
479
- return withMtime
480
- .sort((left, right) =>
481
- right.mtimeMs !== left.mtimeMs
482
- ? right.mtimeMs - left.mtimeMs
483
- : left.file.localeCompare(right.file),
484
- )
485
- .map((entry) => entry.file);
486
- }
487
-
488
- function parseCountLine(
489
- line: string,
490
- workspaceRoot: string,
491
- ): { filePath: string; count: number } {
492
- const separator = line.lastIndexOf(":");
493
- const absolutePath = separator === -1 ? line : line.slice(0, separator);
494
- const count = separator === -1 ? 0 : Number(line.slice(separator + 1));
495
-
496
- return {
497
- filePath: toDisplayPath(workspaceRoot, absolutePath),
498
- count: Number.isFinite(count) ? count : 0,
499
- };
500
- }
501
-
502
- function relativizeContentLine(line: string, workspaceRoot: string): string {
503
- const prefix = path.resolve(workspaceRoot) + path.sep;
504
- return line.startsWith(prefix) ? line.slice(prefix.length) : line;
505
- }
506
-
507
- function extractContentFilenames(lines: string[]): string[] {
508
- const filenames = new Set<string>();
509
-
510
- for (const line of lines) {
511
- const match = /^(.+?):\d+[:-]/.exec(line);
512
- if (match?.[1] !== undefined) {
513
- filenames.add(match[1]);
514
- }
515
- }
516
-
517
- return [...filenames];
518
- }
519
-
520
506
  function appliedOffset(offset: number | undefined): number | undefined {
521
507
  return offset !== undefined && offset > 0 ? offset : undefined;
522
508
  }
@@ -537,7 +523,6 @@ function grepFailure(input: {
537
523
  mode: input.mode,
538
524
  filenames: [],
539
525
  numFiles: 0,
540
- ignored: ignoredDirectories,
541
526
  truncated: input.truncated,
542
527
  error: input.error,
543
528
  };
package/src/tools/read.ts CHANGED
@@ -121,15 +121,6 @@ export function createReadToolExecutor(options: ReadToolOptions): ToolExecutor {
121
121
  const offset = input.offset ?? 1;
122
122
 
123
123
  if (lines.length === 0) {
124
- if (input.offset !== undefined || input.limit !== undefined) {
125
- return {
126
- ok: false,
127
- filePath: input.file_path,
128
- absolutePath,
129
- error: "File is empty. Omit offset and limit to read the empty file.",
130
- };
131
- }
132
-
133
124
  options.snapshots.set(absolutePath, {
134
125
  sha256,
135
126
  mtimeMs: currentInfo.mtimeMs,
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import path from "node:path";
2
3
  import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
3
4
  import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
4
5
 
@@ -7,7 +8,8 @@ export const RIPGREP_MISSING_ERROR =
7
8
 
8
9
  export type RipgrepResult = {
9
10
  ok: boolean;
10
- lines: string[];
11
+ /** Raw stdout; the caller must decode its selected record protocol. */
12
+ stdout: string;
11
13
  exitCode?: number;
12
14
  truncated: boolean;
13
15
  error?: string;
@@ -16,6 +18,7 @@ export type RipgrepResult = {
16
18
  export type RipgrepOptions = {
17
19
  signal: AbortSignal;
18
20
  command?: string;
21
+ cwd?: string;
19
22
  timeoutMs?: number;
20
23
  maxBufferBytes?: number;
21
24
  };
@@ -32,7 +35,13 @@ export async function ripGrep(
32
35
  const timeoutMs = options.timeoutMs ?? DEFAULT_PUBLIC_TOOLING_CONFIG.grepTimeoutMs;
33
36
  const maxBufferBytes =
34
37
  options.maxBufferBytes ?? DEFAULT_PUBLIC_TOOLING_CONFIG.grepMaxBufferBytes;
35
- const command = findRipgrepCommand(options.command);
38
+ const configuredCommand = findRipgrepCommand(options.command);
39
+ // Preserve relative executable paths when only the search subprocess changes cwd.
40
+ const command =
41
+ options.cwd !== undefined &&
42
+ (configuredCommand.includes("/") || configuredCommand.includes(path.sep))
43
+ ? path.resolve(configuredCommand)
44
+ : configuredCommand;
36
45
 
37
46
  const first = await runRipgrep(
38
47
  command,
@@ -40,6 +49,7 @@ export async function ripGrep(
40
49
  timeoutMs,
41
50
  maxBufferBytes,
42
51
  options.signal,
52
+ options.cwd,
43
53
  );
44
54
  if (first.retryWithSingleThread) {
45
55
  throwIfTurnCancelled(options.signal);
@@ -50,6 +60,7 @@ export async function ripGrep(
50
60
  timeoutMs,
51
61
  maxBufferBytes,
52
62
  options.signal,
63
+ options.cwd,
53
64
  ),
54
65
  );
55
66
  }
@@ -72,6 +83,7 @@ function runRipgrep(
72
83
  timeoutMs: number,
73
84
  maxBufferBytes: number,
74
85
  signal: AbortSignal,
86
+ cwd?: string,
75
87
  ): Promise<RipgrepAttempt> {
76
88
  return new Promise((resolve, reject) => {
77
89
  if (signal.aborted) {
@@ -82,7 +94,7 @@ function runRipgrep(
82
94
  execFile(
83
95
  command,
84
96
  args,
85
- { timeout: timeoutMs, maxBuffer: maxBufferBytes, signal },
97
+ { timeout: timeoutMs, maxBuffer: maxBufferBytes, signal, cwd },
86
98
  (error, stdout, stderr) => {
87
99
  if (signal.aborted) {
88
100
  reject(cancellationError(signal, error));
@@ -158,21 +170,19 @@ function runRipgrep(
158
170
  }
159
171
 
160
172
  function finalizeResult(attempt: RipgrepAttempt): RipgrepResult {
161
- const lines = splitCompleteLines(attempt.stdout, attempt.truncated);
162
-
163
173
  if (attempt.ok) {
164
174
  return {
165
175
  ok: true,
166
- lines,
176
+ stdout: attempt.stdout,
167
177
  exitCode: attempt.exitCode,
168
178
  truncated: false,
169
179
  };
170
180
  }
171
181
 
172
- if (attempt.truncated && lines.length > 0) {
182
+ if (attempt.truncated && attempt.stdout.length > 0) {
173
183
  return {
174
184
  ok: true,
175
- lines,
185
+ stdout: attempt.stdout,
176
186
  exitCode: attempt.exitCode,
177
187
  truncated: true,
178
188
  error: attempt.error,
@@ -181,30 +191,13 @@ function finalizeResult(attempt: RipgrepAttempt): RipgrepResult {
181
191
 
182
192
  return {
183
193
  ok: false,
184
- lines: [],
194
+ stdout: "",
185
195
  exitCode: attempt.exitCode,
186
196
  truncated: attempt.truncated,
187
197
  error: attempt.error ?? "ripgrep failed.",
188
198
  };
189
199
  }
190
200
 
191
- function splitCompleteLines(stdout: string, droppedPartialTail: boolean): string[] {
192
- if (stdout === "") {
193
- return [];
194
- }
195
-
196
- const lines = stdout.split("\n");
197
- const last = lines.at(-1);
198
-
199
- if (last === "") {
200
- lines.pop();
201
- } else if (droppedPartialTail) {
202
- lines.pop();
203
- }
204
-
205
- return lines;
206
- }
207
-
208
201
  function isEagainError(
209
202
  error: Error & { code?: number | string },
210
203
  stderr: string,