@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.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 (165) hide show
  1. package/LICENSE +4 -0
  2. package/README.md +50 -17
  3. package/dist/agent/approval-json.d.ts +43 -0
  4. package/dist/agent/approval-json.js +69 -0
  5. package/dist/agent/approval-native.d.ts +3 -0
  6. package/dist/agent/approval-native.js +165 -0
  7. package/dist/agent/approval-store.d.ts +16 -0
  8. package/dist/agent/approval-store.js +21 -0
  9. package/dist/agent/approval.d.ts +33 -0
  10. package/dist/agent/approval.js +40 -5
  11. package/dist/agent/compaction.d.ts +2 -0
  12. package/dist/agent/compaction.js +60 -17
  13. package/dist/agent/guards.d.ts +16 -16
  14. package/dist/agent/guards.js +978 -24
  15. package/dist/agent/policy/defaults.d.ts +16 -0
  16. package/dist/agent/policy/defaults.js +17 -0
  17. package/dist/agent/policy/egress.d.ts +32 -0
  18. package/dist/agent/policy/egress.js +280 -0
  19. package/dist/agent/process-tree.d.ts +20 -0
  20. package/dist/agent/process-tree.js +81 -10
  21. package/dist/agent/protocol.d.ts +20 -1
  22. package/dist/agent/protocol.js +165 -38
  23. package/dist/agent/registry.d.ts +3 -1
  24. package/dist/agent/registry.js +168 -11
  25. package/dist/agent/runner.d.ts +21 -0
  26. package/dist/agent/runner.js +44 -5
  27. package/dist/agent/shell-ast.d.ts +11 -0
  28. package/dist/agent/shell-ast.js +204 -0
  29. package/dist/agent/shell-session.d.ts +14 -0
  30. package/dist/agent/shell-session.js +251 -40
  31. package/dist/agent/tool-schemas.js +26 -7
  32. package/dist/agent/tools/append_file.js +3 -1
  33. package/dist/agent/tools/apply_patch.js +44 -4
  34. package/dist/agent/tools/copy_file.js +26 -3
  35. package/dist/agent/tools/delete_file.js +22 -3
  36. package/dist/agent/tools/grep.js +35 -4
  37. package/dist/agent/tools/make_dir.js +3 -1
  38. package/dist/agent/tools/move_path.d.ts +7 -0
  39. package/dist/agent/tools/move_path.js +31 -5
  40. package/dist/agent/tools/read_file.js +63 -3
  41. package/dist/agent/tools/read_many.js +21 -3
  42. package/dist/agent/tools/readers.d.ts +53 -1
  43. package/dist/agent/tools/readers.js +236 -26
  44. package/dist/agent/tools/replace_in_file.js +38 -5
  45. package/dist/agent/tools/shell.js +2 -2
  46. package/dist/agent/tools/types.d.ts +26 -0
  47. package/dist/agent/tools/validate.d.ts +15 -2
  48. package/dist/agent/tools/validate.js +66 -3
  49. package/dist/agent/tools/write_file.js +3 -1
  50. package/dist/app.d.ts +2 -1
  51. package/dist/app.js +526 -85
  52. package/dist/cli-main.js +363 -0
  53. package/dist/cli.js +397 -337
  54. package/dist/commands/about.js +1 -1
  55. package/dist/commands/clear.d.ts +1 -0
  56. package/dist/commands/clear.js +1 -1
  57. package/dist/commands/compact.d.ts +12 -0
  58. package/dist/commands/compact.js +14 -2
  59. package/dist/commands/context.js +5 -4
  60. package/dist/commands/index.js +2 -0
  61. package/dist/commands/jobs.js +3 -2
  62. package/dist/commands/kill.js +3 -1
  63. package/dist/commands/login.d.ts +2 -2
  64. package/dist/commands/login.js +15 -15
  65. package/dist/commands/model.d.ts +1 -13
  66. package/dist/commands/model.js +6 -38
  67. package/dist/commands/provider.d.ts +1 -15
  68. package/dist/commands/provider.js +4 -22
  69. package/dist/commands/skills.js +34 -4
  70. package/dist/commands/threads.d.ts +9 -0
  71. package/dist/commands/threads.js +26 -0
  72. package/dist/config/store.d.ts +21 -0
  73. package/dist/config/store.js +47 -9
  74. package/dist/config/trust.d.ts +9 -0
  75. package/dist/config/trust.js +41 -2
  76. package/dist/headless.d.ts +7 -0
  77. package/dist/headless.js +213 -0
  78. package/dist/index.js +19 -3
  79. package/dist/lib/debug.d.ts +2 -0
  80. package/dist/lib/debug.js +22 -0
  81. package/dist/lib/diagnostics.d.ts +19 -0
  82. package/dist/lib/diagnostics.js +32 -6
  83. package/dist/lib/highlight-subset.d.ts +21 -0
  84. package/dist/lib/highlight-subset.js +88 -0
  85. package/dist/lib/lifecycle.d.ts +5 -0
  86. package/dist/lib/lifecycle.js +62 -0
  87. package/dist/lib/markdown.js +22 -4
  88. package/dist/providers/deepseek.js +1 -1
  89. package/dist/providers/openaiCompatible.d.ts +24 -2
  90. package/dist/providers/openaiCompatible.js +20 -6
  91. package/dist/providers/spec.d.ts +12 -0
  92. package/dist/providers/spec.js +29 -0
  93. package/dist/session/log.d.ts +14 -2
  94. package/dist/session/log.js +56 -27
  95. package/dist/session/projection.d.ts +34 -1
  96. package/dist/session/projection.js +315 -46
  97. package/dist/skills/manager.d.ts +15 -0
  98. package/dist/skills/manager.js +135 -18
  99. package/dist/types.d.ts +15 -0
  100. package/dist/ui/StatusBar.js +1 -1
  101. package/package.json +3 -2
  102. package/src/agent/approval-json.ts +103 -0
  103. package/src/agent/approval-native.ts +168 -0
  104. package/src/agent/approval-store.ts +38 -0
  105. package/src/agent/approval.ts +58 -11
  106. package/src/agent/compaction.ts +62 -17
  107. package/src/agent/guards.ts +1049 -25
  108. package/src/agent/policy/defaults.ts +34 -0
  109. package/src/agent/policy/egress.ts +335 -0
  110. package/src/agent/process-tree.ts +105 -23
  111. package/src/agent/protocol.ts +187 -39
  112. package/src/agent/registry.ts +186 -14
  113. package/src/agent/runner.ts +50 -7
  114. package/src/agent/shell-ast.ts +227 -0
  115. package/src/agent/shell-session.ts +265 -41
  116. package/src/agent/tool-schemas.ts +26 -7
  117. package/src/agent/tools/append_file.ts +3 -1
  118. package/src/agent/tools/apply_patch.ts +45 -4
  119. package/src/agent/tools/copy_file.ts +27 -3
  120. package/src/agent/tools/delete_file.ts +22 -3
  121. package/src/agent/tools/grep.ts +36 -4
  122. package/src/agent/tools/make_dir.ts +3 -1
  123. package/src/agent/tools/move_path.ts +38 -5
  124. package/src/agent/tools/read_file.ts +64 -3
  125. package/src/agent/tools/read_many.ts +21 -3
  126. package/src/agent/tools/readers.ts +289 -26
  127. package/src/agent/tools/replace_in_file.ts +39 -5
  128. package/src/agent/tools/shell.ts +2 -2
  129. package/src/agent/tools/types.ts +43 -1
  130. package/src/agent/tools/validate.ts +73 -4
  131. package/src/agent/tools/write_file.ts +3 -1
  132. package/src/app.ts +533 -82
  133. package/src/{cli.ts → cli-main.ts} +34 -6
  134. package/src/commands/about.ts +1 -1
  135. package/src/commands/clear.ts +1 -1
  136. package/src/commands/compact.ts +14 -2
  137. package/src/commands/context.ts +5 -4
  138. package/src/commands/index.ts +2 -0
  139. package/src/commands/jobs.ts +3 -2
  140. package/src/commands/kill.ts +3 -1
  141. package/src/commands/login.ts +15 -15
  142. package/src/commands/model.ts +6 -38
  143. package/src/commands/provider.ts +4 -22
  144. package/src/commands/skills.ts +34 -4
  145. package/src/commands/threads.ts +28 -0
  146. package/src/config/store.ts +47 -9
  147. package/src/config/trust.ts +45 -2
  148. package/src/headless.ts +225 -0
  149. package/src/index.ts +19 -3
  150. package/src/lib/debug.ts +24 -0
  151. package/src/lib/diagnostics.ts +32 -6
  152. package/src/lib/highlight-subset.ts +93 -0
  153. package/src/lib/lifecycle.ts +69 -0
  154. package/src/lib/markdown.ts +22 -4
  155. package/src/providers/deepseek.ts +1 -1
  156. package/src/providers/openaiCompatible.ts +47 -10
  157. package/src/providers/spec.ts +42 -0
  158. package/src/session/log.ts +70 -27
  159. package/src/session/projection.ts +360 -47
  160. package/src/skills/manager.ts +140 -18
  161. package/src/types/highlight-shims.d.ts +14 -0
  162. package/src/types.ts +16 -1
  163. package/src/ui/StatusBar.ts +1 -1
  164. package/tsconfig.json +1 -0
  165. /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
