reasonix 0.5.22 → 0.5.24

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
@@ -47,8 +47,8 @@ function computeWait(attempt, initial, cap, retryAfter) {
47
47
  }
48
48
  function sleep(ms, signal) {
49
49
  if (ms <= 0) return Promise.resolve();
50
- return new Promise((resolve8, reject) => {
51
- const timer = setTimeout(resolve8, ms);
50
+ return new Promise((resolve9, reject) => {
51
+ const timer = setTimeout(resolve9, ms);
52
52
  if (signal) {
53
53
  const onAbort = () => {
54
54
  clearTimeout(timer);
@@ -533,7 +533,7 @@ function matchesTool(hook, toolName) {
533
533
  }
534
534
  }
535
535
  function defaultSpawner(input) {
536
- return new Promise((resolve8) => {
536
+ return new Promise((resolve9) => {
537
537
  const child = spawn(input.command, {
538
538
  cwd: input.cwd,
539
539
  shell: true,
@@ -560,7 +560,7 @@ function defaultSpawner(input) {
560
560
  });
561
561
  child.once("error", (err) => {
562
562
  clearTimeout(timer);
563
- resolve8({
563
+ resolve9({
564
564
  exitCode: null,
565
565
  stdout,
566
566
  stderr,
@@ -570,7 +570,7 @@ function defaultSpawner(input) {
570
570
  });
571
571
  child.once("close", (code) => {
572
572
  clearTimeout(timer);
573
- resolve8({
573
+ resolve9({
574
574
  exitCode: code,
575
575
  stdout: stdout.trim(),
576
576
  stderr: stderr.trim(),
@@ -900,6 +900,12 @@ var ToolRegistry = class {
900
900
  * bounced until the user approves a submitted plan.
901
901
  */
902
902
  _planMode = false;
903
+ /**
904
+ * Optional hook run after arg parsing but before tool.fn. Lets the TUI
905
+ * reroute specific tool calls (e.g. edit_file in review mode) without
906
+ * modifying the tool definitions themselves.
907
+ */
908
+ _interceptor = null;
903
909
  constructor(opts = {}) {
904
910
  this._autoFlatten = opts.autoFlatten !== false;
905
911
  }
@@ -911,6 +917,14 @@ var ToolRegistry = class {
911
917
  get planMode() {
912
918
  return this._planMode;
913
919
  }
920
+ /**
921
+ * Install or clear the dispatch interceptor. At most one interceptor
922
+ * is active at a time — calling twice replaces the previous. Pass
923
+ * `null` to remove.
924
+ */
925
+ setToolInterceptor(fn) {
926
+ this._interceptor = fn;
927
+ }
914
928
  register(def) {
915
929
  if (!def.name) throw new Error("tool requires a name");
916
930
  const internal = { ...def };
@@ -967,6 +981,16 @@ var ToolRegistry = class {
967
981
  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.`
968
982
  });
969
983
  }
984
+ if (this._interceptor) {
985
+ try {
986
+ const short = await this._interceptor(name, args);
987
+ if (typeof short === "string") return short;
988
+ } catch (err) {
989
+ return JSON.stringify({
990
+ error: `${name}: interceptor failed \u2014 ${err.message}`
991
+ });
992
+ }
993
+ }
970
994
  try {
971
995
  const result = await tool.fn(args, { signal: opts.signal });
972
996
  const str = typeof result === "string" ? result : JSON.stringify(result);
@@ -1700,6 +1724,7 @@ function round(n, digits) {
1700
1724
  }
1701
1725
 
1702
1726
  // src/loop.ts
1727
+ var ARGS_COMPACT_THRESHOLD_TOKENS = 800;
1703
1728
  var CacheFirstLoop = class {
1704
1729
  client;
1705
1730
  prefix;
@@ -1803,12 +1828,62 @@ var CacheFirstLoop = class {
1803
1828
  * authored intent we can't mechanically shrink without losing
1804
1829
  * meaning.
1805
1830
  */
1806
- compact(maxTokens = 4e3) {
1831
+ /**
1832
+ * Conservative args-only shrink fired after every tool response —
1833
+ * strictly about ONE thing: stop oversized `edit_file` / `write_file`
1834
+ * arguments from riding every future turn's prompt.
1835
+ *
1836
+ * Why this is worth doing AUTOMATICALLY (not just on /compact):
1837
+ * Each tool-call arguments string sticks in the log verbatim. On a
1838
+ * coding session with ~10 edits, that's 20-40K tokens of stale
1839
+ * SEARCH/REPLACE text riding along on every turn. Even at a 98.9%
1840
+ * cache hit rate the input cost still adds up linearly (cache-hit
1841
+ * price × tokens × turns). Compacting IMMEDIATELY after the tool
1842
+ * responds means the next turn's prompt is already smaller — the
1843
+ * shrink is a one-time write that saves every future prompt.
1844
+ *
1845
+ * Threshold rationale: 800 tokens ≈ 3 KB. A typical 20-line edit's
1846
+ * args land well under that; massive rewrites (whole-file content,
1847
+ * 100+ line refactors) land above and get the compaction. Small
1848
+ * edits stay byte-verbatim so nothing common-case changes.
1849
+ *
1850
+ * Safety: we ONLY shrink args whose tool has ALREADY responded.
1851
+ * Structurally that's every call in `log.toMessages()` at this
1852
+ * point — the current turn's assistant/tool pairing is by
1853
+ * construction closed by the time we get here (append happens
1854
+ * AFTER dispatch). The in-flight assistant message being built
1855
+ * lives in scratch, not the log, so this pass can't touch it.
1856
+ *
1857
+ * Model impact: the model may occasionally want to reference the
1858
+ * exact SEARCH text of a prior edit — it then reads the file
1859
+ * directly (which shows current state) or looks at the preceding
1860
+ * assistant text (which has its plan). Losing the stale args is a
1861
+ * net win: one extra read_file vs. dragging N KB of stale text
1862
+ * through every subsequent turn.
1863
+ */
1864
+ compactToolCallArgsAfterResponse() {
1807
1865
  const before = this.log.toMessages();
1808
- const { messages, healedCount, tokensSaved, charsSaved } = shrinkOversizedToolResultsByTokens(
1866
+ const { messages, healedCount } = shrinkOversizedToolCallArgsByTokens(
1809
1867
  before,
1810
- maxTokens
1868
+ ARGS_COMPACT_THRESHOLD_TOKENS
1811
1869
  );
1870
+ if (healedCount === 0) return;
1871
+ this.log.compactInPlace(messages);
1872
+ if (this.sessionName) {
1873
+ try {
1874
+ rewriteSession(this.sessionName, messages);
1875
+ } catch {
1876
+ }
1877
+ }
1878
+ }
1879
+ compact(maxTokens = 4e3) {
1880
+ const before = this.log.toMessages();
1881
+ const resultsPass = shrinkOversizedToolResultsByTokens(before, maxTokens);
1882
+ const argsPass = shrinkOversizedToolCallArgsByTokens(resultsPass.messages, maxTokens);
1883
+ const messages = argsPass.messages;
1884
+ const healedCount = resultsPass.healedCount + argsPass.healedCount;
1885
+ const tokensSaved = resultsPass.tokensSaved + argsPass.tokensSaved;
1886
+ const charsSaved = resultsPass.charsSaved + argsPass.charsSaved;
1812
1887
  if (healedCount > 0) {
1813
1888
  this.log.compactInPlace(messages);
1814
1889
  if (this.sessionName) {
@@ -2051,8 +2126,8 @@ var CacheFirstLoop = class {
2051
2126
  }
2052
2127
  );
2053
2128
  for (let k = 0; k < budget; k++) {
2054
- const sample = queue.shift() ?? await new Promise((resolve8) => {
2055
- waiter = resolve8;
2129
+ const sample = queue.shift() ?? await new Promise((resolve9) => {
2130
+ waiter = resolve9;
2056
2131
  });
2057
2132
  yield {
2058
2133
  turn: this._turn,
@@ -2318,6 +2393,7 @@ ${reason}`;
2318
2393
  name,
2319
2394
  content: result
2320
2395
  });
2396
+ this.compactToolCallArgsAfterResponse();
2321
2397
  yield {
2322
2398
  turn: this._turn,
2323
2399
  role: "tool",
@@ -2503,6 +2579,56 @@ function shrinkOversizedToolResultsByTokens(messages, maxTokens) {
2503
2579
  });
2504
2580
  return { messages: out, healedCount, tokensSaved, charsSaved };
2505
2581
  }
2582
+ function shrinkOversizedToolCallArgsByTokens(messages, maxTokens) {
2583
+ let healedCount = 0;
2584
+ let tokensSaved = 0;
2585
+ let charsSaved = 0;
2586
+ const out = messages.map((msg) => {
2587
+ if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return msg;
2588
+ let changed = false;
2589
+ const newCalls = msg.tool_calls.map((call) => {
2590
+ const args = call.function?.arguments;
2591
+ if (typeof args !== "string" || args.length <= maxTokens) return call;
2592
+ const beforeTokens = countTokens(args);
2593
+ if (beforeTokens <= maxTokens) return call;
2594
+ const shrunk = shrinkJsonLongStrings(args);
2595
+ const afterTokens = countTokens(shrunk);
2596
+ if (afterTokens >= beforeTokens) return call;
2597
+ changed = true;
2598
+ healedCount += 1;
2599
+ tokensSaved += beforeTokens - afterTokens;
2600
+ charsSaved += args.length - shrunk.length;
2601
+ return { ...call, function: { ...call.function, arguments: shrunk } };
2602
+ });
2603
+ if (!changed) return msg;
2604
+ return { ...msg, tool_calls: newCalls };
2605
+ });
2606
+ return { messages: out, healedCount, tokensSaved, charsSaved };
2607
+ }
2608
+ function shrinkJsonLongStrings(jsonStr) {
2609
+ let parsed;
2610
+ try {
2611
+ parsed = JSON.parse(jsonStr);
2612
+ } catch {
2613
+ const head = jsonStr.slice(0, 200);
2614
+ return `${head}\u2026[shrunk: ${jsonStr.length} chars, unparsed]`;
2615
+ }
2616
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2617
+ return jsonStr;
2618
+ }
2619
+ const LONG_THRESHOLD = 300;
2620
+ const input = parsed;
2621
+ const output = {};
2622
+ for (const [k, v] of Object.entries(input)) {
2623
+ if (typeof v === "string" && v.length > LONG_THRESHOLD) {
2624
+ const newlines = v.match(/\n/g)?.length ?? 0;
2625
+ output[k] = `[\u2026shrunk: ${v.length} chars, ${newlines} lines \u2014 tool already responded, see result]`;
2626
+ } else {
2627
+ output[k] = v;
2628
+ }
2629
+ }
2630
+ return JSON.stringify(output);
2631
+ }
2506
2632
  function fixToolCallPairing(messages) {
2507
2633
  const out = [];
2508
2634
  let droppedAssistantCalls = 0;
@@ -2566,10 +2692,41 @@ function formatLoopError(err) {
2566
2692
  if (msg.includes("maximum context length")) {
2567
2693
  const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
2568
2694
  const requested = reqMatch ? `${Number(reqMatch[1]).toLocaleString()} tokens` : "too many tokens";
2569
- return `Context overflow (DeepSeek 400): session history is ${requested}, past the 131,072-token limit. Usually this means a single tool call returned a huge payload. v0.3.0-alpha.6+ caps new tool results at 32k chars, AND auto-heals oversized history on session load \u2014 restart Reasonix and this session should come back trimmed. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
2695
+ return `Context overflow (DeepSeek 400): session history is ${requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load \u2014 a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
2696
+ }
2697
+ const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
2698
+ if (!m) return msg;
2699
+ const status = m[1] ?? "";
2700
+ const body = m[2] ?? "";
2701
+ const inner = extractDeepSeekErrorMessage(body);
2702
+ if (status === "401") {
2703
+ return `Authentication failed (DeepSeek 401): ${inner}. Your API key is rejected. Fix with \`reasonix setup\` or \`export DEEPSEEK_API_KEY=sk-...\`. Get one at https://platform.deepseek.com/api_keys.`;
2704
+ }
2705
+ if (status === "402") {
2706
+ return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up \u2014 the panel header shows your balance once it's non-zero.`;
2707
+ }
2708
+ if (status === "422") {
2709
+ return `Invalid parameter (DeepSeek 422): ${inner}`;
2710
+ }
2711
+ if (status === "400") {
2712
+ return `Bad request (DeepSeek 400): ${inner}`;
2570
2713
  }
2571
2714
  return msg;
2572
2715
  }
2716
+ function extractDeepSeekErrorMessage(body) {
2717
+ const trimmed = body.trim();
2718
+ if (!trimmed) return "(no message)";
2719
+ try {
2720
+ const parsed = JSON.parse(trimmed);
2721
+ if (parsed && typeof parsed === "object") {
2722
+ const obj = parsed;
2723
+ if (obj.error && typeof obj.error.message === "string") return obj.error.message;
2724
+ if (typeof obj.message === "string") return obj.message;
2725
+ }
2726
+ } catch {
2727
+ }
2728
+ return trimmed;
2729
+ }
2573
2730
 
2574
2731
  // src/at-mentions.ts
2575
2732
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
@@ -3335,6 +3492,9 @@ import { promises as fs } from "fs";
3335
3492
  import * as pathMod from "path";
3336
3493
  var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
3337
3494
  var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
3495
+ var DEFAULT_AUTO_PREVIEW_LINES = 200;
3496
+ var AUTO_PREVIEW_HEAD_LINES = 80;
3497
+ var AUTO_PREVIEW_TAIL_LINES = 40;
3338
3498
  var SKIP_DIR_NAMES = /* @__PURE__ */ new Set([
3339
3499
  "node_modules",
3340
3500
  ".git",
@@ -3427,14 +3587,22 @@ function registerFilesystemTools(registry, opts) {
3427
3587
  };
3428
3588
  registry.register({
3429
3589
  name: "read_file",
3430
- 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.",
3590
+ description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
3591
+ - head: N \u2192 first N lines (imports, public API, small configs)
3592
+ - tail: N \u2192 last N lines (recently-added code, log tails)
3593
+ - range: "A-B" \u2192 inclusive line range A..B, 1-indexed (e.g. "120-180" around an edit site)
3594
+ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_LINES} lines, the tool auto-returns a head+tail preview with an "N lines omitted" marker rather than dumping everything. If you need the middle, re-call with a range. Prefer search_content to locate a symbol first, then read_file with a range around the hit \u2014 one scoped read beats three full-file reads.`,
3431
3595
  readOnly: true,
3432
3596
  parameters: {
3433
3597
  type: "object",
3434
3598
  properties: {
3435
3599
  path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
3436
3600
  head: { type: "integer", description: "If set, return only the first N lines." },
3437
- tail: { type: "integer", description: "If set, return only the last N lines." }
3601
+ tail: { type: "integer", description: "If set, return only the last N lines." },
3602
+ range: {
3603
+ type: "string",
3604
+ description: 'Inclusive line range like "50-100" or "50-50". 1-indexed. Takes precedence over head/tail when all three are set. Out-of-range requests clamp to file bounds.'
3605
+ }
3438
3606
  },
3439
3607
  required: ["path"]
3440
3608
  },
@@ -3446,21 +3614,52 @@ function registerFilesystemTools(registry, opts) {
3446
3614
  }
3447
3615
  const raw = await fs.readFile(abs);
3448
3616
  if (raw.length > maxReadBytes) {
3449
- const head = raw.slice(0, maxReadBytes).toString("utf8");
3450
- return `${head}
3617
+ const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
3618
+ return `${headBytes}
3451
3619
 
3452
- [\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail for targeted view.]`;
3620
+ [\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail/range for targeted view.]`;
3453
3621
  }
3454
3622
  const text = raw.toString("utf8");
3623
+ let lines = text.split(/\r?\n/);
3624
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
3625
+ const totalLines = lines.length;
3626
+ if (typeof args.range === "string" && /^\d+\s*-\s*\d+$/.test(args.range)) {
3627
+ const [rawStart, rawEnd] = args.range.split("-").map((s) => Number.parseInt(s, 10));
3628
+ const start = Math.max(1, rawStart ?? 1);
3629
+ const end = Math.min(totalLines, Math.max(start, rawEnd ?? totalLines));
3630
+ const slice = lines.slice(start - 1, end);
3631
+ const label = `[range ${start}-${end} of ${totalLines} lines]`;
3632
+ return `${label}
3633
+ ${slice.join("\n")}`;
3634
+ }
3455
3635
  if (typeof args.head === "number" && args.head > 0) {
3456
- return text.split(/\r?\n/).slice(0, args.head).join("\n");
3636
+ const count = Math.min(args.head, totalLines);
3637
+ const slice = lines.slice(0, count);
3638
+ const marker = count < totalLines ? `
3639
+
3640
+ [\u2026head ${count} of ${totalLines} lines \u2014 call again with range / tail for more]` : "";
3641
+ return slice.join("\n") + marker;
3457
3642
  }
3458
3643
  if (typeof args.tail === "number" && args.tail > 0) {
3459
- let lines = text.split(/\r?\n/);
3460
- if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
3461
- return lines.slice(Math.max(0, lines.length - args.tail)).join("\n");
3644
+ const count = Math.min(args.tail, totalLines);
3645
+ const slice = lines.slice(totalLines - count);
3646
+ const marker = count < totalLines ? `[\u2026tail ${count} of ${totalLines} lines \u2014 call again with range / head for more]
3647
+
3648
+ ` : "";
3649
+ return marker + slice.join("\n");
3462
3650
  }
3463
- return text;
3651
+ if (totalLines <= DEFAULT_AUTO_PREVIEW_LINES) return lines.join("\n");
3652
+ const head = lines.slice(0, AUTO_PREVIEW_HEAD_LINES).join("\n");
3653
+ const tail = lines.slice(totalLines - AUTO_PREVIEW_TAIL_LINES).join("\n");
3654
+ const omitted = totalLines - AUTO_PREVIEW_HEAD_LINES - AUTO_PREVIEW_TAIL_LINES;
3655
+ return [
3656
+ `[auto-preview: head ${AUTO_PREVIEW_HEAD_LINES} + tail ${AUTO_PREVIEW_TAIL_LINES} of ${totalLines} lines]`,
3657
+ head,
3658
+ `
3659
+ [\u2026 ${omitted} lines omitted \u2014 call read_file again with range:"A-B" (1-indexed) or head / tail to get the middle]
3660
+ `,
3661
+ tail
3662
+ ].join("\n");
3464
3663
  }
3465
3664
  });
3466
3665
  registry.register({
@@ -3485,21 +3684,34 @@ function registerFilesystemTools(registry, opts) {
3485
3684
  });
3486
3685
  registry.register({
3487
3686
  name: "directory_tree",
3488
- 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.",
3687
+ description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
3688
+ - maxDepth defaults to 2 (root + one level). A depth-4 tree on a real repo blew ~5K tokens in one call. If you truly need deeper, pass maxDepth:N explicitly.
3689
+ - Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
3690
+ - Large subtrees (>50 children) auto-collapse to "[N files, M dirs hidden \u2014 list_directory <path> to inspect]" so one huge folder can't dominate the output.
3691
+ Prefer \`list_directory\` for a single-level view, \`search_files\` to find specific paths, and \`search_content\` to find code.`,
3489
3692
  readOnly: true,
3490
3693
  parameters: {
3491
3694
  type: "object",
3492
3695
  properties: {
3493
3696
  path: { type: "string", description: "Root of the tree (default: sandbox root)." },
3494
- maxDepth: { type: "integer", description: "Max recursion depth (default 4)." }
3697
+ maxDepth: {
3698
+ type: "integer",
3699
+ description: "Max recursion depth (default 2). Depth 0 shows only the top-level entries; depth 2 is usually enough to see module structure."
3700
+ },
3701
+ include_deps: {
3702
+ type: "boolean",
3703
+ description: "When true, also traverse node_modules / .git / dist / build / etc. Off by default \u2014 most exploration questions are about the user's own code."
3704
+ }
3495
3705
  }
3496
3706
  },
3497
3707
  fn: async (args) => {
3498
3708
  const startAbs = safePath(args.path ?? ".");
3499
- const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 4;
3709
+ const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 2;
3710
+ const includeDeps = args.include_deps === true;
3500
3711
  const lines = [];
3501
3712
  let totalBytes = 0;
3502
3713
  let truncated = false;
3714
+ const PER_DIR_CHILD_CAP = 50;
3503
3715
  const walk2 = async (dir, depth) => {
3504
3716
  if (truncated) return;
3505
3717
  if (depth > maxDepth) return;
@@ -3510,10 +3722,27 @@ function registerFilesystemTools(registry, opts) {
3510
3722
  return;
3511
3723
  }
3512
3724
  entries.sort((a, b) => a.name.localeCompare(b.name));
3725
+ let emitted = 0;
3513
3726
  for (const e of entries) {
3514
3727
  if (truncated) return;
3728
+ const skip = e.isDirectory() && !includeDeps && SKIP_DIR_NAMES.has(e.name);
3729
+ if (emitted >= PER_DIR_CHILD_CAP) {
3730
+ const remaining = entries.length - emitted;
3731
+ let restFiles = 0;
3732
+ let restDirs = 0;
3733
+ for (const r of entries.slice(emitted)) {
3734
+ if (r.isDirectory()) restDirs++;
3735
+ else restFiles++;
3736
+ }
3737
+ const indent2 = " ".repeat(depth);
3738
+ lines.push(
3739
+ `${indent2}[\u2026 ${remaining} entries hidden (${restDirs} dirs, ${restFiles} files) \u2014 list_directory on this path to see all]`
3740
+ );
3741
+ return;
3742
+ }
3515
3743
  const indent = " ".repeat(depth);
3516
- const line = e.isDirectory() ? `${indent}${e.name}/` : `${indent}${e.name}`;
3744
+ const suffix = skip ? " (skipped \u2014 pass include_deps:true to traverse)" : "";
3745
+ const line = e.isDirectory() ? `${indent}${e.name}/${suffix}` : `${indent}${e.name}`;
3517
3746
  totalBytes += line.length + 1;
3518
3747
  if (totalBytes > maxListBytes) {
3519
3748
  lines.push(` [\u2026 tree truncated at ${maxListBytes} bytes \u2026]`);
@@ -3521,7 +3750,8 @@ function registerFilesystemTools(registry, opts) {
3521
3750
  return;
3522
3751
  }
3523
3752
  lines.push(line);
3524
- if (e.isDirectory()) {
3753
+ emitted++;
3754
+ if (e.isDirectory() && !skip) {
3525
3755
  await walk2(pathMod.join(dir, e.name), depth + 1);
3526
3756
  }
3527
3757
  }
@@ -4231,9 +4461,311 @@ function forkRegistryExcluding(parent, exclude) {
4231
4461
  }
4232
4462
 
4233
4463
  // src/tools/shell.ts
4234
- import { spawn as spawn2 } from "child_process";
4464
+ import { spawn as spawn3 } from "child_process";
4235
4465
  import { existsSync as existsSync8, statSync as statSync4 } from "fs";
4466
+ import * as pathMod3 from "path";
4467
+
4468
+ // src/tools/jobs.ts
4469
+ import { spawn as spawn2 } from "child_process";
4236
4470
  import * as pathMod2 from "path";
4471
+ function killProcessTree(pid, signal) {
4472
+ if (process.platform === "win32") {
4473
+ const args = ["/pid", String(pid), "/T"];
4474
+ if (signal === "SIGKILL") args.push("/F");
4475
+ try {
4476
+ const killer = spawn2("taskkill", args, {
4477
+ stdio: "ignore",
4478
+ windowsHide: true
4479
+ });
4480
+ killer.on("error", () => {
4481
+ });
4482
+ } catch {
4483
+ }
4484
+ return;
4485
+ }
4486
+ try {
4487
+ process.kill(-pid, signal);
4488
+ return;
4489
+ } catch {
4490
+ }
4491
+ try {
4492
+ process.kill(pid, signal);
4493
+ } catch {
4494
+ }
4495
+ }
4496
+ var DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
4497
+ var READY_SIGNALS = [
4498
+ // HTTP server banners
4499
+ /\blistening on\b/i,
4500
+ /\blocal:\s+https?:\/\//i,
4501
+ /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?\b/i,
4502
+ /\b(?:ready|server started|started server|app listening)\b/i,
4503
+ // Bundlers / compilers
4504
+ /\bcompiled successfully\b/i,
4505
+ /\bbuild complete(?:d)?\b/i,
4506
+ /\bwatching for (?:file )?changes\b/i,
4507
+ /\bready in \d+/i,
4508
+ // Generic
4509
+ /\bstartup (?:complete|finished)\b/i
4510
+ ];
4511
+ var JobRegistry = class {
4512
+ jobs = /* @__PURE__ */ new Map();
4513
+ nextId = 1;
4514
+ /**
4515
+ * Spawn a background child. Resolves after `waitSec` OR on ready
4516
+ * signal OR on early exit, whichever comes first. The child continues
4517
+ * to run (and buffer output) regardless of which path fires.
4518
+ */
4519
+ async start(command, opts) {
4520
+ const trimmed = command.trim();
4521
+ if (!trimmed) throw new Error("run_background: empty command");
4522
+ const op = detectShellOperator(trimmed);
4523
+ if (op !== null) {
4524
+ throw new Error(
4525
+ `run_background: shell operator "${op}" is not supported \u2014 spawn one process per background job. Compose via your orchestration, not the shell.`
4526
+ );
4527
+ }
4528
+ const argv = tokenizeCommand(trimmed);
4529
+ if (argv.length === 0) throw new Error("run_background: empty command");
4530
+ const waitMs = Math.max(0, Math.min(30, opts.waitSec ?? 3)) * 1e3;
4531
+ const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
4532
+ const { bin, args, spawnOverrides } = prepareSpawn(argv);
4533
+ const spawnOpts = {
4534
+ cwd: pathMod2.resolve(opts.cwd),
4535
+ shell: false,
4536
+ windowsHide: true,
4537
+ env: process.env,
4538
+ // POSIX: detach so the child becomes its own process-group leader.
4539
+ // Required for `process.kill(-pid, …)` later — without it a group
4540
+ // kill fails and we end up only signaling the wrapper, leaving
4541
+ // grandchildren (node → vite → esbuild …) orphaned.
4542
+ // Windows: detached would spawn a new console window; leave the
4543
+ // default and use taskkill /T for tree termination.
4544
+ detached: process.platform !== "win32",
4545
+ ...spawnOverrides
4546
+ };
4547
+ let child;
4548
+ try {
4549
+ child = spawn2(bin, args, spawnOpts);
4550
+ } catch (err) {
4551
+ const id2 = this.nextId++;
4552
+ const job2 = {
4553
+ id: id2,
4554
+ command: trimmed,
4555
+ pid: null,
4556
+ startedAt: Date.now(),
4557
+ exitCode: null,
4558
+ output: `[spawn failed] ${err.message}`,
4559
+ totalBytesWritten: 0,
4560
+ running: false,
4561
+ spawnError: err.message,
4562
+ child: null,
4563
+ readyPromise: Promise.resolve(),
4564
+ signalReady: () => {
4565
+ }
4566
+ };
4567
+ this.jobs.set(id2, job2);
4568
+ return {
4569
+ jobId: id2,
4570
+ pid: null,
4571
+ stillRunning: false,
4572
+ readyMatched: false,
4573
+ preview: job2.output,
4574
+ exitCode: null
4575
+ };
4576
+ }
4577
+ const id = this.nextId++;
4578
+ let readyResolve = () => {
4579
+ };
4580
+ const readyPromise = new Promise((res) => {
4581
+ readyResolve = res;
4582
+ });
4583
+ const job = {
4584
+ id,
4585
+ command: trimmed,
4586
+ pid: child.pid ?? null,
4587
+ startedAt: Date.now(),
4588
+ exitCode: null,
4589
+ output: "",
4590
+ totalBytesWritten: 0,
4591
+ running: true,
4592
+ child,
4593
+ readyPromise,
4594
+ signalReady: readyResolve
4595
+ };
4596
+ this.jobs.set(id, job);
4597
+ let readyMatched = false;
4598
+ const onData = (chunk) => {
4599
+ const s = chunk.toString();
4600
+ job.totalBytesWritten += s.length;
4601
+ job.output += s;
4602
+ if (job.output.length > maxBytes) {
4603
+ const overflow = job.output.length - maxBytes;
4604
+ const cut = job.output.indexOf("\n", overflow);
4605
+ const start = cut >= 0 ? cut + 1 : overflow;
4606
+ job.output = `[\u2026 older output dropped \u2026]
4607
+ ${job.output.slice(start)}`;
4608
+ }
4609
+ if (!readyMatched) {
4610
+ for (const re of READY_SIGNALS) {
4611
+ if (re.test(s) || re.test(job.output)) {
4612
+ readyMatched = true;
4613
+ job.signalReady();
4614
+ break;
4615
+ }
4616
+ }
4617
+ }
4618
+ };
4619
+ child.stdout?.on("data", onData);
4620
+ child.stderr?.on("data", onData);
4621
+ child.on("error", (err) => {
4622
+ job.running = false;
4623
+ job.spawnError = err.message;
4624
+ job.signalReady();
4625
+ });
4626
+ child.on("close", (code) => {
4627
+ job.running = false;
4628
+ job.exitCode = code;
4629
+ job.signalReady();
4630
+ });
4631
+ const onAbort = () => this.stop(id, { graceMs: 100 });
4632
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
4633
+ let timer = null;
4634
+ await Promise.race([
4635
+ readyPromise,
4636
+ new Promise((res) => {
4637
+ timer = setTimeout(res, waitMs);
4638
+ })
4639
+ ]);
4640
+ if (timer) clearTimeout(timer);
4641
+ return {
4642
+ jobId: id,
4643
+ pid: job.pid,
4644
+ stillRunning: job.running,
4645
+ readyMatched,
4646
+ preview: job.output,
4647
+ exitCode: job.exitCode
4648
+ };
4649
+ }
4650
+ /**
4651
+ * Read a job's accumulated output. `since` lets a caller poll
4652
+ * incrementally: pass the byte count returned from the last call to
4653
+ * get only newly-written content. Returns both full output and a
4654
+ * running snapshot so the caller can use whichever.
4655
+ */
4656
+ read(id, opts = {}) {
4657
+ const job = this.jobs.get(id);
4658
+ if (!job) return null;
4659
+ const full = job.output;
4660
+ let slice = full;
4661
+ if (typeof opts.since === "number" && opts.since >= 0 && opts.since < full.length) {
4662
+ slice = full.slice(opts.since);
4663
+ }
4664
+ if (typeof opts.tailLines === "number" && opts.tailLines > 0) {
4665
+ const lines = slice.split("\n");
4666
+ const keep = lines.slice(Math.max(0, lines.length - opts.tailLines));
4667
+ slice = keep.join("\n");
4668
+ }
4669
+ return {
4670
+ output: slice,
4671
+ byteLength: full.length,
4672
+ running: job.running,
4673
+ exitCode: job.exitCode,
4674
+ command: job.command,
4675
+ pid: job.pid,
4676
+ spawnError: job.spawnError
4677
+ };
4678
+ }
4679
+ /**
4680
+ * Send SIGTERM, wait `graceMs`, then SIGKILL if still alive. Returns
4681
+ * the final job record (or null when the job id is unknown). Safe to
4682
+ * call on an already-exited job — returns the record unchanged.
4683
+ */
4684
+ async stop(id, opts = {}) {
4685
+ const job = this.jobs.get(id);
4686
+ if (!job) return null;
4687
+ if (!job.running || !job.child) return snapshot(job);
4688
+ const graceMs = Math.max(0, opts.graceMs ?? 2e3);
4689
+ if (job.pid !== null) {
4690
+ killProcessTree(job.pid, "SIGTERM");
4691
+ } else {
4692
+ try {
4693
+ job.child.kill("SIGTERM");
4694
+ } catch {
4695
+ }
4696
+ }
4697
+ await Promise.race([job.readyPromise, new Promise((res) => setTimeout(res, graceMs))]);
4698
+ if (job.running) {
4699
+ if (job.pid !== null) {
4700
+ killProcessTree(job.pid, "SIGKILL");
4701
+ } else {
4702
+ try {
4703
+ job.child.kill("SIGKILL");
4704
+ } catch {
4705
+ }
4706
+ }
4707
+ await new Promise((res) => setTimeout(res, 800));
4708
+ }
4709
+ return snapshot(job);
4710
+ }
4711
+ list() {
4712
+ return [...this.jobs.values()].map(snapshot);
4713
+ }
4714
+ /**
4715
+ * Best-effort kill of every still-running job. Called on TUI shutdown
4716
+ * so dev servers don't outlive the Reasonix process. Resolves after
4717
+ * every child has closed or a hard deadline passes (3s total).
4718
+ */
4719
+ async shutdown(deadlineMs = 5e3) {
4720
+ const start = Date.now();
4721
+ const runningJobs = [...this.jobs.values()].filter((j) => j.running && j.child);
4722
+ if (runningJobs.length === 0) return;
4723
+ for (const job of runningJobs) {
4724
+ if (job.pid !== null) killProcessTree(job.pid, "SIGTERM");
4725
+ else
4726
+ try {
4727
+ job.child?.kill("SIGTERM");
4728
+ } catch {
4729
+ }
4730
+ }
4731
+ const allClose = Promise.all(runningJobs.map((j) => j.readyPromise));
4732
+ const elapsed = () => Date.now() - start;
4733
+ const graceMs = Math.min(1500, Math.max(0, deadlineMs / 2));
4734
+ await Promise.race([allClose, new Promise((res) => setTimeout(res, graceMs))]);
4735
+ for (const job of runningJobs) {
4736
+ if (!job.running) continue;
4737
+ if (job.pid !== null) killProcessTree(job.pid, "SIGKILL");
4738
+ else
4739
+ try {
4740
+ job.child?.kill("SIGKILL");
4741
+ } catch {
4742
+ }
4743
+ }
4744
+ const remaining = Math.max(800, deadlineMs - elapsed());
4745
+ await Promise.race([allClose, new Promise((res) => setTimeout(res, remaining))]);
4746
+ }
4747
+ /** Count of still-running jobs — drives the TUI status-bar indicator. */
4748
+ runningCount() {
4749
+ let n = 0;
4750
+ for (const job of this.jobs.values()) if (job.running) n++;
4751
+ return n;
4752
+ }
4753
+ };
4754
+ function snapshot(job) {
4755
+ return {
4756
+ id: job.id,
4757
+ command: job.command,
4758
+ pid: job.pid,
4759
+ startedAt: job.startedAt,
4760
+ exitCode: job.exitCode,
4761
+ output: job.output,
4762
+ totalBytesWritten: job.totalBytesWritten,
4763
+ running: job.running,
4764
+ spawnError: job.spawnError
4765
+ };
4766
+ }
4767
+
4768
+ // src/tools/shell.ts
4237
4769
  var DEFAULT_TIMEOUT_SEC = 60;
4238
4770
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
4239
4771
  var BUILTIN_ALLOWLIST = [
@@ -4402,10 +4934,10 @@ async function runCommand(cmd, opts) {
4402
4934
  };
4403
4935
  const { bin, args, spawnOverrides } = prepareSpawn(argv);
4404
4936
  const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
4405
- return await new Promise((resolve8, reject) => {
4937
+ return await new Promise((resolve9, reject) => {
4406
4938
  let child;
4407
4939
  try {
4408
- child = spawn2(bin, args, effectiveSpawnOpts);
4940
+ child = spawn3(bin, args, effectiveSpawnOpts);
4409
4941
  } catch (err) {
4410
4942
  reject(err);
4411
4943
  return;
@@ -4435,7 +4967,7 @@ async function runCommand(cmd, opts) {
4435
4967
  const output = buf.length > maxChars ? `${buf.slice(0, maxChars)}
4436
4968
 
4437
4969
  [\u2026 truncated ${buf.length - maxChars} chars \u2026]` : buf;
4438
- resolve8({ exitCode: code, output, timedOut });
4970
+ resolve9({ exitCode: code, output, timedOut });
4439
4971
  });
4440
4972
  });
4441
4973
  }
@@ -4443,16 +4975,16 @@ function resolveExecutable(cmd, opts = {}) {
4443
4975
  const platform = opts.platform ?? process.platform;
4444
4976
  if (platform !== "win32") return cmd;
4445
4977
  if (!cmd) return cmd;
4446
- if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
4447
- if (pathMod2.extname(cmd)) return cmd;
4978
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod3.isAbsolute(cmd)) return cmd;
4979
+ if (pathMod3.extname(cmd)) return cmd;
4448
4980
  const env = opts.env ?? process.env;
4449
4981
  const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
4450
- const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
4982
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod3.delimiter);
4451
4983
  const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
4452
4984
  const isFile = opts.isFile ?? defaultIsFile;
4453
4985
  for (const dir of pathDirs) {
4454
4986
  for (const ext of pathExt) {
4455
- const full = pathMod2.win32.join(dir, cmd + ext);
4987
+ const full = pathMod3.win32.join(dir, cmd + ext);
4456
4988
  if (isFile(full)) return full;
4457
4989
  }
4458
4990
  }
@@ -4522,8 +5054,8 @@ function withUtf8Codepage(cmdline) {
4522
5054
  function isBareWindowsName(s) {
4523
5055
  if (!s) return false;
4524
5056
  if (s.includes("/") || s.includes("\\")) return false;
4525
- if (pathMod2.isAbsolute(s)) return false;
4526
- if (pathMod2.extname(s)) return false;
5057
+ if (pathMod3.isAbsolute(s)) return false;
5058
+ if (pathMod3.extname(s)) return false;
4527
5059
  return true;
4528
5060
  }
4529
5061
  function quoteForCmdExe(arg) {
@@ -4542,12 +5074,13 @@ var NeedsConfirmationError = class extends Error {
4542
5074
  }
4543
5075
  };
4544
5076
  function registerShellTools(registry, opts) {
4545
- const rootDir = pathMod2.resolve(opts.rootDir);
5077
+ const rootDir = pathMod3.resolve(opts.rootDir);
4546
5078
  const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
4547
5079
  const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
5080
+ const jobs = opts.jobs ?? new JobRegistry();
4548
5081
  const getExtraAllowed = typeof opts.extraAllowed === "function" ? opts.extraAllowed : (() => {
4549
- const snapshot = opts.extraAllowed ?? [];
4550
- return () => snapshot;
5082
+ const snapshot2 = opts.extraAllowed ?? [];
5083
+ return () => snapshot2;
4551
5084
  })();
4552
5085
  const allowAll = opts.allowAll ?? false;
4553
5086
  registry.register({
@@ -4593,8 +5126,126 @@ function registerShellTools(registry, opts) {
4593
5126
  return formatCommandResult(cmd, result);
4594
5127
  }
4595
5128
  });
5129
+ registry.register({
5130
+ name: "run_background",
5131
+ description: "Spawn a long-running process (dev server, watcher, any command that doesn't naturally exit) and detach. Waits up to `waitSec` seconds for startup (or until the output matches a readiness signal like 'Local:', 'listening on', 'compiled successfully'), then returns the job id + startup preview. The process keeps running; call `job_output` to tail its logs, `stop_job` to kill it, `list_jobs` to see all running jobs. USE THIS \u2014 not `run_command` \u2014 for: npm/yarn/pnpm run dev, uvicorn / flask run, go run, cargo watch, tsc --watch, webpack serve, anything with 'dev' / 'serve' / 'watch' in the name.",
5132
+ parameters: {
5133
+ type: "object",
5134
+ properties: {
5135
+ command: {
5136
+ type: "string",
5137
+ description: "Full command line. Same quoting rules as run_command (no pipes / redirects / chaining)."
5138
+ },
5139
+ waitSec: {
5140
+ type: "integer",
5141
+ description: "Max seconds to wait for startup before returning. 0..30, default 3. A ready-signal match short-circuits this."
5142
+ }
5143
+ },
5144
+ required: ["command"]
5145
+ },
5146
+ fn: async (args, ctx) => {
5147
+ const cmd = args.command.trim();
5148
+ if (!cmd) throw new Error("run_background: empty command");
5149
+ if (!allowAll && !isAllowed(cmd, getExtraAllowed())) {
5150
+ throw new NeedsConfirmationError(cmd);
5151
+ }
5152
+ const result = await jobs.start(cmd, {
5153
+ cwd: rootDir,
5154
+ waitSec: args.waitSec,
5155
+ signal: ctx?.signal
5156
+ });
5157
+ return formatJobStart(result);
5158
+ }
5159
+ });
5160
+ registry.register({
5161
+ name: "job_output",
5162
+ description: "Read the latest output of a background job started with `run_background`. By default returns the tail of the buffer (last 80 lines). Pass `since` (the `byteLength` from a previous call) to stream only new content incrementally. Tells you whether the job is still running, so you can stop polling when it's done.",
5163
+ readOnly: true,
5164
+ parameters: {
5165
+ type: "object",
5166
+ properties: {
5167
+ jobId: { type: "integer", description: "Job id returned by run_background." },
5168
+ since: {
5169
+ type: "integer",
5170
+ description: "Return only output written past this byte offset (for incremental polling)."
5171
+ },
5172
+ tailLines: {
5173
+ type: "integer",
5174
+ description: "Cap the returned slice to the last N lines. Default 80, 0 = unlimited."
5175
+ }
5176
+ },
5177
+ required: ["jobId"]
5178
+ },
5179
+ fn: async (args) => {
5180
+ const out = jobs.read(args.jobId, {
5181
+ since: args.since,
5182
+ tailLines: args.tailLines ?? 80
5183
+ });
5184
+ if (!out) return `job ${args.jobId}: not found (use list_jobs)`;
5185
+ return formatJobRead(args.jobId, out);
5186
+ }
5187
+ });
5188
+ registry.register({
5189
+ name: "stop_job",
5190
+ description: "Stop a background job started with `run_background`. SIGTERM first; SIGKILL after a short grace period if it doesn't exit cleanly. Returns the final output + exit code. Safe to call on an already-exited job.",
5191
+ parameters: {
5192
+ type: "object",
5193
+ properties: {
5194
+ jobId: { type: "integer" }
5195
+ },
5196
+ required: ["jobId"]
5197
+ },
5198
+ fn: async (args) => {
5199
+ const rec = await jobs.stop(args.jobId);
5200
+ if (!rec) return `job ${args.jobId}: not found`;
5201
+ return formatJobStop(rec);
5202
+ }
5203
+ });
5204
+ registry.register({
5205
+ name: "list_jobs",
5206
+ description: "List every background job started this session \u2014 running and exited \u2014 with id, command, pid, status. Use when you've lost track of which job_id corresponds to which process, or to see what's still alive.",
5207
+ readOnly: true,
5208
+ parameters: { type: "object", properties: {} },
5209
+ fn: async () => {
5210
+ const all = jobs.list();
5211
+ if (all.length === 0) return "(no background jobs started this session)";
5212
+ return all.map(formatJobRow).join("\n");
5213
+ }
5214
+ });
4596
5215
  return registry;
4597
5216
  }
5217
+ function formatJobStart(r) {
5218
+ const header = r.stillRunning ? `[job ${r.jobId} started \xB7 pid ${r.pid ?? "?"} \xB7 ${r.readyMatched ? "READY signal matched" : "running (no ready signal yet)"}]` : r.exitCode !== null ? `[job ${r.jobId} exited during startup \xB7 exit ${r.exitCode}]` : `[job ${r.jobId} failed to start]`;
5219
+ return r.preview ? `${header}
5220
+ ${r.preview}` : header;
5221
+ }
5222
+ function formatJobRead(jobId, r) {
5223
+ const status = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exited ${r.exitCode}` : r.spawnError ? `failed (${r.spawnError})` : "stopped";
5224
+ const header = `[job ${jobId} \xB7 ${status} \xB7 byteLength=${r.byteLength}]
5225
+ $ ${r.command}`;
5226
+ return r.output ? `${header}
5227
+ ${r.output}` : header;
5228
+ }
5229
+ function formatJobStop(r) {
5230
+ const running = r.running ? "still running (SIGKILL may be pending)" : `exit ${r.exitCode ?? "?"}`;
5231
+ const tail = tailLines(r.output, 40);
5232
+ const header = `[job ${r.id} stopped \xB7 ${running}]
5233
+ $ ${r.command}`;
5234
+ return tail ? `${header}
5235
+ ${tail}` : header;
5236
+ }
5237
+ function formatJobRow(r) {
5238
+ const age = ((Date.now() - r.startedAt) / 1e3).toFixed(1);
5239
+ const state = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exit ${r.exitCode}` : r.spawnError ? "failed" : "stopped";
5240
+ return ` ${String(r.id).padStart(3)} ${state.padEnd(24)} ${age}s ago $ ${r.command}`;
5241
+ }
5242
+ function tailLines(s, n) {
5243
+ if (!s) return "";
5244
+ const lines = s.split("\n");
5245
+ if (lines.length <= n) return s;
5246
+ const dropped = lines.length - n;
5247
+ return [`[\u2026 ${dropped} earlier lines \u2026]`, ...lines.slice(-n)].join("\n");
5248
+ }
4598
5249
  function formatCommandResult(cmd, r) {
4599
5250
  const header = r.timedOut ? `$ ${cmd}
4600
5251
  [killed after timeout]` : `$ ${cmd}
@@ -4788,11 +5439,11 @@ ${i + 1}. ${r.title}`);
4788
5439
 
4789
5440
  // src/env.ts
4790
5441
  import { readFileSync as readFileSync8 } from "fs";
4791
- import { resolve as resolve6 } from "path";
5442
+ import { resolve as resolve7 } from "path";
4792
5443
  function loadDotenv(path = ".env") {
4793
5444
  let raw;
4794
5445
  try {
4795
- raw = readFileSync8(resolve6(process.cwd(), path), "utf8");
5446
+ raw = readFileSync8(resolve7(process.cwd(), path), "utf8");
4796
5447
  } catch {
4797
5448
  return;
4798
5449
  }
@@ -5474,7 +6125,7 @@ var McpClient = class {
5474
6125
  const id = this.nextId++;
5475
6126
  const frame = { jsonrpc: "2.0", id, method, params };
5476
6127
  let abortHandler = null;
5477
- const promise = new Promise((resolve8, reject) => {
6128
+ const promise = new Promise((resolve9, reject) => {
5478
6129
  const timeout = setTimeout(() => {
5479
6130
  this.pending.delete(id);
5480
6131
  if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
@@ -5483,7 +6134,7 @@ var McpClient = class {
5483
6134
  );
5484
6135
  }, this.requestTimeoutMs);
5485
6136
  this.pending.set(id, {
5486
- resolve: resolve8,
6137
+ resolve: resolve9,
5487
6138
  reject,
5488
6139
  timeout
5489
6140
  });
@@ -5565,7 +6216,7 @@ var McpClient = class {
5565
6216
  };
5566
6217
 
5567
6218
  // src/mcp/stdio.ts
5568
- import { spawn as spawn3 } from "child_process";
6219
+ import { spawn as spawn4 } from "child_process";
5569
6220
  var StdioTransport = class {
5570
6221
  child;
5571
6222
  queue = [];
@@ -5580,14 +6231,14 @@ var StdioTransport = class {
5580
6231
  opts.command,
5581
6232
  ...(opts.args ?? []).map((a) => quoteArg(a, process.platform === "win32"))
5582
6233
  ].join(" ");
5583
- this.child = spawn3(line, [], {
6234
+ this.child = spawn4(line, [], {
5584
6235
  env,
5585
6236
  cwd: opts.cwd,
5586
6237
  stdio: ["pipe", "pipe", "inherit"],
5587
6238
  shell: true
5588
6239
  });
5589
6240
  } else {
5590
- this.child = spawn3(opts.command, opts.args ?? [], {
6241
+ this.child = spawn4(opts.command, opts.args ?? [], {
5591
6242
  env,
5592
6243
  cwd: opts.cwd,
5593
6244
  stdio: ["pipe", "pipe", "inherit"]
@@ -5606,12 +6257,12 @@ var StdioTransport = class {
5606
6257
  }
5607
6258
  async send(message) {
5608
6259
  if (this.closed) throw new Error("MCP transport is closed");
5609
- return new Promise((resolve8, reject) => {
6260
+ return new Promise((resolve9, reject) => {
5610
6261
  const line = `${JSON.stringify(message)}
5611
6262
  `;
5612
6263
  this.child.stdin.write(line, "utf8", (err) => {
5613
6264
  if (err) reject(err);
5614
- else resolve8();
6265
+ else resolve9();
5615
6266
  });
5616
6267
  });
5617
6268
  }
@@ -5622,8 +6273,8 @@ var StdioTransport = class {
5622
6273
  continue;
5623
6274
  }
5624
6275
  if (this.closed) return;
5625
- const next = await new Promise((resolve8) => {
5626
- this.waiters.push(resolve8);
6276
+ const next = await new Promise((resolve9) => {
6277
+ this.waiters.push(resolve9);
5627
6278
  });
5628
6279
  if (next === null) return;
5629
6280
  yield next;
@@ -5689,8 +6340,8 @@ var SseTransport = class {
5689
6340
  constructor(opts) {
5690
6341
  this.url = opts.url;
5691
6342
  this.headers = opts.headers ?? {};
5692
- this.endpointReady = new Promise((resolve8, reject) => {
5693
- this.resolveEndpoint = resolve8;
6343
+ this.endpointReady = new Promise((resolve9, reject) => {
6344
+ this.resolveEndpoint = resolve9;
5694
6345
  this.rejectEndpoint = reject;
5695
6346
  });
5696
6347
  this.endpointReady.catch(() => void 0);
@@ -5717,8 +6368,8 @@ var SseTransport = class {
5717
6368
  continue;
5718
6369
  }
5719
6370
  if (this.closed) return;
5720
- const next = await new Promise((resolve8) => {
5721
- this.waiters.push(resolve8);
6371
+ const next = await new Promise((resolve9) => {
6372
+ this.waiters.push(resolve9);
5722
6373
  });
5723
6374
  if (next === null) return;
5724
6375
  yield next;
@@ -5918,7 +6569,7 @@ async function trySection(load) {
5918
6569
 
5919
6570
  // src/code/edit-blocks.ts
5920
6571
  import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "fs";
5921
- import { dirname as dirname4, resolve as resolve7 } from "path";
6572
+ import { dirname as dirname4, resolve as resolve8 } from "path";
5922
6573
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
5923
6574
  function parseEditBlocks(text) {
5924
6575
  const out = [];
@@ -5936,8 +6587,8 @@ function parseEditBlocks(text) {
5936
6587
  return out;
5937
6588
  }
5938
6589
  function applyEditBlock(block, rootDir) {
5939
- const absRoot = resolve7(rootDir);
5940
- const absTarget = resolve7(absRoot, block.path);
6590
+ const absRoot = resolve8(rootDir);
6591
+ const absTarget = resolve8(absRoot, block.path);
5941
6592
  if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
5942
6593
  return {
5943
6594
  path: block.path,
@@ -5987,13 +6638,13 @@ function applyEditBlocks(blocks, rootDir) {
5987
6638
  return blocks.map((b) => applyEditBlock(b, rootDir));
5988
6639
  }
5989
6640
  function snapshotBeforeEdits(blocks, rootDir) {
5990
- const absRoot = resolve7(rootDir);
6641
+ const absRoot = resolve8(rootDir);
5991
6642
  const seen = /* @__PURE__ */ new Set();
5992
6643
  const snapshots = [];
5993
6644
  for (const b of blocks) {
5994
6645
  if (seen.has(b.path)) continue;
5995
6646
  seen.add(b.path);
5996
- const abs = resolve7(absRoot, b.path);
6647
+ const abs = resolve8(absRoot, b.path);
5997
6648
  if (!existsSync9(abs)) {
5998
6649
  snapshots.push({ path: b.path, prevContent: null });
5999
6650
  continue;
@@ -6007,9 +6658,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
6007
6658
  return snapshots;
6008
6659
  }
6009
6660
  function restoreSnapshots(snapshots, rootDir) {
6010
- const absRoot = resolve7(rootDir);
6661
+ const absRoot = resolve8(rootDir);
6011
6662
  return snapshots.map((snap) => {
6012
- const abs = resolve7(absRoot, snap.path);
6663
+ const abs = resolve8(absRoot, snap.path);
6013
6664
  if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
6014
6665
  return {
6015
6666
  path: snap.path,
@@ -6116,6 +6767,15 @@ In those cases, use tools to gather what you need, then reply in prose. No SEARC
6116
6767
 
6117
6768
  When you do propose edits, the user will review them and decide whether to \`/apply\` or \`/discard\`. Don't assume they'll accept \u2014 write as if each edit will be audited, because it will.
6118
6769
 
6770
+ Reasonix runs an **edit gate**. The user's current mode (\`review\` or \`auto\`) decides what happens to your writes; you DO NOT see which mode is active, and you SHOULD NOT ask. Write the same way in both cases.
6771
+
6772
+ - In \`auto\` mode \`edit_file\` / \`write_file\` calls land on disk immediately with an undo window \u2014 you'll get the normal "edit blocks: 1/1 applied" style response.
6773
+ - In \`review\` mode EACH \`edit_file\` / \`write_file\` call pauses tool dispatch while the user decides. You'll get one of these responses:
6774
+ - \`"edit blocks: 1/1 applied"\` \u2014 user approved it. Continue as normal.
6775
+ - \`"User rejected this edit to <path>. Don't retry the same SEARCH/REPLACE\u2026"\` \u2014 user said no to THIS specific edit. Do NOT re-emit the same block, do NOT switch tools to sneak it past the gate (write_file \u2192 edit_file, or text-form SEARCH/REPLACE). Either take a clearly different approach or stop and ask the user what they want instead.
6776
+ - Text-form SEARCH/REPLACE blocks in your assistant reply queue for end-of-turn /apply \u2014 same "don't retry on rejection" rule.
6777
+ - If the user presses Esc mid-prompt the whole turn is aborted; you won't get another tool response. Don't keep spamming tool calls after an abort.
6778
+
6119
6779
  # Editing files
6120
6780
 
6121
6781
  When you've been asked to change a file, output one or more SEARCH/REPLACE blocks in this exact format:
@@ -6156,6 +6816,40 @@ Two different rules depending on which tool:
6156
6816
  - **Filesystem tools** (\`read_file\`, \`list_directory\`, \`search_files\`, \`edit_file\`, etc.): paths are sandbox-relative. \`/\` means the project root, \`/src/foo.ts\` means \`<project>/src/foo.ts\`. Both relative (\`src/foo.ts\`) and POSIX-absolute (\`/src/foo.ts\`) forms work.
6157
6817
  - **\`run_command\`**: the command runs in a real OS shell with cwd pinned to the project root. Paths inside the shell command are interpreted by THAT shell, not by us. **Never use leading \`/\` in run_command arguments** \u2014 Windows treats \`/tests\` as drive-root \`F:\\tests\` (non-existent), POSIX shells treat it as filesystem root. Use plain relative paths (\`tests\`, \`./tests\`, \`src/loop.ts\`) instead.
6158
6818
 
6819
+ # Foreground vs. background commands
6820
+
6821
+ You have TWO tools for running shell commands, and picking the right one is non-negotiable:
6822
+
6823
+ - \`run_command\` \u2014 blocks until the process exits. Use for: **tests, builds, lints, typechecks, git operations, one-shot scripts**. Anything that naturally returns in under a minute.
6824
+ - \`run_background\` \u2014 spawns and detaches after a brief startup window. Use for: **dev servers, watchers, any command with "dev" / "serve" / "watch" / "start" in the name**. Examples: \`npm run dev\`, \`pnpm dev\`, \`yarn start\`, \`vite\`, \`next dev\`, \`uvicorn app:app --reload\`, \`flask run\`, \`python -m http.server\`, \`cargo watch\`, \`tsc --watch\`, \`webpack serve\`.
6825
+
6826
+ **Never use run_command for a dev server.** It will block for 60s, time out, and the user will see a frozen tool call while the server was actually running fine. Always \`run_background\`, then \`job_output\` to peek at the logs when you need to verify something.
6827
+
6828
+ After \`run_background\`, tools available to you:
6829
+ - \`job_output(jobId, tailLines?)\` \u2014 read recent logs to verify startup / debug errors.
6830
+ - \`list_jobs\` \u2014 see every job this session (running + exited).
6831
+ - \`stop_job(jobId)\` \u2014 SIGTERM \u2192 SIGKILL after grace. Stop before switching port / config.
6832
+
6833
+ Don't re-start an already-running dev server \u2014 call \`list_jobs\` first when in doubt.
6834
+
6835
+ # Scope discipline on "run it" / "start it" requests
6836
+
6837
+ When the user's request is to **run / start / launch / serve / boot up** something, your job is ONLY:
6838
+
6839
+ 1. Start it (\`run_background\` for dev servers, \`run_command\` for one-shots).
6840
+ 2. Verify it came up (read a ready signal via \`job_output\`, or fetch the URL with \`web_fetch\` if they want you to confirm).
6841
+ 3. Report what's running, where (URL / port / pid), and STOP.
6842
+
6843
+ Do NOT, in the same turn:
6844
+ - Run \`tsc\` / type-checkers / linters unless the user asked for it.
6845
+ - Scan for bugs to "proactively" fix. The page rendering is success.
6846
+ - Clean up unused imports, dead code, or refactor "while you're here."
6847
+ - Edit files to improve anything the user didn't mention.
6848
+
6849
+ If you notice an obvious issue, MENTION it in one sentence and wait for the user to say "fix it." The cost of over-eagerness is real: you burn tokens, make surprise edits the user didn't want, and chain into cascading "fix the new error I just introduced" loops. The storm-breaker will cut you off, but the user still sees the mess.
6850
+
6851
+ "It works" is the end state. Resist the urge to polish.
6852
+
6159
6853
  # Style
6160
6854
 
6161
6855
  - Show edits; don't narrate them in prose. "Here's the fix:" is enough.