tinker-agent 2.8.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 (66) hide show
  1. package/CHANGELOG.md +79 -1
  2. package/README.md +81 -11
  3. package/package.json +5 -3
  4. package/src/agent/runtime-context-capabilities.ts +19 -0
  5. package/src/agent/runtime-context-events.ts +127 -0
  6. package/src/agent/runtime-context-maintenance.ts +780 -0
  7. package/src/agent/runtime-hosted-session.ts +443 -0
  8. package/src/agent/runtime-interactions.ts +291 -0
  9. package/src/agent/runtime-prompt-scheduler.ts +182 -0
  10. package/src/agent/runtime-session-contracts.ts +317 -0
  11. package/src/agent/runtime-session.ts +250 -2130
  12. package/src/agent/runtime-skills.ts +544 -0
  13. package/src/cli/command-line.ts +26 -2
  14. package/src/cli/connect-runner.tsx +26 -0
  15. package/src/cli/main.ts +26 -0
  16. package/src/cli/output.ts +1 -1
  17. package/src/cli/public-cli-contract.ts +18 -0
  18. package/src/cli/public-config-contract.ts +1 -1
  19. package/src/cli/runner-dependencies.ts +6 -5
  20. package/src/cli/serve-runner.ts +45 -0
  21. package/src/cli/serve-runtime.ts +100 -0
  22. package/src/context/context-automation-policy.ts +12 -118
  23. package/src/context/context-swap-renderer.ts +14 -0
  24. package/src/events/types.ts +12 -0
  25. package/src/memory/memory-get-tool.ts +1 -1
  26. package/src/observation/observation-builder.ts +128 -48
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/resume-projection.ts +47 -21
  36. package/src/session/session-history-access.ts +238 -0
  37. package/src/session/session-store-context-readers.ts +183 -0
  38. package/src/session/session-store-ledger-writer.ts +315 -0
  39. package/src/session/session-store-record-writer.ts +318 -0
  40. package/src/session/session-store-recovery.ts +225 -0
  41. package/src/session/session-store-revisions.ts +1004 -0
  42. package/src/session/session-store-sql.ts +40 -0
  43. package/src/session/session-store-validation.ts +657 -0
  44. package/src/session/session-store.ts +756 -3186
  45. package/src/tools/bash-task.ts +46 -18
  46. package/src/tools/bash.ts +44 -2
  47. package/src/tools/glob.ts +107 -19
  48. package/src/tools/grep-output.ts +130 -0
  49. package/src/tools/grep-pagination.ts +73 -0
  50. package/src/tools/grep-path.ts +11 -0
  51. package/src/tools/grep-snippets.ts +111 -0
  52. package/src/tools/grep.ts +139 -154
  53. package/src/tools/read.ts +0 -9
  54. package/src/tools/recall.ts +106 -50
  55. package/src/tools/registry.ts +4 -6
  56. package/src/tools/ripgrep.ts +19 -26
  57. package/src/tools/shell-process.ts +30 -4
  58. package/src/tools/task-output-range.ts +146 -0
  59. package/src/tools/task-output-tool.ts +35 -5
  60. package/src/tools/task-output.ts +35 -0
  61. package/src/tools/task-stop.ts +2 -1
  62. package/src/tools/task-tool-args.ts +34 -0
  63. package/src/tools/terminal-screen.ts +11 -2
  64. package/src/tools/types.ts +39 -2
  65. package/src/tui/event-store.ts +23 -5
  66. package/src/tui/remote-app.tsx +210 -0
@@ -14,12 +14,8 @@ import {
14
14
  spawnShellProcess,
15
15
  } from "./shell-process";
16
16
  import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
17
- import {
18
- createTerminalScreen,
19
- TERMINAL_SCREEN_COLUMNS,
20
- TERMINAL_SCREEN_ROWS,
21
- type TerminalScreen,
22
- } from "./terminal-screen";
17
+ import type { TaskOutputRangeRequest } from "./task-output-range";
18
+ import { createTerminalScreen, type TerminalScreen } from "./terminal-screen";
23
19
  import { resolveWorkspaceStorageRoot } from "../session/workspace-storage";