@@ -1,16 +1,65 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import { open, readdir, stat } from "node:fs/promises";
2
- import { basename, extname, join } from "node:path";
3
+ import { basename, extname, join, relative } from "node:path";
4
+ import readline from "node:readline";
3
5
  import { createIgnore, isBinaryName } from "../ignore.js";
4
6
  import { createOutputBuffer } from "../limits.js";
5
7
  import { allowedRoots, isInsideAny, realpathOf, sandboxPath } from "./paths.js";
6
8
 
7
- export async function readMany(cwd: string, paths: string[]): Promise<string> {
9
+ export interface ReadManyResult {
10
+ text: string;
11
+ truncated: boolean;
12
+ totalBytes: number;
13
+ returnedBytes: number;
14
+ filesRead: number;
15
+ filesSkipped: number;
16
+ }
17
+
18
+ export async function readManyResult(cwd: string, paths: string[], maxBytes = readManyMaxBytes()): Promise<ReadManyResult> {
8
19
  const blocks: string[] = [];
20
+ let used = 0;
21
+ let totalBytes = 0;
22
+ let filesRead = 0;
23
+ let truncated = false;
9
24
  for (const path of paths) {
25
+ if (used >= maxBytes) {
26
+ truncated = true;
27
+ break;
28
+ }
10
29
  const file = sandboxPath(cwd, path);
11
- blocks.push(`### ${file}\n${await readTextFile(file)}`);
30
+ const result = await readTextFileResult(file, maxBytes - used);
31
+ totalBytes += result.totalBytes;
32
+ const block = `### ${file}\n${result.text}`;
33
+ const blockBytes = Buffer.byteLength(block, "utf8");
34
+ if (used + blockBytes > maxBytes) {
35
+ const allowed = Math.max(0, maxBytes - used);
36
+ const clipped = Buffer.from(block, "utf8").subarray(0, allowed).toString("utf8");
37
+ blocks.push(`${clipped}\n[... read_many budget exhausted at ${maxBytes} bytes ...]`);
38
+ used = maxBytes;
39
+ filesRead += 1;
40
+ truncated = true;
41
+ break;
42
+ }
43
+ blocks.push(block);
44
+ used += blockBytes;
45
+ filesRead += 1;
46
+ if (result.truncated)
47
+ truncated = true;
12
48
  }
13
- return blocks.join("\n\n");
49
+ if (filesRead < paths.length)
50
+ truncated = true;
51
+ return {
52
+ text: blocks.join("\n\n"),
53
+ truncated,
54
+ totalBytes,
55
+ returnedBytes: used,
56
+ filesRead,
57
+ filesSkipped: Math.max(0, paths.length - filesRead),
58
+ };
59
+ }
60
+
61
+ export async function readMany(cwd: string, paths: string[]): Promise<string> {
62
+ return (await readManyResult(cwd, paths)).text;
14
63
  }
15
64
 
16
65
  const CODE_EXT = new Set([
@@ -51,7 +100,7 @@ export async function listFiles(sandbox: string, root: string, maxDepth: number,
51
100
  let line = `${indent(depth + 1)}${entry.name} (${formatSize(info.size)})`;
52
101
  const symbols = await fileSymbols(full, entry.name, info.size);
53
102
  if (symbols)
54
- line += ` — ${symbols}`;
103
+ line += ` 鈥?${symbols}`;
55
104
  out.line(line);
56
105
  if (out.full)
57
106
  return;
@@ -104,67 +153,279 @@ export function formatSize(bytes: number): string {
104
153
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
105
154
  }
106
155
 
107
- export async function grepFiles(sandbox: string, root: string, pattern: string, maxDepth: number): Promise<string> {
108
- const re = new RegExp(pattern, "i");
109
- const out = createOutputBuffer();
156
+ export interface GrepOptions {
157
+ pattern: string;
158
+ flags?: string;
159
+ glob?: string;
160
+ context?: number;
161
+ maxResults?: number;
162
+ maxDepth?: number;
163
+ signal?: AbortSignal;
164
+ }
165
+
166
+ export interface GrepResult {
167
+ text: string;
168
+ truncated: boolean;
169
+ matchCount: number;
170
+ filesScanned: number;
171
+ }
172
+
173
+ export async function grepFiles(sandbox: string, root: string, options: GrepOptions): Promise<GrepResult> {
174
+ const re = new RegExp(options.pattern, options.flags ?? "i");
175
+ const maxResults = Math.max(1, options.maxResults ?? 200);
176
+ const context = Math.max(0, options.context ?? 0);
110
177
  const maxBytes = grepMaxBytes();
111
- await walkWithin(sandbox, root, maxDepth, async (file, info) => {
178
+ const out = createOutputBuffer();
179
+ const globRe = options.glob ? globToRegExp(options.glob) : undefined;
180
+ let matchCount = 0;
181
+ let filesScanned = 0;
182
+ let truncated = false;
183
+ await walkWithin(sandbox, root, options.maxDepth ?? 4, async (file, info) => {
184
+ if (options.signal?.aborted)
185
+ throw Object.assign(new Error("grep aborted"), { code: "ABORTED" });
112
186
  if (!info.isFile())
113
187
  return;
114
188
  if (isBinaryName(basename(file)))
115
189
  return;
190
+ if (globRe) {
191
+ const rel = relative(root, file).replace(/\\/g, "/");
192
+ const target = options.glob && options.glob.includes("/") ? rel : basename(file);
193
+ if (!globRe.test(target))
194
+ return;
195
+ }
116
196
  if (info.size > maxBytes) {
117
197
  out.line(`${file}: skipped (${formatSize(info.size)} exceeds ${formatSize(maxBytes)})`);
118
198
  return !out.full;
119
199
  }
200
+ filesScanned += 1;
120
201
  try {
121
- const text = await readTextFile(file, maxBytes);
122
- const lines = text.split(/\r?\n/);
123
- lines.forEach((line, index) => {
124
- if (re.test(line))
125
- out.line(file + ":" + (index + 1) + ": " + line);
126
- });
202
+ const result = await readTextFileResult(file, maxBytes);
203
+ if (result.binary)
204
+ return;
205
+ const lines = result.text.split(/\r?\n/);
206
+ const printed = new Set<number>();
207
+ for (let index = 0; index < lines.length; index += 1) {
208
+ if (!re.test(lines[index]))
209
+ continue;
210
+ matchCount += 1;
211
+ const start = Math.max(0, index - context);
212
+ const end = Math.min(lines.length - 1, index + context);
213
+ for (let cursor = start; cursor <= end; cursor += 1) {
214
+ if (printed.has(cursor) && cursor !== index)
215
+ continue;
216
+ printed.add(cursor);
217
+ const isMatch = cursor === index;
218
+ out.line(`${file}:${cursor + 1}${isMatch ? ": " : "- "}${lines[cursor]}`);
219
+ if (out.full) {
220
+ truncated = true;
221
+ return false;
222
+ }
223
+ }
224
+ if (matchCount >= maxResults) {
225
+ truncated = true;
226
+ return false;
227
+ }
228
+ }
127
229
  }
128
- catch {
230
+ catch (error: any) {
231
+ if (error?.code === "ABORTED")
232
+ throw error;
129
233
  // Binary or unreadable files are skipped.
130
234
  }
131
235
  return !out.full;
132
236
  });
133
- return out.value();
237
+ if (out.full)
238
+ truncated = true;
239
+ return { text: out.value(), truncated, matchCount, filesScanned };
134
240
  }
135
241
 
136
- export async function readTextFile(file: string, limit = readMaxBytes()): Promise<string> {
242
+ function globToRegExp(glob: string): RegExp {
243
+ let source = "^";
244
+ for (let i = 0; i < glob.length; i += 1) {
245
+ const char = glob[i];
246
+ if (char === "*") {
247
+ if (glob[i + 1] === "*") {
248
+ source += ".*";
249
+ i += 1;
250
+ if (glob[i + 1] === "/")
251
+ i += 1;
252
+ }
253
+ else {
254
+ source += "[^/]*";
255
+ }
256
+ }
257
+ else if (char === "?") {
258
+ source += "[^/]";
259
+ }
260
+ else if (/[.+^${}()|[\]\\]/.test(char)) {
261
+ source += "\\" + char;
262
+ }
263
+ else {
264
+ source += char;
265
+ }
266
+ }
267
+ return new RegExp(source + "$", "i");
268
+ }
269
+
270
+ export interface ReadTextResult {
271
+ text: string;
272
+ truncated: boolean;
273
+ totalLines?: number;
274
+ returnedLines: number;
275
+ totalBytes: number;
276
+ returnedBytes: number;
277
+ binary: boolean;
278
+ }
279
+
280
+ export async function readTextFileResult(file: string, limit = readMaxBytes()): Promise<ReadTextResult> {
137
281
  const info = await stat(file);
138
282
  if (!info.isFile())
139
- throw new Error(`not a file: ${file}`);
283
+ throw Object.assign(new Error(`not a file: ${file}`), { code: "EISDIR" });
140
284
  const length = Math.min(info.size, limit);
141
285
  const handle = await open(file, "r");
142
286
  try {
143
287
  const buffer = Buffer.alloc(length);
144
288
  const { bytesRead } = await handle.read(buffer, 0, length, 0);
145
289
  const slice = buffer.subarray(0, bytesRead);
146
- if (slice.subarray(0, Math.min(8192, slice.length)).includes(0))
147
- return `[binary file omitted: ${file} (${info.size} bytes)]`;
290
+ if (slice.subarray(0, Math.min(8192, slice.length)).includes(0)) {
291
+ return {
292
+ text: `[binary file omitted: ${file} (${info.size} bytes)]`,
293
+ truncated: false,
294
+ totalLines: undefined,
295
+ returnedLines: 0,
296
+ totalBytes: info.size,
297
+ returnedBytes: 0,
298
+ binary: true,
299
+ };
300
+ }
148
301
  const text = slice.toString("utf8");
149
- return info.size > length
150
- ? `${text}\n[... truncated at ${length} of ${info.size} bytes; use grep or read a narrower file ...]`
151
- : text;
302
+ const truncated = info.size > length;
303
+ const returnedLines = text ? text.split(/\r?\n/).length : 0;
304
+ return {
305
+ text,
306
+ truncated,
307
+ totalLines: truncated ? undefined : returnedLines,
308
+ returnedLines,
309
+ totalBytes: info.size,
310
+ returnedBytes: Buffer.byteLength(text, "utf8"),
311
+ binary: false,
312
+ };
152
313
  }
153
314
  finally {
154
315
  await handle.close();
155
316
  }
156
317
  }
157
318
 
319
+ export interface ReadRangeResult {
320
+ text: string;
321
+ truncated: boolean;
322
+ totalLines: number;
323
+ returnedLines: number;
324
+ totalBytes: number;
325
+ returnedBytes: number;
326
+ scannedAll: boolean;
327
+ binary: boolean;
328
+ }
329
+
330
+ export async function readFileRange(file: string, offset: number, limit: number, options: { maxBytes?: number; scanMaxBytes?: number; signal?: AbortSignal } = {}): Promise<ReadRangeResult> {
331
+ const info = await stat(file);
332
+ if (!info.isFile())
333
+ throw Object.assign(new Error(`not a file: ${file}`), { code: "EISDIR" });
334
+ const probe = await readTextFileResult(file, 8192);
335
+ if (probe.binary) {
336
+ return {
337
+ text: probe.text,
338
+ truncated: false,
339
+ totalLines: 0,
340
+ returnedLines: 0,
341
+ totalBytes: info.size,
342
+ returnedBytes: 0,
343
+ scannedAll: true,
344
+ binary: true,
345
+ };
346
+ }
347
+ const start = Math.max(1, Math.floor(offset));
348
+ const count = Math.max(1, Math.floor(limit));
349
+ const maxBytes = options.maxBytes ?? readMaxBytes();
350
+ const scanMaxBytes = options.scanMaxBytes ?? readScanMaxBytes();
351
+ const collected: string[] = [];
352
+ let lineNo = 0;
353
+ let returnedBytes = 0;
354
+ let totalBytes = 0;
355
+ let truncated = false;
356
+ let scannedAll = true;
357
+ let sawRange = false;
358
+ const stream = createReadStream(file, { encoding: "utf8" });
359
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
360
+ try {
361
+ for await (const line of lines) {
362
+ if (options.signal?.aborted)
363
+ throw Object.assign(new Error("read aborted"), { code: "ABORTED" });
364
+ lineNo += 1;
365
+ totalBytes += Buffer.byteLength(line, "utf8") + 1;
366
+ if (lineNo >= start && collected.length < count) {
367
+ sawRange = true;
368
+ collected.push(line);
369
+ returnedBytes += Buffer.byteLength(line, "utf8") + 1;
370
+ if (returnedBytes > maxBytes) {
371
+ truncated = true;
372
+ scannedAll = false;
373
+ break;
374
+ }
375
+ }
376
+ else if (lineNo > start + count - 1) {
377
+ truncated = true;
378
+ }
379
+ if (totalBytes > scanMaxBytes) {
380
+ truncated = true;
381
+ scannedAll = false;
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ finally {
387
+ lines.close();
388
+ stream.destroy();
389
+ }
390
+ return {
391
+ text: collected.join("\n"),
392
+ truncated,
393
+ totalLines: lineNo,
394
+ returnedLines: sawRange ? collected.length : 0,
395
+ totalBytes: info.size,
396
+ returnedBytes,
397
+ scannedAll,
398
+ binary: false,
399
+ };
400
+ }
401
+
402
+ export async function readTextFile(file: string, limit = readMaxBytes()): Promise<string> {
403
+ const result = await readTextFileResult(file, limit);
404
+ return result.truncated
405
+ ? `${result.text}\n[... truncated at ${result.returnedBytes} of ${result.totalBytes} bytes; use read_file offset/limit, grep, or a narrower file ...]`
406
+ : result.text;
407
+ }
408
+
158
409
  export function readMaxBytes(): number {
159
410
  const raw = Number(process.env["FASTCODE_READ_MAX_BYTES"]);
160
411
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 200_000;
161
412
  }
162
413
 
163
- function grepMaxBytes(): number {
414
+ export function grepMaxBytes(): number {
164
415
  const raw = Number(process.env["FASTCODE_GREP_MAX_BYTES"]);
165
416
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000_000;
166
417
  }
167
418
 
419
+ export function readScanMaxBytes(): number {
420
+ const raw = Number(process.env["FASTCODE_READ_SCAN_MAX_BYTES"]);
421
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8_000_000;
422
+ }
423
+
424
+ export function readManyMaxBytes(): number {
425
+ const raw = Number(process.env["FASTCODE_READ_MANY_MAX_BYTES"]);
426
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 400_000;
427
+ }
428
+
168
429
  async function walkWithin(sandbox: string, root: string, maxDepth: number, visit: (file: string, info: any) => Promise<boolean | void>): Promise<void> {
169
430
  const roots = allowedRoots(sandbox).map((entry) => realpathOf(entry)).filter(Boolean) as string[];
170
431
  const ignore = createIgnore(sandbox);
@@ -192,6 +453,8 @@ async function walk(node: string, maxDepth: number, visit: (file: string, info:
192
453
  return;
193
454
  state.visited += 1;
194
455
  const insideSandbox = !state.roots || state.roots.length === 0 || isInsideAny(real, state.roots);
456
+ if (!insideSandbox)
457
+ return;
195
458
  let keepGoing: boolean | void = true;
196
459
  try {
197
460
  keepGoing = await visit(node, info);
@@ -201,7 +464,7 @@ async function walk(node: string, maxDepth: number, visit: (file: string, info:
201
464
  }
202
465
  if (keepGoing === false)
203
466
  return false;
204
- if (!info.isDirectory() || depth >= maxDepth || !insideSandbox)
467
+ if (!info.isDirectory() || depth >= maxDepth)
205
468
  return;
206
469
  let entries;
207
470
  try {
@@ -1,7 +1,13 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { resolvePath } from "./paths.js";
3
3
  import type { Tool } from "./types.js";
4
- import { invalid, preparePath, requiredString } from "./validate.js";
4
+ import { invalid, optionalNumber, preparePath, requiredString } from "./validate.js";
5
+
6
+ function countOccurrences(source: string, find: string): number {
7
+ if (!find)
8
+ return 0;
9
+ return source.split(find).length - 1;
10
+ }
5
11
 
6
12
  export const replaceInFileTool: Tool = {
7
13
  name: "replace_in_file",
@@ -11,8 +17,13 @@ export const replaceInFileTool: Tool = {
11
17
  prepare(call, ctx) {
12
18
  try {
13
19
  requiredString(call, "path");
14
- requiredString(call, "find");
20
+ const find = requiredString(call, "find");
15
21
  requiredString(call, "replace");
22
+ if (!find)
23
+ return invalid("find must not be empty");
24
+ const expected = optionalNumber(call, "expectedMatches");
25
+ if (expected !== undefined && (expected < 1 || !Number.isInteger(expected)))
26
+ return invalid("expectedMatches must be a positive integer");
16
27
  }
17
28
  catch (err: any) {
18
29
  return invalid(err.message);
@@ -23,10 +34,33 @@ export const replaceInFileTool: Tool = {
23
34
  const file = resolvePath(ctx.cwd, call);
24
35
  const find = requiredString(call, "find");
25
36
  const replacement = requiredString(call, "replace");
37
+ const expected = optionalNumber(call, "expectedMatches");
26
38
  const source = await readFile(file, "utf8");
27
- if (!source.includes(find))
28
- return { ok: false, output: `text not found in ${file}` };
39
+ const count = countOccurrences(source, find);
40
+ if (count === 0) {
41
+ return {
42
+ ok: false,
43
+ output: `text not found in ${file}`,
44
+ code: "NOT_FOUND",
45
+ hint: "Read the file first and pass the exact current text.",
46
+ matches: 0,
47
+ };
48
+ }
49
+ if (expected !== undefined && count !== expected) {
50
+ return {
51
+ ok: false,
52
+ output: `expected ${expected} match(es) but found ${count} in ${file}; file unchanged`,
53
+ code: "INVALID_ARGS",
54
+ hint: "Update find or expectedMatches so the replacement is explicit.",
55
+ matches: count,
56
+ };
57
+ }
29
58
  await writeFile(file, source.split(find).join(replacement), "utf8");
30
- return { ok: true, output: `replaced text in ${file}`, sideEffects: { touched: [file] } };
59
+ return {
60
+ ok: true,
61
+ output: `replaced ${count} occurrence(s) in ${file}`,
62
+ matches: count,
63
+ sideEffects: { touched: [file] },
64
+ };
31
65
  },
32
66
  };
@@ -1,7 +1,7 @@
1
1
  import type { ShellExecResult } from "../shell-session.js";
2
2
  import { executeShellCommand, pollShellSession } from "../shell-session.js";
3
3
  import type { Tool } from "./types.js";
4
- import { invalid, numberOption } from "./validate.js";
4
+ import { invalid, normalizeTimeoutMs, numberOption } from "./validate.js";
5
5
 
6
6
  export const shellTool: Tool = {
7
7
  name: "shell",
@@ -26,7 +26,7 @@ export const shellTool: Tool = {
26
26
  const result = await executeShellCommand(call.command, ctx.cwd, {
27
27
  signal: ctx.signal,
28
28
  turn: ctx.turn,
29
- timeoutMs: numberOption(call.timeoutMs, 120000),
29
+ timeoutMs: normalizeTimeoutMs(call.timeoutMs),
30
30
  });
31
31
  return formatExec(result);
32
32
  },
@@ -2,12 +2,39 @@ export type StateScope = "call" | "turn" | "session";
2
2
 
3
3
  export type RevalidateKey = "trust" | "sandboxRoots" | "schema" | "modelCaps";
4
4
 
5
+ export type ToolErrorCode =
6
+ | "ENOENT"
7
+ | "EACCES"
8
+ | "EEXIST"
9
+ | "ENOTDIR"
10
+ | "EISDIR"
11
+ | "EXDEV"
12
+ | "INVALID_ARGS"
13
+ | "SANDBOX_DENY"
14
+ | "APPROVAL_DENY"
15
+ | "EGRESS_DENY"
16
+ | "NOT_FOUND"
17
+ | "ABORTED"
18
+ | "UNKNOWN";
19
+
5
20
  export interface SideEffects {
6
21
  touched?: string[];
7
22
  created?: string[];
8
23
  removed?: string[];
9
24
  }
10
25
 
26
+ export interface EgressAudit {
27
+ outlet: string;
28
+ target?: string;
29
+ rule: string;
30
+ mode: "audit" | "block";
31
+ wouldBlock: boolean;
32
+ code: "EGRESS_DENY";
33
+ reason: string;
34
+ }
35
+
36
+ import type { ApprovalResolver } from "../approval.js";
37
+
11
38
  export interface ToolCall {
12
39
  tool: string;
13
40
  [key: string]: any;
@@ -16,6 +43,17 @@ export interface ToolCall {
16
43
  export interface ToolResult {
17
44
  ok: boolean;
18
45
  output: string;
46
+ code?: ToolErrorCode;
47
+ hint?: string;
48
+ truncated?: boolean;
49
+ totalLines?: number;
50
+ returnedLines?: number;
51
+ totalBytes?: number;
52
+ returnedBytes?: number;
53
+ filesRead?: number;
54
+ filesSkipped?: number;
55
+ matches?: number;
56
+ egress?: EgressAudit;
19
57
  sideEffects?: SideEffects;
20
58
  }
21
59
 
@@ -23,9 +61,13 @@ export interface ToolCtx {
23
61
  cwd: string;
24
62
  signal?: AbortSignal;
25
63
  turn?: number;
64
+ requestApproval?: ApprovalResolver;
65
+ catalogHash?: string;
26
66
  }
27
67
 
28
- export type Prepared = { ok: true } | { ok: false; output: string };
68
+ export type Prepared =
69
+ | { ok: true }
70
+ | { ok: false; output: string; code?: ToolErrorCode; hint?: string };
29
71
 
30
72
  export interface ApprovalHints {
31
73
  mayRequireApproval: boolean;
@@ -1,8 +1,9 @@
1
+ import { debugLog } from "../../lib/debug.js";
1
2
  import { sandboxPath } from "./paths.js";
2
- import type { Prepared, ToolCall } from "./types.js";
3
+ import type { Prepared, ToolCall, ToolErrorCode } from "./types.js";
3
4
 
4
- export function invalid(output: string): Prepared {
5
- return { ok: false, output };
5
+ export function invalid(output: string, code: ToolErrorCode = "INVALID_ARGS", hint?: string): Prepared {
6
+ return { ok: false, output, code, ...(hint ? { hint } : {}) };
6
7
  }
7
8
 
8
9
  export function requiredString(call: ToolCall, key: string): string {
@@ -24,12 +25,80 @@ export function numberOption(value: any, fallback: number): number {
24
25
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
25
26
  }
26
27
 
28
+ export function optionalNumber(call: ToolCall, key: string): number | undefined {
29
+ const value = call[key];
30
+ if (value === undefined || value === null)
31
+ return undefined;
32
+ if (typeof value !== "number" || !Number.isFinite(value))
33
+ throw new Error(`${key} must be a finite number`);
34
+ return value;
35
+ }
36
+
37
+ export function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
38
+ if (value === undefined)
39
+ return fallback;
40
+ return Math.min(max, Math.max(min, Math.floor(value)));
41
+ }
42
+
43
+ export function booleanOption(value: any, fallback = false): boolean {
44
+ return typeof value === "boolean" ? value : fallback;
45
+ }
46
+
47
+ export const MAX_TIMEOUT_MS = 2147483647;
48
+
49
+ /**
50
+ * Normalize a tool `timeoutMs` value.
51
+ *
52
+ * Only `null`/`undefined` mean "not specified". Any other non-number, NaN,
53
+ * Infinity, or negative value is a caller error and must be surfaced instead
54
+ * of silently falling back. Large finite values are clamped to the
55
+ * `setTimeout` ceiling (2^31-1) rather than overflowing to 1ms.
56
+ */
57
+ export function normalizeTimeoutMs(value: unknown, fallback = 120000): number {
58
+ if (value === undefined || value === null)
59
+ return fallback;
60
+ if (typeof value !== "number") {
61
+ throw new TypeError(`timeoutMs must be a finite number; received ${describeValue(value)}`);
62
+ }
63
+ if (!Number.isFinite(value)) {
64
+ throw new TypeError(`timeoutMs must be a finite number; received ${describeValue(value)}`);
65
+ }
66
+ if (value < 0) {
67
+ throw new TypeError(`timeoutMs must not be negative; received ${describeValue(value)}`);
68
+ }
69
+ const floored = Math.floor(value);
70
+ const clamped = Math.min(floored, MAX_TIMEOUT_MS);
71
+ if (clamped !== floored)
72
+ debugLog("timeout", `timeoutMs ${value} clamped to ${clamped}`);
73
+ return clamped;
74
+ }
75
+
76
+ function describeValue(value: unknown): string {
77
+ if (typeof value === "string")
78
+ return `string ${JSON.stringify(value)}`;
79
+ if (typeof value === "number")
80
+ return String(value);
81
+ if (typeof value === "bigint")
82
+ return `bigint ${value}n`;
83
+ if (typeof value === "object" && value !== null) {
84
+ try {
85
+ return `object ${JSON.stringify(value)}`;
86
+ }
87
+ catch {
88
+ return "object";
89
+ }
90
+ }
91
+ return String(value);
92
+ }
93
+
27
94
  export function preparePath(cwd: string, raw: string): Prepared {
28
95
  try {
29
96
  sandboxPath(cwd, raw);
30
97
  return { ok: true };
31
98
  }
32
99
  catch (err: any) {
33
- return invalid(err?.message ?? String(err));
100
+ const message = err?.message ?? String(err);
101
+ const code = /outside sandbox/i.test(message) ? "SANDBOX_DENY" : "INVALID_ARGS";
102
+ return invalid(message, code, /outside sandbox/i.test(message) ? "Use a path inside the project or an allowed root." : undefined);
34
103
  }
35
104
  }
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { mkdir, writeFile } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
3
4
  import { resolvePath } from "./paths.js";
@@ -21,8 +22,9 @@ export const writeFileTool: Tool = {
21
22
  },
22
23
  async execute(call, ctx) {
23
24
  const file = resolvePath(ctx.cwd, call);
25
+ const existed = existsSync(file);
24
26
  await mkdir(dirname(file), { recursive: true });
25
27
  await writeFile(file, requiredString(call, "content"), "utf8");
26
- return { ok: true, output: `wrote ${file}`, sideEffects: { touched: [file] } };
28
+ return { ok: true, output: `wrote ${file}`, sideEffects: existed ? { touched: [file] } : { created: [file] } };
27
29
  },
28
30
  };