@gaunt-sloth/agent 2.0.0-alpha.3 → 2.0.0-alpha.30

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 (96) hide show
  1. package/README.md +73 -23
  2. package/dist/builtInToolsConfig.d.ts +17 -3
  3. package/dist/builtInToolsConfig.js +40 -41
  4. package/dist/builtInToolsConfig.js.map +1 -1
  5. package/dist/core/GthDeepAgent.d.ts +52 -32
  6. package/dist/core/GthDeepAgent.js +438 -198
  7. package/dist/core/GthDeepAgent.js.map +1 -1
  8. package/dist/core/debugCapture.d.ts +8 -49
  9. package/dist/core/debugCapture.js +1 -1
  10. package/dist/core/debugCapture.js.map +1 -1
  11. package/dist/core/deepAgentPermissions.d.ts +67 -2
  12. package/dist/core/deepAgentPermissions.js +151 -3
  13. package/dist/core/deepAgentPermissions.js.map +1 -1
  14. package/dist/core/gthAcpServer.js +11 -0
  15. package/dist/core/gthAcpServer.js.map +1 -1
  16. package/dist/core/gthDeepAgentFactory.d.ts +3 -0
  17. package/dist/core/gthDeepAgentFactory.js +9 -1
  18. package/dist/core/gthDeepAgentFactory.js.map +1 -1
  19. package/dist/core/resolveAgentFactory.d.ts +14 -0
  20. package/dist/core/resolveAgentFactory.js +18 -0
  21. package/dist/core/resolveAgentFactory.js.map +1 -0
  22. package/dist/core/subagentProfiles.d.ts +50 -0
  23. package/dist/core/subagentProfiles.js +76 -0
  24. package/dist/core/subagentProfiles.js.map +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp/OAuthClientProviderImpl.js.map +1 -1
  29. package/dist/mcp/tlsTrust.d.ts +61 -0
  30. package/dist/mcp/tlsTrust.js +109 -0
  31. package/dist/mcp/tlsTrust.js.map +1 -0
  32. package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
  33. package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
  34. package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
  35. package/dist/middleware/frontendImageInjectionMiddleware.d.ts +106 -0
  36. package/dist/middleware/frontendImageInjectionMiddleware.js +166 -0
  37. package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
  38. package/dist/middleware/registry.js +36 -1
  39. package/dist/middleware/registry.js.map +1 -1
  40. package/dist/middleware/types.d.ts +16 -2
  41. package/dist/modules/a2a/A2AClientWrapper.d.ts +56 -1
  42. package/dist/modules/a2a/A2AClientWrapper.js +131 -31
  43. package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
  44. package/dist/modules/acpModule.js +17 -1
  45. package/dist/modules/acpModule.js.map +1 -1
  46. package/dist/modules/apiAgUiModule.d.ts +87 -0
  47. package/dist/modules/apiAgUiModule.js +229 -28
  48. package/dist/modules/apiAgUiModule.js.map +1 -1
  49. package/dist/modules/interactiveSessionModule.js +501 -41
  50. package/dist/modules/interactiveSessionModule.js.map +1 -1
  51. package/dist/modules/slashCommands.d.ts +576 -0
  52. package/dist/modules/slashCommands.js +995 -0
  53. package/dist/modules/slashCommands.js.map +1 -0
  54. package/dist/resolvers.js +119 -9
  55. package/dist/resolvers.js.map +1 -1
  56. package/dist/tools/GthCustomToolkit.js +95 -11
  57. package/dist/tools/GthCustomToolkit.js.map +1 -1
  58. package/dist/tools/GthDevToolkit.d.ts +11 -3
  59. package/dist/tools/GthDevToolkit.js +120 -34
  60. package/dist/tools/GthDevToolkit.js.map +1 -1
  61. package/dist/tools/GthFileSystemToolkit.d.ts +63 -0
  62. package/dist/tools/GthFileSystemToolkit.js +506 -105
  63. package/dist/tools/GthFileSystemToolkit.js.map +1 -1
  64. package/dist/tools/McpResourceTool.d.ts +31 -0
  65. package/dist/tools/McpResourceTool.js +106 -0
  66. package/dist/tools/McpResourceTool.js.map +1 -0
  67. package/dist/tools/gthChecklistTool.d.ts +30 -0
  68. package/dist/tools/gthChecklistTool.js +81 -0
  69. package/dist/tools/gthChecklistTool.js.map +1 -0
  70. package/dist/tools/gthGrepTool.d.ts +54 -0
  71. package/dist/tools/gthGrepTool.js +482 -0
  72. package/dist/tools/gthGrepTool.js.map +1 -0
  73. package/dist/tools/shell/env.js +1 -1
  74. package/dist/tools/shell/env.js.map +1 -1
  75. package/dist/tools/shell/outputBuffer.js.map +1 -1
  76. package/dist/tools/shell/workDir.d.ts +11 -0
  77. package/dist/tools/shell/workDir.js +45 -0
  78. package/dist/tools/shell/workDir.js.map +1 -0
  79. package/dist/utils/mcpAuthError.d.ts +36 -0
  80. package/dist/utils/mcpAuthError.js +89 -0
  81. package/dist/utils/mcpAuthError.js.map +1 -0
  82. package/dist/utils/mcpUtils.js +16 -0
  83. package/dist/utils/mcpUtils.js.map +1 -1
  84. package/package.json +12 -11
  85. package/dist/tools/shell/allowlist.d.ts +0 -11
  86. package/dist/tools/shell/allowlist.js +0 -12
  87. package/dist/tools/shell/allowlist.js.map +0 -1
  88. package/dist/tools/shell/arity.d.ts +0 -11
  89. package/dist/tools/shell/arity.js +0 -12
  90. package/dist/tools/shell/arity.js.map +0 -1
  91. package/dist/tools/shell/hardline.d.ts +0 -15
  92. package/dist/tools/shell/hardline.js +0 -88
  93. package/dist/tools/shell/hardline.js.map +0 -1
  94. package/dist/tools/shell/normalize.d.ts +0 -10
  95. package/dist/tools/shell/normalize.js +0 -11
  96. package/dist/tools/shell/normalize.js.map +0 -1
@@ -4,7 +4,6 @@ import fs from 'fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'os';
6
6
  import { createTwoFilesPatch } from 'diff';
7
- import { displayInfo } from '@gaunt-sloth/core/utils/consoleUtils.js';
8
7
  import { shouldIgnoreFile } from '@gaunt-sloth/core/utils/aiignoreUtils.js';
9
8
  import { getCurrentWorkDir } from '@gaunt-sloth/core/utils/systemUtils.js';