24
20
 
25
21
  export type ShellTaskStatus =
@@ -145,6 +141,8 @@ export class ShellTaskManager {
145
141
  description: string;
146
142
  origin: ShellTaskOrigin;
147
143
  tty: boolean;
144
+ cols?: number;
145
+ rows?: number;
148
146
  }): Promise<ShellTaskHandle> {
149
147
  if (!this.acceptingTasks) {
150
148
  throw new Error("Cannot start a Bash task after task manager shutdown.");
@@ -163,7 +161,7 @@ export class ShellTaskManager {
163
161
  throw new Error("Cannot start a Bash task after task manager shutdown.");
164
162
  }
165
163
 
166
- const terminalScreen = input.tty ? createTerminalScreen() : undefined;
164
+ const terminalScreen = input.tty ? createTerminalScreen(input) : undefined;
167
165
  let shellProcess: ShellProcessHandle;
168
166
  try {
169
167
  shellProcess = await spawnShellProcess({
@@ -171,6 +169,8 @@ export class ShellTaskManager {
171
169
  command: input.command,
172
170
  cwd: this.options.cwdState.cwd,
173
171
  cwdFilePath,
172
+ cols: terminalScreen?.columns,
173
+ rows: terminalScreen?.rows,
174
174
  onOutput(bytes) {
175
175
  output.write(Buffer.from(bytes));
176
176
  if (terminalScreen !== undefined) {
@@ -259,7 +259,11 @@ export class ShellTaskManager {
259
259
  return this.inspection(task);
260
260
  }
261
261
 
262
- async inspectTaskOutput(taskId: string): Promise<ShellTaskInspection | undefined> {
262
+ async inspectTaskOutput(
263
+ taskId: string,
264
+ range?: TaskOutputRangeRequest,
265
+ signal?: AbortSignal,
266
+ ): Promise<ShellTaskInspection | undefined> {
263
267
  const task = this.tasks.get(taskId);
264
268
  if (task === undefined) {
265
269
  return undefined;
@@ -271,7 +275,20 @@ export class ShellTaskManager {
271
275
  } else {
272
276
  await task.terminalScreen?.flush();
273
277
  }
274
- return this.inspection(task);
278
+ const inspection = this.inspection(task);
279
+ if (range !== undefined && task.mode !== "pty") {
280
+ const output = await task.output.readRange(range, signal);
281
+ return {
282
+ ...inspection,
283
+ task: {
284
+ ...inspection.task,
285
+ outputBytes: output.outputBytes,
286
+ outputLines: output.outputLines,
287
+ },
288
+ output,
289
+ };
290
+ }
291
+ return inspection;
275
292
  }
276
293
 
277
294
  taskCompletion(taskId: string): Promise<ShellTaskSnapshot> {
@@ -399,8 +416,15 @@ export class ShellTaskManager {
399
416
  if (!(await completesWithin(task.completion, this.stopGraceMs))) {
400
417
  this.synchronizeTerminalState(task);
401
418
  if (!isTerminalStatus(task.status)) {
402
- signalProcessGroup(task, "SIGKILL");
403
- escalated = true;
419
+ escalated = signalProcessGroup(task, "SIGKILL");
420
+ if (
421
+ !(await completesWithin(task.completion, this.stopGraceMs)) &&
422
+ !task.process.outputClosed
423
+ ) {
424
+ task.error =
425
+ "Task output remained open after forced termination; closed local output streams. Descendant processes may still be running.";
426
+ task.process.close();
427
+ }
404
428
  }
405
429
  }
406
430
 
@@ -456,8 +480,8 @@ export class ShellTaskManager {
456
480
  private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
457
481
  const result = await task.process.wait();
458
482
 
459
- this.applyTermination(task, result);
460
483
  await task.process.waitForOutputClose();
484
+ this.applyTermination(task, result);
461
485
  await task.output.end();
462
486
  if (task.terminalScreen !== undefined) {
463
487
  await task.terminalScreen.flush();
@@ -487,9 +511,9 @@ export class ShellTaskManager {
487
511
  }
488
512
 
489
513
  task.endedAt ??= new Date().toISOString();
490
- if (result.error !== undefined) {
514
+ if (result.error !== undefined || task.error !== undefined) {
491
515
  task.status = "failed";
492
- task.error = result.error;
516
+ task.error ??= result.error;
493
517
  return;
494
518
  }
495
519
 
@@ -504,7 +528,9 @@ export class ShellTaskManager {
504
528
  }
505
529
 
506
530
  private synchronizeTerminalState(task: ManagedShellTask): void {
507
- if (isTerminalStatus(task.status)) {
531
+ // An exited wrapper can leave children holding its output descriptors open.
532
+ // Keep the task stoppable until both process exit and output closure occur.
533
+ if (isTerminalStatus(task.status) || !task.process.outputClosed) {
508
534
  return;
509
535
  }
510
536
 
@@ -558,8 +584,8 @@ export class ShellTaskManager {
558
584
  ...(screen === undefined
559
585
  ? {}
560
586
  : {
561
- screenRows: TERMINAL_SCREEN_ROWS,
562
- screenColumns: TERMINAL_SCREEN_COLUMNS,
587
+ screenRows: task.terminalScreen?.rows,
588
+ screenColumns: task.terminalScreen?.columns,
563
589
  screen,
564
590
  }),
565
591
  };
@@ -589,13 +615,15 @@ export class ShellTaskManager {
589
615
  function signalProcessGroup(
590
616
  task: ManagedShellTask,
591
617
  signal: "SIGTERM" | "SIGKILL",
592
- ): void {
618
+ ): boolean {
593
619
  try {
594
620
  process.kill(-task.processGroupId, signal);
621
+ return true;
595
622
  } catch (error) {
596
623
  if (!isNoSuchProcess(error)) {
597
624
  throw error;
598
625
  }
626
+ return false;
599
627
  }
600
628
  }
601
629
 
package/src/tools/bash.ts CHANGED
@@ -13,6 +13,7 @@ import type { TaskOutputSnapshot } from "./task-output";
13
13
  import type { BashRawResult, ToolExecutionContext, ToolExecutor } from "./types";
14
14
  import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
15
15
  import { classifyBashRisk } from "./bash-guard";
16
+ import { MAX_TERMINAL_DIMENSION, MIN_TERMINAL_COLUMNS } from "./terminal-screen";
16
17
 
17
18
  type BashArgs = {
18
19
  command: string;
@@ -20,6 +21,8 @@ type BashArgs = {
20
21
  description?: string;
21
22
  run_in_background?: boolean;
22
23
  tty?: boolean;
24
+ cols?: number;
25
+ rows?: number;
23
26
  };
24
27
 
25
28
  export type BashToolOptions = {
@@ -44,7 +47,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
44
47
  return defineToolExecutor("bash", {
45
48
  definition: {
46
49
  name: "Bash",
47
- description: "Run a shell command locally.",
50
+ description:
51
+ "Run a shell command locally. If the foreground timeout expires while the command is still running, it continues as a background task and returns a task ID; it is not killed. Use TaskOutput to inspect progress, then decide whether to keep waiting or stop it with TaskStop.",
48
52
  parameters: {
49
53
  type: "object",
50
54
  additionalProperties: false,
@@ -57,7 +61,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
57
61
  type: "integer",
58
62
  minimum: 1,
59
63
  maximum: maxTimeoutMs,
60
- description: "Optional foreground timeout in milliseconds.",
64
+ description:
65
+ "Optional foreground wait duration in milliseconds. On timeout, a still-running command continues in the background instead of being killed.",
61
66
  },
62
67
  description: {
63
68
  type: "string",
@@ -72,6 +77,20 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
72
77
  description:
73
78
  "Run the command in a pseudo-terminal so it can receive interactive input.",
74
79
  },
80
+ cols: {
81
+ type: "integer",
82
+ minimum: MIN_TERMINAL_COLUMNS,
83
+ maximum: MAX_TERMINAL_DIMENSION,
84
+ description:
85
+ "Initial PTY width in columns. Defaults to 80. Ignored unless tty=true.",
86
+ },
87
+ rows: {
88
+ type: "integer",
89
+ minimum: 1,
90
+ maximum: MAX_TERMINAL_DIMENSION,
91
+ description:
92
+ "Initial PTY height in rows. Defaults to 24. Ignored unless tty=true.",
93
+ },
75
94
  },
76
95
  required: ["command"],
77
96
  },
@@ -137,6 +156,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
137
156
  description: input.description ?? input.command,
138
157
  origin: call,
139
158
  tty: input.tty === true,
159
+ cols: input.cols,
160
+ rows: input.rows,
140
161
  });
141
162
 
142
163
  if (input.run_in_background === true) {
@@ -232,6 +253,25 @@ export function parseBashArgs(
232
253
  return { ok: false, error: "Bash.tty must be a boolean." };
233
254
  }
234
255
 
256
+ if (args.tty === true) {
257
+ for (const name of ["cols", "rows"] as const) {
258
+ const value = args[name];
259
+ const minimum = name === "cols" ? MIN_TERMINAL_COLUMNS : 1;
260
+ if (
261
+ value !== undefined &&
262
+ (typeof value !== "number" ||
263
+ !Number.isInteger(value) ||
264
+ value < minimum ||
265
+ value > MAX_TERMINAL_DIMENSION)
266
+ ) {
267
+ return {
268
+ ok: false,
269
+ error: `Bash.${name} must be an integer between ${minimum} and ${MAX_TERMINAL_DIMENSION}.`,
270
+ };
271
+ }
272
+ }
273
+ }
274
+
235
275
  return {
236
276
  ok: true,
237
277
  value: {
@@ -243,6 +283,8 @@ export function parseBashArgs(
243
283
  : args.description,
244
284
  run_in_background: args.run_in_background,
245
285
  tty: args.tty,
286
+ cols: args.tty === true ? (args.cols as number | undefined) : undefined,
287
+ rows: args.tty === true ? (args.rows as number | undefined) : undefined,
246
288
  },
247
289
  };
248
290
  }
package/src/tools/glob.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
- import { stat } from "node:fs/promises";
3
- import { glob } from "glob";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { glob, type Path } from "glob";
4
4
  import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
5
5
  import { resolveWorkspacePath, toDisplayPath } from "./path-safety";
6
6
  import { defineToolExecutor } from "./types";
@@ -9,6 +9,8 @@ import type { GlobRawResult, ToolExecutionContext, ToolExecutor } from "./types"
9
9
  type GlobArgs = {
10
10
  pattern: string;
11
11
  path?: string;
12
+ head_limit: number;
13
+ offset: number;
12
14
  };
13
15
 
14
16
  export type GlobToolOptions = {
@@ -16,12 +18,15 @@ export type GlobToolOptions = {
16
18
  };
17
19
 
18
20
  const ignoredDirectories = ["node_modules", ".git"];
21
+ const defaultHeadLimit = 200;
22
+ const maxHeadLimit = 500;
19
23
 
20
24
  export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
21
25
  return defineToolExecutor("glob", {
22
26
  definition: {
23
27
  name: "Glob",
24
- description: "Find files by glob pattern. node_modules and .git are ignored.",
28
+ description:
29
+ "Find regular files by glob pattern, including symbolic links to regular files. Case sensitivity follows platform defaults. Directory links and broken links are excluded. node_modules and .git are skipped during traversal; to search inside either, set path directly to that directory.",
25
30
  parameters: {
26
31
  type: "object",
27
32
  additionalProperties: false,
@@ -33,7 +38,21 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
33
38
  path: {
34
39
  type: "string",
35
40
  description:
36
- "Optional workspace-relative or absolute search directory. Defaults to the workspace root.",
41
+ "Optional workspace-relative or absolute search directory. Defaults to the workspace root. A directory symbolic link is allowed as the search root.",
42
+ },
43
+ head_limit: {
44
+ type: "integer",
45
+ minimum: 1,
46
+ maximum: maxHeadLimit,
47
+ description:
48
+ "Maximum paths to return. Defaults to 200; must be between 1 and 500.",
49
+ },
50
+ offset: {
51
+ type: "integer",
52
+ minimum: 0,
53
+ maximum: Number.MAX_SAFE_INTEGER,
54
+ description:
55
+ "Skip the first N sorted matches. Defaults to 0. To continue, pass nextOffset from the previous result with the same pattern and path.",
37
56
  },
38
57
  },
39
58
  required: ["pattern"],
@@ -46,8 +65,16 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
46
65
  if (!parsed.ok) {
47
66
  return {
48
67
  ok: false,
49
- pattern: "",
50
- searchPath: ".",
68
+ pattern:
69
+ isRecord(args) && typeof args.pattern === "string"
70
+ ? args.pattern
71
+ : undefined,
72
+ searchPath:
73
+ isRecord(args) && args.path !== undefined
74
+ ? typeof args.path === "string"
75
+ ? args.path
76
+ : "(invalid path)"
77
+ : ".",
51
78
  ignored: ignoredDirectories,
52
79
  error: parsed.error,
53
80
  };
@@ -87,27 +114,43 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
87
114
  }
88
115
 
89
116
  try {
117
+ // Resolve the root only; keep the traversal policy for links below it.
118
+ const realSearchPath = await realpath(absoluteSearchPath);
119
+ throwIfTurnCancelled(context.signal);
90
120
  const matches = await glob(input.pattern, {
91
- cwd: absoluteSearchPath,
121
+ cwd: realSearchPath,
92
122
  nodir: true,
93
123
  dot: true,
94
124
  follow: false,
125
+ withFileTypes: true,
95
126
  signal: context.signal,
96
127
  ignore: ["**/node_modules/**", "**/.git/**"],
97
128
  });
98
- const displayMatches = toDisplayMatches({
129
+ const displayMatches = await toDisplayMatches({
99
130
  workspaceRoot: options.workspaceRoot,
100
131
  absoluteSearchPath,
132
+ realSearchPath,
101
133
  matches,
134
+ signal: context.signal,
102
135
  });
136
+ const page = displayMatches.slice(
137
+ input.offset,
138
+ input.offset + input.head_limit,
139
+ );
140
+ const hasMore = input.offset + page.length < displayMatches.length;
103
141
 
104
142
  return {
105
143
  ok: true,
106
144
  pattern: input.pattern,
107
145
  searchPath,
108
146
  absoluteSearchPath,
109
- matches: displayMatches,
110
- matchCount: displayMatches.length,
147
+ matches: page,
148
+ matchCount: page.length,
149
+ totalMatches: displayMatches.length,
150
+ returnedCount: page.length,
151
+ appliedOffset: input.offset,
152
+ hasMore,
153
+ ...(hasMore ? { nextOffset: input.offset + page.length } : {}),
111
154
  ignored: ignoredDirectories,
112
155
  };
113
156
  } catch (error) {
@@ -151,11 +194,35 @@ function parseGlobArgs(
151
194
  return { ok: false, error: "Glob.path must be a string." };
152
195
  }
153
196
 
197
+ if (
198
+ args.head_limit !== undefined &&
199
+ (typeof args.head_limit !== "number" ||
200
+ !Number.isSafeInteger(args.head_limit) ||
201
+ args.head_limit < 1 ||
202
+ args.head_limit > maxHeadLimit)
203
+ ) {
204
+ return {
205
+ ok: false,
206
+ error: "Glob.head_limit must be an integer between 1 and 500.",
207
+ };
208
+ }
209
+
210
+ if (
211
+ args.offset !== undefined &&
212
+ (typeof args.offset !== "number" ||
213
+ !Number.isSafeInteger(args.offset) ||
214
+ args.offset < 0)
215
+ ) {
216
+ return { ok: false, error: "Glob.offset must be a non-negative safe integer." };
217
+ }
218
+
154
219
  return {
155
220
  ok: true,
156
221
  value: {
157
222
  pattern: args.pattern,
158
223
  path: args.path,
224
+ head_limit: args.head_limit ?? defaultHeadLimit,
225
+ offset: args.offset ?? 0,
159
226
  },
160
227
  };
161
228
  }
@@ -177,22 +244,43 @@ async function ensureDirectory(
177
244
  }
178
245
  }
179
246
 
180
- function toDisplayMatches(input: {
247
+ async function toDisplayMatches(input: {
181
248
  workspaceRoot: string;
182
249
  absoluteSearchPath: string;
183
- matches: string[];
184
- }): string[] {
185
- const normalized = input.matches.map((match) => {
186
- const absolutePath = resolveWorkspacePath(
187
- input.workspaceRoot,
188
- path.resolve(input.absoluteSearchPath, match),
250
+ realSearchPath: string;
251
+ matches: Path[];
252
+ signal: AbortSignal;
253
+ }): Promise<string[]> {
254
+ const normalized: string[] = [];
255
+ for (const match of input.matches) {
256
+ throwIfTurnCancelled(input.signal);
257
+ if (!(await isRegularFileMatch(match))) continue;
258
+ // Preserve the caller's root spelling, including directory links and aliases.
259
+ const absolutePath = path.resolve(
260
+ input.absoluteSearchPath,
261
+ path.relative(input.realSearchPath, match.fullpath()),
189
262
  );
190
- return toDisplayPath(input.workspaceRoot, absolutePath);
191
- });
263
+ normalized.push(toDisplayPath(input.workspaceRoot, absolutePath));
264
+ }
265
+ throwIfTurnCancelled(input.signal);
192
266
 
193
267
  return [...new Set(normalized)].sort((left, right) => left.localeCompare(right));
194
268
  }
195
269
 
270
+ async function isRegularFileMatch(match: Path): Promise<boolean> {
271
+ if (!match.isSymbolicLink()) return match.isFile();
272
+
273
+ try {
274
+ // Resolve only the type; keep the link's path in the returned matches.
275
+ return (await stat(match.fullpath())).isFile();
276
+ } catch (error) {
277
+ if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
278
+ return false;
279
+ }
280
+ throw error;
281
+ }
282
+ }
283
+
196
284
  function isRecord(value: unknown): value is Record<string, unknown> {
197
285
  return typeof value === "object" && value !== null && !Array.isArray(value);
198
286
  }
@@ -0,0 +1,130 @@
1
+ import path from "node:path";
2
+ import { toDisplayPath } from "./path-safety";
3
+ import type { GrepOutputMode } from "./types";
4
+ import { excerptGrepLines } from "./grep-snippets";
5
+
6
+ export type GrepContentRecord = {
7
+ kind: "match" | "context";
8
+ filePath: string;
9
+ lineNumber: number;
10
+ /** Keep each JSON event intact so pagination cannot split a multiline match. */
11
+ lines: string[];
12
+ };
13
+
14
+ export type GrepRecord =
15
+ | { kind: "file"; filePath: string }
16
+ | { kind: "count"; filePath: string; count: number }
17
+ | GrepContentRecord;
18
+
19
+ /** Decode complete protocol records only. A truncated tail is never a record. */
20
+ export function parseGrepOutput(
21
+ stdout: string,
22
+ mode: GrepOutputMode,
23
+ workspaceRoot: string,
24
+ truncated: boolean,
25
+ searchCwd: string = workspaceRoot,
26
+ ): GrepRecord[] {
27
+ if (mode === "content") {
28
+ return parseJsonOutput(stdout, workspaceRoot, truncated, searchCwd);
29
+ }
30
+ const records: GrepRecord[] = [];
31
+ let start = 0;
32
+ while (start < stdout.length) {
33
+ const nul = stdout.indexOf("\0", start);
34
+ if (nul === -1) {
35
+ if (truncated) break;
36
+ throw new Error("Missing NUL path delimiter.");
37
+ }
38
+ const reportedPath = stdout.slice(start, nul);
39
+ if (reportedPath === "") throw new Error("Empty path in ripgrep output.");
40
+ const filePath = toDisplayPath(
41
+ workspaceRoot,
42
+ path.resolve(searchCwd, reportedPath),
43
+ );
44
+ if (mode === "files_with_matches") {
45
+ records.push({ kind: "file", filePath });
46
+ start = nul + 1;
47
+ continue;
48
+ }
49
+ const end = stdout.indexOf("\n", nul + 1);
50
+ if (end === -1) {
51
+ if (truncated) break;
52
+ throw new Error("Unterminated count record.");
53
+ }
54
+ const value = stdout.slice(nul + 1, end);
55
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) {
56
+ throw new Error("Invalid count in ripgrep output.");
57
+ }
58
+ records.push({ kind: "count", filePath, count: Number(value) });
59
+ start = end + 1;
60
+ }
61
+ return records;
62
+ }
63
+
64
+ function parseJsonOutput(
65
+ stdout: string,
66
+ workspaceRoot: string,
67
+ truncated: boolean,
68
+ searchCwd: string,
69
+ ): GrepRecord[] {
70
+ const records: GrepRecord[] = [];
71
+ let start = 0;
72
+ while (start < stdout.length) {
73
+ const end = stdout.indexOf("\n", start);
74
+ if (end === -1) {
75
+ if (truncated) break;
76
+ throw new Error("Unterminated JSON event.");
77
+ }
78
+ const event: unknown = JSON.parse(stdout.slice(start, end));
79
+ start = end + 1;
80
+ if (!isRecord(event) || typeof event.type !== "string" || !isRecord(event.data)) {
81
+ throw new Error("Invalid ripgrep JSON event.");
82
+ }
83
+ if (["begin", "end", "summary"].includes(event.type)) continue;
84
+ if (event.type !== "match" && event.type !== "context") {
85
+ throw new Error("Unexpected ripgrep JSON event type.");
86
+ }
87
+ const data = event.data;
88
+ const reportedPath = decodeText(data.path, true);
89
+ if (reportedPath === "") throw new Error("Empty path in ripgrep JSON event.");
90
+ if (
91
+ typeof data.line_number !== "number" ||
92
+ !Number.isSafeInteger(data.line_number) ||
93
+ data.line_number < 1
94
+ ) {
95
+ throw new Error("Invalid line number in ripgrep JSON event.");
96
+ }
97
+ const filePath = toDisplayPath(
98
+ workspaceRoot,
99
+ path.resolve(searchCwd, reportedPath),
100
+ );
101
+ records.push({
102
+ kind: event.type,
103
+ filePath,
104
+ lineNumber: data.line_number,
105
+ lines: excerptGrepLines(decodeBytes(data.lines), data.submatches),
106
+ });
107
+ }
108
+ return records;
109
+ }
110
+
111
+ function decodeText(value: unknown, isPath: boolean): string {
112
+ if (isRecord(value) && typeof value.text === "string") return value.text;
113
+ return new TextDecoder("utf-8", { fatal: isPath }).decode(decodeBytes(value));
114
+ }
115
+
116
+ function decodeBytes(value: unknown): Buffer {
117
+ if (!isRecord(value)) throw new Error("Invalid text field in ripgrep JSON event.");
118
+ if (typeof value.text === "string") return Buffer.from(value.text, "utf8");
119
+ if (
120
+ typeof value.bytes === "string" &&
121
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.bytes)
122
+ ) {
123
+ return Buffer.from(value.bytes, "base64");
124
+ }
125
+ throw new Error("Invalid text encoding in ripgrep JSON event.");
126
+ }
127
+
128
+ function isRecord(value: unknown): value is Record<string, unknown> {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
@@ -0,0 +1,73 @@
1
+ import type { GrepContentRecord, GrepRecord } from "./grep-output";
2
+
3
+ const defaultHeadLimit = 250;
4
+
5
+ export function applyHeadLimit<T>(items: T[], limit: number | undefined, offset = 0) {
6
+ const effectiveLimit = limit ?? defaultHeadLimit;
7
+ const selected =
8
+ effectiveLimit === 0
9
+ ? items.slice(offset)
10
+ : items.slice(offset, offset + effectiveLimit);
11
+ const hasMore = offset + selected.length < items.length;
12
+ return {
13
+ items: selected,
14
+ totalResults: items.length,
15
+ returnedResults: selected.length,
16
+ hasMore,
17
+ nextOffset: hasMore ? offset + selected.length : undefined,
18
+ appliedLimit: hasMore ? effectiveLimit : undefined,
19
+ };
20
+ }
21
+
22
+ /** Match events select windows; matches encountered inside a window never expand it. */
23
+ export function applyContentHeadLimit(
24
+ records: GrepRecord[],
25
+ limit: number | undefined,
26
+ offset: number,
27
+ context: { before: number; after: number },
28
+ ) {
29
+ const matches = records.filter(
30
+ (record): record is GrepContentRecord => record.kind === "match",
31
+ );
32
+ const page = applyHeadLimit(matches, limit, offset);
33
+ const windows = new Map<string, { start: number; end: number }[]>();
34
+ for (const match of page.items) {
35
+ const ranges = windows.get(match.filePath) ?? [];
36
+ const start = Math.max(1, match.lineNumber - context.before);
37
+ const end = match.lineNumber + match.lines.length - 1 + context.after;
38
+ const last = ranges.at(-1);
39
+ if (last !== undefined && start <= last.end + 1) {
40
+ last.end = Math.max(last.end, end);
41
+ } else {
42
+ ranges.push({ start, end });
43
+ }
44
+ windows.set(match.filePath, ranges);
45
+ }
46
+
47
+ const items: GrepContentRecord[] = [];
48
+ // rg emits each physical line once, in file/line order. Walk merged windows
49
+ // linearly rather than rescanning all records for every selected match.
50
+ const cursors = new Map<string, number>();
51
+ for (const record of records) {
52
+ if (record.kind !== "match" && record.kind !== "context") continue;
53
+ const ranges = windows.get(record.filePath);
54
+ if (ranges === undefined) continue;
55
+ let cursor = cursors.get(record.filePath) ?? 0;
56
+ for (const [index, text] of record.lines.entries()) {
57
+ const lineNumber = record.lineNumber + index;
58
+ while (cursor < ranges.length && ranges[cursor].end < lineNumber) cursor++;
59
+ const range = ranges[cursor];
60
+ if (range === undefined) break;
61
+ if (lineNumber >= range.start) {
62
+ items.push({
63
+ kind: record.kind,
64
+ filePath: record.filePath,
65
+ lineNumber,
66
+ lines: [text],
67
+ });
68
+ }
69
+ }
70
+ cursors.set(record.filePath, cursor);
71
+ }
72
+ return { ...page, items };
73
+ }
@@ -0,0 +1,11 @@
1
+ // Quote only paths that need escaping; ordinary paths remain easy to scan/copy.
2
+ export function formatGrepPath(filePath: string): string {
3
+ return /[\p{Cc}\p{Cf}"\\\u2028\u2029]/u.test(filePath)
4
+ ? JSON.stringify(filePath).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, (character) =>
5
+ character
6
+ .split("")
7
+ .map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`)
8
+ .join(""),
9
+ )
10
+ : filePath;
11
+ }