reasonix 0.5.23 → 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;
@@ -3366,6 +3492,9 @@ import { promises as fs } from "fs";
3366
3492
  import * as pathMod from "path";
3367
3493
  var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
3368
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;
3369
3498
  var SKIP_DIR_NAMES = /* @__PURE__ */ new Set([
3370
3499
  "node_modules",
3371
3500
  ".git",
@@ -3458,14 +3587,22 @@ function registerFilesystemTools(registry, opts) {
3458
3587
  };
3459
3588
  registry.register({
3460
3589
  name: "read_file",
3461
- 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.`,
3462
3595
  readOnly: true,
3463
3596
  parameters: {
3464
3597
  type: "object",
3465
3598
  properties: {
3466
3599
  path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
3467
3600
  head: { type: "integer", description: "If set, return only the first N lines." },
3468
- 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
+ }
3469
3606
  },
3470
3607
  required: ["path"]
3471
3608
  },
@@ -3477,21 +3614,52 @@ function registerFilesystemTools(registry, opts) {
3477
3614
  }
3478
3615
  const raw = await fs.readFile(abs);
3479
3616
  if (raw.length > maxReadBytes) {
3480
- const head = raw.slice(0, maxReadBytes).toString("utf8");
3481
- return `${head}
3617
+ const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
3618
+ return `${headBytes}
3482
3619
 
3483
- [\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.]`;
3484
3621
  }
3485
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
+ }
3486
3635
  if (typeof args.head === "number" && args.head > 0) {
3487
- 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;
3488
3642
  }
3489
3643
  if (typeof args.tail === "number" && args.tail > 0) {
3490
- let lines = text.split(/\r?\n/);
3491
- if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
3492
- 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");
3493
3650
  }
3494
- 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");
3495
3663
  }
3496
3664
  });