10
9
  import { getFormatForExtension, getMimeType, readBinaryFile } from '#src/tools/binaryUtils.js';
@@ -14,6 +13,17 @@ import { getFormatForExtension, getMimeType, readBinaryFile } from '#src/tools/b
14
13
  */
15
14
  // TODO make it configurable
16
15
  const IGNORED_DIRS = ['node_modules', '.git', '.idea', 'dist'];
16
+ // read_file safety envelope (GS2-39). A single large file — or a minified bundle that is one
17
+ // multi-MB line — must not consume the whole context window in one uncapped read. Defaults are
18
+ // adopted from opencode's read tool (packages/core/src/tool/read-filesystem.ts): at most
19
+ // MAX_READ_LINES lines or MAX_READ_BYTES bytes are returned, and any single line longer than
20
+ // MAX_LINE_LENGTH characters is truncated with LINE_TRUNCATED_SUFFIX. offset/limit paging is the
21
+ // escape hatch to fetch whatever the cap dropped. This bounds context, not process memory: like
22
+ // the existing head/tail helpers the file is still read whole and then trimmed.
23
+ const MAX_READ_LINES = 2000;
24
+ const MAX_READ_BYTES = 50 * 1024;
25
+ const MAX_LINE_LENGTH = 2000;
26
+ const LINE_TRUNCATED_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
17
27
  // Helper function to create a tool with filesystem type
