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