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/cli/{chunk-ANMDY236.js → chunk-C266QOQU.js} +44 -1
- package/dist/cli/chunk-C266QOQU.js.map +1 -0
- package/dist/cli/index.js +1751 -374
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/{prompt-75XLIUTO.js → prompt-OVVMCH5F.js} +2 -2
- package/dist/index.d.ts +210 -1
- package/dist/index.js +723 -60
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli/chunk-ANMDY236.js.map +0 -1
- /package/dist/cli/{prompt-75XLIUTO.js.map → prompt-OVVMCH5F.js.map} +0 -0
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((
|
|
51
|
-
const timer = setTimeout(
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1866
|
+
const { messages, healedCount } = shrinkOversizedToolCallArgsByTokens(
|
|
1809
1867
|
before,
|
|
1810
|
-
|
|
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((
|
|
2055
|
-
waiter =
|
|
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:
|
|
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
|
|
3481
|
-
return `${
|
|
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
|
-
|
|
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
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
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
|
|
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:
|
|
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: {
|
|
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 :
|
|
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
|
|
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
|
-
|
|
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
|
|
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((
|
|
4937
|
+
return await new Promise((resolve9, reject) => {
|
|
4437
4938
|
let child;
|
|
4438
4939
|
try {
|
|
4439
|
-
child =
|
|
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
|
-
|
|
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("\\") ||
|
|
4478
|
-
if (
|
|
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" ? ";" :
|
|
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 =
|
|
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 (
|
|
4557
|
-
if (
|
|
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 =
|
|
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
|
|
4581
|
-
return () =>
|
|
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
|
|
5442
|
+
import { resolve as resolve7 } from "path";
|
|
4823
5443
|
function loadDotenv(path = ".env") {
|
|
4824
5444
|
let raw;
|
|
4825
5445
|
try {
|
|
4826
|
-
raw = readFileSync8(
|
|
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((
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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((
|
|
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
|
|
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((
|
|
5657
|
-
this.waiters.push(
|
|
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((
|
|
5724
|
-
this.resolveEndpoint =
|
|
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((
|
|
5752
|
-
this.waiters.push(
|
|
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
|
|
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 =
|
|
5971
|
-
const absTarget =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
6661
|
+
const absRoot = resolve8(rootDir);
|
|
6042
6662
|
return snapshots.map((snap) => {
|
|
6043
|
-
const abs =
|
|
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.
|