reasonix 0.5.23 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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);
@@ -1690,7 +1714,8 @@ var SessionStats = class {
1690
1714
  claudeEquivalentUsd: round(this.totalClaudeEquivalent, 6),
1691
1715
  savingsVsClaudePct: round(this.savingsVsClaude * 100, 2),
1692
1716
  cacheHitRatio: round(this.aggregateCacheHitRatio, 4),
1693
- lastPromptTokens: last?.usage.promptTokens ?? 0
1717
+ lastPromptTokens: last?.usage.promptTokens ?? 0,
1718
+ lastTurnCostUsd: round(last?.cost ?? 0, 6)
1694
1719
  };
1695
1720
  }
1696
1721
  };
@@ -1700,6 +1725,12 @@ function round(n, digits) {
1700
1725
  }
1701
1726
 
1702
1727
  // src/loop.ts
1728
+ var ARGS_COMPACT_THRESHOLD_TOKENS = 800;
1729
+ var TURN_END_RESULT_CAP_TOKENS = 3e3;
1730
+ var FAILURE_ESCALATION_THRESHOLD = 3;
1731
+ var ESCALATION_MODEL = "deepseek-v4-pro";
1732
+ var NEEDS_PRO_MARKER = "<<<NEEDS_PRO>>>";
1733
+ var NEEDS_PRO_BUFFER_CHARS = 80;
1703
1734
  var CacheFirstLoop = class {
1704
1735
  client;
1705
1736
  prefix;
@@ -1740,11 +1771,36 @@ var CacheFirstLoop = class {
1740
1771
  * `step()` (the prior turn's signal has already fired).
1741
1772
  */
1742
1773
  _turnAbort = new AbortController();
1774
+ /**
1775
+ * "Next turn should run on pro, regardless of this.model." Set by the
1776
+ * `/pro` slash command; consumed at the next turn's start (flipping
1777
+ * `_escalateThisTurn` on and self-clearing) so it's a fire-and-forget
1778
+ * single-turn upgrade. Survives across multiple slash inputs so
1779
+ * typing `/pro` and then hesitating a while before submitting a real
1780
+ * message still applies.
1781
+ */
1782
+ _proArmedForNextTurn = false;
1783
+ /**
1784
+ * Active for the current turn only — true means every model call
1785
+ * this turn uses pro instead of `this.model`. Turned on by EITHER
1786
+ * the pro-armed consumption OR the mid-turn auto-escalation
1787
+ * threshold (see `_turnFailureCount`). Cleared at turn end.
1788
+ */
1789
+ _escalateThisTurn = false;
1790
+ /**
1791
+ * Visible-failure count for the current turn. Incremented by tool
1792
+ * dispatch paths when a result matches a known "flash is struggling"
1793
+ * shape (SEARCH-not-found errors, scavenge / truncation / storm
1794
+ * repair fires). Once it hits {@link FAILURE_ESCALATION_THRESHOLD},
1795
+ * the remainder of the turn's model calls auto-upgrade to pro so
1796
+ * the user doesn't watch flash retry the same edit 5 times.
1797
+ */
1798
+ _turnFailureCount = 0;
1743
1799
  constructor(opts) {
1744
1800
  this.client = opts.client;
1745
1801
  this.prefix = opts.prefix;
1746
1802
  this.tools = opts.tools ?? new ToolRegistry();
1747
- this.model = opts.model ?? "deepseek-v4-pro";
1803
+ this.model = opts.model ?? "deepseek-v4-flash";
1748
1804
  this.reasoningEffort = opts.reasoningEffort ?? "max";
1749
1805
  this.maxToolIters = opts.maxToolIters ?? 64;
1750
1806
  this.hooks = opts.hooks ?? [];
@@ -1803,12 +1859,93 @@ var CacheFirstLoop = class {
1803
1859
  * authored intent we can't mechanically shrink without losing
1804
1860
  * meaning.
1805
1861
  */
1806
- compact(maxTokens = 4e3) {
1862
+ /**
1863
+ * Conservative args-only shrink fired after every tool response —
1864
+ * strictly about ONE thing: stop oversized `edit_file` / `write_file`
1865
+ * arguments from riding every future turn's prompt.
1866
+ *
1867
+ * Why this is worth doing AUTOMATICALLY (not just on /compact):
1868
+ * Each tool-call arguments string sticks in the log verbatim. On a
1869
+ * coding session with ~10 edits, that's 20-40K tokens of stale
1870
+ * SEARCH/REPLACE text riding along on every turn. Even at a 98.9%
1871
+ * cache hit rate the input cost still adds up linearly (cache-hit
1872
+ * price × tokens × turns). Compacting IMMEDIATELY after the tool
1873
+ * responds means the next turn's prompt is already smaller — the
1874
+ * shrink is a one-time write that saves every future prompt.
1875
+ *
1876
+ * Threshold rationale: 800 tokens ≈ 3 KB. A typical 20-line edit's
1877
+ * args land well under that; massive rewrites (whole-file content,
1878
+ * 100+ line refactors) land above and get the compaction. Small
1879
+ * edits stay byte-verbatim so nothing common-case changes.
1880
+ *
1881
+ * Safety: we ONLY shrink args whose tool has ALREADY responded.
1882
+ * Structurally that's every call in `log.toMessages()` at this
1883
+ * point — the current turn's assistant/tool pairing is by
1884
+ * construction closed by the time we get here (append happens
1885
+ * AFTER dispatch). The in-flight assistant message being built
1886
+ * lives in scratch, not the log, so this pass can't touch it.
1887
+ *
1888
+ * Model impact: the model may occasionally want to reference the
1889
+ * exact SEARCH text of a prior edit — it then reads the file
1890
+ * directly (which shows current state) or looks at the preceding
1891
+ * assistant text (which has its plan). Losing the stale args is a
1892
+ * net win: one extra read_file vs. dragging N KB of stale text
1893
+ * through every subsequent turn.
1894
+ */
1895
+ compactToolCallArgsAfterResponse() {
1807
1896
  const before = this.log.toMessages();
1808
- const { messages, healedCount, tokensSaved, charsSaved } = shrinkOversizedToolResultsByTokens(
1897
+ const { messages, healedCount } = shrinkOversizedToolCallArgsByTokens(
1809
1898
  before,
1810
- maxTokens
1899
+ ARGS_COMPACT_THRESHOLD_TOKENS
1811
1900
  );
1901
+ if (healedCount === 0) return;
1902
+ this.log.compactInPlace(messages);
1903
+ if (this.sessionName) {
1904
+ try {
1905
+ rewriteSession(this.sessionName, messages);
1906
+ } catch {
1907
+ }
1908
+ }
1909
+ }
1910
+ /**
1911
+ * Fired at the END of a turn (just before `done` is yielded). Shrinks
1912
+ * every tool RESULT in the log that exceeds {@link TURN_END_RESULT_CAP_TOKENS}
1913
+ * to a tight cap so the NEXT turn's prompt doesn't re-pay for big
1914
+ * reads or searches done earlier. Unlike the reactive 40/80%
1915
+ * thresholds which react to context pressure, this runs unconditionally
1916
+ * — the win is preventive: each turn's big outputs get trimmed before
1917
+ * they ride into the next prompt. Saves compounding cost on long
1918
+ * sessions.
1919
+ *
1920
+ * Why compact the JUST-finished turn's results too (not just older
1921
+ * turns)? The same-turn iters already consumed the raw content to
1922
+ * make their decisions — the log is only carried forward for future
1923
+ * prompts. And "let me re-read the file" is vastly cheaper than
1924
+ * "carry this 12KB result in every future turn's prompt forever."
1925
+ *
1926
+ * Safe by construction: args-compact for THIS turn already ran
1927
+ * inside `compactToolCallArgsAfterResponse`; this pass is orthogonal.
1928
+ */
1929
+ autoCompactToolResultsOnTurnEnd() {
1930
+ const before = this.log.toMessages();
1931
+ const shrunk = shrinkOversizedToolResultsByTokens(before, TURN_END_RESULT_CAP_TOKENS);
1932
+ if (shrunk.healedCount === 0) return;
1933
+ this.log.compactInPlace(shrunk.messages);
1934
+ if (this.sessionName) {
1935
+ try {
1936
+ rewriteSession(this.sessionName, shrunk.messages);
1937
+ } catch {
1938
+ }
1939
+ }
1940
+ }
1941
+ compact(maxTokens = 4e3) {
1942
+ const before = this.log.toMessages();
1943
+ const resultsPass = shrinkOversizedToolResultsByTokens(before, maxTokens);
1944
+ const argsPass = shrinkOversizedToolCallArgsByTokens(resultsPass.messages, maxTokens);
1945
+ const messages = argsPass.messages;
1946
+ const healedCount = resultsPass.healedCount + argsPass.healedCount;
1947
+ const tokensSaved = resultsPass.tokensSaved + argsPass.tokensSaved;
1948
+ const charsSaved = resultsPass.charsSaved + argsPass.charsSaved;
1812
1949
  if (healedCount > 0) {
1813
1950
  this.log.compactInPlace(messages);
1814
1951
  if (this.sessionName) {
@@ -1883,6 +2020,78 @@ var CacheFirstLoop = class {
1883
2020
  }
1884
2021
  this.stream = this.branchEnabled ? false : this._streamPreference;
1885
2022
  }
2023
+ /**
2024
+ * Arm pro for the next turn (consumed at turn start). Called by
2025
+ * `/pro`. Idempotent — repeated calls stay armed, `disarmPro()`
2026
+ * clears. Separate from `/preset max` which persistently switches
2027
+ * this.model; armed state is strictly single-turn.
2028
+ */
2029
+ armProForNextTurn() {
2030
+ this._proArmedForNextTurn = true;
2031
+ }
2032
+ /** Cancel `/pro` arming before the next turn starts. */
2033
+ disarmPro() {
2034
+ this._proArmedForNextTurn = false;
2035
+ }
2036
+ /** UI surface — true while `/pro` is queued but hasn't fired yet. */
2037
+ get proArmed() {
2038
+ return this._proArmedForNextTurn;
2039
+ }
2040
+ /** UI surface — true while the current turn is running on pro (armed or auto-escalated). */
2041
+ get escalatedThisTurn() {
2042
+ return this._escalateThisTurn;
2043
+ }
2044
+ /**
2045
+ * Model the current model call should use. Defaults to `this.model`;
2046
+ * upgrades to {@link ESCALATION_MODEL} when the turn is armed for
2047
+ * pro (via `/pro`) or has hit the failure-escalation threshold.
2048
+ * Same thinking + effort policy applies regardless — pro defaults
2049
+ * to thinking=enabled and effort=max, which the current turn wanted
2050
+ * anyway when flash was struggling.
2051
+ */
2052
+ modelForCurrentCall() {
2053
+ return this._escalateThisTurn ? ESCALATION_MODEL : this.model;
2054
+ }
2055
+ /**
2056
+ * True when the assistant's content is a self-reported escalation
2057
+ * request. Only the FIRST line matters — the model is instructed
2058
+ * to emit the marker as the first output token if at all. Matching
2059
+ * anywhere else in the text is a normal content reference (e.g.
2060
+ * the user asked about the marker itself, or prose that happens
2061
+ * to contain angle-brackets).
2062
+ */
2063
+ isEscalationRequest(content) {
2064
+ return content.trimStart().startsWith(NEEDS_PRO_MARKER);
2065
+ }
2066
+ /**
2067
+ * Check whether a tool result string looks like a "flash struggled"
2068
+ * signal and, if so, increment the turn's failure counter. Escalates
2069
+ * the REST of the current turn to pro once the threshold is hit.
2070
+ * Idempotent after escalation — further failures don't re-escalate,
2071
+ * but the turn is already on pro so it doesn't matter.
2072
+ *
2073
+ * Return: `true` when this call tipped the turn into escalation
2074
+ * mode (so the loop can surface a one-time warning to the user).
2075
+ */
2076
+ noteToolFailureSignal(resultJson, repair) {
2077
+ let bumped = false;
2078
+ if (resultJson.includes('"error"') && resultJson.includes("search text not found")) {
2079
+ this._turnFailureCount += 1;
2080
+ bumped = true;
2081
+ }
2082
+ if (repair) {
2083
+ const repairs = repair.scavenged + repair.truncationsFixed + repair.stormsBroken;
2084
+ if (repairs > 0) {
2085
+ this._turnFailureCount += repairs;
2086
+ bumped = true;
2087
+ }
2088
+ }
2089
+ if (bumped && !this._escalateThisTurn && this._turnFailureCount >= FAILURE_ESCALATION_THRESHOLD) {
2090
+ this._escalateThisTurn = true;
2091
+ return true;
2092
+ }
2093
+ return false;
2094
+ }
1886
2095
  buildMessages(pendingUser) {
1887
2096
  const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
1888
2097
  const msgs = [...this.prefix.toMessages(), ...healed.messages];
@@ -1937,8 +2146,23 @@ var CacheFirstLoop = class {
1937
2146
  this._turn++;
1938
2147
  this.scratch.reset();
1939
2148
  this.repair.resetStorm();
2149
+ this._turnFailureCount = 0;
2150
+ this._escalateThisTurn = false;
2151
+ let armedConsumed = false;
2152
+ if (this._proArmedForNextTurn) {
2153
+ this._escalateThisTurn = true;
2154
+ this._proArmedForNextTurn = false;
2155
+ armedConsumed = true;
2156
+ }
1940
2157
  this._turnAbort = new AbortController();
1941
2158
  const signal = this._turnAbort.signal;
2159
+ if (armedConsumed) {
2160
+ yield {
2161
+ turn: this._turn,
2162
+ role: "warning",
2163
+ content: "\u21E7 /pro armed \u2014 this turn runs on deepseek-v4-pro (one-shot \xB7 disarms after turn)"
2164
+ };
2165
+ }
1942
2166
  let pendingUser = userInput;
1943
2167
  const toolSpecs = this.prefix.tools();
1944
2168
  const warnAt = Math.max(1, Math.floor(this.maxToolIters * 0.7));
@@ -1958,6 +2182,7 @@ var CacheFirstLoop = class {
1958
2182
  content: stoppedMsg,
1959
2183
  forcedSummary: true
1960
2184
  };
2185
+ this.autoCompactToolResultsOnTurnEnd();
1961
2186
  yield { turn: this._turn, role: "done", content: stoppedMsg };
1962
2187
  return;
1963
2188
  }
@@ -2034,14 +2259,15 @@ var CacheFirstLoop = class {
2034
2259
  queue.push(sample);
2035
2260
  }
2036
2261
  };
2262
+ const callModel = this.modelForCurrentCall();
2037
2263
  const branchPromise = runBranches(
2038
2264
  this.client,
2039
2265
  {
2040
- model: this.model,
2266
+ model: callModel,
2041
2267
  messages,
2042
2268
  tools: toolSpecs.length ? toolSpecs : void 0,
2043
2269
  signal,
2044
- thinking: thinkingModeForModel(this.model),
2270
+ thinking: thinkingModeForModel(callModel),
2045
2271
  reasoningEffort: this.reasoningEffort
2046
2272
  },
2047
2273
  {
@@ -2051,8 +2277,8 @@ var CacheFirstLoop = class {
2051
2277
  }
2052
2278
  );
2053
2279
  for (let k = 0; k < budget; k++) {
2054
- const sample = queue.shift() ?? await new Promise((resolve8) => {
2055
- waiter = resolve8;
2280
+ const sample = queue.shift() ?? await new Promise((resolve9) => {
2281
+ waiter = resolve9;
2056
2282
  });
2057
2283
  yield {
2058
2284
  turn: this._turn,
@@ -2090,21 +2316,41 @@ var CacheFirstLoop = class {
2090
2316
  } else if (this.stream) {
2091
2317
  const callBuf = /* @__PURE__ */ new Map();
2092
2318
  const readyIndices = /* @__PURE__ */ new Set();
2319
+ const callModel = this.modelForCurrentCall();
2320
+ const bufferForEscalation = callModel !== ESCALATION_MODEL;
2321
+ let escalationBuf = "";
2322
+ let escalationBufFlushed = false;
2093
2323
  for await (const chunk of this.client.stream({
2094
- model: this.model,
2324
+ model: callModel,
2095
2325
  messages,
2096
2326
  tools: toolSpecs.length ? toolSpecs : void 0,
2097
2327
  signal,
2098
- thinking: thinkingModeForModel(this.model),
2328
+ thinking: thinkingModeForModel(callModel),
2099
2329
  reasoningEffort: this.reasoningEffort
2100
2330
  })) {
2101
2331
  if (chunk.contentDelta) {
2102
2332
  assistantContent += chunk.contentDelta;
2103
- yield {
2104
- turn: this._turn,
2105
- role: "assistant_delta",
2106
- content: chunk.contentDelta
2107
- };
2333
+ if (bufferForEscalation && !escalationBufFlushed) {
2334
+ escalationBuf += chunk.contentDelta;
2335
+ if (this.isEscalationRequest(escalationBuf)) {
2336
+ break;
2337
+ }
2338
+ if (escalationBuf.length >= NEEDS_PRO_BUFFER_CHARS || escalationBuf.includes("\n")) {
2339
+ escalationBufFlushed = true;
2340
+ yield {
2341
+ turn: this._turn,
2342
+ role: "assistant_delta",
2343
+ content: escalationBuf
2344
+ };
2345
+ escalationBuf = "";
2346
+ }
2347
+ } else {
2348
+ yield {
2349
+ turn: this._turn,
2350
+ role: "assistant_delta",
2351
+ content: chunk.contentDelta
2352
+ };
2353
+ }
2108
2354
  }
2109
2355
  if (chunk.reasoningDelta) {
2110
2356
  reasoningContent += chunk.reasoningDelta;
@@ -2145,13 +2391,23 @@ var CacheFirstLoop = class {
2145
2391
  if (chunk.usage) usage = chunk.usage;
2146
2392
  }
2147
2393
  toolCalls = [...callBuf.values()];
2394
+ if (bufferForEscalation && !escalationBufFlushed && escalationBuf.length > 0) {
2395
+ if (!this.isEscalationRequest(escalationBuf)) {
2396
+ yield {
2397
+ turn: this._turn,
2398
+ role: "assistant_delta",
2399
+ content: escalationBuf
2400
+ };
2401
+ }
2402
+ }
2148
2403
  } else {
2404
+ const callModel = this.modelForCurrentCall();
2149
2405
  const resp = await this.client.chat({
2150
- model: this.model,
2406
+ model: callModel,
2151
2407
  messages,
2152
2408
  tools: toolSpecs.length ? toolSpecs : void 0,
2153
2409
  signal,
2154
- thinking: thinkingModeForModel(this.model),
2410
+ thinking: thinkingModeForModel(callModel),
2155
2411
  reasoningEffort: this.reasoningEffort
2156
2412
  });
2157
2413
  assistantContent = resp.content;
@@ -2161,6 +2417,7 @@ var CacheFirstLoop = class {
2161
2417
  }
2162
2418
  } catch (err) {
2163
2419
  if (signal.aborted) {
2420
+ this.autoCompactToolResultsOnTurnEnd();
2164
2421
  yield { turn: this._turn, role: "done", content: "" };
2165
2422
  return;
2166
2423
  }
@@ -2172,7 +2429,27 @@ var CacheFirstLoop = class {
2172
2429
  };
2173
2430
  return;
2174
2431
  }
2175
- const turnStats = this.stats.record(this._turn, this.model, usage ?? new Usage());
2432
+ if (this.modelForCurrentCall() !== ESCALATION_MODEL && this.isEscalationRequest(assistantContent)) {
2433
+ this._escalateThisTurn = true;
2434
+ yield {
2435
+ turn: this._turn,
2436
+ role: "warning",
2437
+ content: `\u21E7 flash requested escalation \u2014 retrying this turn on ${ESCALATION_MODEL}`
2438
+ };
2439
+ assistantContent = "";
2440
+ reasoningContent = "";
2441
+ toolCalls = [];
2442
+ usage = null;
2443
+ branchSummary = void 0;
2444
+ preHarvestedPlanState = void 0;
2445
+ iter--;
2446
+ continue;
2447
+ }
2448
+ const turnStats = this.stats.record(
2449
+ this._turn,
2450
+ this.modelForCurrentCall(),
2451
+ usage ?? new Usage()
2452
+ );
2176
2453
  if (pendingUser !== null) {
2177
2454
  this.appendAndPersist({ role: "user", content: pendingUser });
2178
2455
  pendingUser = null;
@@ -2203,6 +2480,13 @@ var CacheFirstLoop = class {
2203
2480
  repair: report,
2204
2481
  branch: branchSummary
2205
2482
  };
2483
+ if (this.noteToolFailureSignal("", report)) {
2484
+ yield {
2485
+ turn: this._turn,
2486
+ role: "warning",
2487
+ content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailureCount} repair/error signals. Next turn falls back to ${this.model} unless /pro is armed.`
2488
+ };
2489
+ }
2206
2490
  if (report.stormsBroken > 0) {
2207
2491
  const noteTail = report.notes.length ? ` \u2014 ${report.notes[report.notes.length - 1]}` : "";
2208
2492
  const allSuppressed = repairedCalls.length === 0 && toolCalls.length > 0;
@@ -2214,13 +2498,14 @@ var CacheFirstLoop = class {
2214
2498
  };
2215
2499
  }
2216
2500
  if (repairedCalls.length === 0) {
2501
+ this.autoCompactToolResultsOnTurnEnd();
2217
2502
  yield { turn: this._turn, role: "done", content: assistantContent };
2218
2503
  return;
2219
2504
  }
2220
2505
  const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
2221
2506
  if (usage) {
2222
2507
  const ratio = usage.promptTokens / ctxMax;
2223
- if (ratio > 0.6 && ratio <= 0.8) {
2508
+ if (ratio > 0.4 && ratio <= 0.8) {
2224
2509
  const before = usage.promptTokens;
2225
2510
  const soft = this.compact(4e3);
2226
2511
  if (soft.healedCount > 0) {
@@ -2318,6 +2603,14 @@ ${reason}`;
2318
2603
  name,
2319
2604
  content: result
2320
2605
  });
2606
+ this.compactToolCallArgsAfterResponse();
2607
+ if (this.noteToolFailureSignal(result)) {
2608
+ yield {
2609
+ turn: this._turn,
2610
+ role: "warning",
2611
+ content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailureCount} edit failure(s). Next turn falls back to ${this.model} unless /pro is armed.`
2612
+ };
2613
+ }
2321
2614
  yield {
2322
2615
  turn: this._turn,
2323
2616
  role: "tool",
@@ -2341,13 +2634,15 @@ ${reason}`;
2341
2634
  role: "user",
2342
2635
  content: "I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks \u2014 they will be silently discarded. Just plain text."
2343
2636
  });
2637
+ const summaryModel = "deepseek-v4-flash";
2638
+ const summaryEffort = "high";
2344
2639
  const resp = await this.client.chat({
2345
- model: this.model,
2640
+ model: summaryModel,
2346
2641
  messages,
2347
2642
  // no tools → model is forced to answer in text
2348
2643
  signal: this._turnAbort.signal,
2349
- thinking: thinkingModeForModel(this.model),
2350
- reasoningEffort: this.reasoningEffort
2644
+ thinking: thinkingModeForModel(summaryModel),
2645
+ reasoningEffort: summaryEffort
2351
2646
  });
2352
2647
  const rawContent = resp.content?.trim() ?? "";
2353
2648
  const cleaned = stripHallucinatedToolMarkup(rawContent);
@@ -2356,7 +2651,7 @@ ${reason}`;
2356
2651
  const annotated = `${reasonPrefix}
2357
2652
 
2358
2653
  ${summary}`;
2359
- const summaryStats = this.stats.record(this._turn, this.model, resp.usage ?? new Usage());
2654
+ const summaryStats = this.stats.record(this._turn, summaryModel, resp.usage ?? new Usage());
2360
2655
  this.appendAndPersist(this.assistantMessage(summary, [], resp.reasoningContent ?? void 0));
2361
2656
  yield {
2362
2657
  turn: this._turn,
@@ -2365,6 +2660,7 @@ ${summary}`;
2365
2660
  stats: summaryStats,
2366
2661
  forcedSummary: true
2367
2662
  };
2663
+ this.autoCompactToolResultsOnTurnEnd();
2368
2664
  yield { turn: this._turn, role: "done", content: summary };
2369
2665
  } catch (err) {
2370
2666
  const label = errorLabelFor(opts.reason, this.maxToolIters);
@@ -2374,6 +2670,7 @@ ${summary}`;
2374
2670
  content: "",
2375
2671
  error: `${label} and the fallback summary call failed: ${err.message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`
2376
2672
  };
2673
+ this.autoCompactToolResultsOnTurnEnd();
2377
2674
  yield { turn: this._turn, role: "done", content: "" };
2378
2675
  }
2379
2676
  }
@@ -2503,6 +2800,56 @@ function shrinkOversizedToolResultsByTokens(messages, maxTokens) {
2503
2800
  });
2504
2801
  return { messages: out, healedCount, tokensSaved, charsSaved };
2505
2802
  }
2803
+ function shrinkOversizedToolCallArgsByTokens(messages, maxTokens) {
2804
+ let healedCount = 0;
2805
+ let tokensSaved = 0;
2806
+ let charsSaved = 0;
2807
+ const out = messages.map((msg) => {
2808
+ if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return msg;
2809
+ let changed = false;
2810
+ const newCalls = msg.tool_calls.map((call) => {
2811
+ const args = call.function?.arguments;
2812
+ if (typeof args !== "string" || args.length <= maxTokens) return call;
2813
+ const beforeTokens = countTokens(args);
2814
+ if (beforeTokens <= maxTokens) return call;
2815
+ const shrunk = shrinkJsonLongStrings(args);
2816
+ const afterTokens = countTokens(shrunk);
2817
+ if (afterTokens >= beforeTokens) return call;
2818
+ changed = true;
2819
+ healedCount += 1;
2820
+ tokensSaved += beforeTokens - afterTokens;
2821
+ charsSaved += args.length - shrunk.length;
2822
+ return { ...call, function: { ...call.function, arguments: shrunk } };
2823
+ });
2824
+ if (!changed) return msg;
2825
+ return { ...msg, tool_calls: newCalls };
2826
+ });
2827
+ return { messages: out, healedCount, tokensSaved, charsSaved };
2828
+ }
2829
+ function shrinkJsonLongStrings(jsonStr) {
2830
+ let parsed;
2831
+ try {
2832
+ parsed = JSON.parse(jsonStr);
2833
+ } catch {
2834
+ const head = jsonStr.slice(0, 200);
2835
+ return `${head}\u2026[shrunk: ${jsonStr.length} chars, unparsed]`;
2836
+ }
2837
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2838
+ return jsonStr;
2839
+ }
2840
+ const LONG_THRESHOLD = 300;
2841
+ const input = parsed;
2842
+ const output = {};
2843
+ for (const [k, v] of Object.entries(input)) {
2844
+ if (typeof v === "string" && v.length > LONG_THRESHOLD) {
2845
+ const newlines = v.match(/\n/g)?.length ?? 0;
2846
+ output[k] = `[\u2026shrunk: ${v.length} chars, ${newlines} lines \u2014 tool already responded, see result]`;
2847
+ } else {
2848
+ output[k] = v;
2849
+ }
2850
+ }
2851
+ return JSON.stringify(output);
2852
+ }
2506
2853
  function fixToolCallPairing(messages) {
2507
2854
  const out = [];
2508
2855
  let droppedAssistantCalls = 0;
@@ -2855,6 +3202,28 @@ import { join as join7, resolve as resolve3 } from "path";
2855
3202
  import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
2856
3203
  import { homedir as homedir3 } from "os";
2857
3204
  import { join as join6, resolve as resolve2 } from "path";
3205
+
3206
+ // src/prompt-fragments.ts
3207
+ var TUI_FORMATTING_RULES = `Formatting (rendered in a TUI with a real markdown renderer):
3208
+ - Tabular data \u2192 GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` header + \`| --- | --- |\` separator). Never use Unicode box-drawing characters (\u2502 \u2500 \u253C \u250C \u2510 \u2514 \u2518 \u251C \u2524) \u2014 they look intentional but break terminal word-wrap and render as garbled columns at narrow widths.
3209
+ - Keep table cells short (one phrase each). If a cell needs a paragraph, use bullets below the table instead.
3210
+ - Code, file paths with line ranges, and shell commands \u2192 fenced code blocks (\`\`\`).
3211
+ - Do NOT draw decorative frames around content with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` characters. The renderer adds its own borders; extra ASCII art adds noise and shatters at narrow widths.
3212
+ - For flow charts and diagrams: a plain bullet list with \`\u2192\` or \`\u2193\` between steps. Don't try to draw boxes-and-arrows in ASCII; it never survives word-wrap.`;
3213
+ var ESCALATION_CONTRACT = `Cost-aware escalation (when you're running on deepseek-v4-flash):
3214
+
3215
+ If a task CLEARLY exceeds what flash can do well \u2014 complex cross-file architecture refactors, subtle concurrency / security / correctness invariants you can't resolve with confidence, or a design trade-off you'd be guessing at \u2014 output the exact string \`<<<NEEDS_PRO>>>\` as the FIRST line of your response (nothing before it, not even whitespace on a separate line). This aborts the current call and retries this turn on deepseek-v4-pro, one shot. Do NOT emit any other content in the same response when you request escalation.
3216
+
3217
+ Use this sparingly. Normal tasks \u2014 reading files, small edits, clear bug fixes, straightforward feature additions \u2014 stay on flash. Request escalation ONLY when you would otherwise produce a guess or a visibly-mediocre answer. If in doubt, attempt the task on flash first; the system also escalates automatically if you hit 3+ repair / SEARCH-mismatch errors in a single turn.`;
3218
+ var NEGATIVE_CLAIM_RULE = `Negative claims ("X is missing", "Y isn't implemented", "there's no Z") are the #1 hallucination shape. They feel safe to write because no citation seems possible \u2014 but that's exactly why you must NOT write them on instinct.
3219
+
3220
+ If you have a search tool (\`search_content\`, \`grep\`, web search), call it FIRST before asserting absence:
3221
+ - Returns matches \u2192 you were wrong; correct yourself and cite the matches.
3222
+ - Returns nothing \u2192 state the absence WITH the search query as evidence: \`No callers of \\\`foo()\\\` found (search_content "foo").\`
3223
+
3224
+ If you have no search tool, qualify hard: "I haven't verified \u2014 this is a guess." Never assert absence with fake authority.`;
3225
+
3226
+ // src/skills.ts
2858
3227
  var SKILLS_DIRNAME = "skills";
2859
3228
  var SKILL_FILE = "SKILL.md";
2860
3229
  var SKILLS_INDEX_MAX_CHARS = 4e3;
@@ -2997,10 +3366,10 @@ function parseRunAs(raw) {
2997
3366
  }
2998
3367
  function skillIndexLine(s) {
2999
3368
  const safeDesc = s.description.replace(/\n/g, " ").trim();
3000
- const marker = s.runAs === "subagent" ? "\u{1F9EC} " : "";
3001
- const max = 130 - s.name.length - marker.length;
3369
+ const tag = s.runAs === "subagent" ? " [\u{1F9EC} subagent]" : "";
3370
+ const max = 130 - s.name.length - tag.length;
3002
3371
  const clipped = safeDesc.length > max ? `${safeDesc.slice(0, Math.max(1, max - 1))}\u2026` : safeDesc;
3003
- return clipped ? `- ${marker}${s.name} \u2014 ${clipped}` : `- ${marker}${s.name}`;
3372
+ return clipped ? `- ${s.name}${tag} \u2014 ${clipped}` : `- ${s.name}${tag}`;
3004
3373
  }
3005
3374
  function applySkillsIndex(basePrompt, opts = {}) {
3006
3375
  const store = new SkillStore(opts);
@@ -3015,7 +3384,7 @@ function applySkillsIndex(basePrompt, opts = {}) {
3015
3384
  "",
3016
3385
  "# Skills \u2014 playbooks you can invoke",
3017
3386
  "",
3018
- 'One-liner index. Each entry is either a built-in or a user-authored playbook. Call `run_skill({ name: "<skill-name>", arguments: "<task>" })` to invoke one. Skills marked with \u{1F9EC} spawn an **isolated subagent** \u2014 its tool calls and reasoning never enter your context, only its final answer does. Use \u{1F9EC} skills for tasks that would otherwise flood your context (deep exploration, multi-step research, anything where you only need the conclusion). Plain skills are inlined: their body becomes a tool result you read and act on directly. The user can also invoke a skill via `/skill <name>`.',
3387
+ 'One-liner index. Each entry is either a built-in or a user-authored playbook. Call `run_skill({ name: "<skill-name>", arguments: "<task>" })` \u2014 the `name` is JUST the skill identifier (e.g. `"explore"`), NOT the `[\u{1F9EC} subagent]` tag that appears after it. Entries tagged `[\u{1F9EC} subagent]` spawn an **isolated subagent** \u2014 its tool calls and reasoning never enter your context, only its final answer does. Use subagent skills for tasks that would otherwise flood your context (deep exploration, multi-step research, anything where you only need the conclusion). Plain skills are inlined: their body becomes a tool result you read and act on directly. The user can also invoke a skill via `/skill <name>`.',
3019
3388
  "",
3020
3389
  "```",
3021
3390
  truncated,
@@ -3037,12 +3406,9 @@ Your final answer:
3037
3406
  - If the question can't be answered from what you found, say so plainly and suggest where to look next.
3038
3407
  - No follow-up offers, no "let me know if you need more." The parent will ask again if they need more.
3039
3408
 
3040
- Formatting (rendered in a TUI):
3041
- - Tabular data \u2192 GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` + \`| --- | --- |\`). Never use Unicode box-drawing characters (\u2502 \u2500 \u253C) \u2014 they break word-wrap.
3042
- - Keep table cells short; if a cell needs a paragraph, use bullets below the table instead.
3043
- - Code, file paths with line ranges, and shell commands \u2192 fenced code blocks (\`\`\`).
3044
- - NEVER draw decorative frames around code or text with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` box-drawing characters. Use plain code blocks; the renderer adds its own border.
3045
- - For flow charts: use a bullet list with \`\u2192\` or \`\u2193\` between steps, not ASCII boxes-and-arrows.
3409
+ ${NEGATIVE_CLAIM_RULE}
3410
+
3411
+ ${TUI_FORMATTING_RULES}
3046
3412
 
3047
3413
  The 'task' the parent gave you is the question you must answer. Treat any other reading of it as scope creep.`;
3048
3414
  var BUILTIN_RESEARCH_BODY = `You are running as a research subagent. Your job is to gather information from code AND the web, synthesize it, and return one focused conclusion.
@@ -3059,12 +3425,9 @@ Your final answer:
3059
3425
  - Distinguish "I verified this in code" from "I read this on a docs page" \u2014 the parent will trust the former more.
3060
3426
  - If the answer is uncertain, say so. Don't invent confidence.
3061
3427
 
3062
- Formatting (rendered in a TUI):
3063
- - Tabular data \u2192 GitHub-Flavored Markdown tables with ASCII pipes (\`| col | col |\` + \`| --- | --- |\`). Never use Unicode box-drawing characters (\u2502 \u2500 \u253C) \u2014 they break word-wrap.
3064
- - Keep table cells short; if a cell needs a paragraph, use bullets below the table instead.
3065
- - Code, file paths with line ranges, and shell commands \u2192 fenced code blocks (\`\`\`).
3066
- - NEVER draw decorative frames around code or text with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` box-drawing characters. Use plain code blocks; the renderer adds its own border.
3067
- - For flow charts: use a bullet list with \`\u2192\` or \`\u2193\` between steps, not ASCII boxes-and-arrows.
3428
+ ${NEGATIVE_CLAIM_RULE}
3429
+
3430
+ ${TUI_FORMATTING_RULES}
3068
3431
 
3069
3432
  The 'task' the parent gave you is the research question. Stay on it.`;
3070
3433
  var BUILTIN_SKILLS = Object.freeze([
@@ -3366,6 +3729,9 @@ import { promises as fs } from "fs";
3366
3729
  import * as pathMod from "path";
3367
3730
  var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
3368
3731
  var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
3732
+ var DEFAULT_AUTO_PREVIEW_LINES = 200;
3733
+ var AUTO_PREVIEW_HEAD_LINES = 80;
3734
+ var AUTO_PREVIEW_TAIL_LINES = 40;
3369
3735
  var SKIP_DIR_NAMES = /* @__PURE__ */ new Set([
3370
3736
  "node_modules",
3371
3737
  ".git",
@@ -3458,14 +3824,22 @@ function registerFilesystemTools(registry, opts) {
3458
3824
  };
3459
3825
  registry.register({
3460
3826
  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.",
3827
+ description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
3828
+ - head: N \u2192 first N lines (imports, public API, small configs)
3829
+ - tail: N \u2192 last N lines (recently-added code, log tails)
3830
+ - range: "A-B" \u2192 inclusive line range A..B, 1-indexed (e.g. "120-180" around an edit site)
3831
+ 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
3832
  readOnly: true,
3463
3833
  parameters: {
3464
3834
  type: "object",
3465
3835
  properties: {
3466
3836
  path: { type: "string", description: "Path to read (relative to rootDir or absolute)." },
3467
3837
  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." }
3838
+ tail: { type: "integer", description: "If set, return only the last N lines." },
3839
+ range: {
3840
+ type: "string",
3841
+ 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.'
3842
+ }
3469
3843
  },
3470
3844
  required: ["path"]
3471
3845
  },
@@ -3477,21 +3851,52 @@ function registerFilesystemTools(registry, opts) {
3477
3851
  }
3478
3852
  const raw = await fs.readFile(abs);
3479
3853
  if (raw.length > maxReadBytes) {
3480
- const head = raw.slice(0, maxReadBytes).toString("utf8");
3481
- return `${head}
3854
+ const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
3855
+ return `${headBytes}
3482
3856
 
3483
- [\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail for targeted view.]`;
3857
+ [\u2026truncated ${raw.length - maxReadBytes} bytes \u2014 file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail/range for targeted view.]`;
3484
3858
  }
3485
3859
  const text = raw.toString("utf8");
3860
+ let lines = text.split(/\r?\n/);
3861
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines = lines.slice(0, -1);
3862
+ const totalLines = lines.length;
3863
+ if (typeof args.range === "string" && /^\d+\s*-\s*\d+$/.test(args.range)) {
3864
+ const [rawStart, rawEnd] = args.range.split("-").map((s) => Number.parseInt(s, 10));
3865
+ const start = Math.max(1, rawStart ?? 1);
3866
+ const end = Math.min(totalLines, Math.max(start, rawEnd ?? totalLines));
3867
+ const slice = lines.slice(start - 1, end);
3868
+ const label = `[range ${start}-${end} of ${totalLines} lines]`;
3869
+ return `${label}
3870
+ ${slice.join("\n")}`;
3871
+ }
3486
3872
  if (typeof args.head === "number" && args.head > 0) {
3487
- return text.split(/\r?\n/).slice(0, args.head).join("\n");
3873
+ const count = Math.min(args.head, totalLines);
3874
+ const slice = lines.slice(0, count);
3875
+ const marker = count < totalLines ? `
3876
+
3877
+ [\u2026head ${count} of ${totalLines} lines \u2014 call again with range / tail for more]` : "";
3878
+ return slice.join("\n") + marker;
3488
3879
  }
3489
3880
  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");
3881
+ const count = Math.min(args.tail, totalLines);
3882
+ const slice = lines.slice(totalLines - count);
3883
+ const marker = count < totalLines ? `[\u2026tail ${count} of ${totalLines} lines \u2014 call again with range / head for more]
3884
+
3885
+ ` : "";
3886
+ return marker + slice.join("\n");
3493
3887
  }
3494
- return text;
3888
+ if (totalLines <= DEFAULT_AUTO_PREVIEW_LINES) return lines.join("\n");
3889
+ const head = lines.slice(0, AUTO_PREVIEW_HEAD_LINES).join("\n");
3890
+ const tail = lines.slice(totalLines - AUTO_PREVIEW_TAIL_LINES).join("\n");
3891
+ const omitted = totalLines - AUTO_PREVIEW_HEAD_LINES - AUTO_PREVIEW_TAIL_LINES;
3892
+ return [
3893
+ `[auto-preview: head ${AUTO_PREVIEW_HEAD_LINES} + tail ${AUTO_PREVIEW_TAIL_LINES} of ${totalLines} lines]`,
3894
+ head,
3895
+ `
3896
+ [\u2026 ${omitted} lines omitted \u2014 call read_file again with range:"A-B" (1-indexed) or head / tail to get the middle]
3897
+ `,
3898
+ tail
3899
+ ].join("\n");
3495
3900
  }
3496
3901
  });
3497
3902
  registry.register({
@@ -3516,21 +3921,34 @@ function registerFilesystemTools(registry, opts) {
3516
3921
  });
3517
3922
  registry.register({
3518
3923
  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.",
3924
+ description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
3925
+ - 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.
3926
+ - Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
3927
+ - 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.
3928
+ Prefer \`list_directory\` for a single-level view, \`search_files\` to find specific paths, and \`search_content\` to find code.`,
3520
3929
  readOnly: true,
3521
3930
  parameters: {
3522
3931
  type: "object",
3523
3932
  properties: {
3524
3933
  path: { type: "string", description: "Root of the tree (default: sandbox root)." },
3525
- maxDepth: { type: "integer", description: "Max recursion depth (default 4)." }
3934
+ maxDepth: {
3935
+ type: "integer",
3936
+ description: "Max recursion depth (default 2). Depth 0 shows only the top-level entries; depth 2 is usually enough to see module structure."
3937
+ },
3938
+ include_deps: {
3939
+ type: "boolean",
3940
+ 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."
3941
+ }
3526
3942
  }
3527
3943
  },
3528
3944
  fn: async (args) => {
3529
3945
  const startAbs = safePath(args.path ?? ".");
3530
- const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 4;
3946
+ const maxDepth = typeof args.maxDepth === "number" ? args.maxDepth : 2;
3947
+ const includeDeps = args.include_deps === true;
3531
3948
  const lines = [];
3532
3949
  let totalBytes = 0;
3533
3950
  let truncated = false;
3951
+ const PER_DIR_CHILD_CAP = 50;
3534
3952
  const walk2 = async (dir, depth) => {
3535
3953
  if (truncated) return;
3536
3954
  if (depth > maxDepth) return;
@@ -3541,10 +3959,27 @@ function registerFilesystemTools(registry, opts) {
3541
3959
  return;
3542
3960
  }
3543
3961
  entries.sort((a, b) => a.name.localeCompare(b.name));
3962
+ let emitted = 0;
3544
3963
  for (const e of entries) {
3545
3964
  if (truncated) return;
3965
+ const skip = e.isDirectory() && !includeDeps && SKIP_DIR_NAMES.has(e.name);
3966
+ if (emitted >= PER_DIR_CHILD_CAP) {
3967
+ const remaining = entries.length - emitted;
3968
+ let restFiles = 0;
3969
+ let restDirs = 0;
3970
+ for (const r of entries.slice(emitted)) {
3971
+ if (r.isDirectory()) restDirs++;
3972
+ else restFiles++;
3973
+ }
3974
+ const indent2 = " ".repeat(depth);
3975
+ lines.push(
3976
+ `${indent2}[\u2026 ${remaining} entries hidden (${restDirs} dirs, ${restFiles} files) \u2014 list_directory on this path to see all]`
3977
+ );
3978
+ return;
3979
+ }
3546
3980
  const indent = " ".repeat(depth);
3547
- const line = e.isDirectory() ? `${indent}${e.name}/` : `${indent}${e.name}`;
3981
+ const suffix = skip ? " (skipped \u2014 pass include_deps:true to traverse)" : "";
3982
+ const line = e.isDirectory() ? `${indent}${e.name}/${suffix}` : `${indent}${e.name}`;
3548
3983
  totalBytes += line.length + 1;
3549
3984
  if (totalBytes > maxListBytes) {
3550
3985
  lines.push(` [\u2026 tree truncated at ${maxListBytes} bytes \u2026]`);
@@ -3552,7 +3987,8 @@ function registerFilesystemTools(registry, opts) {
3552
3987
  return;
3553
3988
  }
3554
3989
  lines.push(line);
3555
- if (e.isDirectory()) {
3990
+ emitted++;
3991
+ if (e.isDirectory() && !skip) {
3556
3992
  await walk2(pathMod.join(dir, e.name), depth + 1);
3557
3993
  }
3558
3994
  }
@@ -4057,15 +4493,15 @@ Rules:
4057
4493
  - When you're done, your final assistant message is the only thing the parent will see \u2014 make it complete and self-contained. No follow-up offers, no questions, no "let me know if you need more."
4058
4494
  - Prefer one clear, distilled answer over a long log of what you tried.
4059
4495
 
4060
- Formatting rules (the parent renders your reply in a TUI with a real markdown renderer):
4061
- - For tabular data use GitHub-Flavored Markdown tables with ASCII pipes: \`| col | col |\` headers, \`| --- | --- |\` separator. NEVER draw tables with Unicode box-drawing characters (\u2502 \u2500 \u253C \u250C \u2510 \u2514 \u2518 \u251C \u2524). They look intentional but break terminal word-wrap and produce garbled output.
4062
- - Keep table cells short \u2014 one short phrase per cell, not multi-line paragraphs. If a description doesn't fit in ~40 chars, use bullets below the table instead.
4063
- - Use fenced code blocks (\`\`\`) for any code, file paths with line ranges, or shell commands.
4064
- - NEVER draw decorative frames around content with \`\u250C\u2500\u2500\u2510 \u2502 \u2514\u2500\u2500\u2518\` box-drawing characters. The renderer handles code blocks and headings on its own \u2014 extra ASCII art adds noise without value and breaks at narrow terminal widths.
4065
- - For flow charts and diagrams: use a markdown bullet list with \`\u2192\` or \`\u2193\` between steps. Don't try to draw boxes-and-arrows in ASCII; it never survives word-wrap.`;
4496
+ ${NEGATIVE_CLAIM_RULE}
4497
+
4498
+ ${ESCALATION_CONTRACT}
4499
+
4500
+ ${TUI_FORMATTING_RULES}`;
4066
4501
  var DEFAULT_MAX_RESULT_CHARS2 = 8e3;
4067
4502
  var DEFAULT_MAX_ITERS = 16;
4068
- var DEFAULT_SUBAGENT_MODEL = "deepseek-v4-pro";
4503
+ var DEFAULT_SUBAGENT_MODEL = "deepseek-v4-flash";
4504
+ var DEFAULT_SUBAGENT_EFFORT = "high";
4069
4505
  var SUBAGENT_TOOL_NAME = "spawn_subagent";
4070
4506
  var NEVER_INHERITED_TOOLS = /* @__PURE__ */ new Set([SUBAGENT_TOOL_NAME, "submit_plan"]);
4071
4507
  async function spawnSubagent(opts) {
@@ -4094,6 +4530,10 @@ async function spawnSubagent(opts) {
4094
4530
  prefix: childPrefix,
4095
4531
  tools: childTools,
4096
4532
  model,
4533
+ // Subagents run on a constrained thinking budget by default — the
4534
+ // task is already narrow by construction, and `high` cuts output
4535
+ // tokens substantially vs `max`.
4536
+ reasoningEffort: DEFAULT_SUBAGENT_EFFORT,
4097
4537
  maxToolIters,
4098
4538
  hooks: [],
4099
4539
  stream: false
@@ -4262,9 +4702,311 @@ function forkRegistryExcluding(parent, exclude) {
4262
4702
  }
4263
4703
 
4264
4704
  // src/tools/shell.ts
4265
- import { spawn as spawn2 } from "child_process";
4705
+ import { spawn as spawn3 } from "child_process";
4266
4706
  import { existsSync as existsSync8, statSync as statSync4 } from "fs";
4707
+ import * as pathMod3 from "path";
4708
+
4709
+ // src/tools/jobs.ts
4710
+ import { spawn as spawn2 } from "child_process";
4267
4711
  import * as pathMod2 from "path";
4712
+ function killProcessTree(pid, signal) {
4713
+ if (process.platform === "win32") {
4714
+ const args = ["/pid", String(pid), "/T"];
4715
+ if (signal === "SIGKILL") args.push("/F");
4716
+ try {
4717
+ const killer = spawn2("taskkill", args, {
4718
+ stdio: "ignore",
4719
+ windowsHide: true
4720
+ });
4721
+ killer.on("error", () => {
4722
+ });
4723
+ } catch {
4724
+ }
4725
+ return;
4726
+ }
4727
+ try {
4728
+ process.kill(-pid, signal);
4729
+ return;
4730
+ } catch {
4731
+ }
4732
+ try {
4733
+ process.kill(pid, signal);
4734
+ } catch {
4735
+ }
4736
+ }
4737
+ var DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
4738
+ var READY_SIGNALS = [
4739
+ // HTTP server banners
4740
+ /\blistening on\b/i,
4741
+ /\blocal:\s+https?:\/\//i,
4742
+ /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?\b/i,
4743
+ /\b(?:ready|server started|started server|app listening)\b/i,
4744
+ // Bundlers / compilers
4745
+ /\bcompiled successfully\b/i,
4746
+ /\bbuild complete(?:d)?\b/i,
4747
+ /\bwatching for (?:file )?changes\b/i,
4748
+ /\bready in \d+/i,
4749
+ // Generic
4750
+ /\bstartup (?:complete|finished)\b/i
4751
+ ];
4752
+ var JobRegistry = class {
4753
+ jobs = /* @__PURE__ */ new Map();
4754
+ nextId = 1;
4755
+ /**
4756
+ * Spawn a background child. Resolves after `waitSec` OR on ready
4757
+ * signal OR on early exit, whichever comes first. The child continues
4758
+ * to run (and buffer output) regardless of which path fires.
4759
+ */
4760
+ async start(command, opts) {
4761
+ const trimmed = command.trim();
4762
+ if (!trimmed) throw new Error("run_background: empty command");
4763
+ const op = detectShellOperator(trimmed);
4764
+ if (op !== null) {
4765
+ throw new Error(
4766
+ `run_background: shell operator "${op}" is not supported \u2014 spawn one process per background job. Compose via your orchestration, not the shell.`
4767
+ );
4768
+ }
4769
+ const argv = tokenizeCommand(trimmed);
4770
+ if (argv.length === 0) throw new Error("run_background: empty command");
4771
+ const waitMs = Math.max(0, Math.min(30, opts.waitSec ?? 3)) * 1e3;
4772
+ const maxBytes = opts.maxBufferBytes ?? DEFAULT_OUTPUT_CAP_BYTES;
4773
+ const { bin, args, spawnOverrides } = prepareSpawn(argv);
4774
+ const spawnOpts = {
4775
+ cwd: pathMod2.resolve(opts.cwd),
4776
+ shell: false,
4777
+ windowsHide: true,
4778
+ env: process.env,
4779
+ // POSIX: detach so the child becomes its own process-group leader.
4780
+ // Required for `process.kill(-pid, …)` later — without it a group
4781
+ // kill fails and we end up only signaling the wrapper, leaving
4782
+ // grandchildren (node → vite → esbuild …) orphaned.
4783
+ // Windows: detached would spawn a new console window; leave the
4784
+ // default and use taskkill /T for tree termination.
4785
+ detached: process.platform !== "win32",
4786
+ ...spawnOverrides
4787
+ };
4788
+ let child;
4789
+ try {
4790
+ child = spawn2(bin, args, spawnOpts);
4791
+ } catch (err) {
4792
+ const id2 = this.nextId++;
4793
+ const job2 = {
4794
+ id: id2,
4795
+ command: trimmed,
4796
+ pid: null,
4797
+ startedAt: Date.now(),
4798
+ exitCode: null,
4799
+ output: `[spawn failed] ${err.message}`,
4800
+ totalBytesWritten: 0,
4801
+ running: false,
4802
+ spawnError: err.message,
4803
+ child: null,
4804
+ readyPromise: Promise.resolve(),
4805
+ signalReady: () => {
4806
+ }
4807
+ };
4808
+ this.jobs.set(id2, job2);
4809
+ return {
4810
+ jobId: id2,
4811
+ pid: null,
4812
+ stillRunning: false,
4813
+ readyMatched: false,
4814
+ preview: job2.output,
4815
+ exitCode: null
4816
+ };
4817
+ }
4818
+ const id = this.nextId++;
4819
+ let readyResolve = () => {
4820
+ };
4821
+ const readyPromise = new Promise((res) => {
4822
+ readyResolve = res;
4823
+ });
4824
+ const job = {
4825
+ id,
4826
+ command: trimmed,
4827
+ pid: child.pid ?? null,
4828
+ startedAt: Date.now(),
4829
+ exitCode: null,
4830
+ output: "",
4831
+ totalBytesWritten: 0,
4832
+ running: true,
4833
+ child,
4834
+ readyPromise,
4835
+ signalReady: readyResolve
4836
+ };
4837
+ this.jobs.set(id, job);
4838
+ let readyMatched = false;
4839
+ const onData = (chunk) => {
4840
+ const s = chunk.toString();
4841
+ job.totalBytesWritten += s.length;
4842
+ job.output += s;
4843
+ if (job.output.length > maxBytes) {
4844
+ const overflow = job.output.length - maxBytes;
4845
+ const cut = job.output.indexOf("\n", overflow);
4846
+ const start = cut >= 0 ? cut + 1 : overflow;
4847
+ job.output = `[\u2026 older output dropped \u2026]
4848
+ ${job.output.slice(start)}`;
4849
+ }
4850
+ if (!readyMatched) {
4851
+ for (const re of READY_SIGNALS) {
4852
+ if (re.test(s) || re.test(job.output)) {
4853
+ readyMatched = true;
4854
+ job.signalReady();
4855
+ break;
4856
+ }
4857
+ }
4858
+ }
4859
+ };
4860
+ child.stdout?.on("data", onData);
4861
+ child.stderr?.on("data", onData);
4862
+ child.on("error", (err) => {
4863
+ job.running = false;
4864
+ job.spawnError = err.message;
4865
+ job.signalReady();
4866
+ });
4867
+ child.on("close", (code) => {
4868
+ job.running = false;
4869
+ job.exitCode = code;
4870
+ job.signalReady();
4871
+ });
4872
+ const onAbort = () => this.stop(id, { graceMs: 100 });
4873
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
4874
+ let timer = null;
4875
+ await Promise.race([
4876
+ readyPromise,
4877
+ new Promise((res) => {
4878
+ timer = setTimeout(res, waitMs);
4879
+ })
4880
+ ]);
4881
+ if (timer) clearTimeout(timer);
4882
+ return {
4883
+ jobId: id,
4884
+ pid: job.pid,
4885
+ stillRunning: job.running,
4886
+ readyMatched,
4887
+ preview: job.output,
4888
+ exitCode: job.exitCode
4889
+ };
4890
+ }
4891
+ /**
4892
+ * Read a job's accumulated output. `since` lets a caller poll
4893
+ * incrementally: pass the byte count returned from the last call to
4894
+ * get only newly-written content. Returns both full output and a
4895
+ * running snapshot so the caller can use whichever.
4896
+ */
4897
+ read(id, opts = {}) {
4898
+ const job = this.jobs.get(id);
4899
+ if (!job) return null;
4900
+ const full = job.output;
4901
+ let slice = full;
4902
+ if (typeof opts.since === "number" && opts.since >= 0 && opts.since < full.length) {
4903
+ slice = full.slice(opts.since);
4904
+ }
4905
+ if (typeof opts.tailLines === "number" && opts.tailLines > 0) {
4906
+ const lines = slice.split("\n");
4907
+ const keep = lines.slice(Math.max(0, lines.length - opts.tailLines));
4908
+ slice = keep.join("\n");
4909
+ }
4910
+ return {
4911
+ output: slice,
4912
+ byteLength: full.length,
4913
+ running: job.running,
4914
+ exitCode: job.exitCode,
4915
+ command: job.command,
4916
+ pid: job.pid,
4917
+ spawnError: job.spawnError
4918
+ };
4919
+ }
4920
+ /**
4921
+ * Send SIGTERM, wait `graceMs`, then SIGKILL if still alive. Returns
4922
+ * the final job record (or null when the job id is unknown). Safe to
4923
+ * call on an already-exited job — returns the record unchanged.
4924
+ */
4925
+ async stop(id, opts = {}) {
4926
+ const job = this.jobs.get(id);
4927
+ if (!job) return null;
4928
+ if (!job.running || !job.child) return snapshot(job);
4929
+ const graceMs = Math.max(0, opts.graceMs ?? 2e3);
4930
+ if (job.pid !== null) {
4931
+ killProcessTree(job.pid, "SIGTERM");
4932
+ } else {
4933
+ try {
4934
+ job.child.kill("SIGTERM");
4935
+ } catch {
4936
+ }
4937
+ }
4938
+ await Promise.race([job.readyPromise, new Promise((res) => setTimeout(res, graceMs))]);
4939
+ if (job.running) {
4940
+ if (job.pid !== null) {
4941
+ killProcessTree(job.pid, "SIGKILL");
4942
+ } else {
4943
+ try {
4944
+ job.child.kill("SIGKILL");
4945
+ } catch {
4946
+ }
4947
+ }
4948
+ await new Promise((res) => setTimeout(res, 800));
4949
+ }
4950
+ return snapshot(job);
4951
+ }
4952
+ list() {
4953
+ return [...this.jobs.values()].map(snapshot);
4954
+ }
4955
+ /**
4956
+ * Best-effort kill of every still-running job. Called on TUI shutdown
4957
+ * so dev servers don't outlive the Reasonix process. Resolves after
4958
+ * every child has closed or a hard deadline passes (3s total).
4959
+ */
4960
+ async shutdown(deadlineMs = 5e3) {
4961
+ const start = Date.now();
4962
+ const runningJobs = [...this.jobs.values()].filter((j) => j.running && j.child);
4963
+ if (runningJobs.length === 0) return;
4964
+ for (const job of runningJobs) {
4965
+ if (job.pid !== null) killProcessTree(job.pid, "SIGTERM");
4966
+ else
4967
+ try {
4968
+ job.child?.kill("SIGTERM");
4969
+ } catch {
4970
+ }
4971
+ }
4972
+ const allClose = Promise.all(runningJobs.map((j) => j.readyPromise));
4973
+ const elapsed = () => Date.now() - start;
4974
+ const graceMs = Math.min(1500, Math.max(0, deadlineMs / 2));
4975
+ await Promise.race([allClose, new Promise((res) => setTimeout(res, graceMs))]);
4976
+ for (const job of runningJobs) {
4977
+ if (!job.running) continue;
4978
+ if (job.pid !== null) killProcessTree(job.pid, "SIGKILL");
4979
+ else
4980
+ try {
4981
+ job.child?.kill("SIGKILL");
4982
+ } catch {
4983
+ }
4984
+ }
4985
+ const remaining = Math.max(800, deadlineMs - elapsed());
4986
+ await Promise.race([allClose, new Promise((res) => setTimeout(res, remaining))]);
4987
+ }
4988
+ /** Count of still-running jobs — drives the TUI status-bar indicator. */
4989
+ runningCount() {
4990
+ let n = 0;
4991
+ for (const job of this.jobs.values()) if (job.running) n++;
4992
+ return n;
4993
+ }
4994
+ };
4995
+ function snapshot(job) {
4996
+ return {
4997
+ id: job.id,
4998
+ command: job.command,
4999
+ pid: job.pid,
5000
+ startedAt: job.startedAt,
5001
+ exitCode: job.exitCode,
5002
+ output: job.output,
5003
+ totalBytesWritten: job.totalBytesWritten,
5004
+ running: job.running,
5005
+ spawnError: job.spawnError
5006
+ };
5007
+ }
5008
+
5009
+ // src/tools/shell.ts
4268
5010
  var DEFAULT_TIMEOUT_SEC = 60;
4269
5011
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
4270
5012
  var BUILTIN_ALLOWLIST = [
@@ -4433,10 +5175,10 @@ async function runCommand(cmd, opts) {
4433
5175
  };
4434
5176
  const { bin, args, spawnOverrides } = prepareSpawn(argv);
4435
5177
  const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
4436
- return await new Promise((resolve8, reject) => {
5178
+ return await new Promise((resolve9, reject) => {
4437
5179
  let child;
4438
5180
  try {
4439
- child = spawn2(bin, args, effectiveSpawnOpts);
5181
+ child = spawn3(bin, args, effectiveSpawnOpts);
4440
5182
  } catch (err) {
4441
5183
  reject(err);
4442
5184
  return;
@@ -4466,7 +5208,7 @@ async function runCommand(cmd, opts) {
4466
5208
  const output = buf.length > maxChars ? `${buf.slice(0, maxChars)}
4467
5209
 
4468
5210
  [\u2026 truncated ${buf.length - maxChars} chars \u2026]` : buf;
4469
- resolve8({ exitCode: code, output, timedOut });
5211
+ resolve9({ exitCode: code, output, timedOut });
4470
5212
  });
4471
5213
  });
4472
5214
  }
@@ -4474,16 +5216,16 @@ function resolveExecutable(cmd, opts = {}) {
4474
5216
  const platform = opts.platform ?? process.platform;
4475
5217
  if (platform !== "win32") return cmd;
4476
5218
  if (!cmd) return cmd;
4477
- if (cmd.includes("/") || cmd.includes("\\") || pathMod2.isAbsolute(cmd)) return cmd;
4478
- if (pathMod2.extname(cmd)) return cmd;
5219
+ if (cmd.includes("/") || cmd.includes("\\") || pathMod3.isAbsolute(cmd)) return cmd;
5220
+ if (pathMod3.extname(cmd)) return cmd;
4479
5221
  const env = opts.env ?? process.env;
4480
5222
  const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean);
4481
- const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod2.delimiter);
5223
+ const delimiter2 = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod3.delimiter);
4482
5224
  const pathDirs = (env.PATH ?? "").split(delimiter2).filter(Boolean);
4483
5225
  const isFile = opts.isFile ?? defaultIsFile;
4484
5226
  for (const dir of pathDirs) {
4485
5227
  for (const ext of pathExt) {
4486
- const full = pathMod2.win32.join(dir, cmd + ext);
5228
+ const full = pathMod3.win32.join(dir, cmd + ext);
4487
5229
  if (isFile(full)) return full;
4488
5230
  }
4489
5231
  }
@@ -4553,8 +5295,8 @@ function withUtf8Codepage(cmdline) {
4553
5295
  function isBareWindowsName(s) {
4554
5296
  if (!s) return false;
4555
5297
  if (s.includes("/") || s.includes("\\")) return false;
4556
- if (pathMod2.isAbsolute(s)) return false;
4557
- if (pathMod2.extname(s)) return false;
5298
+ if (pathMod3.isAbsolute(s)) return false;
5299
+ if (pathMod3.extname(s)) return false;
4558
5300
  return true;
4559
5301
  }
4560
5302
  function quoteForCmdExe(arg) {
@@ -4573,17 +5315,18 @@ var NeedsConfirmationError = class extends Error {
4573
5315
  }
4574
5316
  };
4575
5317
  function registerShellTools(registry, opts) {
4576
- const rootDir = pathMod2.resolve(opts.rootDir);
5318
+ const rootDir = pathMod3.resolve(opts.rootDir);
4577
5319
  const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
4578
5320
  const maxOutputChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
5321
+ const jobs = opts.jobs ?? new JobRegistry();
4579
5322
  const getExtraAllowed = typeof opts.extraAllowed === "function" ? opts.extraAllowed : (() => {
4580
- const snapshot = opts.extraAllowed ?? [];
4581
- return () => snapshot;
5323
+ const snapshot2 = opts.extraAllowed ?? [];
5324
+ return () => snapshot2;
4582
5325
  })();
4583
5326
  const allowAll = opts.allowAll ?? false;
4584
5327
  registry.register({
4585
5328
  name: "run_command",
4586
- description: "Run a shell command in the project root and return its combined stdout+stderr. Common read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
5329
+ description: "Run a shell command in the project root and return its combined stdout+stderr.\n\nConstraints (read these before the first call):\n\u2022 ONE process per call, NO shell expansion. `&&`, `||`, `|`, `;`, `>`, `<`, `2>&1` are all rejected up-front \u2014 split into separate calls and combine results in reasoning. Example: instead of `grep foo *.ts | wc -l`, use `grep -c foo *.ts`; instead of `cd sub && npm test`, use `npm test --prefix sub` (or whatever --cwd flag the binary accepts).\n\u2022 `cd` DOES NOT PERSIST between calls \u2014 each call spawns a fresh process rooted at the project. If a tool needs a subdirectory, pass it via the tool's own flag (`npm --prefix`, `cargo -C`, `git -C`, `pytest tests/\u2026`), NOT via a preceding `cd`.\n\u2022 Avoid commands with unbounded output (`netstat -ano`, `find /`, etc.) \u2014 they waste tokens. Filter at source: `netstat -ano -p TCP`, `find src -name '*.ts'`, `grep -c`, `wc -l`.\n\nCommon read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually \u2014 after edits, run the project's tests to verify.",
4587
5330
  // Plan-mode gate: allow allowlisted commands through (git status,
4588
5331
  // cargo check, ls, grep …) so the model can actually investigate
4589
5332
  // during planning. Anything that would otherwise trigger a
@@ -4624,8 +5367,126 @@ function registerShellTools(registry, opts) {
4624
5367
  return formatCommandResult(cmd, result);
4625
5368
  }
4626
5369
  });
5370
+ registry.register({
5371
+ name: "run_background",
5372
+ 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.\n\nSame shell constraints as run_command: NO `&&` / `||` / `|` / `;` / `>` / `<` / `2>&1`, `cd` doesn't persist. Dev servers that need a subdirectory: use the tool's own --prefix / --cwd flag. For Vite specifically, `--prefix` on npm only tells npm where package.json is; vite's server root still defaults to process cwd, so pass `vite <project-dir>` or configure via `vite.config.ts` root.\n\nUSE 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.",
5373
+ parameters: {
5374
+ type: "object",
5375
+ properties: {
5376
+ command: {
5377
+ type: "string",
5378
+ description: "Full command line. Same quoting rules as run_command (no pipes / redirects / chaining)."
5379
+ },
5380
+ waitSec: {
5381
+ type: "integer",
5382
+ description: "Max seconds to wait for startup before returning. 0..30, default 3. A ready-signal match short-circuits this."
5383
+ }
5384
+ },
5385
+ required: ["command"]
5386
+ },
5387
+ fn: async (args, ctx) => {
5388
+ const cmd = args.command.trim();
5389
+ if (!cmd) throw new Error("run_background: empty command");
5390
+ if (!allowAll && !isAllowed(cmd, getExtraAllowed())) {
5391
+ throw new NeedsConfirmationError(cmd);
5392
+ }
5393
+ const result = await jobs.start(cmd, {
5394
+ cwd: rootDir,
5395
+ waitSec: args.waitSec,
5396
+ signal: ctx?.signal
5397
+ });
5398
+ return formatJobStart(result);
5399
+ }
5400
+ });
5401
+ registry.register({
5402
+ name: "job_output",
5403
+ 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.",
5404
+ readOnly: true,
5405
+ parameters: {
5406
+ type: "object",
5407
+ properties: {
5408
+ jobId: { type: "integer", description: "Job id returned by run_background." },
5409
+ since: {
5410
+ type: "integer",
5411
+ description: "Return only output written past this byte offset (for incremental polling)."
5412
+ },
5413
+ tailLines: {
5414
+ type: "integer",
5415
+ description: "Cap the returned slice to the last N lines. Default 80, 0 = unlimited."
5416
+ }
5417
+ },
5418
+ required: ["jobId"]
5419
+ },
5420
+ fn: async (args) => {
5421
+ const out = jobs.read(args.jobId, {
5422
+ since: args.since,
5423
+ tailLines: args.tailLines ?? 80
5424
+ });
5425
+ if (!out) return `job ${args.jobId}: not found (use list_jobs)`;
5426
+ return formatJobRead(args.jobId, out);
5427
+ }
5428
+ });
5429
+ registry.register({
5430
+ name: "stop_job",
5431
+ 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.",
5432
+ parameters: {
5433
+ type: "object",
5434
+ properties: {
5435
+ jobId: { type: "integer" }
5436
+ },
5437
+ required: ["jobId"]
5438
+ },
5439
+ fn: async (args) => {
5440
+ const rec = await jobs.stop(args.jobId);
5441
+ if (!rec) return `job ${args.jobId}: not found`;
5442
+ return formatJobStop(rec);
5443
+ }
5444
+ });
5445
+ registry.register({
5446
+ name: "list_jobs",
5447
+ 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.",
5448
+ readOnly: true,
5449
+ parameters: { type: "object", properties: {} },
5450
+ fn: async () => {
5451
+ const all = jobs.list();
5452
+ if (all.length === 0) return "(no background jobs started this session)";
5453
+ return all.map(formatJobRow).join("\n");
5454
+ }
5455
+ });
4627
5456
  return registry;
4628
5457
  }
5458
+ function formatJobStart(r) {
5459
+ 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]`;
5460
+ return r.preview ? `${header}
5461
+ ${r.preview}` : header;
5462
+ }
5463
+ function formatJobRead(jobId, r) {
5464
+ const status = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exited ${r.exitCode}` : r.spawnError ? `failed (${r.spawnError})` : "stopped";
5465
+ const header = `[job ${jobId} \xB7 ${status} \xB7 byteLength=${r.byteLength}]
5466
+ $ ${r.command}`;
5467
+ return r.output ? `${header}
5468
+ ${r.output}` : header;
5469
+ }
5470
+ function formatJobStop(r) {
5471
+ const running = r.running ? "still running (SIGKILL may be pending)" : `exit ${r.exitCode ?? "?"}`;
5472
+ const tail = tailLines(r.output, 40);
5473
+ const header = `[job ${r.id} stopped \xB7 ${running}]
5474
+ $ ${r.command}`;
5475
+ return tail ? `${header}
5476
+ ${tail}` : header;
5477
+ }
5478
+ function formatJobRow(r) {
5479
+ const age = ((Date.now() - r.startedAt) / 1e3).toFixed(1);
5480
+ const state = r.running ? `running \xB7 pid ${r.pid ?? "?"}` : r.exitCode !== null ? `exit ${r.exitCode}` : r.spawnError ? "failed" : "stopped";
5481
+ return ` ${String(r.id).padStart(3)} ${state.padEnd(24)} ${age}s ago $ ${r.command}`;
5482
+ }
5483
+ function tailLines(s, n) {
5484
+ if (!s) return "";
5485
+ const lines = s.split("\n");
5486
+ if (lines.length <= n) return s;
5487
+ const dropped = lines.length - n;
5488
+ return [`[\u2026 ${dropped} earlier lines \u2026]`, ...lines.slice(-n)].join("\n");
5489
+ }
4629
5490
  function formatCommandResult(cmd, r) {
4630
5491
  const header = r.timedOut ? `$ ${cmd}
4631
5492
  [killed after timeout]` : `$ ${cmd}
@@ -4819,11 +5680,11 @@ ${i + 1}. ${r.title}`);
4819
5680
 
4820
5681
  // src/env.ts
4821
5682
  import { readFileSync as readFileSync8 } from "fs";
4822
- import { resolve as resolve6 } from "path";
5683
+ import { resolve as resolve7 } from "path";
4823
5684
  function loadDotenv(path = ".env") {
4824
5685
  let raw;
4825
5686
  try {
4826
- raw = readFileSync8(resolve6(process.cwd(), path), "utf8");
5687
+ raw = readFileSync8(resolve7(process.cwd(), path), "utf8");
4827
5688
  } catch {
4828
5689
  return;
4829
5690
  }
@@ -5003,7 +5864,8 @@ function summarizeTurns(turns) {
5003
5864
  claudeEquivalentUsd: round2(totalClaude, 6),
5004
5865
  savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
5005
5866
  cacheHitRatio: round2(cacheHitRatio, 4),
5006
- lastPromptTokens: lastTurn?.usage.promptTokens ?? 0
5867
+ lastPromptTokens: lastTurn?.usage.promptTokens ?? 0,
5868
+ lastTurnCostUsd: round2(lastTurn?.cost ?? 0, 6)
5007
5869
  };
5008
5870
  }
5009
5871
  function round2(n, digits) {
@@ -5505,7 +6367,7 @@ var McpClient = class {
5505
6367
  const id = this.nextId++;
5506
6368
  const frame = { jsonrpc: "2.0", id, method, params };
5507
6369
  let abortHandler = null;
5508
- const promise = new Promise((resolve8, reject) => {
6370
+ const promise = new Promise((resolve9, reject) => {
5509
6371
  const timeout = setTimeout(() => {
5510
6372
  this.pending.delete(id);
5511
6373
  if (abortHandler && signal) signal.removeEventListener("abort", abortHandler);
@@ -5514,7 +6376,7 @@ var McpClient = class {
5514
6376
  );
5515
6377
  }, this.requestTimeoutMs);
5516
6378
  this.pending.set(id, {
5517
- resolve: resolve8,
6379
+ resolve: resolve9,
5518
6380
  reject,
5519
6381
  timeout
5520
6382
  });
@@ -5596,7 +6458,7 @@ var McpClient = class {
5596
6458
  };
5597
6459
 
5598
6460
  // src/mcp/stdio.ts
5599
- import { spawn as spawn3 } from "child_process";
6461
+ import { spawn as spawn4 } from "child_process";
5600
6462
  var StdioTransport = class {
5601
6463
  child;
5602
6464
  queue = [];
@@ -5611,14 +6473,14 @@ var StdioTransport = class {
5611
6473
  opts.command,
5612
6474
  ...(opts.args ?? []).map((a) => quoteArg(a, process.platform === "win32"))
5613
6475
  ].join(" ");
5614
- this.child = spawn3(line, [], {
6476
+ this.child = spawn4(line, [], {
5615
6477
  env,
5616
6478
  cwd: opts.cwd,
5617
6479
  stdio: ["pipe", "pipe", "inherit"],
5618
6480
  shell: true
5619
6481
  });
5620
6482
  } else {
5621
- this.child = spawn3(opts.command, opts.args ?? [], {
6483
+ this.child = spawn4(opts.command, opts.args ?? [], {
5622
6484
  env,
5623
6485
  cwd: opts.cwd,
5624
6486
  stdio: ["pipe", "pipe", "inherit"]
@@ -5637,12 +6499,12 @@ var StdioTransport = class {
5637
6499
  }
5638
6500
  async send(message) {
5639
6501
  if (this.closed) throw new Error("MCP transport is closed");
5640
- return new Promise((resolve8, reject) => {
6502
+ return new Promise((resolve9, reject) => {
5641
6503
  const line = `${JSON.stringify(message)}
5642
6504
  `;
5643
6505
  this.child.stdin.write(line, "utf8", (err) => {
5644
6506
  if (err) reject(err);
5645
- else resolve8();
6507
+ else resolve9();
5646
6508
  });
5647
6509
  });
5648
6510
  }
@@ -5653,8 +6515,8 @@ var StdioTransport = class {
5653
6515
  continue;
5654
6516
  }
5655
6517
  if (this.closed) return;
5656
- const next = await new Promise((resolve8) => {
5657
- this.waiters.push(resolve8);
6518
+ const next = await new Promise((resolve9) => {
6519
+ this.waiters.push(resolve9);
5658
6520
  });
5659
6521
  if (next === null) return;
5660
6522
  yield next;
@@ -5720,8 +6582,8 @@ var SseTransport = class {
5720
6582
  constructor(opts) {
5721
6583
  this.url = opts.url;
5722
6584
  this.headers = opts.headers ?? {};
5723
- this.endpointReady = new Promise((resolve8, reject) => {
5724
- this.resolveEndpoint = resolve8;
6585
+ this.endpointReady = new Promise((resolve9, reject) => {
6586
+ this.resolveEndpoint = resolve9;
5725
6587
  this.rejectEndpoint = reject;
5726
6588
  });
5727
6589
  this.endpointReady.catch(() => void 0);
@@ -5748,8 +6610,8 @@ var SseTransport = class {
5748
6610
  continue;
5749
6611
  }
5750
6612
  if (this.closed) return;
5751
- const next = await new Promise((resolve8) => {
5752
- this.waiters.push(resolve8);
6613
+ const next = await new Promise((resolve9) => {
6614
+ this.waiters.push(resolve9);
5753
6615
  });
5754
6616
  if (next === null) return;
5755
6617
  yield next;
@@ -5949,7 +6811,7 @@ async function trySection(load) {
5949
6811
 
5950
6812
  // src/code/edit-blocks.ts
5951
6813
  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";
6814
+ import { dirname as dirname4, resolve as resolve8 } from "path";
5953
6815
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
5954
6816
  function parseEditBlocks(text) {
5955
6817
  const out = [];
@@ -5967,8 +6829,8 @@ function parseEditBlocks(text) {
5967
6829
  return out;
5968
6830
  }
5969
6831
  function applyEditBlock(block, rootDir) {
5970
- const absRoot = resolve7(rootDir);
5971
- const absTarget = resolve7(absRoot, block.path);
6832
+ const absRoot = resolve8(rootDir);
6833
+ const absTarget = resolve8(absRoot, block.path);
5972
6834
  if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
5973
6835
  return {
5974
6836
  path: block.path,
@@ -6018,13 +6880,13 @@ function applyEditBlocks(blocks, rootDir) {
6018
6880
  return blocks.map((b) => applyEditBlock(b, rootDir));
6019
6881
  }
6020
6882
  function snapshotBeforeEdits(blocks, rootDir) {
6021
- const absRoot = resolve7(rootDir);
6883
+ const absRoot = resolve8(rootDir);
6022
6884
  const seen = /* @__PURE__ */ new Set();
6023
6885
  const snapshots = [];
6024
6886
  for (const b of blocks) {
6025
6887
  if (seen.has(b.path)) continue;
6026
6888
  seen.add(b.path);
6027
- const abs = resolve7(absRoot, b.path);
6889
+ const abs = resolve8(absRoot, b.path);
6028
6890
  if (!existsSync9(abs)) {
6029
6891
  snapshots.push({ path: b.path, prevContent: null });
6030
6892
  continue;
@@ -6038,9 +6900,9 @@ function snapshotBeforeEdits(blocks, rootDir) {
6038
6900
  return snapshots;
6039
6901
  }
6040
6902
  function restoreSnapshots(snapshots, rootDir) {
6041
- const absRoot = resolve7(rootDir);
6903
+ const absRoot = resolve8(rootDir);
6042
6904
  return snapshots.map((snap) => {
6043
- const abs = resolve7(absRoot, snap.path);
6905
+ const abs = resolve8(absRoot, snap.path);
6044
6906
  if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
6045
6907
  return {
6046
6908
  path: snap.path,
@@ -6075,7 +6937,7 @@ function sep() {
6075
6937
  // src/code/prompt.ts
6076
6938
  import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
6077
6939
  import { join as join9 } from "path";
6078
- var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
6940
+ var CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, list_directory, directory_tree, search_files, search_content, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell.
6079
6941
 
6080
6942
  # Cite or shut up \u2014 non-negotiable
6081
6943
 
@@ -6116,15 +6978,17 @@ The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit con
6116
6978
  - You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
6117
6979
 
6118
6980
 
6119
- # Delegating to subagents via Skills (\u{1F9EC})
6981
+ # Delegating to subagents via Skills
6982
+
6983
+ The pinned Skills index below lists playbooks you can invoke with \`run_skill\`. Entries tagged \`[\u{1F9EC} subagent]\` spawn an **isolated subagent** \u2014 a fresh child loop that runs the playbook in its own context and returns only the final answer. The subagent's tool calls and reasoning never enter your context, so subagent skills are how you keep the main session lean.
6120
6984
 
6121
- The pinned Skills index below lists playbooks you can invoke with \`run_skill\`. Skills marked with **\u{1F9EC}** spawn an **isolated subagent** \u2014 a fresh child loop that runs the playbook in its own context and returns only the final answer. The subagent's tool calls and reasoning never enter your context, so \u{1F9EC} skills are how you keep the main session lean.
6985
+ **When you call \`run_skill\`, the \`name\` is ONLY the identifier before the tag** \u2014 e.g. \`run_skill({ name: "explore", arguments: "..." })\`, NOT \`"[\u{1F9EC} subagent] explore"\` and NOT \`"explore [\u{1F9EC} subagent]"\`. The tag is display sugar; the name argument is just the bare identifier.
6122
6986
 
6123
6987
  Two built-ins ship by default:
6124
- - **\u{1F9EC} explore** \u2014 read-only investigation across the codebase. Use when the user says things like "find all places that...", "how does X work across the project", "survey the code for Y". Pass \`arguments\` describing the concrete question.
6125
- - **\u{1F9EC} research** \u2014 combines web search + code reading. Use for "is X supported by lib Y", "what's the canonical way to Z", "compare our impl to the spec".
6988
+ - **explore** \`[\u{1F9EC} subagent]\` \u2014 read-only investigation across the codebase. Use when the user says things like "find all places that...", "how does X work across the project", "survey the code for Y". Pass \`arguments\` describing the concrete question.
6989
+ - **research** \`[\u{1F9EC} subagent]\` \u2014 combines web search + code reading. Use for "is X supported by lib Y", "what's the canonical way to Z", "compare our impl to the spec".
6126
6990
 
6127
- When to delegate (call \`run_skill\` with a \u{1F9EC} skill):
6991
+ When to delegate (call \`run_skill\` with a subagent skill):
6128
6992
  - The task would otherwise need >5 file reads or searches.
6129
6993
  - You only need the conclusion, not the exploration trail.
6130
6994
  - The work is self-contained (you can describe it in one paragraph).
@@ -6147,6 +7011,15 @@ In those cases, use tools to gather what you need, then reply in prose. No SEARC
6147
7011
 
6148
7012
  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
7013
 
7014
+ 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.
7015
+
7016
+ - 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.
7017
+ - In \`review\` mode EACH \`edit_file\` / \`write_file\` call pauses tool dispatch while the user decides. You'll get one of these responses:
7018
+ - \`"edit blocks: 1/1 applied"\` \u2014 user approved it. Continue as normal.
7019
+ - \`"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.
7020
+ - Text-form SEARCH/REPLACE blocks in your assistant reply queue for end-of-turn /apply \u2014 same "don't retry on rejection" rule.
7021
+ - 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.
7022
+
6150
7023
  # Editing files
6151
7024
 
6152
7025
  When you've been asked to change a file, output one or more SEARCH/REPLACE blocks in this exact format:
@@ -6187,11 +7060,49 @@ Two different rules depending on which tool:
6187
7060
  - **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
7061
  - **\`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
7062
 
7063
+ # Foreground vs. background commands
7064
+
7065
+ You have TWO tools for running shell commands, and picking the right one is non-negotiable:
7066
+
7067
+ - \`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.
7068
+ - \`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\`.
7069
+
7070
+ **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.
7071
+
7072
+ After \`run_background\`, tools available to you:
7073
+ - \`job_output(jobId, tailLines?)\` \u2014 read recent logs to verify startup / debug errors.
7074
+ - \`list_jobs\` \u2014 see every job this session (running + exited).
7075
+ - \`stop_job(jobId)\` \u2014 SIGTERM \u2192 SIGKILL after grace. Stop before switching port / config.
7076
+
7077
+ Don't re-start an already-running dev server \u2014 call \`list_jobs\` first when in doubt.
7078
+
7079
+ # Scope discipline on "run it" / "start it" requests
7080
+
7081
+ When the user's request is to **run / start / launch / serve / boot up** something, your job is ONLY:
7082
+
7083
+ 1. Start it (\`run_background\` for dev servers, \`run_command\` for one-shots).
7084
+ 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).
7085
+ 3. Report what's running, where (URL / port / pid), and STOP.
7086
+
7087
+ Do NOT, in the same turn:
7088
+ - Run \`tsc\` / type-checkers / linters unless the user asked for it.
7089
+ - Scan for bugs to "proactively" fix. The page rendering is success.
7090
+ - Clean up unused imports, dead code, or refactor "while you're here."
7091
+ - Edit files to improve anything the user didn't mention.
7092
+
7093
+ 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.
7094
+
7095
+ "It works" is the end state. Resist the urge to polish.
7096
+
6190
7097
  # Style
6191
7098
 
6192
7099
  - Show edits; don't narrate them in prose. "Here's the fix:" is enough.
6193
7100
  - One short paragraph explaining *why*, then the blocks.
6194
7101
  - If you need to explore first (list / read / search), do it with tool calls before writing any prose \u2014 silence while exploring is fine.
7102
+
7103
+ ${ESCALATION_CONTRACT}
7104
+
7105
+ ${TUI_FORMATTING_RULES}
6195
7106
  `;
6196
7107
  function codeSystemPrompt(rootDir) {
6197
7108
  const withMemory = applyMemoryStack(CODE_SYSTEM_PROMPT, rootDir);