reasonix 0.4.17 → 0.4.19

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.
package/dist/index.js CHANGED
@@ -499,9 +499,25 @@ function setByPath(target, path, value) {
499
499
  var ToolRegistry = class {
500
500
  _tools = /* @__PURE__ */ new Map();
501
501
  _autoFlatten;
502
+ /**
503
+ * When true, `dispatch` refuses any tool whose `readOnly` flag isn't
504
+ * set (and whose `readOnlyCheck` doesn't pass on the specific args).
505
+ * Drives `reasonix code`'s Plan Mode — the model can still explore
506
+ * via read tools but its writes and non-allowlisted shell calls are
507
+ * bounced until the user approves a submitted plan.
508
+ */
509
+ _planMode = false;
502
510
  constructor(opts = {}) {
503
511
  this._autoFlatten = opts.autoFlatten !== false;
504
512
  }
513
+ /** Enable / disable plan-mode enforcement at dispatch. */
514
+ setPlanMode(on) {
515
+ this._planMode = Boolean(on);
516
+ }
517
+ /** True when the registry is currently refusing non-readonly calls. */
518
+ get planMode() {
519
+ return this._planMode;
520
+ }
505
521
  register(def) {
506
522
  if (!def.name) throw new Error("tool requires a name");
507
523
  const internal = { ...def };
@@ -553,16 +569,38 @@ var ToolRegistry = class {
553
569
  if (tool.flatSchema && args && typeof args === "object" && hasDotKey(args)) {
554
570
  args = nestArguments(args);
555
571
  }
572
+ if (this._planMode && !isReadOnlyCall(tool, args)) {
573
+ return JSON.stringify({
574
+ error: `${name}: unavailable in plan mode \u2014 this is a read-only exploration phase. Use read_file / list_directory / search_files / directory_tree / web_search / allowlisted shell commands to investigate. Call submit_plan with your proposed plan when you're ready for the user's review.`
575
+ });
576
+ }
556
577
  try {
557
578
  const result = await tool.fn(args, { signal: opts.signal });
558
579
  return typeof result === "string" ? result : JSON.stringify(result);
559
580
  } catch (err) {
581
+ const e = err;
582
+ if (typeof e.toToolResult === "function") {
583
+ try {
584
+ return JSON.stringify(e.toToolResult());
585
+ } catch {
586
+ }
587
+ }
560
588
  return JSON.stringify({
561
- error: `${err.name}: ${err.message}`
589
+ error: `${e.name}: ${e.message}`
562
590
  });
563
591
  }
564
592
  }
565
593
  };
594
+ function isReadOnlyCall(tool, args) {
595
+ if (tool.readOnlyCheck) {
596
+ try {
597
+ return Boolean(tool.readOnlyCheck(args));
598
+ } catch {
599
+ return false;
600
+ }
601
+ }
602
+ return tool.readOnly === true;
603
+ }
566
604
  function hasDotKey(obj) {
567
605
  for (const k of Object.keys(obj)) {
568
606
  if (k.includes(".")) return true;
@@ -949,6 +987,16 @@ var ToolCallRepair = class {
949
987
  this.opts = opts;
950
988
  this.storm = new StormBreaker(opts.stormWindow ?? 6, opts.stormThreshold ?? 3);
951
989
  }
990
+ /**
991
+ * Drop the StormBreaker's sliding window of recent (name, args)
992
+ * signatures. Called at the start of every user turn — a fresh user
993
+ * message is a new intent, so carrying old repetition state into it
994
+ * would turn a valid "try again with different input" flow into a
995
+ * false-positive block.
996
+ */
997
+ resetStorm() {
998
+ this.storm.reset();
999
+ }
952
1000
  process(declaredCalls, reasoningContent, content = null) {
953
1001
  const report = {
954
1002
  scavenged: 0,
@@ -1401,6 +1449,7 @@ var CacheFirstLoop = class {
1401
1449
  async *step(userInput) {
1402
1450
  this._turn++;
1403
1451
  this.scratch.reset();
1452
+ this.repair.resetStorm();
1404
1453
  this._turnAbort = new AbortController();
1405
1454
  const signal = this._turnAbort.signal;
1406
1455
  let pendingUser = userInput;
@@ -1624,6 +1673,16 @@ var CacheFirstLoop = class {
1624
1673
  repair: report,
1625
1674
  branch: branchSummary
1626
1675
  };
1676
+ if (report.stormsBroken > 0) {
1677
+ const noteTail = report.notes.length ? ` \u2014 ${report.notes[report.notes.length - 1]}` : "";
1678
+ const allSuppressed = repairedCalls.length === 0 && toolCalls.length > 0;
1679
+ const phrase = allSuppressed ? `stopped the model from calling the same tool with identical args repeatedly (all ${toolCalls.length} call(s) this turn were already in the recent-repeat window). Likely a stuck retry \u2014 reword your instruction, rule out the underlying blocker, or try /retry after fixing it` : `suppressed ${report.stormsBroken} repeat tool call(s) that had fired 3+ times with identical args in a sliding window`;
1680
+ yield {
1681
+ turn: this._turn,
1682
+ role: "warning",
1683
+ content: `${phrase}${noteTail}`
1684
+ };
1685
+ }
1627
1686
  if (repairedCalls.length === 0) {
1628
1687
  yield { turn: this._turn, role: "done", content: assistantContent };
1629
1688
  return;
@@ -1916,6 +1975,7 @@ function registerFilesystemTools(registry, opts) {
1916
1975
  registry.register({
1917
1976
  name: "read_file",
1918
1977
  description: "Read a file under the sandbox root. Returns the full contents (truncated with a notice if larger than the per-call cap). Paths may be relative to the root or absolute-under-root.",
1978
+ readOnly: true,
1919
1979
  parameters: {
1920
1980
  type: "object",
1921
1981
  properties: {
@@ -1953,6 +2013,7 @@ function registerFilesystemTools(registry, opts) {
1953
2013
  registry.register({
1954
2014
  name: "list_directory",
1955
2015
  description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
2016
+ readOnly: true,
1956
2017
  parameters: {
1957
2018
  type: "object",
1958
2019
  properties: {
@@ -1972,6 +2033,7 @@ function registerFilesystemTools(registry, opts) {
1972
2033
  registry.register({
1973
2034
  name: "directory_tree",
1974
2035
  description: "Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Caps output so a huge tree doesn't drown the context.",
2036
+ readOnly: true,
1975
2037
  parameters: {
1976
2038
  type: "object",
1977
2039
  properties: {
@@ -2018,6 +2080,7 @@ function registerFilesystemTools(registry, opts) {
2018
2080
  registry.register({
2019
2081
  name: "search_files",
2020
2082
  description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
2083
+ readOnly: true,
2021
2084
  parameters: {
2022
2085
  type: "object",
2023
2086
  properties: {
@@ -2070,6 +2133,7 @@ function registerFilesystemTools(registry, opts) {
2070
2133
  registry.register({
2071
2134
  name: "get_file_info",
2072
2135
  description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
2136
+ readOnly: true,
2073
2137
  parameters: {
2074
2138
  type: "object",
2075
2139
  properties: {
@@ -2226,8 +2290,54 @@ function lineDiff(a, b) {
2226
2290
  return out;
2227
2291
  }
2228
2292
 
2293
+ // src/tools/plan.ts
2294
+ var PlanProposedError = class extends Error {
2295
+ plan;
2296
+ constructor(plan) {
2297
+ super(
2298
+ "PlanProposedError: plan submitted. STOP calling tools now \u2014 the TUI has shown the plan to the user. Wait for their next message; it will either approve (you'll then implement the plan), request a refinement (you should explore more and submit an updated plan), or cancel (drop the plan and ask what they want instead). Don't call any tools in the meantime."
2299
+ );
2300
+ this.name = "PlanProposedError";
2301
+ this.plan = plan;
2302
+ }
2303
+ /**
2304
+ * Structured tool-result shape. Consumed by the TUI to extract the
2305
+ * plan without regex-scraping the error message.
2306
+ */
2307
+ toToolResult() {
2308
+ return { error: `${this.name}: ${this.message}`, plan: this.plan };
2309
+ }
2310
+ };
2311
+ function registerPlanTool(registry, opts = {}) {
2312
+ registry.register({
2313
+ name: "submit_plan",
2314
+ description: "Submit a concrete plan to the user for review before executing. Use this for tasks that warrant a review gate \u2014 multi-file refactors, architecture changes, anything that would be expensive or confusing to undo. Skip it for small fixes (one-line typo, obvious bug with a clear fix) \u2014 just make the change. The user will either approve (you then implement it), ask for refinement, or cancel. If the user has already enabled /plan mode, writes are blocked at dispatch and you MUST use this. Write the plan as markdown with a one-line summary, a bulleted list of files to touch and what will change, and any risks or open questions.",
2315
+ readOnly: true,
2316
+ parameters: {
2317
+ type: "object",
2318
+ properties: {
2319
+ plan: {
2320
+ type: "string",
2321
+ description: "Markdown-formatted plan. Lead with a one-sentence summary. Then a file-by-file breakdown of what you'll change and why. Flag any risks or open questions at the end so the user can weigh in before you start."
2322
+ }
2323
+ },
2324
+ required: ["plan"]
2325
+ },
2326
+ fn: async (args) => {
2327
+ const plan = (args?.plan ?? "").trim();
2328
+ if (!plan) {
2329
+ throw new Error("submit_plan: empty plan \u2014 write a markdown plan and try again.");
2330
+ }
2331
+ opts.onPlanSubmitted?.(plan);
2332
+ throw new PlanProposedError(plan);
2333
+ }
2334
+ });
2335
+ return registry;
2336
+ }
2337
+
2229
2338
  // src/tools/shell.ts
2230
2339
  import { spawn } from "child_process";
2340
+ import { existsSync as existsSync3, statSync as statSync2 } from "fs";
2231
2341
  import * as pathMod2 from "path";
2232
2342
  var DEFAULT_TIMEOUT_SEC = 60;
2233
2343
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
@@ -2345,10 +2455,12 @@ async function runCommand(cmd, opts) {
2345
2455
  windowsHide: true,
2346
2456
  env: process.env
2347
2457
  };
2458
+ const { bin, args, spawnOverrides } = prepareSpawn(argv);
2459
+ const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
2348
2460
  return await new Promise((resolve5, reject) => {
2349
2461
  let child;
2350
2462
  try {
2351
- child = spawn(argv[0], argv.slice(1), spawnOpts);
2463
+ child = spawn(bin, args, effectiveSpawnOpts);
2352
2464
  } catch (err) {
2353
2465
  reject(err);
2354
2466
  return;
@@ -2382,6 +2494,59 @@ async function runCommand(cmd, opts) {
2382
2494
  });
2383
2495
  });
2384
2496
  }
2497
+ function resolveExecutable(cmd, opts = {}) {
2498
+ const platform = opts.platform ?? process.platform;
2499
+ if (platform !== "win32") return cmd;
2500
+ if (!cmd) return cmd;
2501
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
2502
+ if (pathMod2.extname(cmd)) return cmd;
2503
+ const env = opts.env ?? process.env;
2504
+ const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
2505
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
2506
+ const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
2507
+ const isFile = opts.isFile ?? defaultIsFile;
2508
+ for (const dir of pathDirs) {
2509
+ for (const ext of pathExt) {
2510
+ const full = pathMod2.join(dir, cmd + ext);
2511
+ if (isFile(full)) return full;
2512
+ }
2513
+ }
2514
+ return cmd;
2515
+ }
2516
+ function defaultIsFile(full) {
2517
+ try {
2518
+ return existsSync3(full) && statSync2(full).isFile();
2519
+ } catch {
2520
+ return false;
2521
+ }
2522
+ }
2523
+ function prepareSpawn(argv, opts = {}) {
2524
+ const head = argv[0] ?? "";
2525
+ const tail = argv.slice(1);
2526
+ const platform = opts.platform ?? process.platform;
2527
+ const resolved = resolveExecutable(head, opts);
2528
+ if (platform !== "win32") {
2529
+ return { bin: resolved, args: [...tail], spawnOverrides: {} };
2530
+ }
2531
+ if (/\.(cmd|bat)$/i.test(resolved)) {
2532
+ const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
2533
+ return {
2534
+ bin: "cmd.exe",
2535
+ args: ["/d", "/s", "/c", cmdline],
2536
+ // windowsVerbatimArguments prevents Node from re-quoting the /c
2537
+ // payload — we've already composed an exact cmd.exe command
2538
+ // line. Without this Node wraps our already-quoted string in
2539
+ // another round of quotes and cmd.exe can't parse it.
2540
+ spawnOverrides: { windowsVerbatimArguments: true }
2541
+ };
2542
+ }
2543
+ return { bin: resolved, args: [...tail], spawnOverrides: {} };
2544
+ }
2545
+ function quoteForCmdExe(arg) {
2546
+ if (arg === "") return '""';
2547
+ if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
2548
+ return `"${arg.replace(/"/g, '""')}"`;
2549
+ }
2385
2550
  var NeedsConfirmationError = class extends Error {
2386
2551
  command;
2387
2552
  constructor(command) {
@@ -2401,6 +2566,16 @@ function registerShellTools(registry, opts) {
2401
2566
  registry.register({
2402
2567
  name: "run_command",
2403
2568
  description: "Run a shell command in the project root and return its combined stdout+stderr. Read-only and test commands (git status, ls, npm test, pytest, cargo test, grep, etc.) run immediately. Anything that could mutate state (npm install, git commit, rm, chmod) is refused and the user has to confirm in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
2569
+ // Plan-mode gate: allow allowlisted commands through (git status,
2570
+ // cargo check, ls, grep …) so the model can actually investigate
2571
+ // during planning. Anything that would otherwise trigger a
2572
+ // confirmation prompt is treated as "not read-only" and bounced.
2573
+ readOnlyCheck: (args) => {
2574
+ if (allowAll) return true;
2575
+ const cmd = typeof args?.command === "string" ? args.command.trim() : "";
2576
+ if (!cmd) return false;
2577
+ return isAllowed(cmd, extraAllowed);
2578
+ },
2404
2579
  parameters: {
2405
2580
  type: "object",
2406
2581
  properties: {
@@ -2567,6 +2742,7 @@ function registerWebTools(registry, opts = {}) {
2567
2742
  registry.register({
2568
2743
  name: "web_search",
2569
2744
  description: "Search the public web. Returns ranked results with title, url, and snippet. Use this when the question needs information more current than your training data, when you're unsure of a factual detail, or when the user asks about a specific webpage/library/release you haven't seen.",
2745
+ readOnly: true,
2570
2746
  parameters: {
2571
2747
  type: "object",
2572
2748
  properties: {
@@ -2589,6 +2765,7 @@ function registerWebTools(registry, opts = {}) {
2589
2765
  registry.register({
2590
2766
  name: "web_fetch",
2591
2767
  description: "Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
2768
+ readOnly: true,
2592
2769
  parameters: {
2593
2770
  type: "object",
2594
2771
  properties: {
@@ -3753,7 +3930,7 @@ async function trySection(load) {
3753
3930
  }
3754
3931
 
3755
3932
  // src/code/edit-blocks.ts
3756
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
3933
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
3757
3934
  import { dirname as dirname3, resolve as resolve4 } from "path";
3758
3935
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
3759
3936
  function parseEditBlocks(text) {
@@ -3782,7 +3959,7 @@ function applyEditBlock(block, rootDir) {
3782
3959
  };
3783
3960
  }
3784
3961
  const searchEmpty = block.search.length === 0;
3785
- const exists = existsSync3(absTarget);
3962
+ const exists = existsSync4(absTarget);
3786
3963
  try {
3787
3964
  if (!exists) {
3788
3965
  if (!searchEmpty) {
@@ -3830,7 +4007,7 @@ function snapshotBeforeEdits(blocks, rootDir) {
3830
4007
  if (seen.has(b.path)) continue;
3831
4008
  seen.add(b.path);
3832
4009
  const abs = resolve4(absRoot, b.path);
3833
- if (!existsSync3(abs)) {
4010
+ if (!existsSync4(abs)) {
3834
4011
  snapshots.push({ path: b.path, prevContent: null });
3835
4012
  continue;
3836
4013
  }
@@ -3855,7 +4032,7 @@ function restoreSnapshots(snapshots, rootDir) {
3855
4032
  }
3856
4033
  try {
3857
4034
  if (snap.prevContent === null) {
3858
- if (existsSync3(abs)) unlinkSync2(abs);
4035
+ if (existsSync4(abs)) unlinkSync2(abs);
3859
4036
  return {
3860
4037
  path: snap.path,
3861
4038
  status: "applied",
@@ -3878,10 +4055,31 @@ function sep() {
3878
4055
  }
3879
4056
 
3880
4057
  // src/code/prompt.ts
3881
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
3882
- import { join as join4 } from "path";
4058
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
4059
+ import { join as join5 } from "path";
3883
4060
  var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
3884
4061
 
4062
+ # When to propose a plan (submit_plan)
4063
+
4064
+ You have a \`submit_plan\` tool that shows the user a markdown plan and lets them Approve / Refine / Cancel before you execute. Use it proactively when the task is large enough to deserve a review gate:
4065
+
4066
+ - Multi-file refactors or renames.
4067
+ - Architecture changes (moving modules, splitting / merging files, new abstractions).
4068
+ - Anything where "undo" after the fact would be expensive \u2014 migrations, destructive cleanups, API shape changes.
4069
+ - When the user's request is ambiguous and multiple reasonable interpretations exist \u2014 propose your reading as a plan and let them confirm.
4070
+
4071
+ Skip submit_plan for small, obvious changes: one-line typo, clear bug with a clear fix, adding a missing import, renaming a local variable. Just do those.
4072
+
4073
+ Plan body: one-sentence summary, then a file-by-file breakdown of what you'll change and why, and any risks or open questions. If some decisions are genuinely up to the user (naming, tradeoffs, out-of-scope possibilities), list them in an "Open questions" or "\u5F85\u786E\u8BA4" section \u2014 the user sees the plan in a picker and has a text input to answer your questions before approving. Don't pretend certainty you don't have; flagged questions are how the user tells you what they care about. After calling submit_plan, STOP \u2014 don't call any more tools, wait for the user's verdict.
4074
+
4075
+ # Plan mode (/plan)
4076
+
4077
+ The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit constraint:
4078
+ - 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.
4079
+ - Read tools (read_file, list_directory, search_files, directory_tree, get_file_info) and allowlisted shell (git status/log/diff, ls, cat, grep, cargo check, npm test) still work \u2014 use them to investigate.
4080
+ - You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
4081
+
4082
+
3885
4083
  # When to edit vs. when to explore
3886
4084
 
3887
4085
  Only propose edits when the user explicitly asks you to change, fix, add, remove, refactor, or write something. Do NOT propose edits when the user asks you to:
@@ -3929,8 +4127,8 @@ Rules:
3929
4127
  `;
3930
4128
  function codeSystemPrompt(rootDir) {
3931
4129
  const withMemory = applyProjectMemory(CODE_SYSTEM_PROMPT, rootDir);
3932
- const gitignorePath = join4(rootDir, ".gitignore");
3933
- if (!existsSync4(gitignorePath)) return withMemory;
4130
+ const gitignorePath = join5(rootDir, ".gitignore");
4131
+ if (!existsSync5(gitignorePath)) return withMemory;
3934
4132
  let content;
3935
4133
  try {
3936
4134
  content = readFileSync6(gitignorePath, "utf8");
@@ -3955,9 +4153,9 @@ ${truncated}
3955
4153
  // src/config.ts
3956
4154
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
3957
4155
  import { homedir as homedir2 } from "os";
3958
- import { dirname as dirname4, join as join5 } from "path";
4156
+ import { dirname as dirname4, join as join6 } from "path";
3959
4157
  function defaultConfigPath() {
3960
- return join5(homedir2(), ".reasonix", "config.json");
4158
+ return join6(homedir2(), ".reasonix", "config.json");
3961
4159
  }
3962
4160
  function readConfig(path = defaultConfigPath()) {
3963
4161
  try {
@@ -3996,7 +4194,7 @@ function redactKey(key) {
3996
4194
  }
3997
4195
 
3998
4196
  // src/index.ts
3999
- var VERSION = "0.4.17";
4197
+ var VERSION = "0.4.19";
4000
4198
  export {
4001
4199
  AppendOnlyLog,
4002
4200
  CODE_SYSTEM_PROMPT,
@@ -4009,6 +4207,7 @@ export {
4009
4207
  NeedsConfirmationError,
4010
4208
  PROJECT_MEMORY_FILE,
4011
4209
  PROJECT_MEMORY_MAX_CHARS,
4210
+ PlanProposedError,
4012
4211
  SessionStats,
4013
4212
  SseTransport,
4014
4213
  StdioTransport,
@@ -4061,18 +4260,22 @@ export {
4061
4260
  parseMcpSpec,
4062
4261
  parseMojeekResults,
4063
4262
  parseTranscript,
4263
+ prepareSpawn,
4264
+ quoteForCmdExe,
4064
4265
  readConfig,
4065
4266
  readProjectMemory,
4066
4267
  readTranscript,
4067
4268
  recordFromLoopEvent,
4068
4269
  redactKey,
4069
4270
  registerFilesystemTools,
4271
+ registerPlanTool,
4070
4272
  registerShellTools,
4071
4273
  registerWebTools,
4072
4274
  renderMarkdown as renderDiffMarkdown,
4073
4275
  renderSummaryTable as renderDiffSummary,
4074
4276
  repairTruncatedJson,
4075
4277
  replayFromFile,
4278
+ resolveExecutable,
4076
4279
  restoreSnapshots,
4077
4280
  runBranches,
4078
4281
  runCommand,