atom-agent 1.3.0 → 1.5.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 (71) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +5 -5
  34. package/dist/ui/diff-view.js +16 -7
  35. package/dist/ui/diff.js +73 -51
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +6 -4
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +88 -27
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +9 -6
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/extensions.md +1 -1
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/examples/extensions/01-audit-gate.js +2 -2
  69. package/examples/extensions/02-notes-tool.js +2 -2
  70. package/examples/extensions/03-custom-command.js +2 -2
  71. package/package.json +3 -2
@@ -6,7 +6,8 @@ import * as path from "node:path";
6
6
  import { editTool, readTool, writeTool, } from "./filesystem.js";
7
7
  import { GREP_OUTPUT_MODES, globTool, grepTool } from "./search.js";
8
8
  import { bashOutputTool, bashTool, } from "./shell.js";
9
- import { err, invalidCall } from "./shared.js";
9
+ import { invalidCall } from "./shared.js";
10
+ import { goalReportOutsideError, validateUpdateGoalArgs } from "../goal.js";
10
11
  import { clearCustomTools, customToolNames, getCustomTool, isCustomTool, listCustomTools, registerCustomTool, unregisterCustomTool, validateCustomToolArgs, validateExtensionToolDef, } from "./custom.js";
11
12
  export { validateExtensionToolDef } from "./custom.js";
12
13
  import { getToolOverride, isToolOverridden, registerToolOverride, unregisterToolOverride, validateExtensionToolOverrideDef, } from "./overrides.js";
@@ -44,20 +45,38 @@ export function needsApproval(name) {
44
45
  return custom.requireApproval;
45
46
  return false;
46
47
  }
47
- // Known tool names (single source: builtin TOOL_DEFINITIONS plus
48
- // extension-registered custom tools, defined below). The validator + loop
49
- // build "Available: ..." lists from this so the message can never drift
50
- // from the schema.
48
+ // Known tool names (single source: builtin TOOL_DEFINITIONS plus the
49
+ // intercepted update_goal definition below plus extension-registered custom
50
+ // tools, defined below). The validator + loop build "Available: ..." lists
51
+ // from this so the message can never drift from the schema — and the loop's
52
+ // unknown-name gate reads this same list, so model visibility and
53
+ // executability cannot drift apart either.
51
54
  export function toolNames() {
52
- return [...TOOL_DEFINITIONS.map((t) => t.function.name), ...customToolNames()];
55
+ return [
56
+ ...TOOL_DEFINITIONS.map((t) => t.function.name),
57
+ UPDATE_GOAL_TOOL_DEFINITION.function.name,
58
+ ...customToolNames(),
59
+ ];
53
60
  }
54
- // Every definition the model sees: builtins plus extension tools. The raw
55
- // TOOL_DEFINITIONS export stays builtin-only (tests pin its 13 entries);
56
- // chat payloads must use this so custom tools are discoverable. A builtin
61
+ // Every definition the model sees: builtins plus the intercepted update_goal
62
+ // definition below plus extension tools. The raw TOOL_DEFINITIONS export
63
+ // stays builtin-only (tests pin its 13 entries); chat payloads must use this
64
+ // so update_goal and custom tools are discoverable. A builtin
57
65
  // shadowed by an extension override (ticket 06) keeps its name, schema, and
58
66
  // position, but its description carries an audit-visible override marker so
59
67
  // the shadowing is never silent.