3497
3665
  registry.register({
@@ -3516,21 +3684,34 @@ function registerFilesystemTools(registry, opts) {
3516
3684
  });
3517
3685
  registry.register({
3518
3686
  name: "directory_tree",
3519
- 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.`,
3520
3692
  readOnly: true,
3521
3693
  parameters: {
3522
3694
  type: "object",
3523
3695
  properties: {
3524
3696
  path: { type: "string", description: "Root of the tree (default: sandbox root)." },
3525
- 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
+ }
3526
3705
  }
3527
3706
  },
3528
3707
  fn: async (args) => {
3529
3708
  const startAbs = safePath(args.path ?? ".");
3530
- 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;
3531
3711
  const lines = [];
3532
3712
  let totalBytes = 0;
3533
3713
  let truncated = false;
3714
+ const PER_DIR_CHILD_CAP = 50;
3534
3715
  const walk2 = async (dir, depth) => {
3535
3716
  if (truncated) return;
3536
3717
  if (depth > maxDepth) return;
@@ -3541,10 +3722,27 @@ function registerFilesystemTools(registry, opts) {
3541
3722
  return;
3542
3723
  }
3543
3724
  entries.sort((a, b) => a.name.localeCompare(b.name));
3725
+ let emitted = 0;
3544
3726
  for (const e of entries) {
3545
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
+ }
3546
3743
  const indent = " ".repeat(depth);
3547
- 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}`;
3548
3746
  totalBytes += line.length + 1;
3549
3747
  if (totalBytes > maxListBytes) {
3550
3748
  lines.push(` [\u2026 tree truncated at ${maxListBytes} bytes \u2026]`);
@@ -3552,7 +3750,8 @@ function registerFilesystemTools(registry, opts) {
3552
3750
  return;
3553
3751
  }
3554
3752
  lines.push(line);
3555
- if (e.isDirectory()) {
3753
+ emitted++;
3754
+ if (e.isDirectory() && !skip) {
3556
3755
  await walk2(pathMod.join(dir, e.name), depth + 1);
3557
3756
  }
3558
3757
  }
@@ -4262,9 +4461,311 @@ function forkRegistryExcluding(parent, exclude) {
4262
4461
  }
4263
4462
 
4264
4463
  // src/tools/shell.ts
4265
- import { spawn as spawn2 } from "child_process";
4464
+ import { spawn as spawn3 } from "child_process";
4266
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";
4267
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
4268
4769
  var DEFAULT_TIMEOUT_SEC = 60;
4269
4770
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
4270
4771
  var BUILTIN_ALLOWLIST = [
@@ -4433,10 +4934,10 @@ async function runCommand(cmd, opts) {
4433
4934
  };
4434
4935
  const { bin, args, spawnOverrides } = prepareSpawn(argv);
4435
4936
  const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
4436
- return await new Promise((resolve8, reject) => {
4937
+ return await new Promise((resolve9, reject) => {
4437
4938
  let child;
4438
4939
  try {
4439
- child = spawn2(bin, args, effectiveSpawnOpts);
4940
+ child = spawn3(bin, args, effectiveSpawnOpts);
4440
4941
  } catch (err) {
4441
4942
  reject(err);
4442
4943
  return;
@@ -4466,7 +4967,7 @@ async function runCommand(cmd, opts) {
4466
4967
  const output = buf.length > maxChars ? `${buf.slice(0, maxChars)}
4467
4968
 
4468
4969
  [\u2026 truncated ${buf.length - maxChars} chars \u2026]` : buf;
4469
- resolve8({ exitCode: code, output, timedOut });
4970
+ resolve9({ exitCode: code, output, timedOut });
4470
4971
  });
4471
4972
  });
4472
4973
  }
@@ -4474,16 +4975,16 @@ function resolveExecutable(cmd, opts = {}) {
4474
4975
  const platform = opts.platform ?? process.platform;
4475
4976
  if (platform !== "win32") return cmd;
4476
4977
  if (!cmd) return cmd;
4477
- if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
4478
- if (pathMod2.extname(cmd)) return cmd;
4978
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod3.isAbsolute(cmd)) return cmd;
4979
+ if (pathMod3.extname(cmd)) return cmd;
4479
4980
  const env = opts.env ?? process.env;
4480
4981
  const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
4481
- const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
4982
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod3.delimiter);
4482
4983
  const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
4483
4984
  const isFile = opts.isFile ?? defaultIsFile;
4484
4985
  for (const dir of pathDirs) {
4485
4986
  for (const ext of pathExt) {
4486
- const full = pathMod2.win32.join(dir, cmd + ext);
4987
+ const full = pathMod3.win32.join(dir, cmd + ext);
4487
4988
  if (isFile(full)) return full;
4488
4989
  }
4489
4990
  }
@@ -4553,8 +5054,8 @@ function withUtf8Codepage(cmdline) {
4553
5054
  function isBareWindowsName(s) {
4554
5055
  if (!s) return false;
4555
5056
  if (s.includes("/") || s.includes("\\")) return false;
4556
- if (pathMod2.isAbsolute(s)) return false;
4557
- if (pathMod2.extname(s)) return false;
5057
+ if (pathMod3.isAbsolute(s)) return false;
5058
+ if (pathMod3.extname(s)) return false;
4558
5059
  return true;
4559
5060
  }
4560
5061
  function quoteForCmdExe(arg) {
@@ -4573,12 +5074,13 @@ var NeedsConfirmationError = class extends Error {
4573
5074
  }
4574
5075
  };
4575
5076
  function registerShellTools(registry, opts) {
4576
- const rootDir = pathMod2.resolve(opts.rootDir);
5077
+ const rootDir = pathMod3.resolve(opts.rootDir);
4577
5078
  const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
4578
5079
  const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
5080
+ const jobs = opts.jobs ?? new JobRegistry();
4579
5081
  const getExtraAllowed = typeof opts.extraAllowed === "function" ? opts.extraAllowed : (() => {
4580
- const snapshot = opts.extraAllowed ?? [];
4581
- return () => snapshot;
5082
+ const snapshot2 = opts.extraAllowed ?? [];
5083
+ return () => snapshot2;
4582
5084
  })();
4583
5085
  const allowAll = opts.allowAll ?? false;
4584
5086
  registry.register({
@@ -4624,8 +5126,126 @@ function registerShellTools(registry, opts) {
4624
5126
  return formatCommandResult(cmd, result);
4625
5127
  }
4626
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
+ });
4627
5215
  return registry;
4628
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
+ }
4629
5249
  function formatCommandResult(cmd, r) {
4630
5250
  const header = r.timedOut ? `$ ${cmd}
4631
5251
  [killed after timeout]` : `$ ${cmd}
@@ -4819,11 +5439,11 @@ ${i + 1}. ${r.title}`);
4819
5439
 
4820
5440
  // src/env.ts
4821
5441
  import { readFileSync as readFileSync8 } from "fs";
4822
- import { resolve as resolve6 } from "path";
5442
+ import { resolve as resolve7 } from "path";
4823
5443
  function loadDotenv(path = ".env") {
4824
5444
  let raw;
4825
5445
  try {
4826
- raw = readFileSync8(resolve6(process.cwd(), path), "utf8");
5446
+ raw = readFileSync8(resolve7(process.cwd(), path), "utf8");
4827
5447
  } catch {
4828
5448
  return;
4829
5449
  }
@@ -5505,7 +6125,7 @@ var McpClient = class {
5505
6125
  const id = this.nextId++;
5506
6126
  const frame = { jsonrpc: "2.0", id, method, params };
5507
6127
  let abortHandler = null;
5508
- const promise = new Promise((resolve8, reject) => {
6128
+ const promise = new Promise((resolve9, reject) => {
5509
6129
  const timeout = setTimeout(() => {
5510
6130
  this.pending.delete(id);
5511
6131
  if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
@@ -5514,7 +6134,7 @@ var McpClient = class {
5514
6134
  );
5515
6135
  }, this.requestTimeoutMs);
5516
6136
  this.pending.set(id, {
5517
- resolve: resolve8,
6137
+ resolve: resolve9,
5518
6138
  reject,
5519
6139
  timeout
5520
6140
  });
@@ -5596,7 +6216,7 @@ var McpClient = class {
5596
6216
  };
5597
6217
 
5598
6218
  // src/mcp/stdio.ts
5599
- import { spawn as spawn3 } from "child_process";
6219
+ import { spawn as spawn4 } from "child_process";
5600
6220
  var StdioTransport = class {
5601
6221
  child;
5602
6222
  queue = [];
@@ -5611,14 +6231,14 @@ var StdioTransport = class {
5611
6231
  opts.command,
5612
6232
  ...(opts.args ?? []).map((a) => quoteArg(a, process.platform === "win32"))
5613
6233
  ].join(" ");
5614
- this.child = spawn3(line, [], {
6234
+ this.child = spawn4(line, [], {
5615
6235
  env,
5616
6236
  cwd: opts.cwd,
5617
6237
  stdio: ["pipe", "pipe", "inherit"],
5618
6238
  shell: true
5619
6239
  });
5620
6240
  } else {
5621
- this.child = spawn3(opts.command, opts.args ?? [], {
6241
+ this.child = spawn4(opts.command, opts.args ?? [], {
5622
6242
  env,
5623
6243
  cwd: opts.cwd,
5624
6244
  stdio: ["pipe", "pipe", "inherit"]
@@ -5637,12 +6257,12 @@ var StdioTransport = class {
5637
6257
  }
5638
6258
  async send(message) {
5639
6259
  if (this.closed) throw new Error("MCP transport is closed");
5640
- return new Promise((resolve8, reject) => {
6260
+ return new Promise((resolve9, reject) => {
5641
6261
  const line = `${JSON.stringify(message)}
5642
6262
  `;
5643
6263
  this.child.stdin.write(line, "utf8", (err) => {
5644
6264
  if (err) reject(err);
5645
- else resolve8();
6265
+ else resolve9();
5646
6266
  });
5647
6267
  });
5648
6268
  }
@@ -5653,8 +6273,8 @@ var StdioTransport = class {
5653
6273
  continue;
5654
6274
  }
5655
6275
  if (this.closed) return;
5656
- const next = await new Promise((resolve8) => {
5657
- this.waiters.push(resolve8);
6276
+ const next = await new Promise((resolve9) => {
6277
+ this.waiters.push(resolve9);
5658
6278
  });
5659
6279
  if (next === null) return;
5660
6280
  yield next;
@@ -5720,8 +6340,8 @@ var SseTransport = class {
5720
6340
  constructor(opts) {
5721
6341
  this.url = opts.url;
5722
6342
  this.headers = opts.headers ?? {};
5723
- this.endpointReady = new Promise((resolve8, reject) => {
5724
- this.resolveEndpoint = resolve8;
6343
+ this.endpointReady = new Promise((resolve9, reject) => {
6344
+ this.resolveEndpoint = resolve9;
5725
6345
  this.rejectEndpoint = reject;
5726
6346
  });
5727
6347
  this.endpointReady.catch(() => void 0);
@@ -5748,8 +6368,8 @@ var SseTransport = class {
5748
6368
  continue;
5749
6369
  }
5750
6370
  if (this.closed) return;
5751
- const next = await new Promise((resolve8) => {
5752
- this.waiters.push(resolve8);
6371
+ const next = await new Promise((resolve9) => {
6372
+ this.waiters.push(resolve9);
5753
6373
  });
5754
6374
  if (next === null) return;
5755
6375
  yield next;
@@ -5949,7 +6569,7 @@ async function trySection(load) {
5949
6569
 
5950
6570
  // src/code/edit-blocks.ts
5951
6571
  import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "fs";
5952
- import { dirname as dirname4, resolve as resolve7 } from "path";
6572
+ import { dirname as dirname4, resolve as resolve8 } from "path";
5953
6573
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
5954
6574
  function parseEditBlocks(text) {
5955
6575
  const out = [];
@@ -5967,8 +6587,8 @@ function parseEditBlocks(text) {
5967
6587
  return out;
5968
6588
  }
5969
6589
  function applyEditBlock(block, rootDir) {
5970
- const absRoot = resolve7(rootDir);
5971
- const absTarget = resolve7(absRoot, block.path);
6590
+ const absRoot = resolve8(rootDir);
6591
+ const absTarget = resolve8(absRoot, block.path);
5972
6592
  if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
5973
6593
  return {
5974
6594
  path: block.path,
@@ -6018,13 +6638,13 @@ function applyEditBlocks(blocks, rootDir) {
6018
6638
  return blocks.map((b) => applyEditBlock(b, rootDir));
6019
6639
  }
6020
6640
  function snapshotBeforeEdits(blocks, rootDir) {
6021
- const absRoot = resolve7(rootDir);
6641
+ const absRoot = resolve8(rootDir);
6022
6642
  const seen = /* @__PURE__ */ new Set();
6023
6643
  const snapshots = [];
6024
6644
  for (const b of blocks) {
6025
6645
  if (seen.has(b.path)) continue;
6026
6646
  seen.add(b.path);
6027
- const abs = resolve7(absRoot, b.path);
6647
+ const abs = resolve8(absRoot, b.path);
6028
6648
  if (!existsSync9(abs)) {
6029
6649
  snapshots.push({ path: b.path, prevContent: null });
6030
6650
  continue;
@@ -6038,9 +6658,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
6038
6658
  return snapshots;
6039
6659
  }
6040
6660
  function restoreSnapshots(snapshots, rootDir) {
6041
- const absRoot = resolve7(rootDir);
6661
+ const absRoot = resolve8(rootDir);
6042
6662
  return snapshots.map((snap) => {
6043
- const abs = resolve7(absRoot, snap.path);
6663
+ const abs = resolve8(absRoot, snap.path);
6044
6664
  if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
6045
6665
  return {
6046
6666
  path: snap.path,
@@ -6147,6 +6767,15 @@ In those cases, use tools to gather what you need, then reply in prose. No SEARC
6147
6767
 
6148
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.
6149
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
+
6150
6779
  # Editing files
6151
6780
 
6152
6781
  When you've been asked to change a file, output one or more SEARCH/REPLACE blocks in this exact format:
@@ -6187,6 +6816,40 @@ Two different rules depending on which tool:
6187
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.
6188
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.
6189
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
+
6190
6853
  # Style
6191
6854
 
6192
6855
  - Show edits; don't narrate them in prose. "Here's the fix:" is enough.