reasonix 0.32.0 → 0.33.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 (118) hide show
  1. package/dist/cli/chat-EIFLHBZ6.js +39 -0
  2. package/dist/cli/chunk-2AWTGJ2C.js +110 -0
  3. package/dist/cli/chunk-2AWTGJ2C.js.map +1 -0
  4. package/dist/cli/chunk-3Q3C4W66.js +30 -0
  5. package/dist/cli/chunk-3Q3C4W66.js.map +1 -0
  6. package/dist/cli/chunk-4DCHFFEY.js +149 -0
  7. package/dist/cli/chunk-4DCHFFEY.js.map +1 -0
  8. package/dist/cli/chunk-5X7LZJDE.js +36 -0
  9. package/dist/cli/chunk-5X7LZJDE.js.map +1 -0
  10. package/dist/cli/chunk-6TMHAK5D.js +576 -0
  11. package/dist/cli/chunk-6TMHAK5D.js.map +1 -0
  12. package/dist/cli/chunk-APPB3ZPQ.js +43 -0
  13. package/dist/cli/chunk-APPB3ZPQ.js.map +1 -0
  14. package/dist/cli/chunk-BQNUJJN7.js +42 -0
  15. package/dist/cli/chunk-BQNUJJN7.js.map +1 -0
  16. package/dist/cli/chunk-CPOV2O73.js +39 -0
  17. package/dist/cli/chunk-CPOV2O73.js.map +1 -0
  18. package/dist/cli/chunk-D5DKXIP5.js +368 -0
  19. package/dist/cli/chunk-D5DKXIP5.js.map +1 -0
  20. package/dist/cli/chunk-DFP4YSVM.js +247 -0
  21. package/dist/cli/chunk-DFP4YSVM.js.map +1 -0
  22. package/dist/cli/chunk-DULSP7JH.js +410 -0
  23. package/dist/cli/chunk-DULSP7JH.js.map +1 -0
  24. package/dist/cli/chunk-FM57FNPJ.js +46 -0
  25. package/dist/cli/chunk-FM57FNPJ.js.map +1 -0
  26. package/dist/cli/chunk-FWGEHRB7.js +54 -0
  27. package/dist/cli/chunk-FWGEHRB7.js.map +1 -0
  28. package/dist/cli/chunk-FXGQ5NHE.js +513 -0
  29. package/dist/cli/chunk-FXGQ5NHE.js.map +1 -0
  30. package/dist/cli/chunk-G3XNWSFN.js +53 -0
  31. package/dist/cli/chunk-G3XNWSFN.js.map +1 -0
  32. package/dist/cli/chunk-I6YIAK6C.js +757 -0
  33. package/dist/cli/chunk-I6YIAK6C.js.map +1 -0
  34. package/dist/cli/chunk-J5VLP23S.js +94 -0
  35. package/dist/cli/chunk-J5VLP23S.js.map +1 -0
  36. package/dist/cli/chunk-KMWKGPFZ.js +303 -0
  37. package/dist/cli/chunk-KMWKGPFZ.js.map +1 -0
  38. package/dist/cli/chunk-LVQX5KGF.js +14934 -0
  39. package/dist/cli/chunk-LVQX5KGF.js.map +1 -0
  40. package/dist/cli/chunk-MHDNZXJJ.js +48 -0
  41. package/dist/cli/chunk-MHDNZXJJ.js.map +1 -0
  42. package/dist/cli/chunk-ORM6PK57.js +140 -0
  43. package/dist/cli/chunk-ORM6PK57.js.map +1 -0
  44. package/dist/cli/chunk-Q5GRLZJF.js +99 -0
  45. package/dist/cli/chunk-Q5GRLZJF.js.map +1 -0
  46. package/dist/cli/chunk-Q6YFXW7H.js +4986 -0
  47. package/dist/cli/chunk-Q6YFXW7H.js.map +1 -0
  48. package/dist/cli/chunk-QGE6AF76.js +1467 -0
  49. package/dist/cli/chunk-QGE6AF76.js.map +1 -0
  50. package/dist/cli/chunk-RFX7TYVV.js +28 -0
  51. package/dist/cli/chunk-RFX7TYVV.js.map +1 -0
  52. package/dist/cli/chunk-RZILUXUC.js +940 -0
  53. package/dist/cli/chunk-RZILUXUC.js.map +1 -0
  54. package/dist/cli/chunk-SDE5U32Z.js +535 -0
  55. package/dist/cli/chunk-SDE5U32Z.js.map +1 -0
  56. package/dist/cli/chunk-SOZE7V7V.js +340 -0
  57. package/dist/cli/chunk-SOZE7V7V.js.map +1 -0
  58. package/dist/cli/chunk-U3V2ZQ5J.js +479 -0
  59. package/dist/cli/chunk-U3V2ZQ5J.js.map +1 -0
  60. package/dist/cli/chunk-W4LDFAZ6.js +1544 -0
  61. package/dist/cli/chunk-W4LDFAZ6.js.map +1 -0
  62. package/dist/cli/chunk-WBDE4IRI.js +208 -0
  63. package/dist/cli/chunk-WBDE4IRI.js.map +1 -0
  64. package/dist/cli/chunk-XHQIK7B6.js +189 -0
  65. package/dist/cli/chunk-XHQIK7B6.js.map +1 -0
  66. package/dist/cli/chunk-XJLZ4HKU.js +307 -0
  67. package/dist/cli/chunk-XJLZ4HKU.js.map +1 -0
  68. package/dist/cli/chunk-ZPTSJGX5.js +88 -0
  69. package/dist/cli/chunk-ZPTSJGX5.js.map +1 -0
  70. package/dist/cli/chunk-ZTLZO42A.js +231 -0
  71. package/dist/cli/chunk-ZTLZO42A.js.map +1 -0
  72. package/dist/cli/code-F4KJOE3K.js +151 -0
  73. package/dist/cli/code-F4KJOE3K.js.map +1 -0
  74. package/dist/cli/commands-JWT2MWVH.js +352 -0
  75. package/dist/cli/commands-JWT2MWVH.js.map +1 -0
  76. package/dist/cli/commit-RPZBOZS2.js +288 -0
  77. package/dist/cli/commit-RPZBOZS2.js.map +1 -0
  78. package/dist/cli/diff-NTEHCSDW.js +145 -0
  79. package/dist/cli/diff-NTEHCSDW.js.map +1 -0
  80. package/dist/cli/doctor-3TGB2NZN.js +19 -0
  81. package/dist/cli/doctor-3TGB2NZN.js.map +1 -0
  82. package/dist/cli/events-P27CX7LN.js +338 -0
  83. package/dist/cli/events-P27CX7LN.js.map +1 -0
  84. package/dist/cli/index.js +80 -33693
  85. package/dist/cli/index.js.map +1 -1
  86. package/dist/cli/mcp-ARTNQ24O.js +266 -0
  87. package/dist/cli/mcp-ARTNQ24O.js.map +1 -0
  88. package/dist/cli/mcp-browse-HLO2ENDL.js +163 -0
  89. package/dist/cli/mcp-browse-HLO2ENDL.js.map +1 -0
  90. package/dist/cli/mcp-inspect-T2HBR22P.js +103 -0
  91. package/dist/cli/mcp-inspect-T2HBR22P.js.map +1 -0
  92. package/dist/cli/{prompt-XHICFAYN.js → prompt-V47QKSAR.js} +3 -2
  93. package/dist/cli/prompt-V47QKSAR.js.map +1 -0
  94. package/dist/cli/prune-sessions-ERL6B4G5.js +42 -0
  95. package/dist/cli/prune-sessions-ERL6B4G5.js.map +1 -0
  96. package/dist/cli/replay-TMJASRC4.js +273 -0
  97. package/dist/cli/replay-TMJASRC4.js.map +1 -0
  98. package/dist/cli/run-JMEOTQCG.js +215 -0
  99. package/dist/cli/run-JMEOTQCG.js.map +1 -0
  100. package/dist/cli/server-SYC3OVOP.js +2967 -0
  101. package/dist/cli/server-SYC3OVOP.js.map +1 -0
  102. package/dist/cli/sessions-MOJAALJI.js +102 -0
  103. package/dist/cli/sessions-MOJAALJI.js.map +1 -0
  104. package/dist/cli/setup-CCJZAWTY.js +404 -0
  105. package/dist/cli/setup-CCJZAWTY.js.map +1 -0
  106. package/dist/cli/stats-5RJCATCE.js +12 -0
  107. package/dist/cli/stats-5RJCATCE.js.map +1 -0
  108. package/dist/cli/update-4TJWRUIN.js +90 -0
  109. package/dist/cli/update-4TJWRUIN.js.map +1 -0
  110. package/dist/cli/version-3MYFE4G6.js +29 -0
  111. package/dist/cli/version-3MYFE4G6.js.map +1 -0
  112. package/dist/index.d.ts +13 -2
  113. package/dist/index.js +493 -89
  114. package/dist/index.js.map +1 -1
  115. package/package.json +1 -1
  116. package/dist/cli/chunk-VWFJNLIK.js +0 -1031
  117. package/dist/cli/chunk-VWFJNLIK.js.map +0 -1
  118. /package/dist/cli/{prompt-XHICFAYN.js.map → chat-EIFLHBZ6.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1032,7 +1032,10 @@ var EN = {
1032
1032
  restoreInfo: '\u25B8 restored "{name}" ({id}) from {when}',
1033
1033
  restoreWrote: " \xB7 wrote back {count} file{s}",
1034
1034
  restoreRemoved: " \xB7 removed {count} file{s} (didn't exist at checkpoint time)",
1035
- restoreSkipped: " \u2717 {count} file{s} skipped:"
1035
+ restoreSkipped: " \u2717 {count} file{s} skipped:",
1036
+ cwdCodeOnly: "/cwd is only available inside `reasonix code`.",
1037
+ cwdUsage: "usage: /cwd <path> (current root: {current}). Re-points filesystem / shell / memory tools to <path>.",
1038
+ cwdUsageNoCurrent: "usage: /cwd <path> re-points the workspace root to <path>."
1036
1039
  },
1037
1040
  model: {
1038
1041
  modelHint: "try deepseek-v4-flash or deepseek-v4-pro \u2014 run /models to fetch the live list",
@@ -1726,7 +1729,10 @@ var zhCN = {
1726
1729
  restoreInfo: '\u25B8 \u5DF2\u6062\u590D "{name}"\uFF08{id}\uFF09\uFF0C\u6765\u81EA {when}',
1727
1730
  restoreWrote: " \xB7 \u5199\u56DE\u4E86 {count} \u4E2A\u6587\u4EF6",
1728
1731
  restoreRemoved: " \xB7 \u79FB\u9664\u4E86 {count} \u4E2A\u6587\u4EF6\uFF08\u68C0\u67E5\u70B9\u65F6\u4E0D\u5B58\u5728\uFF09",
1729
- restoreSkipped: " \u2717 \u8DF3\u8FC7\u4E86 {count} \u4E2A\u6587\u4EF6\uFF1A"
1732
+ restoreSkipped: " \u2717 \u8DF3\u8FC7\u4E86 {count} \u4E2A\u6587\u4EF6\uFF1A",
1733
+ cwdCodeOnly: "/cwd \u4EC5\u5728 `reasonix code` \u4E2D\u53EF\u7528\u3002",
1734
+ cwdUsage: "\u7528\u6CD5\uFF1A/cwd <path> \uFF08\u5F53\u524D\u6839\u76EE\u5F55\uFF1A{current}\uFF09\u3002\u91CD\u65B0\u6307\u5411 filesystem / shell / memory \u5DE5\u5177\u5230 <path>\u3002",
1735
+ cwdUsageNoCurrent: "\u7528\u6CD5\uFF1A/cwd <path> \u5C06\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55\u5207\u6362\u5230 <path>\u3002"
1730
1736
  },
1731
1737
  model: {
1732
1738
  modelHint: "\u5C1D\u8BD5 deepseek-v4-flash \u6216 deepseek-v4-pro \u2014 \u8FD0\u884C /models \u83B7\u53D6\u5B9E\u65F6\u5217\u8868",
@@ -4873,15 +4879,25 @@ function rankPickerCandidates(files, query, limitOrOpts) {
4873
4879
  for (const e of entries) {
4874
4880
  const lower = e.path.toLowerCase();
4875
4881
  const hit = lower.indexOf(needle);
4876
- if (hit < 0) continue;
4877
- const slash = lower.lastIndexOf("/");
4878
- const base = slash >= 0 ? lower.slice(slash + 1) : lower;
4879
- let score = 2;
4880
- if (base.startsWith(needle)) score = 0;
4881
- else if (lower.startsWith(needle)) score = 1;
4882
+ if (hit >= 0) {
4883
+ const slash = lower.lastIndexOf("/");
4884
+ const base = slash >= 0 ? lower.slice(slash + 1) : lower;
4885
+ let cls = 2;
4886
+ if (base.startsWith(needle)) cls = 0;
4887
+ else if (lower.startsWith(needle)) cls = 1;
4888
+ scored.push({
4889
+ path: e.path,
4890
+ score: cls * 1e4 + Math.min(hit, 9999),
4891
+ mtimeMs: e.mtimeMs,
4892
+ recent: recent.has(e.path)
4893
+ });
4894
+ continue;
4895
+ }
4896
+ const fuzzy = fuzzySubseqScore(needle, lower);
4897
+ if (fuzzy === null) continue;
4882
4898
  scored.push({
4883
4899
  path: e.path,
4884
- score: score * 1e4 + hit,
4900
+ score: 3e4 + fuzzy,
4885
4901
  mtimeMs: e.mtimeMs,
4886
4902
  recent: recent.has(e.path)
4887
4903
  });
@@ -4893,11 +4909,33 @@ function rankPickerCandidates(files, query, limitOrOpts) {
4893
4909
  });
4894
4910
  return scored.slice(0, limit).map((s) => s.path);
4895
4911
  }
4912
+ function fuzzySubseqScore(needle, target) {
4913
+ if (needle.length === 0) return 0;
4914
+ const slashIdx = target.lastIndexOf("/");
4915
+ const basenameStart = slashIdx >= 0 ? slashIdx + 1 : 0;
4916
+ let qi = 0;
4917
+ let lastMatchIdx = -2;
4918
+ let consecutive = 0;
4919
+ let basenameMatches = 0;
4920
+ let totalGap = 0;
4921
+ for (let ti = 0; ti < target.length && qi < needle.length; ti++) {
4922
+ if (target[ti] !== needle[qi]) continue;
4923
+ if (ti === lastMatchIdx + 1) consecutive++;
4924
+ else if (lastMatchIdx >= 0) totalGap += ti - lastMatchIdx - 1;
4925
+ if (ti >= basenameStart) basenameMatches++;
4926
+ lastMatchIdx = ti;
4927
+ qi++;
4928
+ }
4929
+ if (qi < needle.length) return null;
4930
+ const quality = Math.max(0, totalGap - consecutive * 10 - basenameMatches * 5);
4931
+ const lengthPenalty = Math.floor(target.length / 4);
4932
+ return quality + lengthPenalty;
4933
+ }
4896
4934
  var AT_MENTION_PATTERN = /(?<=^|\s)@([a-zA-Z0-9_./\\-]+)/g;
4897
4935
  function expandAtMentions(text, rootDir, opts = {}) {
4898
4936
  const maxBytes = opts.maxBytes ?? DEFAULT_AT_MENTION_MAX_BYTES;
4899
4937
  const maxDirEntries = Math.max(1, opts.maxDirEntries ?? DEFAULT_AT_DIR_MAX_ENTRIES);
4900
- const fs4 = opts.fs ?? defaultFs;
4938
+ const fs5 = opts.fs ?? defaultFs;
4901
4939
  const root = resolve(rootDir);
4902
4940
  const seen = /* @__PURE__ */ new Map();
4903
4941
  const expansions = [];
@@ -4910,7 +4948,7 @@ function expandAtMentions(text, rootDir, opts = {}) {
4910
4948
  if (!cleaned) continue;
4911
4949
  const token = `@${cleaned}`;
4912
4950
  if (seen.has(token)) continue;
4913
- const expansion = resolveMention(cleaned, root, maxBytes, maxDirEntries, fs4, dirListings);
4951
+ const expansion = resolveMention(cleaned, root, maxBytes, maxDirEntries, fs5, dirListings);
4914
4952
  seen.set(token, expansion);
4915
4953
  expansions.push(expansion);
4916
4954
  }
@@ -4927,7 +4965,7 @@ ${files.join("\n")}
4927
4965
  `<directory path="${ex.path}" entries="${ex.entries ?? files.length}"${truncAttr}>${body}</directory>`
4928
4966
  );
4929
4967
  } else if (ex.ok) {
4930
- const content = readSafe(root, ex.path, fs4);
4968
+ const content = readSafe(root, ex.path, fs5);
4931
4969
  blocks.push(`<file path="${ex.path}">
4932
4970
  ${content}
4933
4971
  </file>`);
@@ -4941,7 +4979,7 @@ ${content}
4941
4979
  ${blocks.join("\n\n")}`;
4942
4980
  return { text: augmented, expansions };
4943
4981
  }
4944
- function resolveMention(rawPath, root, maxBytes, maxDirEntries, fs4, dirListings) {
4982
+ function resolveMention(rawPath, root, maxBytes, maxDirEntries, fs5, dirListings) {
4945
4983
  if (isAbsolute(rawPath)) {
4946
4984
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
4947
4985
  }
@@ -4950,18 +4988,18 @@ function resolveMention(rawPath, root, maxBytes, maxDirEntries, fs4, dirListings
4950
4988
  if (rel.startsWith("..") || isAbsolute(rel)) {
4951
4989
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
4952
4990
  }
4953
- if (!fs4.exists(resolved)) {
4991
+ if (!fs5.exists(resolved)) {
4954
4992
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "missing" };
4955
4993
  }
4956
- if (fs4.isFile(resolved)) {
4957
- const size = fs4.size(resolved);
4994
+ if (fs5.isFile(resolved)) {
4995
+ const size = fs5.size(resolved);
4958
4996
  if (size > maxBytes) {
4959
4997
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
4960
4998
  }
4961
4999
  return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
4962
5000
  }
4963
- if (fs4.isDir?.(resolved) && fs4.listDir) {
4964
- const { files, truncated } = fs4.listDir(resolved, root, maxDirEntries);
5001
+ if (fs5.isDir?.(resolved) && fs5.listDir) {
5002
+ const { files, truncated } = fs5.listDir(resolved, root, maxDirEntries);
4965
5003
  dirListings.set(rawPath, files);
4966
5004
  return {
4967
5005
  token: `@${rawPath}`,
@@ -4974,10 +5012,10 @@ function resolveMention(rawPath, root, maxBytes, maxDirEntries, fs4, dirListings
4974
5012
  }
4975
5013
  return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
4976
5014
  }
4977
- function readSafe(root, rawPath, fs4) {
5015
+ function readSafe(root, rawPath, fs5) {
4978
5016
  const resolved = resolve(root, rawPath);
4979
5017
  try {
4980
- return fs4.read(resolved);
5018
+ return fs5.read(resolved);
4981
5019
  } catch {
4982
5020
  return "(read failed)";
4983
5021
  }
@@ -5790,9 +5828,9 @@ function applyMemoryStack(basePrompt, rootDir) {
5790
5828
  }
5791
5829
 
5792
5830
  // src/tools/filesystem.ts
5793
- import { promises as fs3 } from "fs";
5794
- import * as pathMod3 from "path";
5795
- import picomatch2 from "picomatch";
5831
+ import { promises as fs4 } from "fs";
5832
+ import * as pathMod4 from "path";
5833
+ import picomatch3 from "picomatch";
5796
5834
 
5797
5835
  // src/tools/fs/edit.ts
5798
5836
  import { promises as fs } from "fs";
@@ -5827,6 +5865,83 @@ async function applyEdit(rootDir, abs, args) {
5827
5865
  return `${header}
5828
5866
  ${diff}`;
5829
5867
  }
5868
+ async function applyMultiEdit(rootDir, edits) {
5869
+ if (edits.length === 0) {
5870
+ throw new Error("multi_edit: edits must contain at least one entry");
5871
+ }
5872
+ const filesByPath = /* @__PURE__ */ new Map();
5873
+ for (let i = 0; i < edits.length; i++) {
5874
+ const e = edits[i];
5875
+ if (typeof e.abs !== "string" || e.abs.length === 0) {
5876
+ throw new Error(`multi_edit: edit #${i + 1} requires a string \`path\` (no edits applied)`);
5877
+ }
5878
+ if (typeof e.search !== "string") {
5879
+ throw new Error(`multi_edit: edit #${i + 1} requires a string \`search\` (no edits applied)`);
5880
+ }
5881
+ if (typeof e.replace !== "string") {
5882
+ throw new Error(
5883
+ `multi_edit: edit #${i + 1} requires a string \`replace\` (no edits applied)`
5884
+ );
5885
+ }
5886
+ const rel = displayRel(rootDir, e.abs);
5887
+ if (e.search.length === 0) {
5888
+ throw new Error(
5889
+ `multi_edit: edit #${i + 1} (${rel}) search cannot be empty (no edits applied)`
5890
+ );
5891
+ }
5892
+ let state = filesByPath.get(e.abs);
5893
+ if (!state) {
5894
+ let before;
5895
+ try {
5896
+ before = await fs.readFile(e.abs, "utf8");
5897
+ } catch (err) {
5898
+ throw new Error(
5899
+ `multi_edit: edit #${i + 1} cannot read ${rel}: ${err.message} (no edits applied)`
5900
+ );
5901
+ }
5902
+ const le = before.includes("\r\n") ? "\r\n" : "\n";
5903
+ state = { buf: before, le, hunks: [], deltaChars: 0, touched: 0 };
5904
+ filesByPath.set(e.abs, state);
5905
+ }
5906
+ const adaptedSearch = e.search.replace(/\r?\n/g, state.le);
5907
+ const adaptedReplace = e.replace.replace(/\r?\n/g, state.le);
5908
+ const firstIdx = state.buf.indexOf(adaptedSearch);
5909
+ if (firstIdx < 0) {
5910
+ throw new Error(
5911
+ `multi_edit: edit #${i + 1} search text not found in ${rel} \u2014 no edits applied (multi_edit is atomic)`
5912
+ );
5913
+ }
5914
+ const nextIdx = state.buf.indexOf(adaptedSearch, firstIdx + 1);
5915
+ if (nextIdx >= 0) {
5916
+ throw new Error(
5917
+ `multi_edit: edit #${i + 1} search text appears multiple times in ${rel} \u2014 include more context to disambiguate (no edits applied)`
5918
+ );
5919
+ }
5920
+ const startLine = state.buf.slice(0, firstIdx).split(/\r?\n/).length;
5921
+ state.buf = state.buf.slice(0, firstIdx) + adaptedReplace + state.buf.slice(firstIdx + adaptedSearch.length);
5922
+ state.hunks.push(`# ${rel}
5923
+ ${renderEditDiff(adaptedSearch, adaptedReplace, startLine)}`);
5924
+ state.deltaChars += adaptedReplace.length - adaptedSearch.length;
5925
+ state.touched++;
5926
+ }
5927
+ for (const [abs, state] of filesByPath) {
5928
+ await fs.writeFile(abs, state.buf, "utf8");
5929
+ }
5930
+ const fileCount = filesByPath.size;
5931
+ const editCount = edits.length;
5932
+ let totalDelta = 0;
5933
+ const allHunks = [];
5934
+ for (const state of filesByPath.values()) {
5935
+ totalDelta += state.deltaChars;
5936
+ allHunks.push(...state.hunks);
5937
+ }
5938
+ const sign = totalDelta >= 0 ? "+" : "";
5939
+ const editNoun = editCount === 1 ? "edit" : "edits";
5940
+ const fileNoun = fileCount === 1 ? "file" : "files";
5941
+ const header = `multi_edit: applied ${editCount} ${editNoun} across ${fileCount} ${fileNoun} (${sign}${totalDelta} chars)`;
5942
+ return `${header}
5943
+ ${allHunks.join("\n")}`;
5944
+ }
5830
5945
  function renderEditDiff(search, replace, startLine) {
5831
5946
  const a = search.split(/\r?\n/);
5832
5947
  const b = replace.split(/\r?\n/);
@@ -5873,15 +5988,78 @@ function lineDiff(a, b) {
5873
5988
  return out;
5874
5989
  }
5875
5990
 
5876
- // src/tools/fs/search.ts
5991
+ // src/tools/fs/glob.ts
5877
5992
  import { promises as fs2 } from "fs";
5878
5993
  import * as pathMod2 from "path";
5994
+ import picomatch2 from "picomatch";
5995
+ function displayRel2(rootDir, full) {
5996
+ return pathMod2.relative(rootDir, full).replaceAll("\\", "/");
5997
+ }
5998
+ async function globFiles(ctx, startAbs, args) {
5999
+ if (args.signal?.aborted) {
6000
+ throw new DOMException("glob aborted by user", "AbortError");
6001
+ }
6002
+ const includeDeps = args.include_deps === true;
6003
+ const sortBy = args.sort_by ?? "mtime";
6004
+ const limit = Math.max(1, Math.min(1e3, Math.floor(args.limit ?? 200)));
6005
+ const isMatch = picomatch2(args.pattern, { dot: true, nocase: true });
6006
+ const hits = [];
6007
+ const walk2 = async (dir) => {
6008
+ if (args.signal?.aborted) {
6009
+ throw new DOMException("glob aborted by user", "AbortError");
6010
+ }
6011
+ let entries;
6012
+ try {
6013
+ entries = await fs2.readdir(dir, { withFileTypes: true });
6014
+ } catch {
6015
+ return;
6016
+ }
6017
+ for (const e of entries) {
6018
+ const full = pathMod2.join(dir, e.name);
6019
+ if (e.isDirectory()) {
6020
+ if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
6021
+ await walk2(full);
6022
+ continue;
6023
+ }
6024
+ if (!e.isFile() && !e.isSymbolicLink()) continue;
6025
+ const rel = displayRel2(ctx.rootDir, full);
6026
+ if (!isMatch(rel)) continue;
6027
+ let mtimeMs = 0;
6028
+ if (sortBy === "mtime") {
6029
+ try {
6030
+ const st = await fs2.stat(full);
6031
+ mtimeMs = st.mtimeMs;
6032
+ } catch {
6033
+ continue;
6034
+ }
6035
+ }
6036
+ hits.push({ rel, mtimeMs });
6037
+ }
6038
+ };
6039
+ await walk2(startAbs);
6040
+ if (hits.length === 0) return "(no matches)";
6041
+ if (sortBy === "mtime") hits.sort((a, b) => b.mtimeMs - a.mtimeMs);
6042
+ else hits.sort((a, b) => a.rel.localeCompare(b.rel));
6043
+ const truncated = hits.length > limit;
6044
+ const shown = hits.slice(0, limit);
6045
+ const lines = shown.map((h) => h.rel);
6046
+ if (truncated) {
6047
+ lines.push(
6048
+ `[\u2026 ${hits.length - limit} more matches \u2014 refine pattern or raise limit (max 1000) \u2026]`
6049
+ );
6050
+ }
6051
+ return lines.join("\n");
6052
+ }
6053
+
6054
+ // src/tools/fs/search.ts
6055
+ import { promises as fs3 } from "fs";
6056
+ import * as pathMod3 from "path";
5879
6057
  function throwIfAborted(signal) {
5880
6058
  if (!signal?.aborted) return;
5881
6059
  throw new DOMException("search aborted by user", "AbortError");
5882
6060
  }
5883
- function displayRel2(rootDir, full) {
5884
- return pathMod2.relative(rootDir, full).replaceAll("\\", "/");
6061
+ function displayRel3(rootDir, full) {
6062
+ return pathMod3.relative(rootDir, full).replaceAll("\\", "/");
5885
6063
  }
5886
6064
  async function searchFiles(ctx, startAbs, args) {
5887
6065
  throwIfAborted(args.signal);
@@ -5899,17 +6077,17 @@ async function searchFiles(ctx, startAbs, args) {
5899
6077
  throwIfAborted(args.signal);
5900
6078
  let entries;
5901
6079
  try {
5902
- entries = await fs2.readdir(dir, { withFileTypes: true });
6080
+ entries = await fs3.readdir(dir, { withFileTypes: true });
5903
6081
  } catch {
5904
6082
  return;
5905
6083
  }
5906
6084
  for (const e of entries) {
5907
6085
  throwIfAborted(args.signal);
5908
- const full = pathMod2.join(dir, e.name);
6086
+ const full = pathMod3.join(dir, e.name);
5909
6087
  const lower = e.name.toLowerCase();
5910
6088
  const hit = re ? re.test(e.name) : lower.includes(needle);
5911
6089
  if (hit) {
5912
- const rel = displayRel2(ctx.rootDir, full);
6090
+ const rel = displayRel3(ctx.rootDir, full);
5913
6091
  if (totalBytes + rel.length + 1 > ctx.maxListBytes) {
5914
6092
  matches.push("[\u2026 search truncated \u2014 refine pattern \u2026]");
5915
6093
  return;
@@ -5930,6 +6108,7 @@ async function searchContent(ctx, startAbs, args) {
5930
6108
  throwIfAborted(args.signal);
5931
6109
  const caseSensitive = args.case_sensitive === true;
5932
6110
  const includeDeps = args.include_deps === true;
6111
+ const ctxLines = Math.max(0, Math.min(20, Math.floor(args.context ?? 0)));
5933
6112
  let re = null;
5934
6113
  try {
5935
6114
  re = new RegExp(args.pattern, caseSensitive ? "" : "i");
@@ -5941,12 +6120,22 @@ async function searchContent(ctx, startAbs, args) {
5941
6120
  let totalBytes = 0;
5942
6121
  let scanned = 0;
5943
6122
  let truncated = false;
6123
+ const pushLine = (out) => {
6124
+ if (totalBytes + out.length + 1 > ctx.maxListBytes) {
6125
+ matches.push(`[\u2026 truncated at ${ctx.maxListBytes} bytes \u2014 refine pattern or path \u2026]`);
6126
+ truncated = true;
6127
+ return false;
6128
+ }
6129
+ matches.push(out);
6130
+ totalBytes += out.length + 1;
6131
+ return true;
6132
+ };
5944
6133
  const walk2 = async (dir) => {
5945
6134
  if (truncated) return;
5946
6135
  throwIfAborted(args.signal);
5947
6136
  let entries;
5948
6137
  try {
5949
- entries = await fs2.readdir(dir, { withFileTypes: true });
6138
+ entries = await fs3.readdir(dir, { withFileTypes: true });
5950
6139
  } catch {
5951
6140
  return;
5952
6141
  }
@@ -5955,16 +6144,16 @@ async function searchContent(ctx, startAbs, args) {
5955
6144
  throwIfAborted(args.signal);
5956
6145
  if (e.isDirectory()) {
5957
6146
  if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
5958
- await walk2(pathMod2.join(dir, e.name));
6147
+ await walk2(pathMod3.join(dir, e.name));
5959
6148
  continue;
5960
6149
  }
5961
6150
  if (!e.isFile()) continue;
5962
- const full = pathMod2.join(dir, e.name);
5963
- if (ctx.nameMatch && !ctx.nameMatch(e.name, displayRel2(ctx.rootDir, full))) continue;
6151
+ const full = pathMod3.join(dir, e.name);
6152
+ if (ctx.nameMatch && !ctx.nameMatch(e.name, displayRel3(ctx.rootDir, full))) continue;
5964
6153
  if (ctx.isBinaryByName(e.name)) continue;
5965
6154
  let fh;
5966
6155
  try {
5967
- fh = await fs2.open(full, "r");
6156
+ fh = await fs3.open(full, "r");
5968
6157
  } catch {
5969
6158
  continue;
5970
6159
  }
@@ -5987,25 +6176,45 @@ async function searchContent(ctx, startAbs, args) {
5987
6176
  const firstNul = raw.indexOf(0);
5988
6177
  if (firstNul !== -1 && firstNul < 8 * 1024) continue;
5989
6178
  const text = raw.toString("utf8");
5990
- const rel = displayRel2(ctx.rootDir, full);
6179
+ const rel = displayRel3(ctx.rootDir, full);
5991
6180
  const lines = text.split(/\r?\n/);
6181
+ const hits = [];
5992
6182
  for (let li = 0; li < lines.length; li++) {
5993
6183
  throwIfAborted(args.signal);
5994
6184
  const line = lines[li];
5995
6185
  const lineForCheck = caseSensitive ? line : line.toLowerCase();
5996
6186
  const hit = re ? re.test(line) : lineForCheck.includes(needle);
5997
- if (!hit) continue;
5998
- const display = line.length > 200 ? `${line.slice(0, 200)}\u2026` : line;
5999
- const out = `${rel}:${li + 1}: ${display}`;
6000
- if (totalBytes + out.length + 1 > ctx.maxListBytes) {
6001
- matches.push(`[\u2026 truncated at ${ctx.maxListBytes} bytes \u2014 refine pattern or path \u2026]`);
6002
- truncated = true;
6003
- return;
6004
- }
6005
- matches.push(out);
6006
- totalBytes += out.length + 1;
6187
+ if (hit) hits.push(li);
6007
6188
  }
6008
6189
  scanned++;
6190
+ if (hits.length === 0) continue;
6191
+ if (ctxLines === 0) {
6192
+ for (const li of hits) {
6193
+ if (truncated) return;
6194
+ const line = lines[li];
6195
+ const display = line.length > 200 ? `${line.slice(0, 200)}\u2026` : line;
6196
+ if (!pushLine(`${rel}:${li + 1}: ${display}`)) return;
6197
+ }
6198
+ continue;
6199
+ }
6200
+ const hitSet = new Set(hits);
6201
+ let prevWindowEnd = -2;
6202
+ for (const li of hits) {
6203
+ if (truncated) return;
6204
+ const winStart = Math.max(0, li - ctxLines);
6205
+ const winEnd = Math.min(lines.length - 1, li + ctxLines);
6206
+ if (winStart > prevWindowEnd + 1 && prevWindowEnd >= 0) {
6207
+ if (!pushLine("--")) return;
6208
+ }
6209
+ const realStart = winStart > prevWindowEnd + 1 ? winStart : prevWindowEnd + 1;
6210
+ for (let i = realStart; i <= winEnd; i++) {
6211
+ const line = lines[i];
6212
+ const display = line.length > 200 ? `${line.slice(0, 200)}\u2026` : line;
6213
+ const sep2 = hitSet.has(i) ? ":" : "-";
6214
+ if (!pushLine(`${rel}:${i + 1}${sep2} ${display}`)) return;
6215
+ }
6216
+ prevWindowEnd = winEnd;
6217
+ }
6009
6218
  }
6010
6219
  };
6011
6220
  await walk2(startAbs);
@@ -6023,8 +6232,8 @@ var AUTO_PREVIEW_HEAD_LINES = 80;
6023
6232
  var AUTO_PREVIEW_TAIL_LINES = 40;
6024
6233
  var SKIP_DIR_NAMES = new Set(DEFAULT_INDEX_EXCLUDES.dirs);
6025
6234
  var BINARY_EXTENSIONS = new Set(DEFAULT_INDEX_EXCLUDES.exts);
6026
- function displayRel3(rootDir, full) {
6027
- return pathMod3.relative(rootDir, full).replaceAll("\\", "/");
6235
+ function displayRel4(rootDir, full) {
6236
+ return pathMod4.relative(rootDir, full).replaceAll("\\", "/");
6028
6237
  }
6029
6238
  var GLOB_METACHARS = /[*?{[]/;
6030
6239
  function compileNameFilter(filter) {
@@ -6034,7 +6243,7 @@ function compileNameFilter(filter) {
6034
6243
  return (name) => name.toLowerCase().includes(needle);
6035
6244
  }
6036
6245
  const matchPath = filter.includes("/");
6037
- const isMatch = picomatch2(filter, { dot: true, nocase: true });
6246
+ const isMatch = picomatch3(filter, { dot: true, nocase: true });
6038
6247
  return matchPath ? (_n, rel) => isMatch(rel) : (name) => isMatch(name);
6039
6248
  }
6040
6249
  function isLikelyBinaryByName(name) {
@@ -6043,7 +6252,7 @@ function isLikelyBinaryByName(name) {
6043
6252
  return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
6044
6253
  }
6045
6254
  function registerFilesystemTools(registry, opts) {
6046
- const rootDir = pathMod3.resolve(opts.rootDir);
6255
+ const rootDir = pathMod4.resolve(opts.rootDir);
6047
6256
  const allowWriting = opts.allowWriting !== false;
6048
6257
  const maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
6049
6258
  const maxListBytes = opts.maxListBytes ?? DEFAULT_MAX_LIST_BYTES;
@@ -6056,10 +6265,10 @@ function registerFilesystemTools(registry, opts) {
6056
6265
  normalized = normalized.slice(1);
6057
6266
  }
6058
6267
  if (normalized.length === 0) normalized = ".";
6059
- const resolved = pathMod3.resolve(rootDir, normalized);
6060
- const normRoot = pathMod3.resolve(rootDir);
6061
- const rel = pathMod3.relative(normRoot, resolved);
6062
- if (rel.startsWith("..") || pathMod3.isAbsolute(rel)) {
6268
+ const resolved = pathMod4.resolve(rootDir, normalized);
6269
+ const normRoot = pathMod4.resolve(rootDir);
6270
+ const rel = pathMod4.relative(normRoot, resolved);
6271
+ if (rel.startsWith("..") || pathMod4.isAbsolute(rel)) {
6063
6272
  throw new Error(
6064
6273
  `path escapes sandbox root (${normRoot}): ${raw} \u2014 workspace is pinned at launch; quit and relaunch with \`reasonix code --dir <path>\` to work in a different folder`
6065
6274
  );
@@ -6091,7 +6300,7 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
6091
6300
  },
6092
6301
  fn: async (args) => {
6093
6302
  const abs = safePath(args.path);
6094
- const fh = await fs3.open(abs, "r");
6303
+ const fh = await fs4.open(abs, "r");
6095
6304
  let raw;
6096
6305
  try {
6097
6306
  const stat2 = await fh.stat();
@@ -6165,7 +6374,7 @@ ${slice.join("\n")}`;
6165
6374
  },
6166
6375
  fn: async (args) => {
6167
6376
  const abs = safePath(args.path ?? ".");
6168
- const entries = await fs3.readdir(abs, { withFileTypes: true });
6377
+ const entries = await fs4.readdir(abs, { withFileTypes: true });
6169
6378
  const lines = [];
6170
6379
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
6171
6380
  lines.push(e.isDirectory() ? `${e.name}/` : e.name);
@@ -6209,7 +6418,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6209
6418
  if (depth > maxDepth) return;
6210
6419
  let entries;
6211
6420
  try {
6212
- entries = await fs3.readdir(dir, { withFileTypes: true });
6421
+ entries = await fs4.readdir(dir, { withFileTypes: true });
6213
6422
  } catch {
6214
6423
  return;
6215
6424
  }
@@ -6244,7 +6453,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6244
6453
  lines.push(line);
6245
6454
  emitted++;
6246
6455
  if (e.isDirectory() && !skip) {
6247
- await walk2(pathMod3.join(dir, e.name), depth + 1);
6456
+ await walk2(pathMod4.join(dir, e.name), depth + 1);
6248
6457
  }
6249
6458
  }
6250
6459
  };
@@ -6305,6 +6514,10 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6305
6514
  include_deps: {
6306
6515
  type: "boolean",
6307
6516
  description: "When true, also search inside node_modules / .git / dist / build / etc. Off by default \u2014 most exploration questions are about the user's own code."
6517
+ },
6518
+ context: {
6519
+ type: "integer",
6520
+ description: "Lines of context to show around each match (both before and after). Default 0 (just the matching line). Capped at 20. Output uses ripgrep style: `:` after the line number on the matching line, `-` on context lines, `--` separating non-adjacent windows."
6308
6521
  }
6309
6522
  },
6310
6523
  required: ["pattern"]
@@ -6321,6 +6534,43 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6321
6534
  { ...args, signal: toolCtx?.signal }
6322
6535
  )
6323
6536
  });
6537
+ registry.register({
6538
+ name: "glob",
6539
+ parallelSafe: true,
6540
+ description: "List files matching a glob pattern, sorted by mtime (most-recently-modified first) by default. Use this for 'what changed lately', 'find all *.test.ts', 'all configs under src/'. Glob syntax matches the cross-tool standard: `*` (any chars in one segment), `**` (any segments), `?` (one char), `{a,b}` (alternation). Pattern matches against the path RELATIVE to the search root (e.g. 'src/**/*.ts' from project root). Skips node_modules / .git / dist / build / etc by default. Default limit 200; raise via `limit` (max 1000). Different from `search_files` (substring on basename) and `search_content` (matches inside file contents).",
6541
+ readOnly: true,
6542
+ parameters: {
6543
+ type: "object",
6544
+ properties: {
6545
+ pattern: {
6546
+ type: "string",
6547
+ description: "Glob pattern, e.g. 'src/**/*.ts', '**/*.{md,mdx}', 'tests/*.test.ts'."
6548
+ },
6549
+ path: {
6550
+ type: "string",
6551
+ description: "Base directory to walk (default: sandbox root). The pattern matches relative to this path."
6552
+ },
6553
+ sort_by: {
6554
+ type: "string",
6555
+ enum: ["mtime", "name"],
6556
+ description: "Sort order. 'mtime' (default) shows most-recently-modified first \u2014 useful for 'what did I change today'. 'name' is alphabetical."
6557
+ },
6558
+ include_deps: {
6559
+ type: "boolean",
6560
+ description: "When true, also walk node_modules / .git / dist / build / etc. Off by default."
6561
+ },
6562
+ limit: {
6563
+ type: "integer",
6564
+ description: "Cap on returned matches. Default 200; clamped to [1, 1000]."
6565
+ }
6566
+ },
6567
+ required: ["pattern"]
6568
+ },
6569
+ fn: async (args, toolCtx) => globFiles({ rootDir, skipDirNames: SKIP_DIR_NAMES }, safePath(args.path ?? "."), {
6570
+ ...args,
6571
+ signal: toolCtx?.signal
6572
+ })
6573
+ });
6324
6574
  registry.register({
6325
6575
  name: "get_file_info",
6326
6576
  parallelSafe: true,
@@ -6335,7 +6585,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6335
6585
  },
6336
6586
  fn: async (args) => {
6337
6587
  const abs = safePath(args.path);
6338
- const st = await fs3.lstat(abs);
6588
+ const st = await fs4.lstat(abs);
6339
6589
  const type = st.isDirectory() ? "directory" : st.isSymbolicLink() ? "symlink" : "file";
6340
6590
  return JSON.stringify({
6341
6591
  type,
@@ -6358,9 +6608,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6358
6608
  },
6359
6609
  fn: async (args) => {
6360
6610
  const abs = safePath(args.path);
6361
- await fs3.mkdir(pathMod3.dirname(abs), { recursive: true });
6362
- await fs3.writeFile(abs, args.content, "utf8");
6363
- return `wrote ${args.content.length} chars to ${displayRel3(rootDir, abs)}`;
6611
+ await fs4.mkdir(pathMod4.dirname(abs), { recursive: true });
6612
+ await fs4.writeFile(abs, args.content, "utf8");
6613
+ return `wrote ${args.content.length} chars to ${displayRel4(rootDir, abs)}`;
6364
6614
  }
6365
6615
  });
6366
6616
  registry.register({
@@ -6377,6 +6627,43 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6377
6627
  },
6378
6628
  fn: async (args) => applyEdit(rootDir, safePath(args.path), args)
6379
6629
  });
6630
+ registry.register({
6631
+ name: "multi_edit",
6632
+ description: "Apply N SEARCH/REPLACE edits across ONE OR MORE files in a single atomic call. Edits run sequentially in array order; for edits that touch the same file, a later edit can match text inserted by an earlier one. If ANY edit fails (search not found, ambiguous match, empty search, file unreadable), NO files are written \u2014 atomic at the validation layer. Same per-edit rules as edit_file: `search` is exact text (whitespace sensitive, no regex) and must be unique in its target file at the moment that edit applies. Use this for renames spanning multiple files, cross-file refactors, or any batch where you'd otherwise loop edit_file.",
6633
+ parameters: {
6634
+ type: "object",
6635
+ properties: {
6636
+ edits: {
6637
+ type: "array",
6638
+ description: "Edits to apply in order. Length \u2265 1. Each edit names its own target file.",
6639
+ items: {
6640
+ type: "object",
6641
+ properties: {
6642
+ path: {
6643
+ type: "string",
6644
+ description: "File the edit targets (sandbox-relative or absolute)."
6645
+ },
6646
+ search: {
6647
+ type: "string",
6648
+ description: "Exact text to find (must be unique in the file)."
6649
+ },
6650
+ replace: { type: "string", description: "Text to substitute in place of `search`." }
6651
+ },
6652
+ required: ["path", "search", "replace"]
6653
+ }
6654
+ }
6655
+ },
6656
+ required: ["edits"]
6657
+ },
6658
+ fn: async (args) => {
6659
+ const resolved = (args.edits ?? []).map((e) => ({
6660
+ abs: safePath(e?.path),
6661
+ search: e?.search,
6662
+ replace: e?.replace
6663
+ }));
6664
+ return applyMultiEdit(rootDir, resolved);
6665
+ }
6666
+ });
6380
6667
  registry.register({
6381
6668
  name: "create_directory",
6382
6669
  description: "Create a directory (and any missing parents) under the sandbox root.",
@@ -6387,8 +6674,8 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6387
6674
  },
6388
6675
  fn: async (args) => {
6389
6676
  const abs = safePath(args.path);
6390
- await fs3.mkdir(abs, { recursive: true });
6391
- return `created ${displayRel3(rootDir, abs)}/`;
6677
+ await fs4.mkdir(abs, { recursive: true });
6678
+ return `created ${displayRel4(rootDir, abs)}/`;
6392
6679
  }
6393
6680
  });
6394
6681
  registry.register({
@@ -6405,9 +6692,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
6405
6692
  fn: async (args) => {
6406
6693
  const src = safePath(args.source);
6407
6694
  const dst = safePath(args.destination);
6408
- await fs3.mkdir(pathMod3.dirname(dst), { recursive: true });
6409
- await fs3.rename(src, dst);
6410
- return `moved ${displayRel3(rootDir, src)} \u2192 ${displayRel3(rootDir, dst)}`;
6695
+ await fs4.mkdir(pathMod4.dirname(dst), { recursive: true });
6696
+ await fs4.rename(src, dst);
6697
+ return `moved ${displayRel4(rootDir, src)} \u2192 ${displayRel4(rootDir, dst)}`;
6411
6698
  }
6412
6699
  });
6413
6700
  return registry;
@@ -6886,6 +7173,108 @@ function registerPlanTool(registry, opts = {}) {
6886
7173
  return registry;
6887
7174
  }
6888
7175
 
7176
+ // src/tools/todo.ts
7177
+ var DESCRIPTION = 'In-session task tracker for multi-step work. NOT a plan \u2014 no approval gate, no checkpoint pauses, doesn\'t touch any files. The tool replaces the entire todo list every call (set semantics, NOT append). Pass the FULL list every time.\n\nWhen to use:\n\u2022 The task has 3+ distinct steps and you want to keep them straight as you work.\n\u2022 The user gave you a multi-part request ("do A, then B, then C").\n\u2022 You\'re partway through a long task and want to record where you are so a future you doesn\'t lose the thread.\n\nWhen NOT to use:\n\u2022 One-shot edits, single-question answers, single-tool tasks.\n\u2022 User-facing approval gates \u2192 that\'s `submit_plan`.\n\u2022 Branching choices \u2192 that\'s `ask_choice`.\n\nRules:\n\u2022 Exactly ONE todo may have status:"in_progress" at a time (or zero \u2014 between steps).\n\u2022 Mark a todo "completed" the moment it\'s actually done \u2014 don\'t batch.\n\u2022 Each todo: `content` (imperative, e.g. "Add tests"), `activeForm` (gerund shown while running, e.g. "Adding tests"), `status`.\n\u2022 Empty `todos:[]` is allowed \u2014 it clears the list when work is fully done.';
7178
+ function validateTodos(raw) {
7179
+ if (!Array.isArray(raw)) {
7180
+ throw new Error("todo_write: `todos` must be an array");
7181
+ }
7182
+ const out = [];
7183
+ let inProgressCount = 0;
7184
+ for (let i = 0; i < raw.length; i++) {
7185
+ const entry = raw[i];
7186
+ if (!entry || typeof entry !== "object") {
7187
+ throw new Error(`todo_write: todo #${i + 1} must be an object`);
7188
+ }
7189
+ const e = entry;
7190
+ const content = typeof e.content === "string" ? e.content.trim() : "";
7191
+ const activeForm = typeof e.activeForm === "string" ? e.activeForm.trim() : "";
7192
+ const status = e.status;
7193
+ if (!content) {
7194
+ throw new Error(`todo_write: todo #${i + 1} \`content\` must be a non-empty string`);
7195
+ }
7196
+ if (!activeForm) {
7197
+ throw new Error(`todo_write: todo #${i + 1} \`activeForm\` must be a non-empty string`);
7198
+ }
7199
+ if (status !== "pending" && status !== "in_progress" && status !== "completed") {
7200
+ throw new Error(
7201
+ `todo_write: todo #${i + 1} \`status\` must be one of pending|in_progress|completed (got ${JSON.stringify(status)})`
7202
+ );
7203
+ }
7204
+ if (status === "in_progress") {
7205
+ inProgressCount++;
7206
+ if (inProgressCount > 1) {
7207
+ throw new Error(
7208
+ "todo_write: at most one todo may be in_progress at a time \u2014 mark the previous one completed first"
7209
+ );
7210
+ }
7211
+ }
7212
+ out.push({ content, status, activeForm });
7213
+ }
7214
+ return out;
7215
+ }
7216
+ function renderTodos(todos) {
7217
+ if (todos.length === 0) return "todos cleared (0 items)";
7218
+ let done = 0;
7219
+ let inProgress = 0;
7220
+ let pending = 0;
7221
+ for (const t2 of todos) {
7222
+ if (t2.status === "completed") done++;
7223
+ else if (t2.status === "in_progress") inProgress++;
7224
+ else pending++;
7225
+ }
7226
+ const header = `todos updated \xB7 ${done} done \xB7 ${inProgress} in progress \xB7 ${pending} pending`;
7227
+ const lines = todos.map((t2) => {
7228
+ if (t2.status === "completed") return `[x] ${t2.content}`;
7229
+ if (t2.status === "in_progress") return `[>] ${t2.activeForm}`;
7230
+ return `[ ] ${t2.content}`;
7231
+ });
7232
+ return `${header}
7233
+ ${lines.join("\n")}`;
7234
+ }
7235
+ function registerTodoTool(registry, opts = {}) {
7236
+ registry.register({
7237
+ name: "todo_write",
7238
+ description: DESCRIPTION,
7239
+ readOnly: true,
7240
+ parameters: {
7241
+ type: "object",
7242
+ properties: {
7243
+ todos: {
7244
+ type: "array",
7245
+ description: "The COMPLETE new todo list. Replaces whatever was there before. Pass [] to clear.",
7246
+ items: {
7247
+ type: "object",
7248
+ properties: {
7249
+ content: {
7250
+ type: "string",
7251
+ description: 'Imperative step description, e.g. "Add tests for parser".'
7252
+ },
7253
+ status: {
7254
+ type: "string",
7255
+ enum: ["pending", "in_progress", "completed"],
7256
+ description: "Current state. Exactly one item may be in_progress."
7257
+ },
7258
+ activeForm: {
7259
+ type: "string",
7260
+ description: 'Gerund form shown while in_progress, e.g. "Adding tests for parser".'
7261
+ }
7262
+ },
7263
+ required: ["content", "status", "activeForm"]
7264
+ }
7265
+ }
7266
+ },
7267
+ required: ["todos"]
7268
+ },
7269
+ fn: async (args) => {
7270
+ const todos = validateTodos(args?.todos);
7271
+ opts.onTodosUpdated?.(todos);
7272
+ return renderTodos(todos);
7273
+ }
7274
+ });
7275
+ return registry;
7276
+ }
7277
+
6889
7278
  // src/tools/subagent-types.ts
6890
7279
  var EXPLORE_SYSTEM = `You are an exploration subagent. Wide-net read-only investigation; return one distilled answer.
6891
7280
 
@@ -7255,11 +7644,11 @@ function forkRegistryWithAllowList(parent, allow, alsoExclude) {
7255
7644
  }
7256
7645
 
7257
7646
  // src/tools/shell.ts
7258
- import * as pathMod7 from "path";
7647
+ import * as pathMod8 from "path";
7259
7648
 
7260
7649
  // src/tools/jobs.ts
7261
7650
  import { spawn as spawn2 } from "child_process";
7262
- import * as pathMod4 from "path";
7651
+ import * as pathMod5 from "path";
7263
7652
  function killProcessTree(pid, signal) {
7264
7653
  if (process.platform === "win32") {
7265
7654
  const args = ["/pid", String(pid), "/T"];
@@ -7319,7 +7708,7 @@ var JobRegistry = class {
7319
7708
  const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
7320
7709
  const { bin, args, spawnOverrides } = prepareSpawn(argv);
7321
7710
  const spawnOpts = {
7322
- cwd: pathMod4.resolve(opts.cwd),
7711
+ cwd: pathMod5.resolve(opts.cwd),
7323
7712
  shell: false,
7324
7713
  windowsHide: true,
7325
7714
  env: process.env,
@@ -7605,12 +7994,12 @@ function latestOutputSince(before, after) {
7605
7994
  // src/tools/shell/exec.ts
7606
7995
  import { spawn as spawn4, spawnSync } from "child_process";
7607
7996
  import { existsSync as existsSync8, statSync as statSync4 } from "fs";
7608
- import * as pathMod6 from "path";
7997
+ import * as pathMod7 from "path";
7609
7998
 
7610
7999
  // src/tools/shell-chain.ts
7611
8000
  import { spawn as spawn3 } from "child_process";
7612
8001
  import { closeSync, openSync } from "fs";
7613
- import * as pathMod5 from "path";
8002
+ import * as pathMod6 from "path";
7614
8003
  var UnsupportedSyntaxError = class extends Error {
7615
8004
  constructor(detail) {
7616
8005
  super(`run_command: ${detail}`);
@@ -7877,7 +8266,7 @@ function openRedirects(redirects, cwd) {
7877
8266
  let bothFd = null;
7878
8267
  const toClose = [];
7879
8268
  const open = (target, flags) => {
7880
- const resolved = pathMod5.resolve(cwd, target);
8269
+ const resolved = pathMod6.resolve(cwd, target);
7881
8270
  const fd = openSync(resolved, flags);
7882
8271
  toClose.push(fd);
7883
8272
  return fd;
@@ -8373,16 +8762,16 @@ function resolveExecutable(cmd, opts = {}) {
8373
8762
  const platform = opts.platform ?? process.platform;
8374
8763
  if (platform !== "win32") return cmd;
8375
8764
  if (!cmd) return cmd;
8376
- if (cmd.includes("/") || cmd.includes("\\") || pathMod6.isAbsolute(cmd)) return cmd;
8377
- if (pathMod6.extname(cmd)) return cmd;
8765
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod7.isAbsolute(cmd)) return cmd;
8766
+ if (pathMod7.extname(cmd)) return cmd;
8378
8767
  const env = opts.env ?? process.env;
8379
8768
  const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
8380
- const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod6.delimiter);
8769
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod7.delimiter);
8381
8770
  const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
8382
8771
  const isFile = opts.isFile ?? defaultIsFile;
8383
8772
  for (const dir of pathDirs) {
8384
8773
  for (const ext of pathExt) {
8385
- const full = pathMod6.win32.join(dir, cmd + ext);
8774
+ const full = pathMod7.win32.join(dir, cmd + ext);
8386
8775
  if (isFile(full)) return full;
8387
8776
  }
8388
8777
  }
@@ -8452,8 +8841,8 @@ function withUtf8Codepage(cmdline) {
8452
8841
  function isBareWindowsName(s) {
8453
8842
  if (!s) return false;
8454
8843
  if (s.includes("/") || s.includes("\\")) return false;
8455
- if (pathMod6.isAbsolute(s)) return false;
8456
- if (pathMod6.extname(s)) return false;
8844
+ if (pathMod7.isAbsolute(s)) return false;
8845
+ if (pathMod7.extname(s)) return false;
8457
8846
  return true;
8458
8847
  }
8459
8848
  function quoteForCmdExe(arg) {
@@ -8474,7 +8863,7 @@ var NeedsConfirmationError = class extends Error {
8474
8863
  }
8475
8864
  };
8476
8865
  function registerShellTools(registry, opts) {
8477
- const rootDir = pathMod7.resolve(opts.rootDir);
8866
+ const rootDir = pathMod8.resolve(opts.rootDir);
8478
8867
  const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
8479
8868
  const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
8480
8869
  const jobs = opts.jobs ?? new JobRegistry();
@@ -9519,7 +9908,7 @@ function truncate(s, n) {
9519
9908
  // src/version.ts
9520
9909
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
9521
9910
  import { homedir as homedir6 } from "os";
9522
- import { dirname as dirname6, join as join11 } from "path";
9911
+ import { dirname as dirname6, join as join12 } from "path";
9523
9912
  import { fileURLToPath as fileURLToPath2 } from "url";
9524
9913
  var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
9525
9914
  var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -9528,7 +9917,7 @@ function readPackageVersion() {
9528
9917
  try {
9529
9918
  let dir = dirname6(fileURLToPath2(import.meta.url));
9530
9919
  for (let i = 0; i < 6; i++) {
9531
- const p = join11(dir, "package.json");
9920
+ const p = join12(dir, "package.json");
9532
9921
  if (existsSync9(p)) {
9533
9922
  const pkg = JSON.parse(readFileSync12(p, "utf8"));
9534
9923
  if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
@@ -9545,7 +9934,7 @@ function readPackageVersion() {
9545
9934
  }
9546
9935
  var VERSION = readPackageVersion();
9547
9936
  function cachePath(homeDirOverride) {
9548
- return join11(homeDirOverride ?? homedir6(), ".reasonix", "version-cache.json");
9937
+ return join12(homeDirOverride ?? homedir6(), ".reasonix", "version-cache.json");
9549
9938
  }
9550
9939
  function readCache(homeDirOverride) {
9551
9940
  try {
@@ -10539,8 +10928,8 @@ function lineEndingOf(text) {
10539
10928
 
10540
10929
  // src/code/prompt.ts
10541
10930
  import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
10542
- import { join as join12 } from "path";
10543
- var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, list_directory, directory_tree, search_files, search_content, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell.
10931
+ import { join as join13 } from "path";
10932
+ var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, multi_edit, list_directory, directory_tree, search_files, search_content, glob, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell, plus \`todo_write\` for in-session multi-step tracking.
10544
10933
 
10545
10934
  # Cite or shut up \u2014 non-negotiable
10546
10935
 
@@ -10588,10 +10977,23 @@ Skip it when one option is clearly correct (just do it, or submit_plan) or a fre
10588
10977
 
10589
10978
  Each option: short stable id (A/B/C), one-line title, optional summary. \`allowCustom: true\` when their real answer might not fit. Max 6. A ~1-sentence lead-in before the call is fine ("I see three directions \u2014 letting you pick"); don't repeat the options in it. After the call, STOP.
10590
10979
 
10980
+ # When to track multi-step intent (todo_write)
10981
+
10982
+ \`todo_write\` is a lightweight in-session task tracker \u2014 NOT a plan. No approval gate, no checkpoint pauses, doesn't touch files. Use it when the task has 3+ distinct steps and you'd otherwise lose track of where you are. Each call REPLACES the entire list (set semantics). Exactly one item may be \`in_progress\` at a time \u2014 flip it to \`completed\` the moment that step's done, before starting the next.
10983
+
10984
+ Use it for:
10985
+ - Multi-part user requests ("do A, then B, then C") \u2014 record the parts so you don't drop one.
10986
+ - Long refactors where you've finished step 2 of 5 and want a visible record.
10987
+ - Any moment where you'd otherwise enumerate "1. ... 2. ... 3. ..." in prose \u2014 the tool is strictly better, the UI shows progress live.
10988
+
10989
+ Skip it for: one-shot edits, single-question answers, anything that fits in one tool call. Don't \`todo_write\` and \`submit_plan\` for the same work \u2014 \`submit_plan\` is for tasks that need a review gate; \`todo_write\` is for personal bookkeeping after the user has already given you the green light.
10990
+
10991
+ Call shape: \`{ todos: [{ content, activeForm, status }, ...] }\` \u2014 \`content\` is imperative ("Add tests"), \`activeForm\` is gerund ("Adding tests") shown while \`in_progress\`. Pass the FULL list every call, not a delta. Pass \`todos: []\` to clear when work's done.
10992
+
10591
10993
  # Plan mode (/plan)
10592
10994
 
10593
10995
  The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit constraint:
10594
- - Write tools (edit_file, write_file, create_directory, move_file) and non-allowlisted run_command calls are BOUNCED at dispatch \u2014 you'll get a tool result like "unavailable in plan mode". Don't retry them.
10996
+ - Write tools (edit_file, multi_edit, write_file, create_directory, move_file) and non-allowlisted run_command calls are BOUNCED at dispatch \u2014 you'll get a tool result like "unavailable in plan mode". Don't retry them.
10595
10997
  - Read tools (read_file, list_directory, search_files, directory_tree, get_file_info) and allowlisted read-only / test shell commands still work \u2014 use them to investigate.
10596
10998
  - You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
10597
10999
 
@@ -10660,6 +11062,7 @@ Rules:
10660
11062
  >>>>>>> REPLACE
10661
11063
  - Do NOT use write_file to change existing files \u2014 the user reviews your edits as SEARCH/REPLACE. write_file is only for files you explicitly want to overwrite wholesale (rare).
10662
11064
  - Paths are relative to the working directory. Don't use absolute paths.
11065
+ - For multi-site changes \u2014 same file or across files \u2014 prefer \`multi_edit\` over N \`edit_file\` calls. Shape: \`{ edits: [{ path, search, replace }, ...] }\`. All edits validate before any file is written; any failure \u2192 ALL files untouched. Per-file edits run in array order, so a later edit can match text inserted by an earlier one.
10663
11066
 
10664
11067
  # Trust what you already know
10665
11068
 
@@ -10669,7 +11072,7 @@ Before exploring the filesystem to answer a factual question, check whether the
10669
11072
 
10670
11073
  - Skip dependency, build, and VCS directories unless the user explicitly asks. The pinned .gitignore block (if any, below) is your authoritative denylist.
10671
11074
  - Prefer \`search_files\` over \`list_directory\` when you know roughly what you're looking for \u2014 it saves context and avoids enumerating huge trees. Note: \`search_files\` matches file NAMES; for searching file CONTENTS use \`search_content\`.
10672
- - Available exploration tools: \`read_file\`, \`list_directory\`, \`directory_tree\`, \`search_files\` (filename match), \`search_content\` (content grep \u2014 use for "where is X called", "find all references to Y"), \`get_file_info\`. Don't call \`grep\` or other tools that aren't in this list \u2014 they don't exist as functions.
11075
+ - Available exploration tools: \`read_file\`, \`list_directory\`, \`directory_tree\`, \`search_files\` (filename match), \`glob\` (mtime-sorted glob \u2014 use for "what changed lately", "all *.ts under src/"), \`search_content\` (content grep \u2014 use for "where is X called", "find all references to Y"; pass \`context:N\` for grep -C N around hits), \`get_file_info\`. Don't call \`grep\` or other tools that aren't in this list \u2014 they don't exist as functions.
10673
11076
 
10674
11077
  # Path conventions
10675
11078
 
@@ -10742,7 +11145,7 @@ If \`semantic_search\` returns nothing useful (low scores, off-topic), THEN fall
10742
11145
  function codeSystemPrompt(rootDir, opts = {}) {
10743
11146
  const base = opts.hasSemanticSearch ? `${CODE_SYSTEM_PROMPT}${SEMANTIC_SEARCH_ROUTING}` : CODE_SYSTEM_PROMPT;
10744
11147
  const withMemory = applyMemoryStack(base, rootDir);
10745
- const gitignorePath = join12(rootDir, ".gitignore");
11148
+ const gitignorePath = join13(rootDir, ".gitignore");
10746
11149
  let result = withMemory;
10747
11150
  if (existsSync11(gitignorePath)) {
10748
11151
  let content;
@@ -10793,9 +11196,9 @@ import {
10793
11196
  writeFileSync as writeFileSync7
10794
11197
  } from "fs";
10795
11198
  import { homedir as homedir7 } from "os";
10796
- import { dirname as dirname8, join as join13 } from "path";
11199
+ import { dirname as dirname8, join as join14 } from "path";
10797
11200
  function defaultUsageLogPath(homeDirOverride) {
10798
- return join13(homeDirOverride ?? homedir7(), ".reasonix", "usage.jsonl");
11201
+ return join14(homeDirOverride ?? homedir7(), ".reasonix", "usage.jsonl");
10799
11202
  }
10800
11203
  var USAGE_COMPACTION_THRESHOLD_BYTES = 5 * 1024 * 1024;
10801
11204
  var USAGE_RETENTION_DAYS = 365;
@@ -11112,6 +11515,7 @@ export {
11112
11515
  registerPlanTool,
11113
11516
  registerShellTools,
11114
11517
  registerSubagentTool,
11518
+ registerTodoTool,
11115
11519
  registerWebTools,
11116
11520
  renderMarkdown as renderDiffMarkdown,
11117
11521
  renderSummaryTable as renderDiffSummary,