18
28
  function createGthTool(fn, config, gthFileSystemType) {
19
29
  const toolInstance = tool(fn, config);
@@ -24,8 +34,33 @@ function createGthTool(fn, config, gthFileSystemType) {
24
34
  // Schema definitions
25
35
  const ReadFileArgsSchema = z.object({
26
36
  path: z.string(),
27
- tail: z.number().optional().describe('If provided, returns only the last N lines of the file'),
28
- head: z.number().optional().describe('If provided, returns only the first N lines of the file'),
37
+ offset: z
38
+ .number()
39
+ .optional()
40
+ .describe('Start of a 1-based line window: the first line to return (inclusive). Combine with ' +
41
+ '`limit` to page through a large file (e.g. offset 2001 continues past the default ' +
42
+ `${MAX_READ_LINES}-line cap). Mutually exclusive with head/tail — pass a line window ` +
43
+ '(offset/limit) OR head/tail, not both.'),
44
+ limit: z
45
+ .number()
46
+ .optional()
47
+ .describe('Number of lines to return starting at `offset` (defaults to and is hard-capped at ' +
48
+ `${MAX_READ_LINES}). Use with \`offset\` to read an arbitrary window. Mutually ` +
49
+ 'exclusive with head/tail.'),
50
+ tail: z
51
+ .number()
52
+ .optional()
53
+ .describe('If provided, returns only the last N lines of the file. May be combined with head ' +
54
+ '(no longer mutually exclusive): head+tail returns the first head lines, a ' +
55
+ '`... [N lines skipped] ...` marker, then the last tail lines. Mutually exclusive ' +
56
+ 'with the offset/limit line window.'),
57
+ head: z
58
+ .number()
59
+ .optional()
60
+ .describe('If provided, returns only the first N lines of the file. May be combined with tail ' +
61
+ '(no longer mutually exclusive): head+tail returns the first head lines, a ' +
62
+ '`... [N lines skipped] ...` marker, then the last tail lines. Mutually exclusive ' +
63
+ 'with the offset/limit line window.'),
29
64
  });
30
65
  const ReadBinaryArgsSchema = z.object({
31
66
  path: z.string().describe('Path to the binary file to read'),
@@ -44,6 +79,12 @@ const WriteFileArgsSchema = z.object({
44
79
  const EditOperation = z.object({
45
80
  oldText: z.string().describe('Text to search for - must match exactly'),
46
81
  newText: z.string().describe('Text to replace with'),
82
+ replaceAll: z
83
+ .boolean()
84
+ .default(false)
85
+ .describe('Replace all occurrences of oldText. Default false requires a unique match: if oldText ' +
86
+ 'appears more than once the edit fails so you can add surrounding context to disambiguate; ' +
87
+ 'set true to replace every occurrence.'),
47
88
  });
48
89
  const EditFileArgsSchema = z.object({
49
90
  path: z.string(),
@@ -274,14 +315,50 @@ export default class GthFileSystemToolkit extends BaseToolkit {
274
315
  const normalizedNew = this.normalizeLineEndings(newContent);
275
316
  return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified');
276
317
  }
318
+ /**
319
+ * Count non-overlapping exact occurrences of `search` in `content`.
320
+ * Uses an indexOf scan (no regex) so arbitrary text is matched literally.
321
+ */
322
+ countOccurrences(content, search) {
323
+ if (search === '')
324
+ return 0;
325
+ let count = 0;
326
+ let offset = 0;
327
+ let idx = content.indexOf(search, offset);
328
+ while (idx !== -1) {
329
+ count++;
330
+ offset = idx + search.length;
331
+ idx = content.indexOf(search, offset);
332
+ }
333
+ return count;
334
+ }
277
335
  async applyFileEdits(filePath, edits, dryRun = false) {
278
336
  const content = this.normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
279
337
  let modifiedContent = content;
338
+ const summaries = [];
339
+ let editIndex = 0;
280
340
  for (const edit of edits) {
341
+ editIndex++;
281
342
  const normalizedOld = this.normalizeLineEndings(edit.oldText);
282
343
  const normalizedNew = this.normalizeLineEndings(edit.newText);
283
- if (modifiedContent.includes(normalizedOld)) {
284
- modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
344
+ const occurrences = this.countOccurrences(modifiedContent, normalizedOld);
345
+ if (occurrences > 0) {
346
+ if (occurrences > 1 && edit.replaceAll !== true) {
347
+ // Ambiguous match: refuse rather than silently editing the first occurrence. Thrown here
348
+ // and caught at the tool boundary (GS2-36), which surfaces it as a recoverable errored
349
+ // result naming the path — so the model can add surrounding context and retry instead of
350
+ // the run aborting. Phrased as an action-oriented recovery hint (EXT-34 hermes style).
351
+ throw new Error(`found ${occurrences} occurrences of the search text, so the edit is ambiguous and the ` +
352
+ 'file was NOT modified. Add more surrounding context to make the match unique, or set ' +
353
+ 'replaceAll: true to replace every occurrence.\n' +
354
+ edit.oldText);
355
+ }
356
+ // split/join replaces every occurrence without regex escaping and, unlike
357
+ // String.replace, does NOT interpret `$`-patterns ($&, $`, $', $$) in the
358
+ // replacement text; for a unique match (occurrences === 1) it replaces exactly
359
+ // that one occurrence.
360
+ modifiedContent = modifiedContent.split(normalizedOld).join(normalizedNew);
361
+ summaries.push(`edit ${editIndex}: replaced ${occurrences} occurrence(s)`);
285
362
  continue;
286
363
  }
287
364
  const oldLines = normalizedOld.split('\n');
@@ -313,8 +390,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
313
390
  }
314
391
  }
315
392
  if (!matchFound) {
316
- throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
393
+ // No occurrence of the search text (exact or fuzzy) — thrown here and caught at the tool
394
+ // boundary (GS2-36) so it reaches the model as a recoverable errored result naming the
395
+ // path, not a fatal abort. Action-oriented recovery hint (EXT-34 hermes style): re-read to
396
+ // get the exact current text, or fall back to a whole-file write_file.
397
+ throw new Error('no occurrence of the search text was found, so the file was NOT modified. Re-read the ' +
398
+ 'file to get its exact current text (whitespace and indentation must match), then retry ' +
399
+ 'the edit, or use write_file to replace the whole file.\n' +
400
+ edit.oldText);
317
401
  }
402
+ // Fuzzy (trim-based line) match applied: flag it so the model knows the edit
403
+ // landed on a near-match, not an exact one.
404
+ summaries.push(`edit ${editIndex}: applied via fuzzy line-match (no exact match)`);
318
405
  }
319
406
  const diff = this.createUnifiedDiff(content, modifiedContent, filePath);
320
407
  let numBackticks = 3;
@@ -325,7 +412,9 @@ export default class GthFileSystemToolkit extends BaseToolkit {
325
412
  if (!dryRun) {
326
413
  await fs.writeFile(filePath, modifiedContent, 'utf-8');
327
414
  }
328
- return formattedDiff;
415
+ // Per-edit machine-readable summary, prepended so the diff block stays intact.
416
+ const summaryBlock = summaries.length > 0 ? `${summaries.join('\n')}\n\n` : '';
417
+ return summaryBlock + formattedDiff;
329
418
  }
330
419
  formatSize(bytes) {
331
420
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -349,6 +438,7 @@ export default class GthFileSystemToolkit extends BaseToolkit {
349
438
  let chunk = Buffer.alloc(CHUNK_SIZE);
350
439
  let linesFound = 0;
351
440
  let remainingText = '';
441
+ let firstChunk = true;
352
442
  while (position > 0 && linesFound < numLines) {
353
443
  const size = Math.min(CHUNK_SIZE, position);
354
444
  position -= size;
@@ -357,7 +447,17 @@ export default class GthFileSystemToolkit extends BaseToolkit {
357
447
  break;
358
448
  const readData = chunk.slice(0, bytesRead).toString('utf-8');
359
449
  const chunkText = readData + remainingText;
360
- const chunkLines = this.normalizeLineEndings(chunkText).split('\n');
450
+ const normalizedChunk = this.normalizeLineEndings(chunkText);
451
+ const chunkLines = normalizedChunk.split('\n');
452
+ // We read backward from EOF, so the file's terminating newline (if any) is only
453
+ // ever in this first chunk. Its split yields a phantom trailing empty element that
454
+ // must not be counted as the last line. Drop exactly one here so tail counting
455
+ // matches head/full reads; a file without a trailing newline is untouched and a
456
+ // genuine blank last line ("...\n\n") is preserved (only one element removed).
457
+ if (firstChunk && normalizedChunk.endsWith('\n')) {
458
+ chunkLines.pop();
459
+ }
460
+ firstChunk = false;
361
461
  if (position > 0) {
362
462
  remainingText = chunkLines[0];
363
463
  chunkLines.shift();
@@ -406,12 +506,150 @@ export default class GthFileSystemToolkit extends BaseToolkit {
406
506
  await fileHandle.close();
407
507
  }
408
508
  }
509
+ /**
510
+ * Returns the first `head` lines and the last `tail` lines of a file, separated by a
511
+ * `... [N lines skipped] ...` marker. When the two windows cover or overlap the whole
512
+ * file (`head + tail >= total`) the entire file is returned unchanged (no marker, no
513
+ * duplicated lines).
514
+ */
515
+ async headAndTailFile(filePath, head, tail) {
516
+ const content = await fs.readFile(filePath, 'utf-8');
517
+ const normalized = this.normalizeLineEndings(content);
518
+ const lines = normalized.split('\n');
519
+ // A terminating newline yields a phantom trailing empty element that would be
520
+ // miscounted as a real line (inflating `total`, so the true last line gets pushed
521
+ // into the "skipped" gap and the tail comes back empty). Drop exactly one when the
522
+ // file ends in a newline so counting/slicing matches head/full reads. Files without a
523
+ // trailing newline are untouched, and a genuine blank last line ("...\n\n") is
524
+ // preserved because only a single element is removed.
525
+ if (normalized.endsWith('\n')) {
526
+ lines.pop();
527
+ }
528
+ const total = lines.length;
529
+ if (head + tail >= total) {
530
+ return content;
531
+ }
532
+ const skipped = total - head - tail;
533
+ const headContent = lines.slice(0, head).join('\n');
534
+ const tailContent = lines.slice(total - tail).join('\n');
535
+ return headContent + '\n... [' + skipped + ' lines skipped] ...\n' + tailContent;
536
+ }
537
+ /**
538
+ * Return the file's lines from the 1-based `offset` to EOF (GS2-39 paging). Deliberately does
539
+ * NOT apply `limit` here — the caller hands the result to capReadContent with maxLines = limit so
540
+ * that a limit-bounded read is *observable* to the envelope (it can then tell "more file remains
541
+ * past this window" from "reached EOF" and emit the correct resume notice). Pre-slicing to
542
+ * exactly `limit` lines here would hide that signal and silently drop the continuation.
543
+ *
544
+ * Mirrors headAndTailFile's trailing-newline handling so line numbering matches head/tail/full
545
+ * reads: a terminating newline yields a phantom trailing empty element that must not be counted
546
+ * as a real line. Clamps rather than throwing: an offset past EOF returns a short recoverable note
547
+ * (not an empty string, which is indistinguishable from an empty file).
548
+ */
549
+ async windowFile(filePath, offset) {
550
+ const content = await fs.readFile(filePath, 'utf-8');
551
+ const normalized = this.normalizeLineEndings(content);
552
+ const lines = normalized.split('\n');
553
+ if (normalized.endsWith('\n')) {
554
+ lines.pop();
555
+ }
556
+ const total = lines.length;
557
+ if (total === 0) {
558
+ return '';
559
+ }
560
+ const start = offset - 1; // offset is >= 1 (clamped by the caller)
561
+ if (start >= total) {
562
+ return `[read_file: offset ${offset} is past end of file (${total} lines total)]`;
563
+ }
564
+ return lines.slice(start).join('\n');
565
+ }
566
+ /**
567
+ * Apply the read safety envelope (GS2-39) to already-selected file content:
568
+ * - any single line longer than MAX_LINE_LENGTH is truncated with LINE_TRUNCATED_SUFFIX;
569
+ * - emission stops once `maxLines` lines OR MAX_READ_BYTES bytes have been produced.
570
+ *
571
+ * When nothing exceeds the caps the content is returned verbatim (byte-identical), so normal
572
+ * files behave exactly as before — the change only bites on pathological / huge files.
573
+ *
574
+ * Three truncation signals are kept deliberately separate:
575
+ * - a per-line cut is marked only by the inline LINE_TRUNCATED_SUFFIX (no global notice);
576
+ * - the BYTE cap always appends the global "output truncated" notice, because it is the hard
577
+ * safety envelope regardless of who asked for the read;
578
+ * - the LINE cap appends the global notice only when `lineCapIsHard` is true — i.e. `maxLines`
579
+ * is the hard MAX_READ_LINES envelope (a defaulted or clamped-down window, or the whole-file
580
+ * read), NOT a deliberate small explicit `limit`. This distinguishes a *capped continuation*
581
+ * (notify so the model can page) from a *satisfied explicit window* (stay quiet).
582
+ *
583
+ * `startLine` is the 1-based file line of the first line in `content`; it is used only to put a
584
+ * concrete resume `offset` in the global notice. Pass `undefined` (head/tail paths, where the
585
+ * first emitted line is not line `startLine` of the file) to emit a generic notice instead.
586
+ */
587
+ capReadContent(content, maxLines, startLine, lineCapIsHard) {
588
+ const lines = content.split('\n');
589
+ const withinBytes = Buffer.byteLength(content, 'utf-8') <= MAX_READ_BYTES;
590
+ const withinLines = lines.length <= maxLines;
591
+ const noLongLine = lines.every((line) => line.length <= MAX_LINE_LENGTH);
592
+ if (withinBytes && withinLines && noLongLine) {
593
+ return content;
594
+ }
595
+ const out = [];
596
+ let bytes = 0;
597
+ let stoppedByLine = false;
598
+ let stoppedByByte = false;
599
+ for (const rawLine of lines) {
600
+ if (out.length >= maxLines) {
601
+ stoppedByLine = true;
602
+ break;
603
+ }
604
+ const line = rawLine.length > MAX_LINE_LENGTH
605
+ ? rawLine.slice(0, MAX_LINE_LENGTH) + LINE_TRUNCATED_SUFFIX
606
+ : rawLine;
607
+ // +1 accounts for the '\n' that re-joins this line to the previous one. Always emit at
608
+ // least one (already per-line-truncated) line so the result is never empty.
609
+ const size = Buffer.byteLength(line, 'utf-8') + (out.length > 0 ? 1 : 0);
610
+ if (out.length > 0 && bytes + size > MAX_READ_BYTES) {
611
+ stoppedByByte = true;
612
+ break;
613
+ }
614
+ out.push(line);
615
+ bytes += size;
616
+ }
617
+ let result = out.join('\n');
618
+ // The byte cap is a hard envelope and always warrants a notice; the line cap warrants one only
619
+ // when it represents the hard MAX_READ_LINES envelope, not a deliberate explicit `limit`.
620
+ if (stoppedByByte || (stoppedByLine && lineCapIsHard)) {
621
+ const nextOffset = startLine !== undefined ? startLine + out.length : undefined;
622
+ const resume = nextOffset !== undefined
623
+ ? `resume with offset:${nextOffset} (and an optional limit)`
624
+ : 'use offset/limit to page through the rest';
625
+ result += `\n... [read_file: output truncated at the ${MAX_READ_LINES}-line / ${MAX_READ_BYTES / 1024} KB read cap — ${resume}] ...`;
626
+ }
627
+ return result;
628
+ }
629
+ /**
630
+ * GS2-36 — turn a fatal tool throw into a recoverable, action-oriented errored result.
631
+ *
632
+ * A filesystem tool that throws (a denied/symlinked-out path from validatePath, a missing file,
633
+ * EISDIR, permission-denied, a directory-not-empty refusal, …) otherwise propagates to
634
+ * GthAgentRunner as a fatal `Stream processing failed` and crashes the whole turn. The house
635
+ * convention (AGENTS.md: a tool result must never abort a run; the write_file precedent in
636
+ * `5892801a`) is to return the failure to the model as a plain string it can act on. This wraps
637
+ * that string in a uniform `Error <action>: <message>` shape — the underlying fs/validation
638
+ * message (ENOENT / EISDIR / EACCES / "Access denied - …") is itself the actionable detail, and
639
+ * the deliberately-thrown logical messages (edit_file no-match/ambiguous, "Directory not empty",
640
+ * "Cannot delete protected directory") are already phrased as recovery hints. EXT-14 containment
641
+ * is preserved everywhere: validatePath runs first and throws on a symlink-out BEFORE any fs
642
+ * mutation, so the mutation never happens — the denial is merely surfaced as a result here.
643
+ */
644
+ recoverableError(action, error) {
645
+ const message = error instanceof Error ? error.message : String(error);
646
+ return `Error ${action}: ${message}`;
647
+ }
409
648
  createReadBinaryTool() {
410
649
  return createGthTool(async (args) => {
411
650
  if (!this.binaryFormats || !Array.isArray(this.binaryFormats)) {
412
651
  return 'Binary formats are not configured. Add binaryFormats to your config to enable this feature.';
413
652
  }
414
- displayInfo(`\nšŸ“ Reading binary file: ${args.path}\n`);
415
653
  let validPath;
416
654
  try {
417
655
  validPath = await this.validatePath(args.path);
@@ -468,35 +706,97 @@ export default class GthFileSystemToolkit extends BaseToolkit {
468
706
  createTools() {
469
707
  const tools = [
470
708
  createGthTool(async (args) => {
471
- displayInfo(`\nšŸ“ Reading file: ${args.path}\n`);
472
- const validPath = await this.validatePath(args.path);
473
- if (args.head && args.tail) {
474
- throw new Error('Cannot specify both head and tail parameters simultaneously');
709
+ // TUI-C32 residual b — the legacy `šŸ“ Reading file: …` notice is dropped: the surface-
710
+ // agnostic tool-call block (`āœ“ šŸ“ read_file(path=…)`, TUI-C30) now announces the op once
711
+ // on both surfaces, so the old notice was a DL-10 double announcement.
712
+ const hasWindow = args.offset !== undefined || args.limit !== undefined;
713
+ const hasHeadTail = Boolean(args.head) || Boolean(args.tail);
714
+ // Argument guard first (pure, no filesystem access): the line window (offset/limit) and
715
+ // head/tail are two different slice mechanisms, so combining them is ambiguous. Return a
716
+ // recoverable string the model can act on rather than throwing — a thrown tool result
717
+ // aborts the whole agent run (the GS2-36 anti-pattern).
718
+ if (hasWindow && hasHeadTail) {
719
+ return ('read_file: pass a line window (offset/limit) OR head/tail, not both. ' +
720
+ 'Use offset/limit to read an arbitrary range (offset = 1-based first line, ' +
721
+ 'limit = number of lines); use head/tail for the first/last N lines.');
475
722
  }
476
- if (args.tail) {
477
- return await this.tailFile(validPath, args.tail);
723
+ // GS2-36: a missing file, a denied / symlinked-out path (validatePath throws before any
724
+ // read), or a read failure (EISDIR, EACCES) becomes a recoverable errored result the
725
+ // model can act on, instead of a fatal `Stream processing failed` that aborts the run.
726
+ try {
727
+ const validPath = await this.validatePath(args.path);
728
+ // Line window (offset/limit). offset defaults to 1; limit defaults to and is hard-capped
729
+ // at MAX_READ_LINES. windowFile returns from offset to EOF and capReadContent (maxLines =
730
+ // limit) does the slicing, so a limit-bounded read that leaves more file behind is
731
+ // *observable* and gets a resume notice. That notice fires only when the limit is the
732
+ // hard envelope — defaulted, or an explicit limit clamped down to MAX_READ_LINES — so a
733
+ // deliberately small explicit window (fully satisfied) stays quiet.
734
+ if (hasWindow) {
735
+ const offset = args.offset !== undefined ? Math.max(1, Math.trunc(args.offset)) : 1;
736
+ const limitWasDefaulted = args.limit === undefined;
737
+ const limitWasClamped = args.limit !== undefined && Math.trunc(args.limit) > MAX_READ_LINES;
738
+ const limit = limitWasDefaulted
739
+ ? MAX_READ_LINES
740
+ : Math.min(Math.max(1, Math.trunc(args.limit)), MAX_READ_LINES);
741
+ const lineCapIsHard = limitWasDefaulted || limitWasClamped;
742
+ const windowed = await this.windowFile(validPath, offset);
743
+ return this.capReadContent(windowed, limit, offset, lineCapIsHard);
744
+ }
745
+ // head/tail paths respect the explicit line count the model asked for (maxLines =
746
+ // Infinity, so the line cap never fires — lineCapIsHard is moot), but still get the
747
+ // per-line and byte safety envelope — e.g. a `tail 1` that returns one multi-MB minified
748
+ // line is still truncated. No resume offset is emitted for these paths because the first
749
+ // emitted line is not line 1 of the file.
750
+ if (args.head && args.tail) {
751
+ return this.capReadContent(await this.headAndTailFile(validPath, args.head, args.tail), Number.POSITIVE_INFINITY, undefined, false);
752
+ }
753
+ if (args.tail) {
754
+ return this.capReadContent(await this.tailFile(validPath, args.tail), Number.POSITIVE_INFINITY, undefined, false);
755
+ }
756
+ if (args.head) {
757
+ return this.capReadContent(await this.headFile(validPath, args.head), Number.POSITIVE_INFINITY, undefined, false);
758
+ }
759
+ // Default whole-file read: capped at MAX_READ_LINES / MAX_READ_BYTES with per-line
760
+ // truncation (the line cap here IS the hard envelope). Files under the caps come back
761
+ // byte-identical to the old uncapped read.
762
+ return this.capReadContent(await fs.readFile(validPath, 'utf-8'), MAX_READ_LINES, 1, true);
478
763
  }
479
- if (args.head) {
480
- return await this.headFile(validPath, args.head);
764
+ catch (error) {
765
+ return this.recoverableError(`reading file ${args.path}`, error);
481
766
  }
482
- return await fs.readFile(validPath, 'utf-8');
483
767
  }, {
484
768
  name: 'read_file',
485
769
  description: 'Read the complete contents of a file from the file system. ' +
486
770
  'Handles various text encodings and provides detailed error messages ' +
487
771
  'if the file cannot be read. Use this tool when you need to examine ' +
488
- "the contents of a single file. Use the 'head' parameter to read only " +
489
- "the first N lines of a file, or the 'tail' parameter to read only " +
490
- 'the last N lines of a file. Only works within allowed directories.',
772
+ 'the contents of a single file. ' +
773
+ `Large reads are capped for safety: at most ${MAX_READ_LINES} lines or ` +
774
+ `${MAX_READ_BYTES / 1024} KB are returned, and any line longer than ${MAX_LINE_LENGTH} ` +
775
+ 'characters is truncated with a `... (line truncated to N chars)` marker; when a read ' +
776
+ 'is capped a `... [read_file: output truncated ...] ...` notice is appended. ' +
777
+ 'To read past the cap or fetch an arbitrary range, use `offset` (1-based first line) ' +
778
+ 'and `limit` (number of lines). ' +
779
+ "Alternatively use the 'head' parameter to read only the first N lines of a file, or " +
780
+ "the 'tail' parameter to read only the last N lines; 'head' and 'tail' may be combined " +
781
+ 'to get the first N lines, a `... [N lines skipped] ...` marker, and the last M lines. ' +
782
+ 'The offset/limit line window and head/tail are mutually exclusive — pass one or the ' +
783
+ 'other, not both. Only works within allowed directories.',
491
784
  schema: ReadFileArgsSchema,
492
785
  }, 'read'),
493
786
  createGthTool(async (args) => {
494
- displayInfo(`\nšŸ“ Reading ${args.paths.length} files\n`);
495
787
  const results = await Promise.all(args.paths.map(async (filePath) => {
496
788
  try {
497
789
  const validPath = await this.validatePath(filePath);
498
790
  const content = await fs.readFile(validPath, 'utf-8');
499
- return `${filePath}:\n${content}\n`;
791
+ // Apply the same whole-file read safety envelope as read_file's default path
792
+ // (GS2-39/GS2-52): a single oversized file — or a one-line minified bundle — in a
793
+ // multi-file read must not blow the context window. maxLines = MAX_READ_LINES,
794
+ // startLine = 1, lineCapIsHard = true mirror read_file's whole-file read, so a
795
+ // capped file carries the "resume with offset:N" notice; the model pages the rest
796
+ // with read_file on that path. Sub-cap files come back byte-identical (verbatim
797
+ // fast-path). There is no offset/limit here on purpose — read_file is the pager.
798
+ const capped = this.capReadContent(content, MAX_READ_LINES, 1, true);
799
+ return `${filePath}:\n${capped}\n`;
500
800
  }
501
801
  catch (error) {
502
802
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -510,25 +810,61 @@ export default class GthFileSystemToolkit extends BaseToolkit {
510
810
  'efficient than reading files one by one when you need to analyze ' +
511
811
  "or compare multiple files. Each file's content is returned with its " +
512
812
  "path as a reference. Failed reads for individual files won't stop " +
513
- 'the entire operation. Only works within allowed directories.',
813
+ 'the entire operation. ' +
814
+ `Each file is subject to the same per-file read cap as read_file: at most ${MAX_READ_LINES} ` +
815
+ `lines or ${MAX_READ_BYTES / 1024} KB are returned and any line longer than ` +
816
+ `${MAX_LINE_LENGTH} characters is truncated; when a file is capped a ` +
817
+ '`... [read_file: output truncated ...] ...` notice is appended — page past it by ' +
818
+ 'reading that file individually with read_file (offset/limit). ' +
819
+ 'Only works within allowed directories.',
514
820
  schema: ReadMultipleFilesArgsSchema,
515
821
  }, 'read'),
516
822
  createGthTool(async (args) => {
517
- displayInfo(`\nšŸ“ Writing file: ${args.path}\n`);
518
- const validPath = await this.validatePath(args.path);
519
- await fs.writeFile(validPath, args.content, 'utf-8');
520
- return `Successfully wrote to ${args.path}`;
823
+ // TUI-C32 residual b — legacy `šŸ“ Writing file: …` notice dropped (see read_file above);
824
+ // the TUI-C30 tool-call block announces the write once on both surfaces.
825
+ try {
826
+ const validPath = await this.validatePath(args.path);
827
+ // Create any missing parent directories (mkdir -p) so a write into a
828
+ // not-yet-created subdirectory succeeds instead of throwing ENOENT. The
829
+ // model expects the near-universal agent-tool `mkdir -p` semantics and
830
+ // routinely writes a new file before creating its dir. validatePath has
831
+ // already confirmed the nearest existing ancestor is inside the sandbox,
832
+ // so the new directories are created within an allowed root.
833
+ await fs.mkdir(path.dirname(validPath), { recursive: true });
834
+ await fs.writeFile(validPath, args.content, 'utf-8');
835
+ return `Successfully wrote to ${args.path}`;
836
+ }
837
+ catch (error) {
838
+ // Surface the failure to the model as a recoverable tool result rather
839
+ // than throwing, which would abort the entire agent run (GS2-36). The
840
+ // model can read the message and adjust (e.g. the path is a directory,
841
+ // or is denied by the sandbox) instead of the session dying.
842
+ const message = error instanceof Error ? error.message : String(error);
843
+ return `Error writing file ${args.path}: ${message}`;
844
+ }
521
845
  }, {
522
846
  name: 'write_file',
523
847
  description: 'Create a new file or completely overwrite an existing file with new content. ' +
848
+ 'Missing parent directories are created automatically. ' +
524
849
  'Use with caution as it will overwrite existing files without warning. ' +
525
850
  'Handles text content with proper encoding. Only works within allowed directories.',
526
851
  schema: WriteFileArgsSchema,
527
852
  }, 'write'),
528
853
  createGthTool(async (args) => {
529
- displayInfo(`\nšŸ“ Editing file: ${args.path}\n`);
530
- const validPath = await this.validatePath(args.path);
531
- return await this.applyFileEdits(validPath, args.edits, args.dryRun);
854
+ // TUI-C32 residual b — legacy `šŸ“ Editing file: …` notice dropped (see read_file above);
855
+ // the TUI-C30 tool-call block announces the edit once on both surfaces.
856
+ // GS2-36: a no-match / ambiguous edit, a missing file, a denied or symlinked-out path
857
+ // (validatePath throws BEFORE applyFileEdits mutates anything — EXT-14 containment
858
+ // preserved), or a write failure becomes a recoverable errored result the model can act
859
+ // on, instead of a fatal throw that aborts the whole run. applyFileEdits' no-match /
860
+ // ambiguous messages are already phrased as action-oriented recovery hints.
861
+ try {
862
+ const validPath = await this.validatePath(args.path);
863
+ return await this.applyFileEdits(validPath, args.edits, args.dryRun);
864
+ }
865
+ catch (error) {
866
+ return this.recoverableError(`editing file ${args.path}`, error);
867
+ }
532
868
  }, {
533
869
  name: 'edit_file',
534
870
  description: 'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
@@ -542,10 +878,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
542
878
  schema: EditFileArgsSchema,
543
879
  }, 'write'),
544
880
  createGthTool(async (args) => {
545
- displayInfo(`\nšŸ“ Creating directory: ${args.path}\n`);
546
- const validPath = await this.validatePath(args.path);
547
- await fs.mkdir(validPath, { recursive: true });
548
- return `Successfully created directory ${args.path}`;
881
+ // GS2-36: a denied / symlinked-out path or an mkdir failure (e.g. a file already exists at
882
+ // the path) becomes a recoverable errored result rather than a fatal run abort.
883
+ try {
884
+ const validPath = await this.validatePath(args.path);
885
+ await fs.mkdir(validPath, { recursive: true });
886
+ return `Successfully created directory ${args.path}`;
887
+ }
888
+ catch (error) {
889
+ return this.recoverableError(`creating directory ${args.path}`, error);
890
+ }
549
891
  }, {
550
892
  name: 'create_directory',
551
893
  description: 'Create a new directory or ensure a directory exists. Can create multiple ' +
@@ -555,17 +897,23 @@ export default class GthFileSystemToolkit extends BaseToolkit {
555
897
  schema: CreateDirectoryArgsSchema,
556
898
  }, 'write'),
557
899
  createGthTool(async (args) => {
558
- displayInfo(`\nšŸ“ Listing directory: ${args.path}\n`);
559
- const validPath = await this.validatePath(args.path);
560
- const entries = await fs.readdir(validPath, { withFileTypes: true });
561
- const aiignoreConfig = this.aiignoreConfig;
562
- const filteredEntries = entries.filter((entry) => {
563
- const fullPath = path.join(validPath, entry.name);
564
- return !shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled);
565
- });
566
- return filteredEntries
567
- .map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
568
- .join('\n');
900
+ // GS2-36: a missing directory, a denied / symlinked-out path, or a not-a-directory
901
+ // (ENOTDIR) error becomes a recoverable errored result rather than a fatal run abort.
902
+ try {
903
+ const validPath = await this.validatePath(args.path);
904
+ const entries = await fs.readdir(validPath, { withFileTypes: true });
905
+ const aiignoreConfig = this.aiignoreConfig;
906
+ const filteredEntries = entries.filter((entry) => {
907
+ const fullPath = path.join(validPath, entry.name);
908
+ return !shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled);
909
+ });
910
+ return filteredEntries
911
+ .map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
912
+ .join('\n');
913
+ }
914
+ catch (error) {
915
+ return this.recoverableError(`listing directory ${args.path}`, error);
916
+ }
569
917
  }, {
570
918
  name: 'list_directory',
571
919
  description: 'Get a detailed listing of all files and directories in a specified path. ' +
@@ -575,9 +923,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
575
923
  schema: ListDirectoryArgsSchema,
576
924
  }, 'read'),
577
925
  createGthTool(async (args) => {
578
- displayInfo(`\nšŸ“ Listing directory with sizes: ${args.path}\n`);
579
- const validPath = await this.validatePath(args.path);
580
- const entries = await fs.readdir(validPath, { withFileTypes: true });
926
+ // GS2-36: a missing directory or denied path becomes a recoverable errored result rather
927
+ // than a fatal run abort. Only validatePath + readdir can throw fatally here (the per-entry
928
+ // stat below already has its own try/catch), so guard just those.
929
+ let validPath;
930
+ let entries;
931
+ try {
932
+ validPath = await this.validatePath(args.path);
933
+ entries = (await fs.readdir(validPath, { withFileTypes: true }));
934
+ }
935
+ catch (error) {
936
+ return this.recoverableError(`listing directory ${args.path}`, error);
937
+ }
581
938
  const aiignoreConfig = this.aiignoreConfig;
582
939
  const filteredEntries = entries.filter((entry) => {
583
940
  const fullPath = path.join(validPath, entry.name);
@@ -628,50 +985,69 @@ export default class GthFileSystemToolkit extends BaseToolkit {
628
985
  schema: ListDirectoryWithSizesArgsSchema,
629
986
  }, 'read'),
630
987
  createGthTool(async (args) => {
631
- displayInfo(`\nšŸ“ Building directory tree: ${args.path}\n`);
632
988
  const buildTree = async (currentPath) => {
633
989
  const validPath = await this.validatePath(currentPath);
634
990
  const entries = await fs.readdir(validPath, { withFileTypes: true });
635
991
  const result = [];
636
992
  const aiignoreConfig = this.aiignoreConfig;
637
993
  for (const entry of entries) {
994
+ // `.aiignore` is a privacy boundary, not an annotation: an ignored entry is dropped
995
+ // outright, exactly as list_directory / list_directory_with_sizes / search_files drop
996
+ // it. Emitting the entry with a marker still hands the model — and therefore the
997
+ // provider — the very filename `.aiignore` exists to hide, and a name is often the
998
+ // whole payload. Tested first so an ignored directory is neither named nor descended.
999
+ const fullPath = path.join(currentPath, entry.name);
1000
+ if (shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled)) {
1001
+ continue;
1002
+ }
638
1003
  const entryData = {
639
1004
  name: entry.name,
640
1005
  type: entry.isDirectory() ? 'directory' : 'file',
641
1006
  };
1007
+ // Noise directories are listed but never walked. `ignored` is what tells the model
1008
+ // why such a directory carries no `children` — without it an unwalked directory reads
1009
+ // as an empty one — and that is the flag's only remaining purpose.
642
1010
  if (IGNORED_DIRS.indexOf(entry.name) >= 0) {
643
1011
  entryData.ignored = true;
644
1012
  }
645
- // Check if file should be ignored by aiignore
646
- const fullPath = path.join(currentPath, entry.name);
647
- const shouldIgnore = shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled);
648
- if (shouldIgnore) {
649
- entryData.ignored = true;
650
- }
651
1013
  if (entry.isDirectory() && !entryData.ignored) {
652
- const subPath = path.join(currentPath, entry.name);
653
- entryData.children = await buildTree(subPath);
1014
+ entryData.children = await buildTree(fullPath);
654
1015
  }
655
1016
  result.push(entryData);
656
1017
  }
657
1018
  return result;
658
1019
  };
659
- const treeData = await buildTree(args.path);
660
- return JSON.stringify(treeData, null, 2);
1020
+ // GS2-36: a missing directory, denied path, or a symlink-out encountered mid-walk
1021
+ // (buildTree calls validatePath on each level) becomes a recoverable errored result
1022
+ // rather than a fatal run abort.
1023
+ try {
1024
+ const treeData = await buildTree(args.path);
1025
+ return JSON.stringify(treeData, null, 2);
1026
+ }
1027
+ catch (error) {
1028
+ return this.recoverableError(`building directory tree for ${args.path}`, error);
1029
+ }
661
1030
  }, {
662
1031
  name: 'directory_tree',
663
1032
  description: 'Get a recursive tree view of files and directories as a JSON structure. ' +
664
1033
  "Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
665
- 'Files have no children array, while directories always have a children array (which may be empty). ' +
1034
+ "Files have no children array; a directory has one (possibly empty) unless it carries 'ignored': true, " +
1035
+ 'which means the directory was listed but not walked, so its contents are unknown rather than absent. ' +
666
1036
  'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
667
1037
  schema: DirectoryTreeArgsSchema,
668
1038
  }, 'read'),
669
1039
  createGthTool(async (args) => {
670
- displayInfo(`\nšŸ“ Moving ${args.source} to ${args.destination}\n`);
671
- const validSourcePath = await this.validatePath(args.source);
672
- const validDestPath = await this.validatePath(args.destination);
673
- await fs.rename(validSourcePath, validDestPath);
674
- return `Successfully moved ${args.source} to ${args.destination}`;
1040
+ // GS2-36: a missing source, an existing destination, or a denied / symlinked-out path
1041
+ // becomes a recoverable errored result rather than a fatal run abort.
1042
+ try {
1043
+ const validSourcePath = await this.validatePath(args.source);
1044
+ const validDestPath = await this.validatePath(args.destination);
1045
+ await fs.rename(validSourcePath, validDestPath);
1046
+ return `Successfully moved ${args.source} to ${args.destination}`;
1047
+ }
1048
+ catch (error) {
1049
+ return this.recoverableError(`moving ${args.source} to ${args.destination}`, error);
1050
+ }
675
1051
  }, {
676
1052
  name: 'move_file',
677
1053
  description: 'Move or rename files and directories. Can move files between directories ' +
@@ -681,10 +1057,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
681
1057
  schema: MoveFileArgsSchema,
682
1058
  }, 'write'),
683
1059
  createGthTool(async (args) => {
684
- displayInfo(`\nšŸ“ Searching for '${args.pattern}' in ${args.path}\n`);
685
- const validPath = await this.validatePath(args.path);
686
- const results = await this.searchFiles(validPath, args.pattern, args.excludePatterns);
687
- return results.length > 0 ? results.join('\n') : 'No matches found';
1060
+ // GS2-36: a missing or denied root path becomes a recoverable errored result rather than a
1061
+ // fatal run abort (searchFiles already swallows per-entry errors internally).
1062
+ try {
1063
+ const validPath = await this.validatePath(args.path);
1064
+ const results = await this.searchFiles(validPath, args.pattern, args.excludePatterns);
1065
+ return results.length > 0 ? results.join('\n') : 'No matches found';
1066
+ }
1067
+ catch (error) {
1068
+ return this.recoverableError(`searching ${args.path}`, error);
1069
+ }
688
1070
  }, {
689
1071
  name: 'search_files',
690
1072
  description: 'Recursively search for files and directories matching a pattern. ' +
@@ -695,12 +1077,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
695
1077
  schema: SearchFilesArgsSchema,
696
1078
  }, 'read'),
697
1079
  createGthTool(async (args) => {
698
- displayInfo(`\nšŸ“ Getting file info: ${args.path}\n`);
699
- const validPath = await this.validatePath(args.path);
700
- const info = await this.getFileStats(validPath);
701
- return Object.entries(info)
702
- .map(([key, value]) => `${key}: ${value}`)
703
- .join('\n');
1080
+ // GS2-36: a missing file or denied path becomes a recoverable errored result rather than a
1081
+ // fatal run abort.
1082
+ try {
1083
+ const validPath = await this.validatePath(args.path);
1084
+ const info = await this.getFileStats(validPath);
1085
+ return Object.entries(info)
1086
+ .map(([key, value]) => `${key}: ${value}`)
1087
+ .join('\n');
1088
+ }
1089
+ catch (error) {
1090
+ return this.recoverableError(`getting file info for ${args.path}`, error);
1091
+ }
704
1092
  }, {
705
1093
  name: 'get_file_info',
706
1094
  description: 'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
@@ -710,14 +1098,20 @@ export default class GthFileSystemToolkit extends BaseToolkit {
710
1098
  schema: GetFileInfoArgsSchema,
711
1099
  }, 'read'),
712
1100
  createGthTool(async (args) => {
713
- displayInfo(`\nšŸ“ Deleting file: ${args.path}\n`);
714
- const validPath = await this.validatePath(args.path);
715
- const stats = await fs.stat(validPath);
716
- if (stats.isDirectory()) {
717
- throw new Error(`Cannot delete directory: ${args.path}. Use rmdir or a recursive delete tool for directories.`);
718
- }
719
- await fs.unlink(validPath);
720
- return `Successfully deleted file: ${args.path}`;
1101
+ // GS2-36: a missing file, a denied path, or the "this is a directory" refusal becomes a
1102
+ // recoverable errored result the model can act on, rather than a fatal run abort.
1103
+ try {
1104
+ const validPath = await this.validatePath(args.path);
1105
+ const stats = await fs.stat(validPath);
1106
+ if (stats.isDirectory()) {
1107
+ throw new Error(`Cannot delete directory: ${args.path}. Use rmdir or a recursive delete tool for directories.`);
1108
+ }
1109
+ await fs.unlink(validPath);
1110
+ return `Successfully deleted file: ${args.path}`;
1111
+ }
1112
+ catch (error) {
1113
+ return this.recoverableError(`deleting file ${args.path}`, error);
1114
+ }
721
1115
  }, {
722
1116
  name: 'delete_file',
723
1117
  description: 'Delete a file from the filesystem. This operation cannot be undone. ' +
@@ -726,28 +1120,35 @@ export default class GthFileSystemToolkit extends BaseToolkit {
726
1120
  schema: DeleteFileArgsSchema,
727
1121
  }, 'write'),
728
1122
  createGthTool(async (args) => {
729
- displayInfo(`\nšŸ“ Deleting directory: ${args.path}${args.recursive ? ' (recursive)' : ''}\n`);
730
- const validPath = await this.validatePath(args.path);
731
- // Check if this is a protected directory
732
- if (this.isProtectedDirectory(validPath)) {
733
- throw new Error(`Cannot delete protected directory: ${args.path}. This is one of the allowed root directories.`);
734
- }
735
- const stats = await fs.stat(validPath);
736
- if (!stats.isDirectory()) {
737
- throw new Error(`Not a directory: ${args.path}. Use delete_file for files.`);
738
- }
739
- if (args.recursive) {
740
- await fs.rm(validPath, { recursive: true, force: true });
741
- return `Successfully deleted directory and all contents: ${args.path}`;
742
- }
743
- else {
744
- // For non-recursive delete, check if directory is empty
745
- const entries = await fs.readdir(validPath);
746
- if (entries.length > 0) {
747
- throw new Error(`Directory not empty: ${args.path}. Use recursive: true to delete non-empty directories.`);
1123
+ // GS2-36: a missing directory, a denied path, the protected-root / not-a-directory /
1124
+ // not-empty refusals all become recoverable errored results the model can act on, rather
1125
+ // than a fatal run abort. Each refusal message is already an action-oriented hint.
1126
+ try {
1127
+ const validPath = await this.validatePath(args.path);
1128
+ // Check if this is a protected directory
1129
+ if (this.isProtectedDirectory(validPath)) {
1130
+ throw new Error(`Cannot delete protected directory: ${args.path}. This is one of the allowed root directories.`);
748
1131
  }
749
- await fs.rmdir(validPath);
750
- return `Successfully deleted empty directory: ${args.path}`;
1132
+ const stats = await fs.stat(validPath);
1133
+ if (!stats.isDirectory()) {
1134
+ throw new Error(`Not a directory: ${args.path}. Use delete_file for files.`);
1135
+ }
1136
+ if (args.recursive) {
1137
+ await fs.rm(validPath, { recursive: true, force: true });
1138
+ return `Successfully deleted directory and all contents: ${args.path}`;
1139
+ }
1140
+ else {
1141
+ // For non-recursive delete, check if directory is empty
1142
+ const entries = await fs.readdir(validPath);
1143
+ if (entries.length > 0) {
1144
+ throw new Error(`Directory not empty: ${args.path}. Use recursive: true to delete non-empty directories.`);
1145
+ }
1146
+ await fs.rmdir(validPath);
1147
+ return `Successfully deleted empty directory: ${args.path}`;
1148
+ }
1149
+ }
1150
+ catch (error) {
1151
+ return this.recoverableError(`deleting directory ${args.path}`, error);
751
1152
  }
752
1153
  }, {
753
1154
  name: 'delete_directory',