60
68
  export function allToolDefinitions() {
69
+ return chatToolDefinitions(true);
70
+ }
71
+ // Per-POST model-visible surface: identical to allToolDefinitions, minus
72
+ // update_goal when the turn has no live goal to report into. The model
73
+ // repeatedly filed `complete` outside goal turns (greetings, task done-ups)
74
+ // despite the description's WHEN NOT — an invisible tool cannot be misused,
75
+ // and hiding it also trims every no-goal POST by one schema. Executability
76
+ // (toolNames + runInterceptedTool) stays full by design: a hallucinated call
77
+ // still routes to the outside-turn error instead of an unknown-name dead
78
+ // end, and the loop's recordGoalReport backstop is untouched.
79
+ export function chatToolDefinitions(includeUpdateGoal = true) {
61
80
  return [
62
81
  ...TOOL_DEFINITIONS.map((t) => isToolOverridden(t.function.name)
63
82
  ? {
@@ -69,6 +88,7 @@ export function allToolDefinitions() {
69
88
  },
70
89
  }
71
90
  : t),
91
+ ...(includeUpdateGoal ? [UPDATE_GOAL_TOOL_DEFINITION] : []),
72
92
  ...listCustomTools().map((c) => ({
73
93
  type: "function",
74
94
  function: { name: c.name, description: c.description, parameters: c.parameters },
@@ -281,6 +301,10 @@ export function validateToolArgs(name, args) {
281
301
  }
282
302
  case "ask_question":
283
303
  return askQuestionDetail(a);
304
+ case "update_goal":
305
+ // Validator lives in goal.ts (same detail-string contract as every
306
+ // other arm here); the schema and executor live beside this arm above.
307
+ return validateUpdateGoalArgs(a);
284
308
  default:
285
309
  return null;
286
310
  }
@@ -377,39 +401,32 @@ export async function executeTool(name, args, cwd = process.cwd()) {
377
401
  return e instanceof Error ? `Error: ${e.message}` : `Error: ${String(e)}`;
378
402
  }
379
403
  }
380
- // ask_question keeps its dedicated hook-missing path, but validation
381
- // still comes first (validateAskQuestionArgs already uses invalidCall).
382
- if (name === "ask_question") {
383
- // No UI hook at this layer: the agentic loop intercepts ask_question
384
- // and serves it via its askUser hook. Direct calls validate, then
385
- // report the missing hook as a result string (never throw).
386
- const invalid = validateAskQuestionArgs(a);
387
- if (invalid)
388
- return invalid;
389
- return err("ask_question has no UI hook");
390
- }
404
+ // Intercepted tools (ask_question/update_goal) resolve without an
405
+ // executor through the registry runner above: validation first (model
406
+ // mistakes never run), then the context-free result — the no-hook error
407
+ // for ask_question, the outside-turn error for update_goal. Direct calls
408
+ // never throw.
409
+ const intercepted = await runInterceptedTool(name, a, {});
410
+ if (intercepted !== null)
411
+ return intercepted.result;
391
412
  const detail = validateToolArgs(name, a);
392
413
  if (detail)
393
414
  return invalidCall(detail);
394
415
  return executeBuiltinTool(name, a, cwd);
395
416
  }
396
417
  // Pristine builtin execution (ticket 06: the override passthrough target).
397
- // Validation first, then the builtin executor — exactly the path above, so
398
- // pass-through behavior is byte-identical to no override. Never consults the
399
- // override store, so recursion is impossible by construction. A throwing
400
- // executor degrades to an `Error:` result string, never a crash.
418
+ // Intercepted names resolve context-free first, then validation, then the
419
+ // builtin executor exactly the path above, so pass-through behavior is
420
+ // byte-identical to no override. Never consults the override store, so
421
+ // recursion is impossible by construction. A throwing executor degrades to
422
+ // an `Error:` result string, never a crash.
401
423
  async function executeBuiltinTool(name, args, cwd) {
402
- // ask_question keeps its dedicated hook-missing path, but validation
403
- // still comes first (validateAskQuestionArgs already uses invalidCall).
404
- if (name === "ask_question") {
405
- // No UI hook at this layer: the agentic loop intercepts ask_question
406
- // and serves it via its askUser hook. Direct calls validate, then
407
- // report the missing hook as a result string (never throw).
408
- const invalid = validateAskQuestionArgs(args);
409
- if (invalid)
410
- return invalid;
411
- return err("ask_question has no UI hook");
412
- }
424
+ // Intercepted tools have no executor: the pristine path resolves them
425
+ // context-free (validated, then the no-hook / outside-turn error), so an
426
+ // override passthrough behaves byte-identically to no override.
427
+ const intercepted = await runInterceptedTool(name, args, {});
428
+ if (intercepted !== null)
429
+ return intercepted.result;
413
430
  const detail = validateToolArgs(name, args);
414
431
  if (detail)
415
432
  return invalidCall(detail);
@@ -598,9 +615,10 @@ export const TOOL_DEFINITIONS = [
598
615
  function: {
599
616
  name: "read",
600
617
  description: "Read a UTF-8 text file with 1-based line numbers (`<n>: <text>` per line) or list a directory (plain entry names, no line numbers). " +
618
+ "PNG, JPEG, GIF, and WebP images (up to 8 MiB) are read as vision input — the result says so and the image reaches the model automatically; describe what you see. " +
601
619
  "WHEN to use: inspecting source before editing — read first, then edit with an exact oldString copied from the numbered output; " +
602
620
  "paging large files with the offset/limit line window (output truncates with a follow pointer). " +
603
- "WHEN NOT to use: binaries or huge dumps — narrow with grep/glob first. " +
621
+ "WHEN NOT to use: other binaries (PDF, audio, video) are rejected — convert to PNG/text first (e.g. pdftoppm, pdftotext); huge dumps — narrow with grep/glob first. " +
604
622
  "Paths may be relative or absolute, anywhere on the computer.",
605
623
  parameters: {
606
624
  type: "object",
@@ -640,7 +658,7 @@ export const TOOL_DEFINITIONS = [
640
658
  description: "Edit a file with exact-match string replacement. " +
641
659
  "WHEN to use: small targeted changes to an already-read file — read first, then pass the exact oldString copied from the numbered output " +
642
660
  "(line numbers are display-only, never file content; never invent oldString from memory). " +
643
- "WHEN NOT to use: don't create or rewrite whole files (use write). " +
661
+ "WHEN NOT to use: don't create or rewrite whole files (use write). Files over 1MB are refused outright — use bash for targeted changes to huge files. " +
644
662
  "oldString must match exactly once unless replaceAll is true. Enforces a stale-read guard: re-read after any external change. " +
645
663
  "Edits report the occurrence count. Asks for approval in normal mode.",
646
664
  parameters: {
@@ -660,11 +678,13 @@ export const TOOL_DEFINITIONS = [
660
678
  type: "function",
661
679
  function: {
662
680
  name: "grep",
663
- description: "Search file contents under dir (default '.') for lines matching a JS regex (JS RegExp engine, ripgrep-style intent). " +
664
- "WHEN to use: finding usages without reading every file — scope with outputMode files_with_matches first, then read; never shell out to a system grep. " +
681
+ description: "Search file contents under dir (default '.') for lines matching a JS regex. " +
682
+ "WHEN to use: finding usages — scope with outputMode files_with_matches first, then read. " +
665
683
  "WHEN NOT to use: don't list files by name (use glob); don't read whole files (use read). " +
666
- "include filters by glob. content returns 'file:line: text' (100 matches); files_with_matches lists paths newest-first; " +
667
- "count adds per-file totals. Long lines trim; binaries skipped; node_modules/.git never searched.",
684
+ "Case-sensitive; (?i) prefix = case-insensitive. dir takes a directory or a file. " +
685
+ "include is a glob with {a,b} (e.g. '*.{ts,tsx}'); prefer one scoped call. " +
686
+ "content returns 'file:line: text' (100); files_with_matches lists paths newest-first; " +
687
+ "count adds totals. Binaries skipped; node_modules/.git never searched.",
668
688
  parameters: {
669
689
  type: "object",
670
690
  properties: {
@@ -686,10 +706,10 @@ export const TOOL_DEFINITIONS = [
686
706
  type: "function",
687
707
  function: {
688
708
  name: "glob",
689
- description: "Find files by glob pattern (*, ?, **) under dir (default '.'). " +
690
- "WHEN to use: locating files by name before reading. " +
709
+ description: "Find files by glob (*, ?, **, {a,b}) under dir (default '.'; a file tests just it). " +
710
+ "WHEN to use: locating files by name one pattern like '**/*goal*' answers most. " +
691
711
  "WHEN NOT to use: don't search contents (use grep); don't read bodies (use read). " +
692
- "A slash-less pattern matches basenames at any depth. Paths newest-first (capped at 200). " +
712
+ "Slash-less patterns match basenames at any depth. Paths newest-first (capped at 200). " +
693
713
  "node_modules/.git skipped.",
694
714
  parameters: {
695
715
  type: "object",
@@ -921,6 +941,124 @@ export const TOOL_DEFINITIONS = [
921
941
  },
922
942
  },
923
943
  ];
944
+ // Intercepted tools (ticket 06): loop-resolved, executor-free registry
945
+ // entries — schema, validator, and executor in one place. ask_question's
946
+ // schema stays in TOOL_DEFINITIONS above (its 13-entry pin holds) with its
947
+ // validator (validateToolArgs/validateAskQuestionArgs) and executor
948
+ // (runInterceptedTool) beside it in this module; update_goal's schema lives
949
+ // here (NOT in TOOL_DEFINITIONS, so the 13-entry pin and the
950
+ // scheduler-effects completeness test stay green) with its validator
951
+ // delegating to goal.ts (same detail-string contract — kept home there per
952
+ // the ticket brief) and its executor beside it below. The loop dispatches
953
+ // both through runInterceptedTool via isInterceptedTool — never by name —
954
+ // so the roster below is the single source for model visibility
955
+ // (allToolDefinitions) and executability (toolNames + this runner).
956
+ export const UPDATE_GOAL_TOOL_DEFINITION = {
957
+ type: "function",
958
+ function: {
959
+ name: "update_goal",
960
+ description: "Report this goal turn's outcome (goal-scoped: only available during an active goal turn). " +
961
+ "WHEN to use: at the end of each goal turn — status \"continue\" with the next action, " +
962
+ "or \"complete\"/\"blocked\" with a reason. " +
963
+ "A \"complete\" lands only on genuinely finished work: verified checks and resolved todos. " +
964
+ "Checks you could not run go in \"unverified\" (recorded openly in the closing summary, never a gate). " +
965
+ "WHEN NOT to use: never outside a goal turn (it records nothing there); " +
966
+ "never for greetings, small talk, or non-goal answers; " +
967
+ "a turn with no report continues the goal.",
968
+ parameters: {
969
+ type: "object",
970
+ properties: {
971
+ status: {
972
+ type: "string",
973
+ enum: ["continue", "complete", "blocked"],
974
+ description: "Turn outcome: \"continue\" (keep working), \"complete\" (goal done), \"blocked\" (cannot proceed).",
975
+ },
976
+ next: {
977
+ type: "string",
978
+ description: "Next action (only with status \"continue\"; omit otherwise).",
979
+ },
980
+ reason: {
981
+ type: "string",
982
+ description: "Why the goal is done or stuck (required with \"complete\"/\"blocked\"; omit otherwise).",
983
+ },
984
+ unverified: {
985
+ type: "array",
986
+ items: { type: "string" },
987
+ description: "Checks that could not be run (only with status \"complete\"; omit otherwise). " +
988
+ "Recorded openly in the closing summary; at most 10 non-empty items of 200 characters each.",
989
+ },
990
+ },
991
+ required: ["status"],
992
+ additionalProperties: false,
993
+ },
994
+ },
995
+ };
996
+ // Roster query for the loop's dispatch stage: true exactly for the tools
997
+ // runInterceptedTool resolves (never by name in the caller).
998
+ export function isInterceptedTool(name) {
999
+ return name === "ask_question" || name === UPDATE_GOAL_TOOL_DEFINITION.function.name;
1000
+ }
1001
+ // Resolve one intercepted call: validated, approval-free, executor-free.
1002
+ // Returns null for non-intercepted names (the caller falls through to the
1003
+ // executor path). Cancel-like askUser failures rethrow raw — the pipeline
1004
+ // maps them to LoopCancelledError (it cannot be named here: the pipeline
1005
+ // imports this module, so that edge would cycle); everything else is an
1006
+ // `Error:` result string, never a throw.
1007
+ export async function runInterceptedTool(name, parsed, ctx = {}) {
1008
+ switch (name) {
1009
+ case "ask_question":
1010
+ return { result: await runAskQuestionTool(parsed, ctx), decision: "ask-question" };
1011
+ case "update_goal":
1012
+ return { result: runUpdateGoalTool(parsed, ctx), decision: "goal-report" };
1013
+ default:
1014
+ return null;
1015
+ }
1016
+ }
1017
+ // Local cancel classification (mirrors agent/tool-pipeline's isCancelError,
1018
+ // which cannot be imported here for the cycle reason above).
1019
+ function isInterceptCancel(e, signal) {
1020
+ if (signal?.aborted)
1021
+ return true;
1022
+ if (e instanceof Error && (e.name === "LoopCancelledError" || e.name === "AbortError"))
1023
+ return true;
1024
+ if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
1025
+ return true;
1026
+ }
1027
+ return false;
1028
+ }
1029
+ async function runAskQuestionTool(parsed, ctx) {
1030
+ const invalid = validateAskQuestionArgs(parsed);
1031
+ if (invalid)
1032
+ return invalid;
1033
+ if (!ctx.askUser)
1034
+ return "Error: ask_question has no UI hook";
1035
+ const q = parsed;
1036
+ const allowCustom = q.allowCustom === true;
1037
+ try {
1038
+ const answer = await ctx.askUser(q.question, q.options, allowCustom);
1039
+ if (typeof answer === "string" && answer.startsWith("Error:"))
1040
+ return answer;
1041
+ return JSON.stringify({ answer });
1042
+ }
1043
+ catch (e) {
1044
+ // A cancelled turn rethrows raw (the pipeline maps it); an Esc-style
1045
+ // cancel message is the user-cancellable result, never a throw.
1046
+ if (isInterceptCancel(e, ctx.signal))
1047
+ throw e;
1048
+ const msg = e instanceof Error ? e.message : String(e);
1049
+ if (/cancel/i.test(msg))
1050
+ return "Error: question cancelled by user";
1051
+ return `Error: ${msg}`;
1052
+ }
1053
+ }
1054
+ function runUpdateGoalTool(parsed, ctx) {
1055
+ const detail = validateUpdateGoalArgs(parsed);
1056
+ if (detail)
1057
+ return invalidCall(detail);
1058
+ if (!ctx.onUpdateGoal)
1059
+ return goalReportOutsideError();
1060
+ return ctx.onUpdateGoal(parsed);
1061
+ }
924
1062
  // One-line summaries for the /tools command (single source of truth for
925
1063
  // the tool list shown in the TUI).
926
1064
  export const TOOL_ONE_LINERS = {
@@ -945,7 +1083,8 @@ export const TOOL_ONE_LINERS = {
945
1083
  // shadow. Returns an unregister function for hot-reload style removal.
946
1084
  export function registerExtensionTool(def) {
947
1085
  validateExtensionToolDef(def);
948
- if (TOOL_DEFINITIONS.some((t) => t.function.name === def.name)) {
1086
+ if (TOOL_DEFINITIONS.some((t) => t.function.name === def.name) ||
1087
+ def.name === UPDATE_GOAL_TOOL_DEFINITION.function.name) {
949
1088
  throw new Error(`extension tool "${def.name}" collides with a builtin tool`);
950
1089
  }
951
1090
  const unregister = registerCustomTool(def);
@@ -115,10 +115,11 @@ function isRegexError(stderr) {
115
115
  // (caller renders the mode's empty shape); anything else is a fallback
116
116
  // signal — with regex-parse errors being the EXPECTED fallback trigger
117
117
  // (lookahead and friends are valid JS, invalid Rust).
118
- async function runRgSearch(absDir, baseArgs, pattern) {
118
+ async function runRgSearch(absDir, baseArgs, pattern, caseInsensitive = false) {
119
119
  let res;
120
120
  try {
121
- res = await runRg(absDir, [...baseArgs, "-e", pattern, "--", "."]);
121
+ const flagArgs = caseInsensitive ? ["-i", ...baseArgs] : baseArgs;
122
+ res = await runRg(absDir, [...flagArgs, "-e", pattern, "--", "."]);
122
123
  }
123
124
  catch {
124
125
  return { kind: "fallback" };
@@ -173,8 +174,8 @@ function parseJsonEvents(stdout) {
173
174
  // order. Hits carry raw line text; the caller applies the shared 200-char
174
175
  // trim + 100-hit cap + note (same code as the walker path would — see
175
176
  // grepTool; this module only supplies ordered raw material).
176
- export async function rgContentHits(absDir, cwd, pattern, allowed) {
177
- const run = await runRgSearch(absDir, [...BASE_ARGS, "--json", "--max-count", String(RG_CONTENT_MAX_COUNT)], pattern);
177
+ export async function rgContentHits(absDir, cwd, pattern, allowed, caseInsensitive = false) {
178
+ const run = await runRgSearch(absDir, [...BASE_ARGS, "--json", "--max-count", String(RG_CONTENT_MAX_COUNT)], pattern, caseInsensitive);
178
179
  if (run.kind !== "ok")
179
180
  return run.kind === "empty" ? { counts: [], hits: [], cappedFile: false } : null;
180
181
  const perFile = new Map();
@@ -226,8 +227,8 @@ export async function rgContentHits(absDir, cwd, pattern, allowed) {
226
227
  // `rg --count` (ripgrep --count semantics already match the walker's).
227
228
  // files_with_matches derives its file set from these counts, so one spawn
228
229
  // serves both modes. Caller applies totals/caps/recency.
229
- export async function rgFileCounts(absDir, cwd, pattern, allowed) {
230
- const run = await runRgSearch(absDir, [...BASE_ARGS, "--count"], pattern);
230
+ export async function rgFileCounts(absDir, cwd, pattern, allowed, caseInsensitive = false) {
231
+ const run = await runRgSearch(absDir, [...BASE_ARGS, "--count"], pattern, caseInsensitive);
231
232
  if (run.kind !== "ok")
232
233
  return run.kind === "empty" ? { counts: [] } : null;
233
234
  const counts = [];
@@ -5,9 +5,68 @@ import * as path from "node:path";
5
5
  import { listFiles } from "./dir-cache.js";
6
6
  import { appendOverflow } from "./overflow.js";
7
7
  import { noteRgFallback, rgAvailable, rgContentHits, rgFileCounts, rgMinFiles, } from "./ripgrep.js";
8
- import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
9
- // Minimal glob matcher: supports **, **/, *, ?. Used for grep `include`
10
- // and the glob tool. Patterns without a slash match the basename.
8
+ import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, READ_CHAR_CAP, READ_FILE_MAX_BYTES, resolveSandbox, truncateHead } from "./shared.js";
9
+ // Minimal glob matcher: supports **, **/, *, ?, and {a,b,c} brace
10
+ // alternation (single- or multi-level, e.g. "*.{ts,tsx}" or
11
+ // "src/**/*.{test,spec}.ts"). Patterns without a slash match the basename.
12
+ function expandBraces(pattern) {
13
+ // Cap: a pathological "{a,b}x{a,b}x..." chain explodes combinatorially;
14
+ // past the cap the raw pattern stands (legacy literal-brace behavior).
15
+ const MAX_EXPANSIONS = 128;
16
+ const out = expandBracesInner(pattern);
17
+ return out.length > MAX_EXPANSIONS ? [pattern] : out;
18
+ }
19
+ function expandBracesInner(pattern) {
20
+ const open = pattern.indexOf("{");
21
+ if (open < 0)
22
+ return [pattern];
23
+ // Find the matching close brace, accounting for nesting.
24
+ let depth = 0;
25
+ let close = -1;
26
+ for (let i = open; i < pattern.length; i++) {
27
+ if (pattern[i] === "{")
28
+ depth += 1;
29
+ else if (pattern[i] === "}") {
30
+ depth -= 1;
31
+ if (depth === 0) {
32
+ close = i;
33
+ break;
34
+ }
35
+ }
36
+ }
37
+ if (close < 0)
38
+ return [pattern]; // unbalanced — literal
39
+ const prefix = pattern.slice(0, open);
40
+ const suffix = pattern.slice(close + 1);
41
+ const inner = pattern.slice(open + 1, close);
42
+ // Split on top-level commas only (nested braces stay intact per part).
43
+ const parts = [];
44
+ let partDepth = 0;
45
+ let current = "";
46
+ for (const ch of inner) {
47
+ if (ch === "{")
48
+ partDepth += 1;
49
+ else if (ch === "}")
50
+ partDepth -= 1;
51
+ if (ch === "," && partDepth === 0) {
52
+ parts.push(current);
53
+ current = "";
54
+ }
55
+ else {
56
+ current += ch;
57
+ }
58
+ }
59
+ parts.push(current);
60
+ if (parts.length < 2)
61
+ return [pattern]; // no alternation — literal
62
+ const out = [];
63
+ for (const part of parts) {
64
+ for (const expanded of expandBracesInner(`${prefix}${part}${suffix}`)) {
65
+ out.push(expanded);
66
+ }
67
+ }
68
+ return out;
69
+ }
11
70
  function globToRegExp(glob) {
12
71
  let re = "";
13
72
  let i = 0;
@@ -43,11 +102,35 @@ function globToRegExp(glob) {
43
102
  }
44
103
  function matchesGlob(pattern, relPosix) {
45
104
  const norm = pattern.replace(/\\/g, "/");
46
- if (!norm.includes("/")) {
47
- const base = relPosix.slice(relPosix.lastIndexOf("/") + 1);
48
- return globToRegExp(norm).test(base);
105
+ for (const alt of expandBraces(norm)) {
106
+ if (!alt.includes("/")) {
107
+ const base = relPosix.slice(relPosix.lastIndexOf("/") + 1);
108
+ if (globToRegExp(alt).test(base))
109
+ return true;
110
+ }
111
+ else {
112
+ // Full-rel match first (exact glob semantics); then a trailing-suffix
113
+ // fallback so a repo-rooted shorthand like "tools/registry.ts" still
114
+ // hits "src/tools/registry.ts" instead of silently matching nothing
115
+ // (the Temp-session c21 trap: correct file, correct pattern shape,
116
+ // zero results, one wasted round plus a manual fallback).
117
+ if (globToRegExp(alt).test(relPosix))
118
+ return true;
119
+ if (globToRegExp(`**/${alt}`).test(relPosix))
120
+ return true;
121
+ }
49
122
  }
50
- return globToRegExp(norm).test(relPosix);
123
+ return false;
124
+ }
125
+ // Grep pattern pre-parse: a leading "(?i)" prefix selects case-insensitive
126
+ // matching (Python-trained models reach for it; JS RegExp has no inline
127
+ // flags, so `new RegExp("(?i)goal")` throws "invalid regex"). The prefix is
128
+ // stripped and re-applied as the `i` flag for the walker and as `-i` for
129
+ // ripgrep — one documented spelling, both engines agree.
130
+ function parseGrepPattern(raw) {
131
+ if (raw.startsWith("(?i)"))
132
+ return { source: raw.slice(4), caseInsensitive: true };
133
+ return { source: raw, caseInsensitive: false };
51
134
  }
52
135
  export const GREP_OUTPUT_MODES = new Set([
53
136
  "content",
@@ -85,9 +168,9 @@ export function formatGrepHit(rel, lineNo, line) {
85
168
  // shape as the walker scan below (or null = run the walker). Content hits
86
169
  // arrive merged in (file alpha, line) order; the 100-hit cap + note apply
87
170
  // exactly like the walker path (including its take-100-blindly quirk).
88
- async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed) {
171
+ async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed, caseInsensitive = false) {
89
172
  if (mode === "content") {
90
- const r = await rgContentHits(absDir, cwd, pattern, allowed);
173
+ const r = await rgContentHits(absDir, cwd, pattern, allowed, caseInsensitive);
91
174
  if (r === null)
92
175
  return null;
93
176
  if (r.cappedFile)
@@ -95,7 +178,7 @@ async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed) {
95
178
  const hits = r.hits.slice(0, GREP_MATCH_CAP).map((h) => formatGrepHit(h.rel, h.line, h.text));
96
179
  return { counts: r.counts, hits, hitsCapped: r.hits.length >= GREP_MATCH_CAP };
97
180
  }
98
- const r = await rgFileCounts(absDir, cwd, pattern, allowed);
181
+ const r = await rgFileCounts(absDir, cwd, pattern, allowed, caseInsensitive);
99
182
  if (r === null)
100
183
  return null;
101
184
  return { counts: r.counts, hits: [], hitsCapped: false };
@@ -128,7 +211,20 @@ async function scanWithWalker(cwd, re, pattern, sorted, include, mode) {
128
211
  if (include && !matchesGlob(include, rel))
129
212
  return null;
130
213
  try {
131
- const text = await fsp.readFile(path.resolve(cwd, rel), "utf8");
214
+ // OOM guard: the walker reads every file fully and concurrently —
215
+ // one GB input (bundle, pack, media) would OOM the heap. Oversize
216
+ // files skip exactly like binaries (the ripgrep path bounds itself
217
+ // via --max-count instead).
218
+ const full = path.resolve(cwd, rel);
219
+ try {
220
+ const st = await fsp.stat(full);
221
+ if (st.isFile() && st.size > READ_FILE_MAX_BYTES)
222
+ return null;
223
+ }
224
+ catch {
225
+ // stat failure falls through to the read below (same as before)
226
+ }
227
+ const text = await fsp.readFile(full, "utf8");
132
228
  if (text.includes("\0"))
133
229
  return null; // binary — skip
134
230
  return { rel, text };
@@ -172,6 +268,51 @@ async function scanWithWalker(cwd, re, pattern, sorted, include, mode) {
172
268
  }
173
269
  return { counts, hits, hitsCapped };
174
270
  }
271
+ // Single-file grep (file-path tolerance for `dir`): same match semantics as
272
+ // the walker over a one-entry set, same output shapes per mode. Skips
273
+ // oversize/binary files exactly like the walker (→ "No matches.").
274
+ async function grepSingleFile(cwd, re, pattern, rel, abs, include, mode) {
275
+ if (include && !matchesGlob(include, rel))
276
+ return "No matches.";
277
+ try {
278
+ const st = await fsp.stat(abs);
279
+ if (st.isFile() && st.size > READ_FILE_MAX_BYTES)
280
+ return "No matches.";
281
+ const text = await fsp.readFile(abs, "utf8");
282
+ if (text.includes("\0"))
283
+ return "No matches.";
284
+ const lines = text.split("\n");
285
+ const hits = [];
286
+ let n = 0;
287
+ for (let i = 0; i < lines.length; i++) {
288
+ let matched;
289
+ try {
290
+ matched = re.test(lines[i]);
291
+ }
292
+ catch {
293
+ return err(`regex failed on input: ${pattern}`);
294
+ }
295
+ re.lastIndex = 0;
296
+ if (!matched)
297
+ continue;
298
+ n += 1;
299
+ if (mode === "content" && hits.length < GREP_MATCH_CAP) {
300
+ hits.push(formatGrepHit(rel, i + 1, lines[i]));
301
+ }
302
+ }
303
+ if (n === 0)
304
+ return "No matches.";
305
+ if (mode === "files_with_matches")
306
+ return capSearchOutput(`Found 1 file(s)\n${rel}`, "grep results");
307
+ if (mode === "count") {
308
+ return capSearchOutput(`${rel}:${n}\nFound ${n} total match(es) across 1 file(s).`, "grep results");
309
+ }
310
+ return capSearchOutput(hits.join("\n"), "grep results");
311
+ }
312
+ catch {
313
+ return "No matches.";
314
+ }
315
+ }
175
316
  // Line-regex search under dir (default "."). `include` is a glob like
176
317
  // "*.ts". `outputMode` selects the shape (Claude-Code-style):
177
318
  // - "content" (default): "file:line: text" lines, capped at 100 matches.
@@ -183,12 +324,13 @@ export async function grepTool(args, cwd = process.cwd()) {
183
324
  try {
184
325
  if (typeof args?.pattern !== "string")
185
326
  return err("pattern must be a string");
327
+ const parsed = parseGrepPattern(args.pattern);
186
328
  let re;
187
329
  try {
188
- re = new RegExp(args.pattern);
330
+ re = new RegExp(parsed.source, parsed.caseInsensitive ? "i" : "");
189
331
  }
190
332
  catch {
191
- return err(`invalid regex: ${args.pattern}`);
333
+ return err(`invalid regex: ${args.pattern} (JS RegExp syntax; prefix with (?i) for case-insensitive)`);
192
334
  }
193
335
  const mode = args?.outputMode ?? "content";
194
336
  if (!GREP_OUTPUT_MODES.has(mode)) {
@@ -205,10 +347,17 @@ export async function grepTool(args, cwd = process.cwd()) {
205
347
  catch {
206
348
  return err(`no such directory: ${dir}`);
207
349
  }
350
+ const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
351
+ // File-path tolerance: `dir` pointing at a file searches just that file
352
+ // (models habitually pass "src/foo.ts" as dir). Same output shapes as
353
+ // the directory path; ripgrep is skipped (cwd-anchored by construction).
354
+ if (st.isFile()) {
355
+ const rel = path.relative(cwd, r.abs).split(path.sep).join("/");
356
+ return grepSingleFile(cwd, re, parsed.source, rel, r.abs, include, mode);
357
+ }
208
358
  if (!st.isDirectory())
209
- return err(`not a directory: ${dir}`);
359
+ return err(`not a directory: ${dir} (pass a directory in dir, or read the file directly)`);
210
360
  const files = await listFiles(r.abs, cwd);
211
- const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
212
361
  const sorted = files.sort();
213
362
  // Allowed set shared by both scan paths (include filtering is identical
214
363
  // either way, so ripgrep coverage matches the walker exactly).
@@ -220,7 +369,7 @@ export async function grepTool(args, cwd = process.cwd()) {
220
369
  let hits;
221
370
  let hitsCapped;
222
371
  if (rgAvailable() && sorted.length >= rgMinFiles()) {
223
- const fast = await scanWithRipgrep(r.abs, cwd, args.pattern, mode, allowed);
372
+ const fast = await scanWithRipgrep(r.abs, cwd, parsed.source, mode, allowed, parsed.caseInsensitive);
224
373
  if (fast !== null) {
225
374
  ({ counts, hits, hitsCapped } = fast);
226
375
  }
@@ -285,8 +434,14 @@ export async function globTool(args, cwd = process.cwd()) {
285
434
  catch {
286
435
  return err(`no such directory: ${dir}`);
287
436
  }
437
+ // File-path tolerance: `dir` pointing at a file tests just that file
438
+ // against the glob (models habitually pass "src/foo.ts" as dir).
439
+ if (st.isFile()) {
440
+ const rel = path.relative(cwd, r.abs).split(path.sep).join("/");
441
+ return matchesGlob(args.pattern, rel) ? capSearchOutput(rel, "glob results") : "No matches.";
442
+ }
288
443
  if (!st.isDirectory())
289
- return err(`not a directory: ${dir}`);
444
+ return err(`not a directory: ${dir} (pass a directory in dir, or read the file directly)`);
290
445
  const files = await listFiles(r.abs, cwd);
291
446
  const matched = files.filter((rel) => matchesGlob(args.pattern, rel));
292
447
  const withTime = await Promise.all(matched.map(async (rel) => ({ rel, t: await mtimeMs(path.resolve(cwd, rel)) })));
@@ -3,6 +3,12 @@
3
3
  import * as path from "node:path";
4
4
  export const READ_CHAR_CAP = 64 * 1024;
5
5
  export const OUTPUT_CAP = 8 * 1024;
6
+ // Full-read byte guard: no tool may materialize a whole file past this size.
7
+ // A GB file becomes ~2x bytes as UTF-16 plus split/join copies — enough to
8
+ // OOM the heap in a single call (observed kill: long session + one huge
9
+ // read). Matches the diff engine + approval preview 1MB precedent, so the
10
+ // model gets one consistent "over 1MB" story everywhere.
11
+ export const READ_FILE_MAX_BYTES = 1_000_000;
6
12
  export const GREP_MATCH_CAP = 100;
7
13
  export const GLOB_MATCH_CAP = 200;
8
14
  export const SKIP_DIRS = new Set(["node_modules", ".git"]);