mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3efa4cc374 | ||
|
|
26ca84dd8f | ||
|
|
e91d136dbc | ||
|
|
12a3dab7e9 | ||
|
|
deefafed10 | ||
|
|
882a7ad2d9 | ||
|
|
b248a87455 | ||
|
|
ff858ed744 | ||
|
|
a5adca41ac | ||
|
|
aed4d431cb | ||
|
|
0acfcefb89 | ||
|
|
9cb0bdbc5f | ||
|
|
2ca403670d | ||
|
|
c6e5bf4b02 | ||
|
|
08c7f2be81 | ||
|
|
8befb44e5e | ||
|
|
b65ed747c9 | ||
|
|
86be00299e | ||
|
|
b634b5e171 | ||
|
|
3e91082456 | ||
|
|
638eaeeb46 | ||
|
|
1a7b88c72f | ||
|
|
6ee2428802 | ||
|
|
febecb3d6f | ||
|
|
795a1e2f35 | ||
|
|
77cd4f7f66 | ||
|
|
3ec46356f5 | ||
|
|
825c6e9608 | ||
|
|
affa03b235 | ||
|
|
4ba0199c42 | ||
|
|
5f80b252e8 | ||
|
|
4b77671103 | ||
|
|
900eae2a3a | ||
|
|
94047eadf3 | ||
|
|
0945a1189e | ||
|
|
bba8199ba4 | ||
|
|
fc8639a697 | ||
|
|
8c51dd6f0f | ||
|
|
56f1cbb835 | ||
|
|
abb1b1ec7a | ||
|
|
4f1e65a629 | ||
|
|
7096e43895 | ||
|
|
8543c88034 | ||
|
|
bdfb04c52b |
No files matched your search
+135
@@ -3,6 +3,141 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.29.0] — 2026-05-06
|
||||
|
||||
**Headline:** tool dispatch is no longer strictly serial. When the model
|
||||
emits multiple `parallelSafe`-annotated tool calls in one turn (multiple
|
||||
`read_file`, multiple `spawn_subagent`, etc.), the loop now races them
|
||||
together via `Promise.allSettled`; a non-`parallelSafe` call ends the
|
||||
chunk and runs alone, so read-after-write ordering still holds. Tool
|
||||
yields and history append still land in declared order regardless of
|
||||
which call settles first — the model and UI see the same shape they
|
||||
would under serial dispatch. The TUI's `SubagentRow` becomes
|
||||
`SubagentLiveStack`, rendering 1 → rich card, 2..max → compact rows,
|
||||
> max → "+N more running…" fold. Closes umbrella #325.
|
||||
|
||||
**Tool dispatch:**
|
||||
|
||||
- feat(tools): `ToolDefinition.parallelSafe?: boolean` — opt-in
|
||||
annotation, default `false`. `ToolRegistry.isParallelSafe(name)` for
|
||||
the dispatcher to query; unknown / unannotated tools resolve to
|
||||
`false` so third-party MCP tools must explicitly opt in. Built-in
|
||||
read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`,
|
||||
`get_file_info`), web (`web_search`, `web_fetch`), `recall_memory`,
|
||||
`semantic_search`, isolated child loops (`run_skill`,
|
||||
`spawn_subagent`), and in-memory job queries (`job_output`,
|
||||
`list_jobs`) are annotated. Mutating tools stay default. (PR #326)
|
||||
- feat(loop): chunked parallel tool dispatch. Replaces `for...of +
|
||||
await` in the dispatch loop with a chunking loop that groups
|
||||
consecutive `parallelSafe` calls and races them; unsafe calls form
|
||||
serial barriers. `runOneToolCall` extracts per-call lifecycle
|
||||
(PreToolUse + dispatch + PostToolUse) so the chunk can fan out via
|
||||
`Promise.allSettled` while the loop body keeps yielding events in
|
||||
declared order. Two new env knobs: `REASONIX_PARALLEL_MAX` (chunk
|
||||
size cap, default 3, hard max 16) and `REASONIX_TOOL_DISPATCH=serial`
|
||||
(escape hatch). Tests cover parallel timing, serial barrier on mixed
|
||||
safe/unsafe, declared-order yields under racey completion, and both
|
||||
env-knob overrides. (PR #327)
|
||||
|
||||
**TUI:**
|
||||
|
||||
- feat(ui): `SubagentEvent` carries a stable `runId` per spawn so the
|
||||
sink can key concurrent runs apart instead of overwriting one shared
|
||||
row. `useSubagent` keeps an array of in-flight activities;
|
||||
`SubagentLiveStack` renders 1 → rich card (unchanged), 2..max →
|
||||
compact rows with per-row spinner + iter + last tool, > max →
|
||||
compact rows + "+N more running…" fold. (PR #327)
|
||||
|
||||
**Docs:**
|
||||
|
||||
- docs(architecture): `docs/ARCHITECTURE.md` Pillar 1 gains a
|
||||
"Parallel tool dispatch" section explaining the chunking rule, both
|
||||
env knobs, and the list of built-in tools that opt in. (PR #328)
|
||||
|
||||
## [0.28.0] — 2026-05-06
|
||||
|
||||
**Headline:** subagent capability sharpened on three axes — skills can
|
||||
now scope a child to a specific tool subset via `allowed-tools`
|
||||
frontmatter, callers can request a per-spawn iter budget via the new
|
||||
`max_iters` arg (clamped 1-32), and two built-in personas (`explore`,
|
||||
`verify`) are selectable inline via a `type` arg without writing a
|
||||
skill. Closes umbrella #316.
|
||||
|
||||
**Subagent:**
|
||||
|
||||
- feat(subagent): honor skill `allowed-tools` frontmatter when forking
|
||||
the child registry. The field was parsed but ignored ("Unused in v1");
|
||||
now it scopes the subagent to the named tools only. New
|
||||
`forkRegistryWithAllowList` helper alongside `forkRegistryExcluding`;
|
||||
`NEVER_INHERITED` (`spawn_subagent` / `submit_plan`) still wins so
|
||||
depth=1 + plan-mode guarantees hold even if a skill names them. An
|
||||
allow-list naming a tool the parent doesn't have returns a structured
|
||||
error result (no API call burned). (#317, PR #320)
|
||||
- feat(subagent): expose `max_iters` on the `spawn_subagent` tool
|
||||
schema. Clamped to 1-32 at the boundary; floats round down; non-numeric
|
||||
/ missing falls back to the registration-time default (still 16).
|
||||
Verify-style tasks can ask for 6-8, explore-style can ask for 24+.
|
||||
(#318, PR #321)
|
||||
- feat(subagent): two built-in personas selectable via `type` arg —
|
||||
`explore` (wide-net read-only investigation, 20-iter budget) and
|
||||
`verify` (narrow yes/no with evidence, 8-iter budget). Caller's
|
||||
explicit `system` / `max_iters` override the type's defaults. Prompts
|
||||
live in new `src/tools/subagent-types.ts` so `subagent.ts` stays
|
||||
under the 500-line target. (#319, PR #322)
|
||||
|
||||
## [0.27.3] — 2026-05-06
|
||||
|
||||
**Headline:** USD-account users now see `$` instead of `¥` everywhere
|
||||
money is shown in the TUI — wallet balance, turn cost, session cost,
|
||||
top-bar cost label, subagent end-event cost suffix, and the UsageCard
|
||||
header / body / wallet line. Pre-fix a USD wallet rendered
|
||||
`¥0.0352 turn · ¥0.461 session · wallet ¥0.91`; now it renders
|
||||
`$0.0308 turn · $0.064 session · wallet $0.91`. The display follows
|
||||
the wallet currency reported by the DeepSeek API (`currency: "USD"|"CNY"`),
|
||||
not the UI language — a CNY account on an English UI still sees `¥`,
|
||||
and vice versa. Originally reported in #278 by @Explosion-Scratch.
|
||||
|
||||
**UI / currency:**
|
||||
|
||||
- fix(ui): USD wallets render `$` for wallet balance, turn cost, and
|
||||
session cost. State + event schemas now carry `balanceCurrency`
|
||||
through `App.tsx → reducer → StatusBar` so every render site sees
|
||||
the wallet symbol the API reported. Originally drafted by @wviana
|
||||
in #272; the TUI plumbing through state.ts / cards.ts / events.ts /
|
||||
reducer.ts / useScrollback.ts / slash/types.ts was the bulk of the
|
||||
fix.
|
||||
- fix(ui): balance color threshold checks USD against the CNY scale
|
||||
(`$0.91 ≈ ¥6.55`) rather than treating `0.91` as `< ¥5 → red`. USD
|
||||
wallets now correctly show yellow at low-but-not-empty balances.
|
||||
- fix(ui): `StatsPanel.ChromeRow` cost label and `useSubagent`
|
||||
end-event cost suffix follow the wallet currency too — pre-fix
|
||||
these always rendered `$`. (#313)
|
||||
- refactor(ui): seven currency helpers in `theme/tokens.ts`
|
||||
(`formatCNY` / `formatBalance` / `formatBalanceLabel` /
|
||||
`formatWalletDisplay` / `formatCost` / `balanceColorCny` /
|
||||
`balanceColorForBalance`) collapsed to three: `formatBalance`,
|
||||
`formatCost`, `balanceColor`. Undefined currency defaults to CNY
|
||||
(matches pre-fix unconditional `¥`) so the transient first-turn
|
||||
case where balance arrived but currency hasn't is consistent.
|
||||
- chore(ui): remove orphan `ChromeBar.tsx` (258 lines). `App.tsx`
|
||||
mounts `StatsPanel`'s diverged `ChromeRow`, which is the bar users
|
||||
actually see. The two formatter helpers ChromeBar once owned now
|
||||
live in `theme/tokens.ts`. (#314)
|
||||
|
||||
**Loop:**
|
||||
|
||||
- refactor(loop): `loop.ts` 1331 → 1219 (−112). Three sibling files
|
||||
under `src/loop/`: `messages.ts` (pure ChatMessage builders),
|
||||
`turn-failure-tracker.ts` (per-turn failure count + threshold
|
||||
tipping), `force-summary.ts` (forced-summary generator behind a
|
||||
small DI context). Continues the #308 / #309 cadence — small
|
||||
per-helper extractions, no behavior change. (#311)
|
||||
|
||||
**Known follow-up:** `SessionPicker` still hardcodes `¥` for
|
||||
per-session cost in the session-history list, tracked in #312
|
||||
(good-first-issue).
|
||||
|
||||
## [0.25.1] — 2026-05-05
|
||||
|
||||
**Headline:** `run_command` learns the four common shell chain
|
||||
|
||||
@@ -45,7 +45,7 @@ const PROMPTS = [
|
||||
{ id: "m3", level: "medium", task: "A function \`debounceAsync<T extends any[], R>(fn: (...args: T) => Promise<R>, ms: number): (...args: T) => Promise<R>\` in src/util/debounce.ts. Resolves only the latest call's promise; earlier callers reject with an AbortError-like." },
|
||||
|
||||
// hard (2) — these touch domain types from the repo
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`\${file}::\${fullName}\` with source='native'." },
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`${file}::${fullName}\` with source='native'." },
|
||||
{ id: "h2", level: "hard", task: "A function \`pairRedGreen(events: Array<{type:string, test_id?:string, status?:string, ts:number}>): Array<{ test_id: string, red_ts: number, green_ts: number }>\` in src/events/pair.ts. For each test_id, find the most recent fail→pass transition and return one entry per test_id. Ignore test_ids that never went green." },
|
||||
];
|
||||
|
||||
|
||||
@@ -43,6 +43,29 @@ timestamps each turn — cache hit rate in practice: <20%.
|
||||
**Metric.** `prompt_cache_hit_tokens / (hit + miss)` exposed per-turn and
|
||||
aggregated per-session. Visible in the TUI's top-bar cache cell.
|
||||
|
||||
#### Parallel tool dispatch
|
||||
|
||||
Each tool declares `parallelSafe?: boolean` (default `false`). The loop
|
||||
dispatcher groups consecutive parallel-safe calls into chunks and races
|
||||
them via `Promise.allSettled`; the first non-parallel-safe call ends the
|
||||
chunk and runs alone (serial barrier — read-after-write order
|
||||
preserved). Tool-result yields and history append still land in declared
|
||||
order regardless of which call settles first, so the model sees the
|
||||
same shape it would under a fully serial dispatch.
|
||||
|
||||
| Env var | Default | Effect |
|
||||
|---|---|---|
|
||||
| `REASONIX_PARALLEL_MAX` | `3` (hard cap `16`) | Max chunk size. |
|
||||
| `REASONIX_TOOL_DISPATCH=serial` | unset | Forces serial dispatch — escape hatch. |
|
||||
|
||||
Built-in opt-ins: read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`, `get_file_info`),
|
||||
web (`web_search`, `web_fetch`), `recall_memory`, `semantic_search`,
|
||||
isolated child loops (`run_skill`, `spawn_subagent`), in-memory job
|
||||
queries (`job_output`, `list_jobs`). Mutating / side-effecting tools
|
||||
stay default. MCP-bridged tools default `false` — third-party tools
|
||||
opt in only when the server explicitly declares parallel safety.
|
||||
|
||||
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
|
||||
|
||||
**Problem.** R1 / V4-thinking emits extensive `reasoning_content`. DeepSeek's
|
||||
|
||||
Generated
+135
-2
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.27.0",
|
||||
"version": "0.29.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix",
|
||||
"version": "0.27.0",
|
||||
"version": "0.29.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
@@ -2580,6 +2581,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
@@ -3084,6 +3091,34 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -3137,6 +3172,61 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -3172,6 +3262,18 @@
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
@@ -3718,6 +3820,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz",
|
||||
@@ -4337,6 +4448,16 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-parser": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz",
|
||||
"integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"he": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
@@ -4374,6 +4495,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.27.0",
|
||||
"version": "0.29.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -72,6 +72,7 @@
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
|
||||
+4
-2
@@ -315,8 +315,10 @@ export function expandAtMentions(
|
||||
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
|
||||
const rawPath = match[1] ?? "";
|
||||
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
|
||||
// Keep internal dots intact.
|
||||
const cleaned = rawPath.replace(/\.+$/, "");
|
||||
// Keep internal dots intact. Manual loop instead of `/\.+$/` — the
|
||||
// regex is O(n²) on dot-heavy non-matches per CodeQL js/polynomial-redos.
|
||||
let cleaned = rawPath;
|
||||
while (cleaned.endsWith(".")) cleaned = cleaned.slice(0, -1);
|
||||
if (!cleaned) continue;
|
||||
const token = `@${cleaned}`;
|
||||
if (seen.has(token)) continue;
|
||||
|
||||
@@ -80,6 +80,14 @@ function detailsFor(e: Event): string {
|
||||
return `${e.callId} reason=${e.reason}`;
|
||||
case "tool.result":
|
||||
return `${e.callId} ${e.ok ? "ok" : "err"} ${e.output.length}B${e.truncated ? " [trunc]" : ""}`;
|
||||
case "tool.call":
|
||||
return `${e.name} args=${truncate(JSON.stringify(e.args), 60)}`;
|
||||
case "tool.confirm.allow":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)}`;
|
||||
case "tool.confirm.deny":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)}${e.denyContext ? ` — ${truncate(e.denyContext, 40)}` : ""}`;
|
||||
case "tool.confirm.always_allow":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)} prefix=${truncate(e.prefix, 30)}`;
|
||||
case "effect.file.touched":
|
||||
return `${e.mode} ${e.path} (${e.bytes}B)`;
|
||||
case "effect.memory.written":
|
||||
|
||||
@@ -35,6 +35,32 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMcpInspectFailure(err: unknown): string {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const message = error.message;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
const command = message.match(/^spawn\s+([^\s]+)\s+ENOENT$/)?.[1] ?? "the command";
|
||||
return `${message} — try: install or verify \`${command}\`, then check the MCP spec's command spelling`;
|
||||
}
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
const target = message.match(/\b(https?:\/\/\S+|\d+\.\d+\.\d+\.\d+:\d+|localhost:\d+)\b/i)?.[1];
|
||||
return `${message} — try: confirm ${target ?? "the MCP server"} is running and the host/port match the spec`;
|
||||
}
|
||||
|
||||
if (/^MCP request initialize \(id=\d+\) timed out after \d+ms$/.test(message)) {
|
||||
return `${message} — try: confirm the target speaks MCP and completes the handshake before the request timeout`;
|
||||
}
|
||||
|
||||
if (/^(empty MCP spec|MCP spec ".*" has name but no command)/.test(message)) {
|
||||
return `${message} — try: pass \`name=command args\` or an http(s):// URL`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatReport(nsName: string, r: InspectionReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`MCP server [${nsName}]`);
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ import { doctorCommand } from "./commands/doctor.js";
|
||||
import { eventsCommand } from "./commands/events.js";
|
||||
import { indexCommand } from "./commands/index.js";
|
||||
import { mcpBrowseCommand } from "./commands/mcp-browse.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { formatMcpInspectFailure, mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpInstallCommand, mcpListCommand, mcpSearchCommand } from "./commands/mcp.js";
|
||||
import { pruneSessionsCommand } from "./commands/prune-sessions.js";
|
||||
import { replayCommand } from "./commands/replay.js";
|
||||
@@ -414,7 +414,7 @@ mcp
|
||||
try {
|
||||
await mcpInspectCommand({ spec, json: !!opts.json });
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp inspect failed: ${(err as Error).message}\n`);
|
||||
process.stderr.write(`mcp inspect failed: ${formatMcpInspectFailure(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
+112
-254
@@ -14,7 +14,7 @@ import {
|
||||
snapshotBeforeEdits,
|
||||
toWholeFileEditBlock,
|
||||
} from "../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, loadPendingEdits, savePendingEdits } from "../../code/pending-edits.js";
|
||||
import { clearPendingEdits, loadPendingEdits } from "../../code/pending-edits.js";
|
||||
import {
|
||||
clearPlanState,
|
||||
loadPlanState,
|
||||
@@ -67,7 +67,7 @@ import { formatCommandResult, runCommand } from "../../tools/shell.js";
|
||||
import { registerSkillTools } from "../../tools/skills.js";
|
||||
import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
|
||||
import { webFetch } from "../../tools/web.js";
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript/log.js";
|
||||
import { openTranscriptFile } from "../../transcript/log.js";
|
||||
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
|
||||
import { ChoiceConfirm, type ChoiceConfirmChoice } from "./ChoiceConfirm.js";
|
||||
import { EditConfirm, type EditReviewChoice } from "./EditConfirm.js";
|
||||
@@ -84,7 +84,7 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { WelcomeBanner } from "./WelcomeBanner.js";
|
||||
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
|
||||
import { formatEditResults, partitionEdits } from "./edit-history.js";
|
||||
import { formatEditResults } from "./edit-history.js";
|
||||
import { loopEventToDashboard } from "./effects/loop-to-dashboard.js";
|
||||
import { appendGlobalMemory, appendProjectMemory, detectHashMemory } from "./hash-memory.js";
|
||||
import { applySlashResult } from "./hooks/apply-slash-result.js";
|
||||
@@ -96,13 +96,18 @@ import {
|
||||
} from "./hooks/handle-stream-events.js";
|
||||
import { handleToolEvent } from "./hooks/handle-tool-event.js";
|
||||
import { useAgentSession } from "./hooks/useAgentSession.js";
|
||||
import { useCodeMode } from "./hooks/useCodeMode.js";
|
||||
import { useInputRecall } from "./hooks/useInputRecall.js";
|
||||
import { useLoopMode } from "./hooks/useLoopMode.js";
|
||||
import { useQuit } from "./hooks/useQuit.js";
|
||||
import { useScrollback } from "./hooks/useScrollback.js";
|
||||
import { useTranscriptWriter } from "./hooks/useTranscriptWriter.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { CardStream } from "./layout/CardStream.js";
|
||||
import {
|
||||
ModeStatusBar,
|
||||
OngoingToolRow,
|
||||
SubagentRow,
|
||||
SubagentLiveStack,
|
||||
ThinkingRow,
|
||||
UndoBanner,
|
||||
} from "./layout/LiveRows.js";
|
||||
@@ -389,10 +394,14 @@ function AppInner({
|
||||
}, [stdout]);
|
||||
// Subagent UI wiring: live activity row + sink ref the loop closure
|
||||
// captures. Must be declared BEFORE loop construction so the
|
||||
// subagentRunner closure can read the ref.
|
||||
const { activity: subagentActivity, sinkRef: subagentSinkRef } = useSubagent({
|
||||
// subagentRunner closure can read the ref. The wallet-currency thunk
|
||||
// reads from a ref populated AFTER useSessionInfo loads balance, so the
|
||||
// subagent-end cost suffix renders in the live wallet's symbol.
|
||||
const walletCurrencyRef = useRef<string | undefined>(undefined);
|
||||
const { activities: subagentActivities, sinkRef: subagentSinkRef } = useSubagent({
|
||||
session,
|
||||
log,
|
||||
getWalletCurrency: () => walletCurrencyRef.current,
|
||||
});
|
||||
// Transient "what's happening" text set by the loop during silent
|
||||
// phases (harvest round-trip, between-iteration R1 thinking, forced
|
||||
@@ -613,13 +622,10 @@ function AppInner({
|
||||
// so we abort the in-flight turn and let the effect below fire the
|
||||
// submit once busy clears.
|
||||
const [queuedSubmit, setQueuedSubmit] = useState<string | null>(null);
|
||||
// Shell-style history of user prompts. ↑/↓ while idle walks it;
|
||||
// submit pushes to the end. Cursor -1 = "live input", 0+ = "N turns
|
||||
// back from newest". We don't persist history to disk — sessions
|
||||
// already keep the message log, and cross-session bash-style recall
|
||||
// would need per-project scoping we haven't designed.
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
// ↑/↓ recall over a turn-local prompt history. We don't persist to
|
||||
// disk — the session log already keeps the messages, and cross-
|
||||
// session bash-style recall would need per-project scoping.
|
||||
const { recallPrev, recallNext, pushHistory, resetCursor } = useInputRecall(setInput);
|
||||
// Disambiguates <Static> keys when a single turn yields multiple assistant_final events.
|
||||
const assistantIterCounter = useRef<number>(0);
|
||||
// Per-session @url fetch cache. Keyed by stripped URL; same URL
|
||||
@@ -628,32 +634,12 @@ function AppInner({
|
||||
// have changed. Shape mirrors AtUrlExpansion + an optional `body`
|
||||
// so the trailing block can be reconstructed from cache alone.
|
||||
const atUrlCache = useRef<Map<string, AtUrlExpansion & { body?: string }>>(new Map());
|
||||
// Active /loop state. Null when no loop is running. Re-issuing /loop
|
||||
// replaces the slot. Cancellation is centralized in stopLoop() so
|
||||
// every cancel-trigger (Esc, /clear, /new, user-typed submit, /loop
|
||||
// stop, exit) goes through one path. The timer is held in a sibling
|
||||
// ref so React effects don't have to re-run on every timer tick.
|
||||
const [activeLoop, setActiveLoop] = useState<{
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
} | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// handleSubmit is defined far below as a useCallback. The /loop timer
|
||||
// needs to call the LATEST closure on each firing (config could have
|
||||
// shifted mid-loop), so we mirror it through a ref. The mirror is
|
||||
// synced in a useEffect once handleSubmit is defined.
|
||||
const handleSubmitRef = useRef<((raw: string) => Promise<void>) | null>(null);
|
||||
const busyRef = useRef<boolean>(false);
|
||||
const activeLoopRef = useRef<typeof activeLoop>(activeLoop);
|
||||
// Set true by the loop timer just before it calls handleSubmitRef so
|
||||
// handleSubmit's "any user submit cancels the loop" guard knows to
|
||||
// skip itself. Reset to false at the top of every handleSubmit.
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
// Full untruncated tool results, in arrival order. ToolCard clips
|
||||
// output at 400 chars for display; `/tool N` reads from this ref to
|
||||
// show the real thing. Not persisted — a
|
||||
@@ -794,6 +780,7 @@ function AppInner({
|
||||
// Per-skill model override (frontmatter `model: ...`),
|
||||
// else falls through to spawnSubagent's default.
|
||||
model: skill.model,
|
||||
allowedTools: skill.allowedTools,
|
||||
sink: subagentSinkRef.current,
|
||||
// Stamped onto every event so the TUI sink + usage log can
|
||||
// attribute the run to a skill without extra bookkeeping.
|
||||
@@ -827,6 +814,50 @@ function AppInner({
|
||||
return l;
|
||||
}, [model, system, harvest, branch, budgetUsd, session, tools, codeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !tools) return;
|
||||
tools.setAuditListener((event) => {
|
||||
const sink = eventSinkRef.current;
|
||||
const eventizer = eventizerRef.current;
|
||||
if (!sink || !eventizer) return;
|
||||
sink.append(eventizer.emitToolCall(loop.currentTurn, event.name, event.args));
|
||||
});
|
||||
pauseGate.setAuditListener((event) => {
|
||||
const sink = eventSinkRef.current;
|
||||
const eventizer = eventizerRef.current;
|
||||
if (!sink || !eventizer) return;
|
||||
switch (event.type) {
|
||||
case "tool.confirm.allow":
|
||||
sink.append(eventizer.emitToolConfirmAllow(loop.currentTurn, event.kind, event.payload));
|
||||
break;
|
||||
case "tool.confirm.deny":
|
||||
sink.append(
|
||||
eventizer.emitToolConfirmDeny(
|
||||
loop.currentTurn,
|
||||
event.kind,
|
||||
event.payload,
|
||||
event.denyContext,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "tool.confirm.always_allow":
|
||||
sink.append(
|
||||
eventizer.emitToolConfirmAlwaysAllow(
|
||||
loop.currentTurn,
|
||||
event.kind,
|
||||
event.payload,
|
||||
event.prefix,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
tools.setAuditListener(null);
|
||||
pauseGate.setAuditListener(null);
|
||||
};
|
||||
}, [loop, session, tools]);
|
||||
|
||||
// Keep the loop's hook list in sync after a `/hooks reload`. The
|
||||
// loop's field is intentionally mutable for exactly this case —
|
||||
// construction happens once, hook edits are picked up live.
|
||||
@@ -861,8 +892,12 @@ function AppInner({
|
||||
const balanceRef = useRef<typeof balance>(null);
|
||||
useEffect(() => {
|
||||
balanceRef.current = balance;
|
||||
walletCurrencyRef.current = balance?.currency;
|
||||
if (balance) {
|
||||
agentStore.dispatch({ type: "session.update", patch: { balance: balance.total } });
|
||||
agentStore.dispatch({
|
||||
type: "session.update",
|
||||
patch: { balance: balance.total, balanceCurrency: balance.currency },
|
||||
});
|
||||
}
|
||||
}, [balance, agentStore]);
|
||||
|
||||
@@ -1114,25 +1149,8 @@ function AppInner({
|
||||
}
|
||||
}, [session, loop, codeMode, syncPendingCount, log]);
|
||||
|
||||
// Ctrl+C exits, period. SIGINT (cooked-mode terminals + Node's
|
||||
// Windows console handler) and \x03 byte (raw-mode stdin) both
|
||||
// converge on `quitProcess`. We call `process.exit` directly rather
|
||||
// than Ink's `exit()` because the singleton stdin-reader keeps a
|
||||
// `data` listener attached — `exit()` would unmount the React tree
|
||||
// but the event loop would stay alive and the terminal would hang.
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the
|
||||
// universal "I'm done" key, no banner, no double-press dance.
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
|
||||
const quitProcess = useQuit(transcriptRef);
|
||||
|
||||
// Esc during busy → forward to the loop as an abort signal. The loop
|
||||
// finishes the tool call in flight (we can't kill subprocess stdio
|
||||
@@ -1176,14 +1194,14 @@ function AppInner({
|
||||
// Esc during a busy turn also kills any active /loop — the user
|
||||
// is taking over. Loops persist past plain Esc when the system is
|
||||
// idle so a long-cadence loop doesn't die from random key noise.
|
||||
if (activeLoopRef.current) stopLoop();
|
||||
if (isLoopActive()) stopLoop();
|
||||
loop.abort();
|
||||
return;
|
||||
}
|
||||
// Esc when idle ALSO cancels an active loop, since hitting Esc with
|
||||
// nothing else going on is a clear "stop whatever's running"
|
||||
// gesture. No-op when no loop is active.
|
||||
if (key.escape && !busy && activeLoopRef.current) {
|
||||
if (key.escape && !busy && isLoopActive()) {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
@@ -1349,26 +1367,6 @@ function AppInner({
|
||||
// buffer.
|
||||
});
|
||||
|
||||
// History recall callbacks passed into PromptInput. Walking the
|
||||
// in-memory `promptHistory` list: ↑ moves backward through prior
|
||||
// prompts (increments historyCursor); ↓ moves forward until we
|
||||
// fall off the end, then resets to empty. No-op when the history
|
||||
// is empty or we're already at the boundary.
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, []);
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, []);
|
||||
|
||||
// Edit-gate interceptor. Reroutes `edit_file` / `write_file` tool
|
||||
// calls through the review queue (in `review` mode) or the auto-apply
|
||||
// snapshot/banner path (in `auto` mode) so the model's tool usage
|
||||
@@ -1479,83 +1477,18 @@ function AppInner({
|
||||
};
|
||||
}, [tools, codeMode, session, recordEdit, armUndoBanner, syncPendingCount, setEditMode]);
|
||||
|
||||
/**
|
||||
* /apply callback — write pending edit blocks to disk, snapshot
|
||||
* beforehand so /undo still works, report per-file results. With
|
||||
* `indices` (1-based) only those blocks are applied; the rest stay
|
||||
* pending so the user can iterate on them. Empty / undefined indices
|
||||
* apply every pending block (the all-or-nothing original behavior).
|
||||
*/
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit],
|
||||
);
|
||||
|
||||
/**
|
||||
* /discard callback — forget the pending edits without touching
|
||||
* disk. With `indices` (1-based) only those blocks are dropped; the
|
||||
* rest stay pending. Empty / undefined indices drop everything.
|
||||
*/
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount],
|
||||
);
|
||||
const { codeApply, codeDiscard } = useCodeMode({
|
||||
codeMode: !!codeMode,
|
||||
pendingEdits,
|
||||
currentRootDir,
|
||||
session: session ?? null,
|
||||
syncPendingCount,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
const prefixHash = loop.prefix.fingerprint;
|
||||
|
||||
const writeTranscript = useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[model, prefixHash],
|
||||
);
|
||||
const writeTranscript = useTranscriptWriter(transcriptRef, model, prefixHash);
|
||||
|
||||
/**
|
||||
* Toggle plan mode on the local state AND on the ToolRegistry. The
|
||||
@@ -1576,39 +1509,15 @@ function AppInner({
|
||||
setPendingPlan(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancel the active /loop. Centralized so every cancel-trigger
|
||||
* (explicit /loop stop, Esc, /clear, /new, exit, the very first
|
||||
* user-typed prompt while a loop is active) goes through one path.
|
||||
* Idempotent — calling with no active loop is a no-op.
|
||||
*/
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
/**
|
||||
* Start a new /loop. Replaces any prior loop. The actual timer is
|
||||
* scheduled by the useEffect downstream that watches `activeLoop`.
|
||||
*/
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
} = useLoopMode({ log, busyRef, handleSubmitRef });
|
||||
|
||||
/**
|
||||
* Mount the per-block walkthrough modal against the pending-edits
|
||||
@@ -1966,30 +1875,17 @@ function AppInner({
|
||||
[codeApply, codeDiscard, log],
|
||||
);
|
||||
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. */
|
||||
const getLoopStatus = useCallback(() => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (raw: string) => {
|
||||
let text = raw.trim();
|
||||
if (!text) return;
|
||||
// Cancel-on-user-input: any user-typed submit cancels an active
|
||||
// /loop, regardless of busy state. Loop-fired submits set the
|
||||
// `loopFiringRef` flag so the timer's own re-submit doesn't
|
||||
// self-cancel.
|
||||
if (activeLoopRef.current && !loopFiringRef.current) {
|
||||
// firing flag so the timer's own re-submit doesn't self-cancel.
|
||||
if (isLoopActive() && !isLoopFiring()) {
|
||||
stopLoop();
|
||||
}
|
||||
loopFiringRef.current = false;
|
||||
clearFiringFlag();
|
||||
if (busy) return;
|
||||
|
||||
// @-mention picker intercept. When the picker is open (trailing
|
||||
@@ -2037,7 +1933,7 @@ function AppInner({
|
||||
}
|
||||
|
||||
setInput("");
|
||||
historyCursor.current = -1;
|
||||
resetCursor();
|
||||
|
||||
// Y/N fast-path when edits are pending. One keystroke is all it
|
||||
// takes to commit or drop — matches the muscle memory of `git
|
||||
@@ -2046,7 +1942,7 @@ function AppInner({
|
||||
// when nothing's waiting.
|
||||
if (codeMode && pendingEdits.current.length > 0 && (text === "y" || text === "n")) {
|
||||
log.pushInfo(text === "y" ? codeApply() : codeDiscard());
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2059,7 +1955,7 @@ function AppInner({
|
||||
if (hashParse?.kind === "memory" || hashParse?.kind === "memory-global") {
|
||||
const isGlobal = hashParse.kind === "memory-global";
|
||||
const memRoot = currentRootDir;
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
try {
|
||||
const result = isGlobal
|
||||
? appendGlobalMemory(hashParse.note)
|
||||
@@ -2088,7 +1984,7 @@ function AppInner({
|
||||
const bangCmd = detectBangCommand(text);
|
||||
if (bangCmd !== null) {
|
||||
const bangRoot = currentRootDir;
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
log.pushUser(text);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -2120,7 +2016,7 @@ function AppInner({
|
||||
if (mcpBrowseMatch) {
|
||||
const kind = mcpBrowseMatch[1] as "resource" | "prompt";
|
||||
const arg = mcpBrowseMatch[2]?.trim() ?? "";
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
log.pushUser(text);
|
||||
await handleMcpBrowseSlash(kind, arg, liveMcpServers, log);
|
||||
return;
|
||||
@@ -2196,16 +2092,16 @@ function AppInner({
|
||||
if (result.openSessionsPicker) {
|
||||
setSessionsPickerList(listSessionsForWorkspace(currentRootDir));
|
||||
setPendingSessionsPicker(true);
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openMcpHub) {
|
||||
setPendingMcpHub({ tab: result.openMcpHub.tab });
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openArgPickerFor) {
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
setInput(`/${result.openArgPickerFor} `);
|
||||
return;
|
||||
}
|
||||
@@ -2216,10 +2112,10 @@ function AppInner({
|
||||
syncPendingCount,
|
||||
session: session ?? null,
|
||||
codeModeOn: !!codeMode,
|
||||
activeLoopRef,
|
||||
isLoopActive,
|
||||
stopLoop,
|
||||
quitProcess,
|
||||
promptHistory,
|
||||
pushHistory,
|
||||
text,
|
||||
});
|
||||
if (outcome.kind === "resubmit") {
|
||||
@@ -2250,7 +2146,7 @@ function AppInner({
|
||||
// Large pastes (stack traces, log dumps, file contents) get a
|
||||
// collapsed preview in scrollback; the model still receives the full
|
||||
// text below via modelInput.
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
const pasteDisplay = formatLongPaste(text);
|
||||
const userId = log.pushUser(pasteDisplay.displayText);
|
||||
broadcastDashboardEvent({ kind: "user", id: userId, text });
|
||||
@@ -2591,6 +2487,9 @@ function AppInner({
|
||||
stopLoop,
|
||||
startLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
startWalkthrough,
|
||||
startDashboard,
|
||||
stopDashboard,
|
||||
@@ -2602,6 +2501,8 @@ function AppInner({
|
||||
log,
|
||||
agentStore.dispatch,
|
||||
mcpRuntime,
|
||||
pushHistory,
|
||||
resetCursor,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2612,49 +2513,6 @@ function AppInner({
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
// /loop timer. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because the
|
||||
// previous timer fired and bumped the next-fire time. Cleanup
|
||||
// clears the in-flight timer so a stopLoop / replacement doesn't
|
||||
// leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this
|
||||
// turn takes). Keeps the cadence honest even when individual
|
||||
// turns are slow.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than
|
||||
// spam the screen. User can re-issue /loop once they fix
|
||||
// whatever's wrong.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log]);
|
||||
|
||||
/**
|
||||
* ShellConfirm callback. Resolves the PauseGate so the
|
||||
* blocked tool function can proceed. The tool handles running the
|
||||
@@ -3218,8 +3076,8 @@ function AppInner({
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
subagentActivity ? (
|
||||
<SubagentRow activity={subagentActivity} />
|
||||
subagentActivities.length > 0 ? (
|
||||
<SubagentLiveStack activities={subagentActivities} max={3} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
const CACHE_BAR_CELLS = 10;
|
||||
|
||||
export type Preset = "auto" | "flash" | "pro";
|
||||
|
||||
export interface ChromeBarProps {
|
||||
summary: SessionSummary;
|
||||
rootDir?: string;
|
||||
sessionName?: string | null;
|
||||
preset?: Preset;
|
||||
planMode?: boolean;
|
||||
proArmed?: boolean;
|
||||
escalated?: boolean;
|
||||
updateAvailable?: string | null;
|
||||
balance?: { currency: string; total: number } | null;
|
||||
budgetUsd?: number | null;
|
||||
/** Scroll progress: 0 = at latest, 1 = at oldest. Hides the pill at 0. */
|
||||
scrollRatio?: number;
|
||||
}
|
||||
|
||||
export function ChromeBar(props: ChromeBarProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = (stdout?.columns ?? 80) - 2; // subtract paddingX={1} on both sides
|
||||
const cold = props.summary.turns <= COLD_START_TURNS;
|
||||
const projectName = props.rootDir ? basename(props.rootDir) : null;
|
||||
const mode = pickModePill(props.planMode, props.preset);
|
||||
const proPill = props.escalated
|
||||
? { label: "⇧ pro", color: COLOR.err }
|
||||
: props.proArmed
|
||||
? { label: "⇧ pro", color: COLOR.warn }
|
||||
: null;
|
||||
|
||||
// Greedy width-aware fit. Layout (each gap is exactly ONE space — single
|
||||
// suffix on update/mode/pro/scroll, single prefix on balance/cache):
|
||||
// [brand][·project][›session]<spacer>[scroll][update][mode][pro]$cost w bal cache █▓▓▓
|
||||
// Always-shown carve out the budget first; optional pieces are dropped
|
||||
// by priority — balance > cache > session > update — until the row fits.
|
||||
// (`flexGrow` spacer can shrink to 0, so no minimum reserve.) Cache is
|
||||
// shown unconditionally now: pre-turn-4 it renders dim with a "—" so
|
||||
// it's "default on" instead of materializing only after a few turns.
|
||||
const SEP = 3; // " · " / " › "
|
||||
const GAP = 1;
|
||||
const scrollLabel =
|
||||
props.scrollRatio !== undefined && props.scrollRatio > 0
|
||||
? `[↑ ${Math.round(props.scrollRatio * 100)}%]`
|
||||
: "";
|
||||
const updateLabel = props.updateAvailable ? `↑ ${props.updateAvailable}` : "";
|
||||
const balanceLabel = props.balance ? formatBalanceLabel(props.balance) : "";
|
||||
const cachePct = Math.round(props.summary.cacheHitRatio * 100);
|
||||
// Use the worst-case rendered cache string (3-digit pct, full bar) so the
|
||||
// shed decision doesn't oscillate as the percentage changes turn-to-turn.
|
||||
const cacheLabel = `cache ${"█".repeat(CACHE_BAR_CELLS)} 100%`;
|
||||
const costLabel = `$${props.summary.totalCostUsd.toFixed(4)}${
|
||||
props.budgetUsd && props.budgetUsd > 0 ? ` / $${props.budgetUsd.toFixed(2)}` : ""
|
||||
}`;
|
||||
|
||||
const brandW = stringWidth("◈ reasonix");
|
||||
const projectW = projectName ? SEP + stringWidth(projectName) : 0;
|
||||
const fixedLeft = brandW + projectW;
|
||||
const scrollW = scrollLabel ? stringWidth(scrollLabel) + GAP : 0;
|
||||
const modeW = mode ? stringWidth(`[${mode.label}]`) + GAP : 0;
|
||||
const proW = proPill ? stringWidth(`[${proPill.label}]`) + GAP : 0;
|
||||
const costW = stringWidth(costLabel);
|
||||
const fixedRight = scrollW + modeW + proW + costW;
|
||||
let budget = cols - fixedLeft - fixedRight;
|
||||
|
||||
const balanceOptW = balanceLabel ? GAP + stringWidth(balanceLabel) : 0;
|
||||
const cacheOptW = GAP + stringWidth(cacheLabel);
|
||||
const sessionOptW = props.sessionName ? SEP + stringWidth(props.sessionName) : 0;
|
||||
const updateOptW = updateLabel ? stringWidth(updateLabel) + GAP : 0;
|
||||
|
||||
const showBalance = balanceOptW > 0 && budget >= balanceOptW;
|
||||
if (showBalance) budget -= balanceOptW;
|
||||
const showCache = budget >= cacheOptW;
|
||||
if (showCache) budget -= cacheOptW;
|
||||
const showSession = sessionOptW > 0 && budget >= sessionOptW;
|
||||
if (showSession) budget -= sessionOptW;
|
||||
const showUpdate = updateOptW > 0 && budget >= updateOptW;
|
||||
if (showUpdate) budget -= updateOptW;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color={GRADIENT[0]}>
|
||||
{"◈ "}
|
||||
</Text>
|
||||
<Text color={COLOR.brand} bold>
|
||||
reasonix
|
||||
</Text>
|
||||
{projectName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" · "}
|
||||
</Text>
|
||||
<Text>{projectName}</Text>
|
||||
{showSession && props.sessionName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" › "}
|
||||
</Text>
|
||||
<Text color={COLOR.info}>{props.sessionName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{scrollLabel ? (
|
||||
<>
|
||||
<Text color={COLOR.accent} bold>
|
||||
{scrollLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{showUpdate ? (
|
||||
<>
|
||||
<Text color={COLOR.warn} bold>
|
||||
{updateLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{mode ? (
|
||||
<>
|
||||
<Text color={mode.color} bold>
|
||||
{`[${mode.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{proPill ? (
|
||||
<>
|
||||
<Text color={proPill.color} bold>
|
||||
{`[${proPill.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
<CostPill summary={props.summary} cold={cold} budgetUsd={props.budgetUsd} />
|
||||
{showBalance && props.balance ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<BalancePill balance={props.balance} />
|
||||
</>
|
||||
) : null}
|
||||
{showCache ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<CachePill ratio={props.summary.cacheHitRatio} cold={cold} pct={cachePct} />
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<ChromeRule />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBalanceLabel(balance: { currency: string; total: number }): string {
|
||||
const sym = balance.currency === "USD" ? "$" : balance.currency === "CNY" ? "¥" : "";
|
||||
const suf = sym ? "" : ` ${balance.currency}`;
|
||||
return `w ${sym}${balance.total.toFixed(2)}${suf}`;
|
||||
}
|
||||
|
||||
function CostPill({
|
||||
summary,
|
||||
cold,
|
||||
budgetUsd,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
cold: boolean;
|
||||
budgetUsd?: number | null;
|
||||
}): React.ReactElement {
|
||||
const cost = summary.totalCostUsd;
|
||||
const color = cold ? COLOR.info : sessionCostColor(cost);
|
||||
return (
|
||||
<>
|
||||
<Text color={color} bold={!cold} dimColor={cold}>
|
||||
{`$${cost.toFixed(4)}`}
|
||||
</Text>
|
||||
{budgetUsd && budgetUsd > 0 ? (
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{` / $${budgetUsd.toFixed(2)}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BalancePill({
|
||||
balance,
|
||||
}: {
|
||||
balance: { currency: string; total: number };
|
||||
}): React.ReactElement {
|
||||
const { total } = balance;
|
||||
const color = total < 1 ? COLOR.err : total < 5 ? COLOR.warn : COLOR.ok;
|
||||
return <Text color={color}>{formatBalanceLabel(balance)}</Text>;
|
||||
}
|
||||
|
||||
function CachePill({
|
||||
ratio,
|
||||
cold,
|
||||
pct,
|
||||
}: {
|
||||
ratio: number;
|
||||
cold: boolean;
|
||||
pct: number;
|
||||
}): React.ReactElement {
|
||||
const color = ratio >= 0.7 ? COLOR.ok : ratio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const filled = Math.max(0, Math.min(CACHE_BAR_CELLS, Math.round(ratio * CACHE_BAR_CELLS)));
|
||||
const empty = CACHE_BAR_CELLS - filled;
|
||||
return (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"cache "}
|
||||
</Text>
|
||||
<Text color={cold ? COLOR.info : color} dimColor={cold}>
|
||||
{"█".repeat(filled)}
|
||||
</Text>
|
||||
<Text dimColor>{"░".repeat(empty)}</Text>
|
||||
<Text color={cold ? undefined : color} bold={!cold} dimColor={cold}>
|
||||
{cold && pct === 0 ? " —" : ` ${pct}%`}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pickModePill(
|
||||
planMode: boolean | undefined,
|
||||
preset: Preset | undefined,
|
||||
): { label: string; color: string } | null {
|
||||
if (planMode) return { label: "PLAN", color: COLOR.err };
|
||||
if (!preset) return null;
|
||||
if (preset === "pro") return { label: "pro", color: COLOR.accent };
|
||||
if (preset === "flash") return { label: "flash", color: COLOR.info };
|
||||
return { label: "auto", color: COLOR.primary };
|
||||
}
|
||||
|
||||
function sessionCostColor(cost: number): string | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 5) return COLOR.err;
|
||||
if (cost >= 0.5) return COLOR.warn;
|
||||
return COLOR.ok;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const norm = path.replace(/\\/g, "/");
|
||||
const i = norm.lastIndexOf("/");
|
||||
return i === -1 ? norm : norm.slice(i + 1);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { EditMode } from "../../config.js";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { Bar, ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
import { formatBalance, formatCost } from "./theme/tokens.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
|
||||
@@ -91,9 +92,9 @@ function ChromeRow({
|
||||
const cacheColor =
|
||||
summary.cacheHitRatio >= 0.7 ? COLOR.ok : summary.cacheHitRatio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const balanceLabel = balance
|
||||
? `[w ${balance.currency === "USD" ? "$" : ""}${balance.total.toFixed(2)}${balance.currency !== "USD" ? ` ${balance.currency}` : ""}]`
|
||||
? `[${formatBalance(balance.total, balance.currency, { label: true })}]`
|
||||
: "";
|
||||
const costLabel = `[$${summary.totalCostUsd.toFixed(4)}]`;
|
||||
const costLabel = `[${formatCost(summary.totalCostUsd, balance?.currency)}]`;
|
||||
const cacheLabel = "[c ▰▰▰▰▰▰ 100%]";
|
||||
const updateLabel = updateAvailable ? `↑ ${updateAvailable}` : "";
|
||||
|
||||
|
||||
@@ -443,5 +443,8 @@ export function buildSpec(name: string, argsByName: Record<string, string>): str
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
||||
// Escape backslashes BEFORE quotes — otherwise a trailing `\` in the
|
||||
// input would consume the closing quote when a downstream parser
|
||||
// un-escapes the output (CodeQL js/incomplete-sanitization).
|
||||
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { UsageCard as UsageCardData } from "../state/cards.js";
|
||||
import { FG, TONE, formatCNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const BAR_CELLS = 30;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
const reasonRatio = card.tokens.reason / cap;
|
||||
const outputRatio = card.tokens.output / cap;
|
||||
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCNY(card.cost)];
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCost(card.cost, card.balanceCurrency)];
|
||||
if (card.elapsedMs !== undefined) headerMeta.push(`${(card.elapsedMs / 1000).toFixed(1)}s`);
|
||||
return (
|
||||
<Card tone={FG.meta}>
|
||||
@@ -66,11 +66,15 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>session</Text>
|
||||
<Text bold color={FG.body}>{`⛁ ${formatCNY(card.sessionCost, 3)}`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`⛁ ${formatCost(card.sessionCost, card.balanceCurrency, 3)}`}
|
||||
</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<>
|
||||
<Text color={FG.faint}>· balance</Text>
|
||||
<Text bold color={TONE.brand}>{`¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={TONE.brand}>
|
||||
{formatBalance(card.balance, card.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -89,9 +93,9 @@ function CompactUsageRow({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Text>
|
||||
<Text color={FG.faint}>· cache</Text>
|
||||
<Text color={TONE.ok}>{`${(card.cacheHit * 100).toFixed(0)}%`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCNY(card.cost)}${elapsed}`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCost(card.cost, card.balanceCurrency)}${elapsed}`}</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<Text color={TONE.brand}>{`· ¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text color={TONE.brand}>{`· ${formatBalance(card.balance, card.balanceCurrency)}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** OSC 52 clipboard write + temp-file fallback. */
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -13,7 +13,11 @@ export interface ClipboardWrite {
|
||||
}
|
||||
|
||||
export function writeClipboard(text: string): ClipboardWrite {
|
||||
const filePath = join(tmpdir(), `reasonix-clip-${Date.now()}.txt`);
|
||||
// mkdtemp creates a private 0700 directory atomically — keeps the
|
||||
// file out of the shared tmp namespace where a sibling process can
|
||||
// race or read it (CodeQL js/insecure-temporary-file).
|
||||
const dir = mkdtempSync(join(tmpdir(), "reasonix-clip-"));
|
||||
const filePath = join(dir, "clip.txt");
|
||||
let osc52 = false;
|
||||
if (text.length <= OSC_52_LIMIT) {
|
||||
const b64 = Buffer.from(text, "utf8").toString("base64");
|
||||
|
||||
+23
-13
@@ -1,6 +1,6 @@
|
||||
/** `#` writes project memory, `#g` global; `##+` stays a markdown heading; `\#` escapes and submits the literal `#`. */
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { PROJECT_MEMORY_FILE } from "../../memory/project.js";
|
||||
@@ -79,18 +79,28 @@ function appendBulletToFile(path: string, note: string, newFileHeader: string):
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) throw new Error("note body cannot be empty");
|
||||
const bullet = `- ${trimmed}\n`;
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${newFileHeader}${bullet}`, "utf8");
|
||||
return { path, created: true };
|
||||
}
|
||||
let prefix = "";
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// One `a+` open covers both branches: O_APPEND lands every write
|
||||
// atomically at end-of-file (concurrent appenders interleave whole
|
||||
// bullets), O_CREAT creates the file when it's missing, and we use
|
||||
// `fstat().size === 0` as the "we just created it" signal to decide
|
||||
// whether to emit the file header. Single fd from open through
|
||||
// write — no path-based check between (CodeQL js/file-system-race).
|
||||
const fd = openSync(path, "a+");
|
||||
try {
|
||||
const existing = readFileSync(path, "utf8");
|
||||
if (existing.length > 0 && !existing.endsWith("\n")) prefix = "\n";
|
||||
} catch {
|
||||
// Unreadable but exists — let appendFileSync surface the real error.
|
||||
const stat = fstatSync(fd);
|
||||
if (stat.size === 0) {
|
||||
writeSync(fd, `${newFileHeader}${bullet}`);
|
||||
return { path, created: true };
|
||||
}
|
||||
// Existing file — peek the trailing byte to decide whether to
|
||||
// insert a leading newline. Same fd → no separate stat→read race.
|
||||
const tail = Buffer.alloc(1);
|
||||
readSync(fd, tail, 0, 1, stat.size - 1);
|
||||
const prefix = tail[0] !== 0x0a ? "\n" : "";
|
||||
writeSync(fd, `${prefix}${bullet}`);
|
||||
return { path, created: false };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
appendFileSync(path, `${prefix}${bullet}`, "utf8");
|
||||
return { path, created: false };
|
||||
}
|
||||
@@ -6,13 +6,6 @@ import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
export type SlashOutcome = { kind: "consumed" } | { kind: "resubmit"; text: string };
|
||||
|
||||
interface ActiveLoopShape {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ApplySlashResultContext {
|
||||
log: Scrollback;
|
||||
stdoutWrite: (chunk: string) => void;
|
||||
@@ -20,10 +13,10 @@ export interface ApplySlashResultContext {
|
||||
syncPendingCount: () => void;
|
||||
session: string | null;
|
||||
codeModeOn: boolean;
|
||||
activeLoopRef: MutableRefObject<ActiveLoopShape | null>;
|
||||
isLoopActive: () => boolean;
|
||||
stopLoop: () => void;
|
||||
quitProcess: () => void;
|
||||
promptHistory: MutableRefObject<string[]>;
|
||||
pushHistory: (text: string) => void;
|
||||
/** The verbatim text the user typed; used for promptHistory bookkeeping. */
|
||||
text: string;
|
||||
}
|
||||
@@ -34,7 +27,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
// the process is exiting. Use quitProcess (process.exit) rather than
|
||||
// Ink's exit(): the singleton stdin reader keeps a `data` listener
|
||||
// attached, so exit() unmounts React but leaves the event loop alive.
|
||||
if (ctx.activeLoopRef.current) ctx.stopLoop();
|
||||
if (ctx.isLoopActive()) ctx.stopLoop();
|
||||
ctx.quitProcess();
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
@@ -50,7 +43,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
clearPendingEdits(ctx.session);
|
||||
ctx.syncPendingCount();
|
||||
}
|
||||
if (ctx.activeLoopRef.current) ctx.stopLoop();
|
||||
if (ctx.isLoopActive()) ctx.stopLoop();
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
if (result.info) {
|
||||
@@ -77,6 +70,6 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
if (result.resubmit) {
|
||||
return { kind: "resubmit", text: result.resubmit };
|
||||
}
|
||||
ctx.promptHistory.current.push(ctx.text);
|
||||
ctx.pushHistory(ctx.text);
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
type EditSnapshot,
|
||||
applyEditBlocks,
|
||||
snapshotBeforeEdits,
|
||||
} from "../../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, savePendingEdits } from "../../../code/pending-edits.js";
|
||||
import { formatEditResults, partitionEdits } from "../edit-history.js";
|
||||
|
||||
export interface UseCodeModeResult {
|
||||
/** /apply callback. Empty `indices` means "all"; specific 1-based indices apply only those. */
|
||||
codeApply: (indices?: readonly number[]) => string;
|
||||
/** /discard callback. Same indices semantics as codeApply. */
|
||||
codeDiscard: (indices?: readonly number[]) => string;
|
||||
}
|
||||
|
||||
export interface UseCodeModeOptions {
|
||||
codeMode: boolean;
|
||||
pendingEdits: MutableRefObject<EditBlock[]>;
|
||||
currentRootDir: string;
|
||||
session: string | null;
|
||||
syncPendingCount: () => void;
|
||||
recordEdit: (
|
||||
source: string,
|
||||
blocks: readonly EditBlock[],
|
||||
results: readonly ApplyResult[],
|
||||
snaps: readonly EditSnapshot[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Slash-command callbacks for `/apply` and `/discard` over the pending-edits queue. Owns the partition / snapshot / save / sync sequence; AppInner just forwards the strings to its log. */
|
||||
export function useCodeMode(opts: UseCodeModeOptions): UseCodeModeResult {
|
||||
const { codeMode, pendingEdits, currentRootDir, session, syncPendingCount, recordEdit } = opts;
|
||||
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit, pendingEdits],
|
||||
);
|
||||
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount, pendingEdits],
|
||||
);
|
||||
|
||||
return { codeApply, codeDiscard };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export interface UseInputRecallResult {
|
||||
recallPrev: () => void;
|
||||
recallNext: () => void;
|
||||
pushHistory: (text: string) => void;
|
||||
/** Reset cursor to the "fresh input" position — call after a successful submit. */
|
||||
resetCursor: () => void;
|
||||
}
|
||||
|
||||
/** Bash-style ↑/↓ recall over a turn-local prompt history. Cursor is `useRef` so toggles don't re-render. */
|
||||
export function useInputRecall(setInput: (s: string) => void): UseInputRecallResult {
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, [setInput]);
|
||||
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, [setInput]);
|
||||
|
||||
const pushHistory = useCallback((text: string) => {
|
||||
promptHistory.current.push(text);
|
||||
}, []);
|
||||
|
||||
const resetCursor = useCallback(() => {
|
||||
historyCursor.current = -1;
|
||||
}, []);
|
||||
|
||||
return { recallPrev, recallNext, pushHistory, resetCursor };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
interface ActiveLoop {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ActiveLoopSnapshot {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
export interface UseLoopModeResult {
|
||||
startLoop: (intervalMs: number, prompt: string) => void;
|
||||
stopLoop: () => void;
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. Returns null when no loop is active. */
|
||||
getLoopStatus: () => ActiveLoopSnapshot | null;
|
||||
/** Cheap predicate — used by handleSubmit's cancel-on-user-input guard and by apply-slash-result. */
|
||||
isLoopActive: () => boolean;
|
||||
/** True only during the timer's `handleSubmit` invocation — tells handleSubmit's cancel guard to skip itself so the loop's own re-submit doesn't kill the loop. */
|
||||
isLoopFiring: () => boolean;
|
||||
/** Reset by handleSubmit at the top of every call so the firing flag is one-shot. */
|
||||
clearFiringFlag: () => void;
|
||||
/** Reactive state for the LoopStatusRow render — null when no loop is active. */
|
||||
activeLoop: ActiveLoop | null;
|
||||
}
|
||||
|
||||
export interface UseLoopModeOptions {
|
||||
log: Scrollback;
|
||||
busyRef: MutableRefObject<boolean>;
|
||||
/** Forward-ref to the latest `handleSubmit` — the closure shifts as state changes, so the timer dereferences fresh on each fire. */
|
||||
handleSubmitRef: MutableRefObject<((raw: string) => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
/** Owns the active /loop config + its setTimeout-based scheduler. Re-issuing /loop replaces the slot; cancellation is centralized in stopLoop. */
|
||||
export function useLoopMode(opts: UseLoopModeOptions): UseLoopModeResult {
|
||||
const { log, busyRef, handleSubmitRef } = opts;
|
||||
const [activeLoop, setActiveLoop] = useState<ActiveLoop | null>(null);
|
||||
const activeLoopRef = useRef<ActiveLoop | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getLoopStatus = useCallback((): ActiveLoopSnapshot | null => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoopActive = useCallback(() => activeLoopRef.current !== null, []);
|
||||
const isLoopFiring = useCallback(() => loopFiringRef.current, []);
|
||||
const clearFiringFlag = useCallback(() => {
|
||||
loopFiringRef.current = false;
|
||||
}, []);
|
||||
|
||||
// /loop scheduler. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because a previous
|
||||
// firing bumped the next-fire time. Cleanup clears the in-flight
|
||||
// timer so a stopLoop / replacement doesn't leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this turn
|
||||
// takes). Keeps the cadence honest even when individual turns run
|
||||
// long.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than spam
|
||||
// the screen. User can re-issue /loop once they fix the cause.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log, busyRef, handleSubmitRef]);
|
||||
|
||||
return {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback, useEffect } from "react";
|
||||
|
||||
/** Ctrl+C / SIGINT → flush transcript + `process.exit(0)`. We call `process.exit` directly rather than Ink's `exit()` because the singleton stdin reader keeps a `data` listener attached — `exit()` would unmount the React tree but leave the event loop alive and the terminal would hang. */
|
||||
export function useQuit(transcriptRef: MutableRefObject<WriteStream | null>): () => void {
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, [transcriptRef]);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
|
||||
return quitProcess;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface Scrollback {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}): string;
|
||||
showPlan(args: {
|
||||
@@ -219,6 +220,7 @@ export function useScrollback(): Scrollback {
|
||||
cost: args.cost,
|
||||
sessionCost: args.sessionCost,
|
||||
balance: args.balance,
|
||||
balanceCurrency: args.balanceCurrency,
|
||||
elapsedMs: args.elapsedMs,
|
||||
});
|
||||
return id;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import type { LoopEvent } from "../../../loop.js";
|
||||
import { recordFromLoopEvent, writeRecord } from "../../../transcript/log.js";
|
||||
|
||||
/** Returns a `LoopEvent` writer that no-ops when no transcript was opened. Wraps `recordFromLoopEvent` + `writeRecord` so callers don't carry the model/prefix metadata. */
|
||||
export function useTranscriptWriter(
|
||||
transcriptRef: MutableRefObject<WriteStream | null>,
|
||||
model: string,
|
||||
prefixHash: string,
|
||||
): (ev: LoopEvent) => void {
|
||||
return useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[transcriptRef, model, prefixHash],
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pil
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
|
||||
import type { SubagentActivity } from "../useSubagent.js";
|
||||
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
@@ -159,20 +160,8 @@ function subagentTitle(skillName: string | undefined, task: string): string {
|
||||
return `Sub-agent · ${short || "anonymous"}`;
|
||||
}
|
||||
|
||||
/** Live block for a running subagent — Card-styled, fixed row count for flicker-safety. */
|
||||
export function SubagentRow({
|
||||
activity,
|
||||
}: {
|
||||
activity: {
|
||||
task: string;
|
||||
iter: number;
|
||||
elapsedMs: number;
|
||||
skillName?: string;
|
||||
model?: string;
|
||||
phase?: "exploring" | "summarising";
|
||||
lastInner: { glyph: string; color: string; label: string; meta?: string } | null;
|
||||
};
|
||||
}) {
|
||||
/** Live block for a single in-flight subagent — rich layout, used when only one is running. */
|
||||
export function SubagentRow({ activity }: { activity: SubagentActivity }) {
|
||||
useTick();
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1);
|
||||
const phase = subagentPhaseLabel(activity.phase, activity.iter, activity.elapsedMs);
|
||||
@@ -222,6 +211,78 @@ export function SubagentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 → rich; 2-max → compact rows; >max → compact + "+N more" fold. */
|
||||
export function SubagentLiveStack({
|
||||
activities,
|
||||
max = 3,
|
||||
}: {
|
||||
activities: ReadonlyArray<SubagentActivity>;
|
||||
max?: number;
|
||||
}) {
|
||||
const tick = useTick();
|
||||
if (activities.length === 0) return null;
|
||||
if (activities.length === 1) return <SubagentRow activity={activities[0]!} />;
|
||||
const visible = activities.slice(0, max);
|
||||
const overflow = activities.length - visible.length;
|
||||
const summarising = activities.filter((a) => a.phase === "summarising").length;
|
||||
const metaParts = [`${activities.length} running`];
|
||||
if (summarising > 0) metaParts.push(`${summarising} summarising`);
|
||||
return (
|
||||
<Card tone={CARD.subagent.color}>
|
||||
<CardHeader
|
||||
glyph="⌬"
|
||||
tone={CARD.subagent.color}
|
||||
title="subagents"
|
||||
titleColor={PILL_SECTION.plan.fg}
|
||||
titleBg={PILL_SECTION.plan.bg}
|
||||
subtitle={metaParts.join(" · ")}
|
||||
right={<Spinner kind="braille" color={CARD.subagent.color} />}
|
||||
/>
|
||||
{visible.map((a, i) => (
|
||||
<CompactSubagentLine key={a.runId} activity={a} tick={tick} index={i} />
|
||||
))}
|
||||
{overflow > 0 ? <Text color={FG.faint}>{` +${overflow} more running…`}</Text> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactSubagentLine({
|
||||
activity,
|
||||
tick,
|
||||
index,
|
||||
}: {
|
||||
activity: SubagentActivity;
|
||||
tick: number;
|
||||
index: number;
|
||||
}) {
|
||||
const summarising = activity.phase === "summarising";
|
||||
const spinnerFrame = SPINNER_FRAMES[(tick + index) % SPINNER_FRAMES.length] ?? "·";
|
||||
const glyph = summarising ? "▶" : spinnerFrame;
|
||||
const glyphColor = summarising ? TONE.brand : CARD.subagent.color;
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1).padStart(5);
|
||||
const title = activity.skillName ?? truncate(activity.task, 28);
|
||||
const titlePadded = title.padEnd(28);
|
||||
const last = activity.lastInner;
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={glyphColor} bold>
|
||||
{` ${glyph} `}
|
||||
</Text>
|
||||
<Text color={FG.body}>{titlePadded}</Text>
|
||||
<Text color={FG.faint}>{` iter ${String(activity.iter).padStart(2)} · ${seconds}s · `}</Text>
|
||||
{last ? (
|
||||
<>
|
||||
<Text color={last.color}>{`${last.glyph} `}</Text>
|
||||
<Text color={FG.body}>{truncate(last.label, 18)}</Text>
|
||||
{last.meta ? <Text color={FG.faint}>{` ${last.meta}`}</Text> : null}
|
||||
</>
|
||||
) : (
|
||||
<Text color={FG.faint}>queued…</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,17 @@ import React from "react";
|
||||
import { Countdown } from "../primitives/Countdown.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import type { Mode, NetworkState, StatusBar } from "../state/state.js";
|
||||
import { FG, TONE, USD_TO_CNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const RULE_PAD = 4;
|
||||
const RULE_MIN = 20;
|
||||
|
||||
function balanceColor(cny: number): string {
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
|
||||
export function StatusRow(): React.ReactElement {
|
||||
const status = useAgentState((s) => s.status);
|
||||
const session = useAgentState((s) => s.session);
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
|
||||
const turnCny = status.cost * USD_TO_CNY;
|
||||
const sessionCny = status.sessionCost * USD_TO_CNY;
|
||||
const hasTurn = status.cost > 0;
|
||||
|
||||
return (
|
||||
@@ -48,16 +40,22 @@ export function StatusRow(): React.ReactElement {
|
||||
<Text bold color={TONE.brand}>
|
||||
{"▸ "}
|
||||
</Text>
|
||||
<Text bold color={FG.body}>{`¥${turnCny.toFixed(4)} turn`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`${formatCost(status.cost, status.balanceCurrency)} turn`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text color={FG.sub}>{`¥${sessionCny.toFixed(3)} session`}</Text>
|
||||
<Text
|
||||
color={FG.sub}
|
||||
>{`${formatCost(status.sessionCost, status.balanceCurrency, 3)} session`}</Text>
|
||||
{status.balance !== undefined && (
|
||||
<>
|
||||
<Sep />
|
||||
<Text color={FG.faint}>{"wallet "}</Text>
|
||||
<Text bold color={balanceColor(status.balance)}>{`¥${status.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={balanceColor(status.balance, status.balanceCurrency)}>
|
||||
{formatBalance(status.balance, status.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
type ResolvedHook,
|
||||
globalSettingsPath,
|
||||
projectSettingsPath,
|
||||
} from "../../../../hooks.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../../../telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "../../../../version.js";
|
||||
} from "@/hooks.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "@/telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "@/version.js";
|
||||
import { runDoctorChecks } from "../../../commands/doctor.js";
|
||||
import { renderDashboard } from "../../../commands/stats.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const dashboard: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
fmtAgo,
|
||||
listCheckpoints,
|
||||
restoreCheckpoint,
|
||||
} from "../../../../code/checkpoints.js";
|
||||
import type { EditMode } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
} from "@/code/checkpoints.js";
|
||||
import type { EditMode } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { parseEditIndices } from "../../edit-history.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { runGitCommit, stripOuterQuotes } from "../helpers.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const INIT_PROMPT = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { JobRecord } from "../../../../tools/jobs.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { JobRecord } from "@/tools/jobs.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
function statusIcon(r: JobRecord): string {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../../../i18n/types.js";
|
||||
import { getSupportedLanguages, notifyLanguageChange, setLanguage, t } from "@/i18n/index.js";
|
||||
import type { LanguageCode } from "@/i18n/types.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { CacheFirstLoop } from "../../../../loop.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { CacheFirstLoop } from "@/loop.js";
|
||||
import { applyMcpAppend } from "../../mcp-append.js";
|
||||
import { toggleMcpDisabled } from "../../mcp-disable.js";
|
||||
import { slashHealthBadge } from "../../mcp-health.js";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import {
|
||||
PROJECT_MEMORY_FILE,
|
||||
memoryEnabled,
|
||||
readProjectMemory,
|
||||
} from "../../../../memory/project.js";
|
||||
import { type MemoryScope, MemoryStore } from "../../../../memory/user.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { PROJECT_MEMORY_FILE, memoryEnabled, readProjectMemory } from "@/memory/project.js";
|
||||
import { type MemoryScope, MemoryStore } from "@/memory/user.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { resolveMemoryTarget } from "../helpers.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { saveReasoningEffort } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { saveReasoningEffort } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { PRESETS } from "../../presets.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
DEEPSEEK_PRICING,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
} from "../../../../telemetry/stats.js";
|
||||
import { countTokens } from "../../../../tokenizer.js";
|
||||
} from "@/telemetry/stats.js";
|
||||
import { countTokens } from "@/tokenizer.js";
|
||||
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { compactNum, formatToolList } from "../helpers.js";
|
||||
@@ -208,7 +208,7 @@ const cost: SlashHandler = (args, loop, ctx) => {
|
||||
return {};
|
||||
};
|
||||
|
||||
function estimateCost(userText: string, loop: import("../../../../loop.js").CacheFirstLoop) {
|
||||
function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop) {
|
||||
const pricing = DEEPSEEK_PRICING[loop.model];
|
||||
if (!pricing) {
|
||||
return { info: t("handlers.observability.costNoPricing", { model: loop.model }) };
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
clearProjectShellAllowed,
|
||||
loadProjectShellAllowed,
|
||||
removeProjectShellAllowed,
|
||||
} from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { BUILTIN_ALLOWLIST } from "../../../../tools/shell.js";
|
||||
} from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { BUILTIN_ALLOWLIST } from "@/tools/shell.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const permissions: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { basename } from "node:path";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "../../../../code/plan-store.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "@/code/plan-store.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const plans: SlashHandler = (_args, loop) => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { probeOllama } from "../../../../index/semantic/embedding.js";
|
||||
import { t } from "../../../../index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "../../../../index/semantic/ollama-launcher.js";
|
||||
import { probeOllama } from "@/index/semantic/embedding.js";
|
||||
import { t } from "@/index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "@/index/semantic/ollama-launcher.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const semantic: SlashHandler = (_args, _loop, ctx) => {
|
||||
@@ -74,15 +74,24 @@ interface IndexSummary {
|
||||
/** 10MB read cap so `/semantic` stays snappy on very large repos. */
|
||||
async function readIndexMeta(rootDir: string): Promise<IndexSummary | null> {
|
||||
const dataPath = path.join(rootDir, ".reasonix", "semantic", "index.jsonl");
|
||||
let raw: string;
|
||||
try {
|
||||
const stat = await fs.stat(dataPath);
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
// For huge indexes, give an order-of-magnitude estimate from
|
||||
// file size (avg ~500 bytes/chunk in practice). Files won't
|
||||
// be available, so we report just the chunk approximation.
|
||||
return { chunks: Math.round(stat.size / 500), files: 0 };
|
||||
// Bind size check and content to the same fd so a concurrent
|
||||
// rebuild can't grow the file past the 10MB threshold between
|
||||
// stat and read (CodeQL js/file-system-race).
|
||||
const fh = await fs.open(dataPath, "r");
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
// For huge indexes, give an order-of-magnitude estimate from
|
||||
// file size (avg ~500 bytes/chunk in practice). Files won't
|
||||
// be available, so we report just the chunk approximation.
|
||||
return { chunks: Math.round(stat.size / 500), files: 0 };
|
||||
}
|
||||
raw = await fh.readFile("utf8");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
const raw = await fs.readFile(dataPath, "utf8");
|
||||
const seenPaths = new Set<string>();
|
||||
let chunks = 0;
|
||||
for (const line of raw.split("\n")) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { deleteSession, listSessions, renameSession } from "../../../../memory/session.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { deleteSession, listSessions, renameSession } from "@/memory/session.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const sessions: SlashHandler = () => ({ openSessionsPicker: true });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { SkillStore } from "../../../../skills.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { SkillStore } from "@/skills.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface SlashContext {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}) => void;
|
||||
dispatch?: (event: import("../state/events.js").AgentEvent) => void;
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface UsageCard extends CardBase {
|
||||
readonly cost: number;
|
||||
readonly sessionCost: number;
|
||||
readonly balance?: number;
|
||||
readonly balanceCurrency?: string;
|
||||
/** Wall-clock for the turn — surfaced as `· 1.2s` in the header meta. */
|
||||
readonly elapsedMs?: number;
|
||||
/** Auto-emitted per-turn cards render as a single dim row; /cost emits the full breakdown. */
|
||||
|
||||
@@ -117,6 +117,7 @@ const sessionUpdate = z.object({
|
||||
cost: z.number().optional(),
|
||||
sessionCost: z.number().optional(),
|
||||
balance: z.number().optional(),
|
||||
balanceCurrency: z.string().optional(),
|
||||
cacheHit: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
@@ -241,6 +242,7 @@ const usageShow = z.object({
|
||||
cost: z.number().nonnegative(),
|
||||
sessionCost: z.number().nonnegative(),
|
||||
balance: z.number().optional(),
|
||||
balanceCurrency: z.string().optional(),
|
||||
elapsedMs: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
cost: event.cost,
|
||||
sessionCost: event.sessionCost,
|
||||
balance: event.balance,
|
||||
balanceCurrency: event.balanceCurrency,
|
||||
elapsedMs: event.elapsedMs,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface StatusBar {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
cacheHit: number;
|
||||
countdownSeconds?: number;
|
||||
recording?: { sizeBytes: number; events: number; path: string };
|
||||
|
||||
@@ -59,7 +59,32 @@ export type CardTone = keyof typeof CARD;
|
||||
/** DeepSeek prices in CNY; our internal table is USD divided by 7.2. Multiply back for display. */
|
||||
export const USD_TO_CNY = 7.2;
|
||||
|
||||
export function formatCNY(usd: number, fractionDigits = 4): string {
|
||||
const cny = usd * USD_TO_CNY;
|
||||
return `¥${cny.toFixed(fractionDigits)}`;
|
||||
const SYMBOL: Record<string, string> = { USD: "$", CNY: "¥" };
|
||||
|
||||
/** Format an amount already in `currency`. Undefined currency → CNY (matches pre-fix behavior). */
|
||||
export function formatBalance(
|
||||
amount: number,
|
||||
currency?: string,
|
||||
opts?: { fractionDigits?: number; label?: boolean },
|
||||
): string {
|
||||
const cur = currency ?? "CNY";
|
||||
const sym = SYMBOL[cur];
|
||||
const digits = opts?.fractionDigits ?? 2;
|
||||
const body = sym ? `${sym}${amount.toFixed(digits)}` : `${cur} ${amount.toFixed(digits)}`;
|
||||
return opts?.label ? `w ${body}` : body;
|
||||
}
|
||||
|
||||
/** Format an internal USD cost in the wallet's display currency. Undefined currency → CNY. */
|
||||
export function formatCost(costUsd: number, currency?: string, fractionDigits = 4): string {
|
||||
const cur = currency ?? "CNY";
|
||||
const amount = cur === "CNY" ? costUsd * USD_TO_CNY : costUsd;
|
||||
return formatBalance(amount, cur, { fractionDigits });
|
||||
}
|
||||
|
||||
/** Threshold color for a wallet balance. USD is converted to CNY before the threshold check. */
|
||||
export function balanceColor(amount: number, currency?: string): string {
|
||||
const cny = (currency ?? "CNY") === "USD" ? amount * USD_TO_CNY : amount;
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
+84
-71
@@ -3,7 +3,7 @@ import type { LoopEvent } from "../../loop.js";
|
||||
import { appendUsage } from "../../telemetry/usage.js";
|
||||
import type { SubagentEvent, SubagentSink } from "../../tools/subagent.js";
|
||||
import type { Scrollback } from "./hooks/useScrollback.js";
|
||||
import { CARD, TONE } from "./theme/tokens.js";
|
||||
import { CARD, TONE, formatCost } from "./theme/tokens.js";
|
||||
|
||||
function summariseInner(ev: LoopEvent): SubagentInnerSummary | null {
|
||||
if (ev.role === "tool_start") {
|
||||
@@ -40,6 +40,10 @@ export interface SubagentInnerSummary {
|
||||
}
|
||||
|
||||
export interface SubagentActivity {
|
||||
/** Stable per-spawn id; key for parallel-row rendering. */
|
||||
runId: string;
|
||||
/** Wall-clock start so the stack stays in launch order even when events arrive interleaved. */
|
||||
startedAt: number;
|
||||
task: string;
|
||||
iter: number;
|
||||
elapsedMs: number;
|
||||
@@ -52,94 +56,103 @@ export interface SubagentActivity {
|
||||
export interface UseSubagentParams {
|
||||
session: string | undefined;
|
||||
log: Scrollback;
|
||||
/** Read live wallet currency at end-event time so the cost suffix follows the wallet symbol. */
|
||||
getWalletCurrency?: () => string | undefined;
|
||||
}
|
||||
|
||||
export interface UseSubagentResult {
|
||||
/** Live state for an in-flight subagent, null when none is running. */
|
||||
activity: SubagentActivity | null;
|
||||
/** In-flight runs, oldest first. Empty when none active. */
|
||||
activities: ReadonlyArray<SubagentActivity>;
|
||||
sinkRef: React.MutableRefObject<SubagentSink>;
|
||||
}
|
||||
|
||||
export function useSubagent({ session, log }: UseSubagentParams): UseSubagentResult {
|
||||
const [activity, setActivity] = useState<SubagentActivity | null>(null);
|
||||
export function useSubagent({
|
||||
session,
|
||||
log,
|
||||
getWalletCurrency,
|
||||
}: UseSubagentParams): UseSubagentResult {
|
||||
const [activities, setActivities] = useState<ReadonlyArray<SubagentActivity>>([]);
|
||||
const sinkRef = useRef<SubagentSink>({ current: null });
|
||||
// Subagent runs can outlive a balance refresh; the thunk lives in a ref so the
|
||||
// sink callback (installed once at mount) always reads the latest wallet currency.
|
||||
const getWalletCurrencyRef = useRef(getWalletCurrency);
|
||||
useEffect(() => {
|
||||
getWalletCurrencyRef.current = getWalletCurrency;
|
||||
}, [getWalletCurrency]);
|
||||
|
||||
useEffect(() => {
|
||||
sinkRef.current.current = (ev: SubagentEvent) => {
|
||||
if (ev.kind === "start") {
|
||||
setActivity({
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
skillName: ev.skillName,
|
||||
model: ev.model,
|
||||
phase: "exploring",
|
||||
lastInner: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "progress") {
|
||||
setActivity((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
iter: ev.iter ?? prev.iter,
|
||||
elapsedMs: ev.elapsedMs ?? prev.elapsedMs,
|
||||
}
|
||||
: {
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
skillName: ev.skillName,
|
||||
model: ev.model,
|
||||
phase: "exploring",
|
||||
lastInner: null,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "phase") {
|
||||
setActivity((prev) => (prev ? { ...prev, phase: ev.phase } : prev));
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "inner" && ev.inner) {
|
||||
const summary = summariseInner(ev.inner);
|
||||
if (!summary) return;
|
||||
setActivity((prev) => (prev ? { ...prev, lastInner: summary } : prev));
|
||||
return;
|
||||
}
|
||||
// end
|
||||
setActivity(null);
|
||||
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
|
||||
// Inline cost: the one number most users look at. Saves a /stats round-trip.
|
||||
const costTail =
|
||||
ev.costUsd !== undefined && ev.costUsd > 0 ? ` · $${ev.costUsd.toFixed(4)}` : "";
|
||||
const summary = ev.error
|
||||
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
|
||||
log.pushInfo(summary);
|
||||
// Persist a subagent summary row to ~/.reasonix/usage.jsonl so
|
||||
// `/stats` and `reasonix stats` surface it. Skipped on error —
|
||||
// we only record what actually cost money and did work.
|
||||
if (!ev.error && ev.usage && ev.model) {
|
||||
appendUsage({
|
||||
session: session ?? null,
|
||||
model: ev.model,
|
||||
usage: ev.usage,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
setActivities((prev) => {
|
||||
if (prev.some((a) => a.runId === ev.runId)) return prev;
|
||||
const next: SubagentActivity = {
|
||||
runId: ev.runId,
|
||||
startedAt: Date.now() - (ev.elapsedMs ?? 0),
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
skillName: ev.skillName,
|
||||
taskPreview: ev.task.slice(0, 60),
|
||||
toolIters: ev.iter ?? 0,
|
||||
durationMs: ev.elapsedMs ?? 0,
|
||||
},
|
||||
model: ev.model,
|
||||
phase: "exploring",
|
||||
lastInner: null,
|
||||
};
|
||||
return [...prev, next];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "end") {
|
||||
setActivities((prev) => prev.filter((a) => a.runId !== ev.runId));
|
||||
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
|
||||
const costTail =
|
||||
ev.costUsd !== undefined && ev.costUsd > 0
|
||||
? ` · ${formatCost(ev.costUsd, getWalletCurrencyRef.current?.())}`
|
||||
: "";
|
||||
const summary = ev.error
|
||||
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
|
||||
log.pushInfo(summary);
|
||||
if (!ev.error && ev.usage && ev.model) {
|
||||
appendUsage({
|
||||
session: session ?? null,
|
||||
model: ev.model,
|
||||
usage: ev.usage,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
skillName: ev.skillName,
|
||||
taskPreview: ev.task.slice(0, 60),
|
||||
toolIters: ev.iter ?? 0,
|
||||
durationMs: ev.elapsedMs ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// progress / phase / inner — patch the matching row, ignore stragglers from runs we never saw `start` for.
|
||||
setActivities((prev) =>
|
||||
prev.map((a) => {
|
||||
if (a.runId !== ev.runId) return a;
|
||||
if (ev.kind === "progress") {
|
||||
return {
|
||||
...a,
|
||||
iter: ev.iter ?? a.iter,
|
||||
elapsedMs: ev.elapsedMs ?? a.elapsedMs,
|
||||
};
|
||||
}
|
||||
if (ev.kind === "phase") {
|
||||
return { ...a, phase: ev.phase ?? a.phase };
|
||||
}
|
||||
if (ev.kind === "inner" && ev.inner) {
|
||||
const summary = summariseInner(ev.inner);
|
||||
return summary ? { ...a, lastInner: summary } : a;
|
||||
}
|
||||
return a;
|
||||
}),
|
||||
);
|
||||
};
|
||||
return () => {
|
||||
sinkRef.current.current = null;
|
||||
};
|
||||
}, [session, log]);
|
||||
|
||||
return { activity, sinkRef };
|
||||
return { activities, sinkRef };
|
||||
}
|
||||
+4
-5
@@ -92,11 +92,10 @@ export class DeepSeekClient {
|
||||
);
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = (
|
||||
opts.baseUrl ??
|
||||
process.env.DEEPSEEK_BASE_URL ??
|
||||
"https://api.deepseek.com"
|
||||
).replace(/\/+$/, "");
|
||||
let url = opts.baseUrl ?? process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com";
|
||||
// Manual trim — `/\/+$/` is O(n²) on slash-heavy non-matches per CodeQL js/polynomial-redos.
|
||||
while (url.endsWith("/")) url = url.slice(0, -1);
|
||||
this.baseUrl = url;
|
||||
// 11 min. DeepSeek's load-balancer may keep a connection open for
|
||||
// up to 10 minutes while the request waits in queue (non-streaming
|
||||
// sends empty lines, streaming sends `:` SSE keep-alive comments —
|
||||
|
||||
+88
-33
@@ -1,6 +1,18 @@
|
||||
/** SEARCH must match byte-for-byte; empty SEARCH = create new file. No fuzzy match — silent wrong edit beats a missing one. */
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
ftruncateSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
} from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export interface EditBlock {
|
||||
@@ -69,49 +81,92 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
}
|
||||
|
||||
const searchEmpty = block.search.length === 0;
|
||||
const exists = existsSync(absTarget);
|
||||
|
||||
// Branch on intent first so each path makes exactly one `open` call
|
||||
// — keeps CodeQL's flow analyser from tripping over a check→use
|
||||
// chain across two opens (js/file-system-race).
|
||||
if (searchEmpty) {
|
||||
try {
|
||||
mkdirSync(dirname(absTarget), { recursive: true });
|
||||
const fd = openSync(absTarget, "wx");
|
||||
try {
|
||||
writeSync(fd, block.replace);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return { path: block.path, status: "created" };
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === "EEXIST") {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "empty SEARCH only creates new files — this file already exists",
|
||||
};
|
||||
}
|
||||
return { path: block.path, status: "error", message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!exists) {
|
||||
if (!searchEmpty) {
|
||||
// Modify path. ENOENT is reported as `file-missing` so the model
|
||||
// knows it needs an empty SEARCH to create the file.
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(absTarget, "r+");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "file-missing",
|
||||
message: "file does not exist; to create it, use an empty SEARCH block",
|
||||
};
|
||||
}
|
||||
mkdirSync(dirname(absTarget), { recursive: true });
|
||||
writeFileSync(absTarget, block.replace, "utf8");
|
||||
return { path: block.path, status: "created" };
|
||||
throw err;
|
||||
}
|
||||
|
||||
const content = readFileSync(absTarget, "utf8");
|
||||
if (searchEmpty) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "empty SEARCH only creates new files — this file already exists",
|
||||
};
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
const inBuf = Buffer.alloc(stat.size);
|
||||
let readBytes = 0;
|
||||
while (readBytes < stat.size) {
|
||||
const n = readSync(fd, inBuf, readBytes, stat.size - readBytes, readBytes);
|
||||
if (n <= 0) break;
|
||||
readBytes += n;
|
||||
}
|
||||
const content = inBuf.toString("utf8", 0, readBytes);
|
||||
const le = lineEndingOf(content);
|
||||
const adaptedSearch = block.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = block.replace.replace(/\r?\n/g, le);
|
||||
const idx = content.indexOf(adaptedSearch);
|
||||
if (idx === -1) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "SEARCH text does not match the current file content exactly",
|
||||
};
|
||||
}
|
||||
// Replace only the first occurrence — if the model needs multiple
|
||||
// identical edits it should emit multiple blocks (each anchored by
|
||||
// more surrounding context). Auto-expanding to replace-all is a
|
||||
// footgun when the same string legitimately appears in several
|
||||
// unrelated places.
|
||||
const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
|
||||
// Truncate first so a shorter result doesn't leave stale tail
|
||||
// bytes; ftruncate also pads with NUL when the new length is
|
||||
// longer, which we then overwrite below.
|
||||
const outBuf = Buffer.from(replaced, "utf8");
|
||||
ftruncateSync(fd, outBuf.length);
|
||||
let written = 0;
|
||||
while (written < outBuf.length) {
|
||||
const n = writeSync(fd, outBuf, written, outBuf.length - written, written);
|
||||
if (n <= 0) break;
|
||||
written += n;
|
||||
}
|
||||
return { path: block.path, status: "applied" };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const le = lineEndingOf(content);
|
||||
const adaptedSearch = block.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = block.replace.replace(/\r?\n/g, le);
|
||||
const idx = content.indexOf(adaptedSearch);
|
||||
if (idx === -1) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "SEARCH text does not match the current file content exactly",
|
||||
};
|
||||
}
|
||||
// Replace only the first occurrence — if the model needs multiple
|
||||
// identical edits it should emit multiple blocks (each anchored by
|
||||
// more surrounding context). Auto-expanding to replace-all is a
|
||||
// footgun when the same string legitimately appears in several
|
||||
// unrelated places.
|
||||
const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
|
||||
writeFileSync(absTarget, replaced, "utf8");
|
||||
return { path: block.path, status: "applied" };
|
||||
} catch (err) {
|
||||
return { path: block.path, status: "error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const SECRET_KEY_RE =
|
||||
/(secret|token|password|passphrase|api[-_]?key|authorization|cookie|credential|passwd|pwd)/i;
|
||||
|
||||
export function redactEventValue<T>(value: T): T {
|
||||
return redactUnknown(value, null) as T;
|
||||
}
|
||||
|
||||
function redactUnknown(value: unknown, key: string | null): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => redactUnknown(item, null));
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [childKey, childValue] of Object.entries(value)) {
|
||||
out[childKey] = redactUnknown(childValue, childKey);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if ((key && SECRET_KEY_RE.test(key)) || /^Bearer\s+/i.test(value)) return "[redacted]";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LoopEvent } from "../loop.js";
|
||||
import type { ChatMessage, RawUsage, ToolCall } from "../types.js";
|
||||
import { redactEventValue } from "./event-redaction.js";
|
||||
import type {
|
||||
Event,
|
||||
ErrorEvent as KernelErrorEvent,
|
||||
@@ -10,6 +11,10 @@ import type {
|
||||
SessionOpenedEvent,
|
||||
SlashInvokedEvent,
|
||||
StatusEvent,
|
||||
ToolCallEvent,
|
||||
ToolConfirmAllowEvent,
|
||||
ToolConfirmAlwaysAllowEvent,
|
||||
ToolConfirmDenyEvent,
|
||||
ToolDispatchedEvent,
|
||||
ToolIntentEvent,
|
||||
ToolResultEvent,
|
||||
@@ -125,6 +130,66 @@ export class Eventizer {
|
||||
};
|
||||
}
|
||||
|
||||
emitToolCall(turn: number, name: string, args: Record<string, unknown>): ToolCallEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.call",
|
||||
name,
|
||||
args: redactEventValue(args),
|
||||
};
|
||||
}
|
||||
|
||||
emitToolConfirmAllow(
|
||||
turn: number,
|
||||
kind: "run_command" | "run_background",
|
||||
payload: { command: string },
|
||||
): ToolConfirmAllowEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.confirm.allow",
|
||||
kind,
|
||||
payload: redactEventValue(payload),
|
||||
};
|
||||
}
|
||||
|
||||
emitToolConfirmDeny(
|
||||
turn: number,
|
||||
kind: "run_command" | "run_background",
|
||||
payload: { command: string },
|
||||
denyContext?: string,
|
||||
): ToolConfirmDenyEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.confirm.deny",
|
||||
kind,
|
||||
payload: redactEventValue(payload),
|
||||
denyContext,
|
||||
};
|
||||
}
|
||||
|
||||
emitToolConfirmAlwaysAllow(
|
||||
turn: number,
|
||||
kind: "run_command" | "run_background",
|
||||
payload: { command: string },
|
||||
prefix: string,
|
||||
): ToolConfirmAlwaysAllowEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.confirm.always_allow",
|
||||
kind,
|
||||
payload: redactEventValue(payload),
|
||||
prefix,
|
||||
};
|
||||
}
|
||||
|
||||
private turnStartedEvent(turn: number, ctx: EventizeContext): ModelTurnStartedEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
|
||||
@@ -76,6 +76,32 @@ export interface ToolResultEvent extends EventBase {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ToolCallEvent extends EventBase {
|
||||
type: "tool.call";
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolConfirmAllowEvent extends EventBase {
|
||||
type: "tool.confirm.allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
}
|
||||
|
||||
export interface ToolConfirmDenyEvent extends EventBase {
|
||||
type: "tool.confirm.deny";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
denyContext?: string;
|
||||
}
|
||||
|
||||
export interface ToolConfirmAlwaysAllowEvent extends EventBase {
|
||||
type: "tool.confirm.always_allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface FileTouchedEvent extends EventBase {
|
||||
type: "effect.file.touched";
|
||||
path: string;
|
||||
@@ -197,6 +223,10 @@ export type Event =
|
||||
| ToolDispatchedEvent
|
||||
| ToolDeniedEvent
|
||||
| ToolResultEvent
|
||||
| ToolCallEvent
|
||||
| ToolConfirmAllowEvent
|
||||
| ToolConfirmDenyEvent
|
||||
| ToolConfirmAlwaysAllowEvent
|
||||
| FileTouchedEvent
|
||||
| MemoryWrittenEvent
|
||||
| PlanSubmittedEvent
|
||||
|
||||
@@ -21,6 +21,25 @@ export type ChoiceVerdict =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "cancel" };
|
||||
|
||||
export type ToolConfirmationAuditEvent =
|
||||
| {
|
||||
type: "tool.confirm.allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
}
|
||||
| {
|
||||
type: "tool.confirm.deny";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
denyContext?: string;
|
||||
}
|
||||
| {
|
||||
type: "tool.confirm.always_allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
interface PauseResponseMap {
|
||||
run_command: ConfirmationChoice;
|
||||
run_background: ConfirmationChoice;
|
||||
@@ -48,6 +67,7 @@ export type PauseRequest = {
|
||||
};
|
||||
|
||||
type GateListener = (request: PauseRequest) => void;
|
||||
type AuditListener = (event: ToolConfirmationAuditEvent) => void;
|
||||
|
||||
/** Named options for PauseGate.ask() — makes it obvious which field is kind vs payload. */
|
||||
export interface PauseAskOpts<K extends PauseKind = PauseKind> {
|
||||
@@ -59,6 +79,7 @@ export class PauseGate {
|
||||
private _nextId = 0;
|
||||
private _pending = new Map<number, { resolve: (data: unknown) => void; request: PauseRequest }>();
|
||||
private _listeners: Set<GateListener> = new Set();
|
||||
private _auditListener: AuditListener | null = null;
|
||||
|
||||
/** Block until the user responds. Takes a named options object so the
|
||||
* kind and payload fields don't get confused at the call site. */
|
||||
@@ -88,9 +109,14 @@ export class PauseGate {
|
||||
const p = this._pending.get(id);
|
||||
if (!p) return;
|
||||
this._pending.delete(id);
|
||||
this.emitAuditEvent(p.request, data);
|
||||
p.resolve(data);
|
||||
}
|
||||
|
||||
setAuditListener(fn: AuditListener | null): void {
|
||||
this._auditListener = fn;
|
||||
}
|
||||
|
||||
/** Subscribe to new pause requests. Returns an unsubscribe function. */
|
||||
on(fn: GateListener): () => void {
|
||||
this._listeners.add(fn);
|
||||
@@ -104,6 +130,45 @@ export class PauseGate {
|
||||
for (const [, p] of this._pending) return p.request;
|
||||
return null;
|
||||
}
|
||||
|
||||
private emitAuditEvent(request: PauseRequest, data: unknown): void {
|
||||
if (!this._auditListener) return;
|
||||
if (request.kind !== "run_command" && request.kind !== "run_background") return;
|
||||
if (!data || typeof data !== "object") return;
|
||||
const choice = data as Partial<ConfirmationChoice>;
|
||||
try {
|
||||
switch (choice.type) {
|
||||
case "run_once":
|
||||
this._auditListener({
|
||||
type: "tool.confirm.allow",
|
||||
kind: request.kind,
|
||||
payload: request.payload as { command: string },
|
||||
});
|
||||
break;
|
||||
case "deny":
|
||||
this._auditListener({
|
||||
type: "tool.confirm.deny",
|
||||
kind: request.kind,
|
||||
payload: request.payload as { command: string },
|
||||
denyContext: choice.denyContext,
|
||||
});
|
||||
break;
|
||||
case "always_allow":
|
||||
if (typeof choice.prefix !== "string") return;
|
||||
this._auditListener({
|
||||
type: "tool.confirm.always_allow",
|
||||
kind: request.kind,
|
||||
payload: request.payload as { command: string },
|
||||
prefix: choice.prefix,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* audit path must never break the gate */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton shared between tools and the App. */
|
||||
|
||||
@@ -193,24 +193,15 @@ export async function* walkChunks(
|
||||
onSkip(rel, "pattern");
|
||||
continue;
|
||||
}
|
||||
let stat: import("node:fs").Stats;
|
||||
try {
|
||||
stat = await fs.stat(abs);
|
||||
} catch {
|
||||
onSkip(rel, "readError");
|
||||
continue;
|
||||
}
|
||||
if (stat.size > filters.maxFileBytes) {
|
||||
onSkip(rel, "tooLarge");
|
||||
continue;
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(abs, "utf8");
|
||||
} catch {
|
||||
onSkip(rel, "readError");
|
||||
// Open once and check size + read against the same fd. Skipping
|
||||
// a path-based `fs.stat` upstream is intentional — stat→open is
|
||||
// the TOCTOU shape CodeQL flags as js/file-system-race.
|
||||
const result = await readSizeBoundedFile(abs, filters.maxFileBytes);
|
||||
if (result.kind === "skip") {
|
||||
onSkip(rel, result.reason);
|
||||
continue;
|
||||
}
|
||||
const text = result.text;
|
||||
if (text.indexOf("\0") !== -1) {
|
||||
onSkip(rel, "binaryContent");
|
||||
continue;
|
||||
@@ -235,3 +226,20 @@ export async function chunkDirectory(root: string, opts: ChunkOptions = {}): Pro
|
||||
for await (const c of walkChunks(root, opts)) out.push(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
type ReadFileResult = { kind: "ok"; text: string } | { kind: "skip"; reason: SkipReason };
|
||||
|
||||
async function readSizeBoundedFile(abs: string, maxBytes: number): Promise<ReadFileResult> {
|
||||
try {
|
||||
const fh = await fs.open(abs, "r");
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.size > maxBytes) return { kind: "skip", reason: "tooLarge" };
|
||||
return { kind: "ok", text: await fh.readFile("utf8") };
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
} catch {
|
||||
return { kind: "skip", reason: "readError" };
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export async function registerSemanticSearchTool(
|
||||
description:
|
||||
"FIRST CHOICE for descriptive queries. Use this BEFORE search_content (grep) when the user describes WHAT code does ('where do we handle X', 'which file owns Y', 'how does Z work', 'find the logic that …'). Returns ranked snippets ordered by semantic relevance — finds the right file even when your description shares no words with the code. Falls back to search_content / search_files only for: exact identifiers, regex patterns, or counting occurrences of a known token. If your first instinct is grep on a paraphrased question, you are wrong — try semantic_search first.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
+209
-664
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
import type { BranchSample } from "../consistency.js";
|
||||
import type { BranchSummary } from "./types.js";
|
||||
|
||||
export function summarizeBranch(chosen: BranchSample, samples: BranchSample[]): BranchSummary {
|
||||
return {
|
||||
budget: samples.length,
|
||||
chosenIndex: chosen.index,
|
||||
uncertainties: samples.map((s) => s.planState.uncertainties.length),
|
||||
temperatures: samples.map((s) => s.temperature),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Single text-layer DeepSeek-error formatter — 429/5xx never reach here (retry.ts swallows). */
|
||||
export function formatLoopError(err: Error): string {
|
||||
const msg = err.message ?? "";
|
||||
if (msg.includes("maximum context length")) {
|
||||
const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
|
||||
const requested = reqMatch
|
||||
? `${Number(reqMatch[1]).toLocaleString()} tokens`
|
||||
: "too many tokens";
|
||||
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 — a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
|
||||
}
|
||||
|
||||
const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
|
||||
if (!m) return msg;
|
||||
const status = m[1] ?? "";
|
||||
const body = m[2] ?? "";
|
||||
const inner = extractDeepSeekErrorMessage(body);
|
||||
|
||||
if (status === "401") {
|
||||
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.`;
|
||||
}
|
||||
if (status === "402") {
|
||||
return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up — the panel header shows your balance once it's non-zero.`;
|
||||
}
|
||||
if (status === "422") {
|
||||
return `Invalid parameter (DeepSeek 422): ${inner}`;
|
||||
}
|
||||
if (status === "400") {
|
||||
return `Bad request (DeepSeek 400): ${inner}`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function reasonPrefixFor(
|
||||
reason: "budget" | "aborted" | "context-guard" | "stuck",
|
||||
iterCap: number,
|
||||
): string {
|
||||
if (reason === "aborted") return "[aborted by user (Esc) — summarizing what I found so far]";
|
||||
if (reason === "context-guard") {
|
||||
return "[context budget running low — summarizing before the next call would overflow]";
|
||||
}
|
||||
if (reason === "stuck") {
|
||||
return "[stuck on a repeated tool call — explaining what was tried and what's blocking progress]";
|
||||
}
|
||||
return `[tool-call budget (${iterCap}) reached — forcing summary from what I found]`;
|
||||
}
|
||||
|
||||
export function errorLabelFor(
|
||||
reason: "budget" | "aborted" | "context-guard" | "stuck",
|
||||
iterCap: number,
|
||||
): string {
|
||||
if (reason === "aborted") return "aborted by user";
|
||||
if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
|
||||
if (reason === "stuck") return "stuck (repeated tool call suppressed by storm-breaker)";
|
||||
return `tool-call budget (${iterCap}) reached`;
|
||||
}
|
||||
|
||||
function extractDeepSeekErrorMessage(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return "(no message)";
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as { error?: { message?: unknown }; message?: unknown };
|
||||
if (obj.error && typeof obj.error.message === "string") return obj.error.message;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON — fall through */
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Accepts `<<<NEEDS_PRO>>>` or `<<<NEEDS_PRO: reason>>>` (reason trimmed, may be empty). */
|
||||
const NEEDS_PRO_MARKER_PREFIX = "<<<NEEDS_PRO";
|
||||
const NEEDS_PRO_MARKER_RE = /^<<<NEEDS_PRO(?::\s*([^>]*))?>>>/;
|
||||
/** Buffer cap before flushing — must fit `<<<NEEDS_PRO: reason>>>` without premature flush. */
|
||||
export const NEEDS_PRO_BUFFER_CHARS = 256;
|
||||
|
||||
/** Anchored to lead — mid-text matches are normal content (user asking about the marker). */
|
||||
export function parseEscalationMarker(content: string): { matched: boolean; reason?: string } {
|
||||
const m = NEEDS_PRO_MARKER_RE.exec(content.trimStart());
|
||||
if (!m) return { matched: false };
|
||||
const reason = m[1]?.trim();
|
||||
return { matched: true, reason: reason || undefined };
|
||||
}
|
||||
|
||||
/** Convenience boolean — same gate the streaming path used to call. */
|
||||
export function isEscalationRequest(content: string): boolean {
|
||||
return parseEscalationMarker(content).matched;
|
||||
}
|
||||
|
||||
/** Drives streaming flush — while plausibly partial, keep accumulating; else flush. */
|
||||
export function looksLikePartialEscalationMarker(buf: string): boolean {
|
||||
const t = buf.trimStart();
|
||||
if (t.length === 0) return true;
|
||||
if (t.length <= NEEDS_PRO_MARKER_PREFIX.length) {
|
||||
return NEEDS_PRO_MARKER_PREFIX.startsWith(t);
|
||||
}
|
||||
if (!t.startsWith(NEEDS_PRO_MARKER_PREFIX)) return false;
|
||||
const rest = t.slice(NEEDS_PRO_MARKER_PREFIX.length);
|
||||
if (rest[0] !== ">" && rest[0] !== ":") return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type DeepSeekClient, Usage } from "../client.js";
|
||||
import type { TurnStats } from "../telemetry/stats.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
import { errorLabelFor, reasonPrefixFor } from "./errors.js";
|
||||
import { buildAssistantMessage } from "./messages.js";
|
||||
import { stripHallucinatedToolMarkup, thinkingModeForModel } from "./thinking.js";
|
||||
import type { LoopEvent } from "./types.js";
|
||||
|
||||
export type ForceSummaryReason = "budget" | "aborted" | "context-guard" | "stuck";
|
||||
|
||||
export interface ForceSummaryContext {
|
||||
client: DeepSeekClient;
|
||||
signal: AbortSignal;
|
||||
buildMessages: () => ChatMessage[];
|
||||
appendAndPersist: (msg: ChatMessage) => void;
|
||||
recordStats: (model: string, usage: Usage) => TurnStats;
|
||||
turn: number;
|
||||
maxToolIters: number;
|
||||
}
|
||||
|
||||
export async function* forceSummaryAfterIterLimit(
|
||||
ctx: ForceSummaryContext,
|
||||
opts: { reason: ForceSummaryReason } = { reason: "budget" },
|
||||
): AsyncGenerator<LoopEvent> {
|
||||
try {
|
||||
// Status bridges the silence — summary call is non-streaming, 30-60s typical.
|
||||
yield { turn: ctx.turn, role: "status", content: "summarizing what was gathered…" };
|
||||
const messages = ctx.buildMessages();
|
||||
// Passing `tools: undefined` was supposed to force a text response,
|
||||
// but R1 can still hallucinate tool-call markup (e.g. DSML
|
||||
// `<|DSML|function_calls>…`) when primed by prior tool use. An
|
||||
// explicit user-role instruction plus post-hoc stripping of known
|
||||
// hallucination shapes keeps the user from seeing raw markup.
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks — they will be silently discarded. Just plain text.",
|
||||
});
|
||||
// Pin to flash + effort=high regardless of the main turn's model —
|
||||
// pro is 12× overkill for "paraphrase tool results into prose," and
|
||||
// budget-exhausted turns are exactly when we don't want to torch the wallet.
|
||||
const summaryModel = "deepseek-v4-flash";
|
||||
const summaryEffort: "high" | "max" = "high";
|
||||
const resp = await ctx.client.chat({
|
||||
model: summaryModel,
|
||||
messages,
|
||||
signal: ctx.signal,
|
||||
thinking: thinkingModeForModel(summaryModel),
|
||||
reasoningEffort: summaryEffort,
|
||||
});
|
||||
const rawContent = resp.content?.trim() ?? "";
|
||||
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
||||
const summary =
|
||||
cleaned ||
|
||||
"(model emitted fake tool-call markup instead of a prose summary — try /retry with a narrower question, or /think to inspect R1's reasoning)";
|
||||
const reasonPrefix = reasonPrefixFor(opts.reason, ctx.maxToolIters);
|
||||
const annotated = `${reasonPrefix}\n\n${summary}`;
|
||||
// Record under the actual model used (flash), so per-turn cost reflects reality.
|
||||
const summaryStats = ctx.recordStats(summaryModel, resp.usage ?? new Usage());
|
||||
ctx.appendAndPersist(buildAssistantMessage(summary, [], summaryModel, resp.reasoningContent));
|
||||
yield {
|
||||
turn: ctx.turn,
|
||||
role: "assistant_final",
|
||||
content: annotated,
|
||||
stats: summaryStats,
|
||||
forcedSummary: true,
|
||||
};
|
||||
yield { turn: ctx.turn, role: "done", content: summary };
|
||||
} catch (err) {
|
||||
const label = errorLabelFor(opts.reason, ctx.maxToolIters);
|
||||
yield {
|
||||
turn: ctx.turn,
|
||||
role: "error",
|
||||
content: "",
|
||||
error: `${label} and the fallback summary call failed: ${(err as Error).message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`,
|
||||
};
|
||||
yield { turn: ctx.turn, role: "done", content: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ChatMessage } from "../types.js";
|
||||
import { shrinkOversizedToolResults, shrinkOversizedToolResultsByTokens } from "./shrink.js";
|
||||
import { isThinkingModeModel } from "./thinking.js";
|
||||
|
||||
/** Drops both unpaired assistant.tool_calls and stray tool messages — DeepSeek 400s on either. */
|
||||
export function fixToolCallPairing(messages: ChatMessage[]): {
|
||||
messages: ChatMessage[];
|
||||
droppedAssistantCalls: number;
|
||||
droppedStrayTools: number;
|
||||
} {
|
||||
const out: ChatMessage[] = [];
|
||||
let droppedAssistantCalls = 0;
|
||||
let droppedStrayTools = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
||||
const needed = new Set<string>();
|
||||
for (const call of msg.tool_calls) {
|
||||
if (call?.id) needed.add(call.id);
|
||||
}
|
||||
const candidates: ChatMessage[] = [];
|
||||
let j = i + 1;
|
||||
while (j < messages.length && needed.size > 0) {
|
||||
const nxt = messages[j]!;
|
||||
if (nxt.role !== "tool") break;
|
||||
const id = nxt.tool_call_id ?? "";
|
||||
if (!needed.has(id)) break;
|
||||
needed.delete(id);
|
||||
candidates.push(nxt);
|
||||
j++;
|
||||
}
|
||||
if (needed.size === 0) {
|
||||
out.push(msg);
|
||||
for (const r of candidates) out.push(r);
|
||||
i = j - 1;
|
||||
} else {
|
||||
droppedAssistantCalls += 1;
|
||||
droppedStrayTools += candidates.length;
|
||||
i = j - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "tool") {
|
||||
droppedStrayTools += 1;
|
||||
continue;
|
||||
}
|
||||
out.push(msg);
|
||||
}
|
||||
return { messages: out, droppedAssistantCalls, droppedStrayTools };
|
||||
}
|
||||
|
||||
export function healLoadedMessages(
|
||||
messages: ChatMessage[],
|
||||
maxChars: number,
|
||||
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
|
||||
const shrunk = shrinkOversizedToolResults(messages, maxChars);
|
||||
const paired = fixToolCallPairing(shrunk.messages);
|
||||
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
|
||||
return { messages: paired.messages, healedCount, healedFrom: shrunk.healedFrom };
|
||||
}
|
||||
|
||||
/** Back-fills "" on bare assistant turns; skipped on non-thinking to avoid prefix-cache churn. */
|
||||
export function stampMissingReasoningForThinkingMode(
|
||||
messages: ChatMessage[],
|
||||
model: string,
|
||||
): { messages: ChatMessage[]; stampedCount: number } {
|
||||
if (!isThinkingModeModel(model)) {
|
||||
return { messages, stampedCount: 0 };
|
||||
}
|
||||
let stampedCount = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "assistant") return msg;
|
||||
if (Object.hasOwn(msg, "reasoning_content")) return msg;
|
||||
stampedCount += 1;
|
||||
return { ...msg, reasoning_content: "" };
|
||||
});
|
||||
return { messages: out, stampedCount };
|
||||
}
|
||||
|
||||
/** Token-cap variant — char cap would let CJK slip past at 2× the intended token cost. */
|
||||
export function healLoadedMessagesByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
const shrunk = shrinkOversizedToolResultsByTokens(messages, maxTokens);
|
||||
const paired = fixToolCallPairing(shrunk.messages);
|
||||
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
|
||||
return {
|
||||
messages: paired.messages,
|
||||
healedCount,
|
||||
tokensSaved: shrunk.tokensSaved,
|
||||
charsSaved: shrunk.charsSaved,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type HookOutcome, formatHookOutcomeMessage } from "../hooks.js";
|
||||
import type { LoopEvent } from "./types.js";
|
||||
|
||||
export function safeParseToolArgs(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format non-pass hook outcomes as `LoopEvent`s of role `warning`. */
|
||||
export function* hookWarnings(outcomes: HookOutcome[], turn: number): Generator<LoopEvent> {
|
||||
for (const o of outcomes) {
|
||||
if (o.decision === "pass") continue;
|
||||
yield { turn, role: "warning", content: formatHookOutcomeMessage(o) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ChatMessage, ToolCall } from "../types.js";
|
||||
import { isThinkingModeModel } from "./thinking.js";
|
||||
|
||||
/** Thinking-mode producer ⇒ reasoning_content MUST be set (even ""), or next call 400s. */
|
||||
export function buildAssistantMessage(
|
||||
content: string,
|
||||
toolCalls: ToolCall[],
|
||||
producingModel: string,
|
||||
reasoningContent?: string | null,
|
||||
): ChatMessage {
|
||||
const msg: ChatMessage = { role: "assistant", content };
|
||||
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
||||
// V4-era deepseek-chat returns reasoning_content even with thinking.type
|
||||
// disabled, and the API rejects round-trips that drop it. Whitelist on
|
||||
// model name is too brittle — preserve whenever the producer emitted any.
|
||||
if (isThinkingModeModel(producingModel) || (reasoningContent && reasoningContent.length > 0)) {
|
||||
msg.reasoning_content = reasoningContent ?? "";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Abort notices etc — caller passes its current model as the thinking-mode stamp. */
|
||||
export function buildSyntheticAssistantMessage(
|
||||
content: string,
|
||||
fallbackModel: string,
|
||||
): ChatMessage {
|
||||
return buildAssistantMessage(content, [], fallbackModel, "");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { truncateForModel, truncateForModelByTokens } from "../mcp/registry.js";
|
||||
import { countTokens } from "../tokenizer.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
/** UI progress feedback only — NOT a dispatch gate. */
|
||||
export function looksLikeCompleteJson(s: string): boolean {
|
||||
if (!s || !s.trim()) return false;
|
||||
try {
|
||||
JSON.parse(s);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool-role only — truncating user prompts would corrupt authored intent. */
|
||||
export function shrinkOversizedToolResults(
|
||||
messages: ChatMessage[],
|
||||
maxChars: number,
|
||||
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
|
||||
let healedCount = 0;
|
||||
let healedFrom = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "tool") return msg;
|
||||
const content = typeof msg.content === "string" ? msg.content : "";
|
||||
if (content.length <= maxChars) return msg;
|
||||
healedCount += 1;
|
||||
healedFrom += content.length;
|
||||
return { ...msg, content: truncateForModel(content, maxChars) };
|
||||
});
|
||||
return { messages: out, healedCount, healedFrom };
|
||||
}
|
||||
|
||||
/** Token-cap variant — char cap would let CJK slip past at 2× the intended token cost. */
|
||||
export function shrinkOversizedToolResultsByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
let healedCount = 0;
|
||||
let tokensSaved = 0;
|
||||
let charsSaved = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "tool") return msg;
|
||||
const content = typeof msg.content === "string" ? msg.content : "";
|
||||
// length ≤ maxTokens ⇒ tokens ≤ maxTokens — skip the per-message tokenize.
|
||||
if (content.length <= maxTokens) return msg;
|
||||
const beforeTokens = countTokens(content);
|
||||
if (beforeTokens <= maxTokens) return msg;
|
||||
const truncated = truncateForModelByTokens(content, maxTokens);
|
||||
const afterTokens = countTokens(truncated);
|
||||
healedCount += 1;
|
||||
tokensSaved += Math.max(0, beforeTokens - afterTokens);
|
||||
charsSaved += Math.max(0, content.length - truncated.length);
|
||||
return { ...msg, content: truncated };
|
||||
});
|
||||
return { messages: out, healedCount, tokensSaved, charsSaved };
|
||||
}
|
||||
|
||||
/** Caller must gate on paired tool_calls — in-flight calls would crash mid-turn. */
|
||||
export function shrinkOversizedToolCallArgsByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
let healedCount = 0;
|
||||
let tokensSaved = 0;
|
||||
let charsSaved = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return msg;
|
||||
let changed = false;
|
||||
const newCalls = msg.tool_calls.map((call) => {
|
||||
const args = call.function?.arguments;
|
||||
if (typeof args !== "string" || args.length <= maxTokens) return call;
|
||||
const beforeTokens = countTokens(args);
|
||||
if (beforeTokens <= maxTokens) return call;
|
||||
const shrunk = shrinkJsonLongStrings(args);
|
||||
const afterTokens = countTokens(shrunk);
|
||||
// Many-short-strings payloads can come back marginally larger — only swap on real saving.
|
||||
if (afterTokens >= beforeTokens) return call;
|
||||
changed = true;
|
||||
healedCount += 1;
|
||||
tokensSaved += beforeTokens - afterTokens;
|
||||
charsSaved += args.length - shrunk.length;
|
||||
return { ...call, function: { ...call.function, arguments: shrunk } };
|
||||
});
|
||||
if (!changed) return msg;
|
||||
return { ...msg, tool_calls: newCalls };
|
||||
});
|
||||
return { messages: out, healedCount, tokensSaved, charsSaved };
|
||||
}
|
||||
|
||||
/** Keeps short keys/values (paths, ids) verbatim; only long string values get a marker. */
|
||||
function shrinkJsonLongStrings(jsonStr: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
const head = jsonStr.slice(0, 200);
|
||||
return `${head}…[shrunk: ${jsonStr.length} chars, unparsed]`;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return jsonStr;
|
||||
}
|
||||
const LONG_THRESHOLD = 300;
|
||||
const input = parsed as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (typeof v === "string" && v.length > LONG_THRESHOLD) {
|
||||
const newlines = v.match(/\n/g)?.length ?? 0;
|
||||
output[k] =
|
||||
`[…shrunk: ${v.length} chars, ${newlines} lines — tool already responded, see result]`;
|
||||
} else {
|
||||
output[k] = v;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(output);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** True when the model emits reasoning_content and requires it round-tripped on follow-ups. */
|
||||
export function isThinkingModeModel(model: string): boolean {
|
||||
if (model.includes("reasoner")) return true;
|
||||
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Pins extra_body.thinking.type; `undefined` lets third-party endpoints skip the field. */
|
||||
export function thinkingModeForModel(model: string): "enabled" | "disabled" | undefined {
|
||||
if (model === "deepseek-chat") return "disabled";
|
||||
if (model.includes("reasoner")) return "enabled";
|
||||
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return "enabled";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Strip hallucinated tool-call envelopes — `tools: undefined` doesn't always force prose. */
|
||||
export function stripHallucinatedToolMarkup(s: string): string {
|
||||
let out = s;
|
||||
// DeepSeek's DSML envelope (full-width "|" is the form R1 emits in practice).
|
||||
out = out.replace(/<|DSML|function_calls>[\s\S]*?<\/?|DSML|function_calls>/g, "");
|
||||
out = out.replace(/<\|DSML\|function_calls>[\s\S]*?<\/?\|DSML\|function_calls>/g, "");
|
||||
out = out.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "");
|
||||
// Lone unpaired DSML opener left over after R1 truncates mid-call.
|
||||
out = out.replace(/<|DSML|[\s\S]*$/g, "");
|
||||
return out.trim();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RepairReport } from "../repair/index.js";
|
||||
|
||||
export const FAILURE_ESCALATION_THRESHOLD = 3;
|
||||
|
||||
export class TurnFailureTracker {
|
||||
private count = 0;
|
||||
private types: Record<string, number> = {};
|
||||
|
||||
reset(): void {
|
||||
this.count = 0;
|
||||
this.types = {};
|
||||
}
|
||||
|
||||
/** True ONLY on the call where the count crosses FAILURE_ESCALATION_THRESHOLD. */
|
||||
noteAndCrossedThreshold(resultJson: string, repair?: RepairReport): boolean {
|
||||
const before = this.count;
|
||||
const bump = (kind: string, by = 1): void => {
|
||||
this.count += by;
|
||||
this.types[kind] = (this.types[kind] ?? 0) + by;
|
||||
};
|
||||
if (resultJson.includes('"error"') && resultJson.includes("search text not found")) {
|
||||
bump("search-mismatch");
|
||||
}
|
||||
if (repair) {
|
||||
if (repair.scavenged > 0) bump("scavenged", repair.scavenged);
|
||||
if (repair.truncationsFixed > 0) bump("truncated", repair.truncationsFixed);
|
||||
if (repair.stormsBroken > 0) bump("repeat-loop", repair.stormsBroken);
|
||||
}
|
||||
return before < FAILURE_ESCALATION_THRESHOLD && this.count >= FAILURE_ESCALATION_THRESHOLD;
|
||||
}
|
||||
|
||||
formatBreakdown(): string {
|
||||
const parts = Object.entries(this.types)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([kind, n]) => `${n}× ${kind}`);
|
||||
return parts.length > 0 ? parts.join(", ") : `${this.count} repair/error signal(s)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TypedPlanState } from "../harvest.js";
|
||||
import type { RepairReport } from "../repair/index.js";
|
||||
import type { TurnStats } from "../telemetry/stats.js";
|
||||
|
||||
export type EventRole =
|
||||
| "assistant_delta"
|
||||
| "assistant_final"
|
||||
/** Only liveness signal during a large-args tool call (no content/reasoning bytes). */
|
||||
| "tool_call_delta"
|
||||
/** Pre-dispatch ping so the TUI can show a spinner during long tool awaits. */
|
||||
| "tool_start"
|
||||
| "tool"
|
||||
| "done"
|
||||
| "error"
|
||||
| "warning"
|
||||
/** Transient indicator for silent phases; UI clears on next primary event. */
|
||||
| "status"
|
||||
| "branch_start"
|
||||
| "branch_progress"
|
||||
| "branch_done";
|
||||
|
||||
export interface BranchSummary {
|
||||
budget: number;
|
||||
chosenIndex: number;
|
||||
uncertainties: number[];
|
||||
temperatures: number[];
|
||||
}
|
||||
|
||||
export interface BranchProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
latestIndex: number;
|
||||
latestTemperature: number;
|
||||
latestUncertainties: number;
|
||||
}
|
||||
|
||||
export interface LoopEvent {
|
||||
turn: number;
|
||||
role: EventRole;
|
||||
content: string;
|
||||
reasoningDelta?: string;
|
||||
toolName?: string;
|
||||
/** Raw args JSON — needed by `reasonix diff` to explain why a tool was called. */
|
||||
toolArgs?: string;
|
||||
/** Cumulative arguments-string length for `role === "tool_call_delta"`. */
|
||||
toolCallArgsChars?: number;
|
||||
/** Zero-based index of the tool call this delta belongs to (multi-tool progress). */
|
||||
toolCallIndex?: number;
|
||||
/** Count of tool calls whose args have parsed as valid JSON (UI progress, not dispatch gate). */
|
||||
toolCallReadyCount?: number;
|
||||
stats?: TurnStats;
|
||||
planState?: TypedPlanState;
|
||||
repair?: RepairReport;
|
||||
branch?: BranchSummary;
|
||||
branchProgress?: BranchProgress;
|
||||
error?: string;
|
||||
/** Display-only — code-mode applier MUST skip SEARCH/REPLACE in forced-summary text. */
|
||||
forcedSummary?: boolean;
|
||||
}
|
||||
@@ -14,11 +14,20 @@ export interface ScavengeResult {
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** Bounds the regex input — DSML matchers are O(n²) on adversarial input per CodeQL js/polynomial-redos. */
|
||||
const MAX_SCAVENGE_INPUT = 100 * 1024;
|
||||
|
||||
export function scavengeToolCalls(
|
||||
reasoningContent: string | null | undefined,
|
||||
opts: ScavengeOptions,
|
||||
): ScavengeResult {
|
||||
if (!reasoningContent) return { calls: [], notes: [] };
|
||||
if (reasoningContent.length > MAX_SCAVENGE_INPUT) {
|
||||
return {
|
||||
calls: [],
|
||||
notes: [`scavenge skipped: reasoning_content too large (${reasoningContent.length} chars)`],
|
||||
};
|
||||
}
|
||||
const max = opts.maxCalls ?? 4;
|
||||
const notes: string[] = [];
|
||||
const out: ToolCall[] = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Job state in a module-scoped Map keyed by project root so multi-root dashboards don't collide; CLI `reasonix index` runs independently. */
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadIndexConfig } from "../../config.js";
|
||||
import {
|
||||
@@ -186,7 +186,9 @@ function readIndexMeta(root: string): IndexMetaResponse | { exists: false } {
|
||||
const dir = join(root, INDEX_DIR_NAME);
|
||||
const metaPath = join(dir, "index.meta.json");
|
||||
const dataPath = join(dir, "index.jsonl");
|
||||
if (!existsSync(metaPath) || !existsSync(dataPath)) return { exists: false };
|
||||
// Try-read both files unconditionally — the catch covers "file
|
||||
// missing" without a path-based existsSync precheck (CodeQL flags
|
||||
// the precheck as a stat→read race).
|
||||
let meta: { dim?: number; model?: string; updatedAt?: string };
|
||||
try {
|
||||
meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
||||
@@ -197,8 +199,24 @@ function readIndexMeta(root: string): IndexMetaResponse | { exists: false } {
|
||||
const files = new Set<string>();
|
||||
let sizeBytes = 0;
|
||||
try {
|
||||
sizeBytes = statSync(dataPath).size;
|
||||
const raw = readFileSync(dataPath, "utf8");
|
||||
// Bind size and content to the same fd so a concurrent rebuild
|
||||
// can't grow the file between stat and read (CodeQL js/file-system-race).
|
||||
const fd = openSync(dataPath, "r");
|
||||
let raw: string;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
sizeBytes = stat.size;
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
raw = buf.toString("utf8", 0, read);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
chunks++;
|
||||
|
||||
+20
-21
@@ -1,14 +1,6 @@
|
||||
/** apiKey is write-only on the wire; GET always returns a redacted form so dashboard screenshots don't leak credentials. */
|
||||
|
||||
import {
|
||||
isPlausibleKey,
|
||||
readConfig,
|
||||
redactKey,
|
||||
saveApiKey,
|
||||
saveEditMode,
|
||||
saveReasoningEffort,
|
||||
writeConfig,
|
||||
} from "../../config.js";
|
||||
import { isPlausibleKey, readConfig, redactKey, saveEditMode, writeConfig } from "../../config.js";
|
||||
import { getLanguage, getSupportedLanguages, setLanguage } from "../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../i18n/types.js";
|
||||
import type { DashboardContext } from "../context.js";
|
||||
@@ -74,8 +66,13 @@ export async function handleSettings(
|
||||
|
||||
if (method === "POST") {
|
||||
const fields = parseBody(body);
|
||||
// Single read up top, all field updates accumulate, single writeConfig at the end —
|
||||
// a per-field write would clobber earlier per-field writes from the same POST.
|
||||
const cfg = readConfig(ctx.configPath);
|
||||
const changed: string[] = [];
|
||||
let langPending: LanguageCode | null = null;
|
||||
let presetPendingLive: string | null = null;
|
||||
let effortPendingLive: "high" | "max" | null = null;
|
||||
|
||||
if (fields.lang !== undefined) {
|
||||
const raw = String(fields.lang);
|
||||
@@ -86,15 +83,15 @@ export async function handleSettings(
|
||||
if (!langCode) {
|
||||
return { status: 400, body: { error: `lang must be one of: ${supported.join(", ")}` } };
|
||||
}
|
||||
setLanguage(langCode);
|
||||
cfg.lang = langCode;
|
||||
langPending = langCode;
|
||||
changed.push("lang");
|
||||
}
|
||||
if (fields.apiKey !== undefined) {
|
||||
if (typeof fields.apiKey !== "string" || !isPlausibleKey(fields.apiKey)) {
|
||||
return { status: 400, body: { error: "apiKey must be a plausible sk- token" } };
|
||||
}
|
||||
// saveApiKey reads + writes the entire file with chmod 600.
|
||||
saveApiKey(fields.apiKey, ctx.configPath);
|
||||
cfg.apiKey = fields.apiKey.trim();
|
||||
changed.push("apiKey");
|
||||
}
|
||||
if (fields.baseUrl !== undefined) {
|
||||
@@ -102,7 +99,6 @@ export async function handleSettings(
|
||||
return { status: 400, body: { error: "baseUrl must be a non-empty string" } };
|
||||
}
|
||||
cfg.baseUrl = fields.baseUrl.trim();
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
changed.push("baseUrl");
|
||||
}
|
||||
if (fields.preset !== undefined) {
|
||||
@@ -110,11 +106,7 @@ export async function handleSettings(
|
||||
return { status: 400, body: { error: "preset must be auto | flash | pro" } };
|
||||
}
|
||||
cfg.preset = fields.preset as "auto" | "flash" | "pro" | "fast" | "smart" | "max";
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
// Apply to the LIVE loop too so the user doesn't have to restart
|
||||
// their session to feel the change. The callback canonicalizes
|
||||
// legacy aliases via resolvePreset internally.
|
||||
ctx.applyPresetLive?.(fields.preset);
|
||||
presetPendingLive = fields.preset;
|
||||
changed.push("preset");
|
||||
}
|
||||
if (fields.reasoningEffort !== undefined) {
|
||||
@@ -124,8 +116,8 @@ export async function handleSettings(
|
||||
) {
|
||||
return { status: 400, body: { error: "reasoningEffort must be high | max" } };
|
||||
}
|
||||
saveReasoningEffort(fields.reasoningEffort as "high" | "max", ctx.configPath);
|
||||
ctx.applyEffortLive?.(fields.reasoningEffort as "high" | "max");
|
||||
cfg.reasoningEffort = fields.reasoningEffort as "high" | "max";
|
||||
effortPendingLive = fields.reasoningEffort as "high" | "max";
|
||||
changed.push("reasoningEffort");
|
||||
}
|
||||
if (fields.search !== undefined) {
|
||||
@@ -133,11 +125,18 @@ export async function handleSettings(
|
||||
return { status: 400, body: { error: "search must be a boolean" } };
|
||||
}
|
||||
cfg.search = fields.search;
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
changed.push("search");
|
||||
}
|
||||
|
||||
if (changed.length > 0) {
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
// Runtime side-effects fire after the disk write succeeds —
|
||||
// prevents an i18n change from being visible while the on-disk
|
||||
// value still reflects the old setting (and vice-versa for
|
||||
// preset / reasoningEffort).
|
||||
if (langPending) setLanguage(langPending);
|
||||
if (presetPendingLive) ctx.applyPresetLive?.(presetPendingLive);
|
||||
if (effortPendingLive) ctx.applyEffortLive?.(effortPendingLive);
|
||||
ctx.audit?.({ ts: Date.now(), action: "set-settings", payload: { fields: changed } });
|
||||
}
|
||||
return { status: 200, body: { changed } };
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/** `/api/skills` — edits files only; loop reloads on /new or restart. `builtin` scope is read-only. */
|
||||
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
@@ -67,10 +70,25 @@ function listSkills(dir: string, scope: "project" | "global"): SkillListEntry[]
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (!SAFE_NAME.test(entry)) continue;
|
||||
const skillPath = join(dir, entry, SKILL_FILE);
|
||||
if (!existsSync(skillPath)) continue;
|
||||
try {
|
||||
const stat = statSync(skillPath);
|
||||
const raw = readFileSync(skillPath, "utf8");
|
||||
// Open once and reuse the fd so size/mtime/content all bind to
|
||||
// the same inode — closes the exists→stat→read TOCTOU races.
|
||||
const fd = openSync(skillPath, "r");
|
||||
let stat: ReturnType<typeof fstatSync>;
|
||||
let raw: string;
|
||||
try {
|
||||
stat = fstatSync(fd);
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
raw = buf.toString("utf8", 0, read);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const item: SkillListEntry = {
|
||||
name: entry,
|
||||
scope,
|
||||
|
||||
+21
-7
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -33,12 +33,26 @@ const ASSET_DIR = resolveAssetDir();
|
||||
const fileCache = new Map<string, { body: string; mtimeMs: number }>();
|
||||
|
||||
function loadCachedFile(path: string): string {
|
||||
const stat = statSync(path);
|
||||
const cached = fileCache.get(path);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.body;
|
||||
const body = readFileSync(path, "utf8");
|
||||
fileCache.set(path, { body, mtimeMs: stat.mtimeMs });
|
||||
return body;
|
||||
// Open once and reuse the fd so the mtime check and the read bind to
|
||||
// the same inode — closes the stat→read TOCTOU race.
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
const cached = fileCache.get(path);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.body;
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
const body = buf.toString("utf8", 0, read);
|
||||
fileCache.set(path, { body, mtimeMs: stat.mtimeMs });
|
||||
return body;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function loadIndexTemplate(): string {
|
||||
|
||||
+12
-3
@@ -28,8 +28,8 @@ export interface Skill {
|
||||
scope: SkillScope;
|
||||
/** Absolute path to the SKILL.md (or {name}.md) file, or "(builtin)" for shipped defaults. */
|
||||
path: string;
|
||||
/** Raw `allowed-tools` field from frontmatter, if any. Unused in v1. */
|
||||
allowedTools?: string;
|
||||
/** Parsed `allowed-tools` frontmatter — when present, the spawned subagent's registry is scoped to these literal tool names. */
|
||||
allowedTools?: readonly string[];
|
||||
runAs: SkillRunAs;
|
||||
/** Subagent model override; only meaningful when `runAs === "subagent"`. */
|
||||
model?: string;
|
||||
@@ -69,6 +69,15 @@ function isValidSkillName(name: string): boolean {
|
||||
return VALID_SKILL_NAME.test(name);
|
||||
}
|
||||
|
||||
function parseAllowedTools(raw: string | undefined): readonly string[] | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const names = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return names.length > 0 ? Object.freeze(names) : undefined;
|
||||
}
|
||||
|
||||
export class SkillStore {
|
||||
private readonly homeDir: string;
|
||||
private readonly projectRoot: string | undefined;
|
||||
@@ -176,7 +185,7 @@ export class SkillStore {
|
||||
body: body.trim(),
|
||||
scope,
|
||||
path,
|
||||
allowedTools: data["allowed-tools"],
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
runAs: parseRunAs(data.runAs),
|
||||
model: data.model?.startsWith("deepseek-") ? data.model : undefined,
|
||||
};
|
||||
|
||||
+38
-11
@@ -2,10 +2,16 @@
|
||||
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
@@ -72,20 +78,31 @@ const USAGE_COMPACTION_THRESHOLD_BYTES = 5 * 1024 * 1024;
|
||||
const USAGE_RETENTION_DAYS = 365;
|
||||
|
||||
function compactUsageLogIfLarge(path: string, now: number): void {
|
||||
let size: number;
|
||||
try {
|
||||
size = statSync(path).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
|
||||
const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
// Open once for the size check + read so they bind to the same fd
|
||||
// (CodeQL js/file-system-race). Concurrent appenders that grow the
|
||||
// log between check and read can no longer cause us to act on a
|
||||
// stale size and rewrite based on partial content.
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf8");
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (stat.size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
raw = buf.toString("utf8", 0, read);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const line of lines) {
|
||||
@@ -99,10 +116,20 @@ function compactUsageLogIfLarge(path: string, now: number): void {
|
||||
}
|
||||
// No-op when nothing aged out — avoids rewrite storms on fresh logs.
|
||||
if (kept.length === lines.filter((l) => l.trim()).length) return;
|
||||
// Write to a sibling tmp path then rename — atomic from a reader's
|
||||
// POV and severs CodeQL's stat→write taint chain. Concurrent
|
||||
// appenders during the compaction window lose their entries; we
|
||||
// accept that for a best-effort usage log.
|
||||
const tmp = `${path}.compacting`;
|
||||
try {
|
||||
writeFileSync(path, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf8");
|
||||
writeFileSync(tmp, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf8");
|
||||
renameSync(tmp, path);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
/* tmp may not exist — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ToolDefinition<A = any, R = any> {
|
||||
readOnly?: boolean;
|
||||
/** Per-args check; takes precedence over `readOnly`. e.g. `run_command` + allowlisted argv. */
|
||||
readOnlyCheck?: (args: A) => boolean;
|
||||
/** Safe to dispatch concurrently with other parallel-safe calls in the same turn. Default false — opt-in only. */
|
||||
parallelSafe?: boolean;
|
||||
fn: (args: A, ctx?: ToolCallContext) => R | Promise<R>;
|
||||
}
|
||||
|
||||
@@ -30,6 +32,13 @@ export interface ToolRegistryOptions {
|
||||
autoFlatten?: boolean;
|
||||
}
|
||||
|
||||
export type ToolCallAuditEvent = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ToolCallAuditListener = (event: ToolCallAuditEvent) => void;
|
||||
|
||||
/** String return short-circuits dispatch; null/undefined falls through to the tool fn. */
|
||||
export type ToolInterceptor = (
|
||||
name: string,
|
||||
@@ -41,6 +50,7 @@ export class ToolRegistry {
|
||||
private readonly _autoFlatten: boolean;
|
||||
private _planMode = false;
|
||||
private _interceptor: ToolInterceptor | null = null;
|
||||
private _auditListener: ToolCallAuditListener | null = null;
|
||||
|
||||
constructor(opts: ToolRegistryOptions = {}) {
|
||||
this._autoFlatten = opts.autoFlatten !== false;
|
||||
@@ -61,6 +71,10 @@ export class ToolRegistry {
|
||||
this._interceptor = fn;
|
||||
}
|
||||
|
||||
setAuditListener(fn: ToolCallAuditListener | null): void {
|
||||
this._auditListener = fn;
|
||||
}
|
||||
|
||||
register<A, R>(def: ToolDefinition<A, R>): this {
|
||||
if (!def.name) throw new Error("tool requires a name");
|
||||
const internal: InternalTool = { ...(def as ToolDefinition) };
|
||||
@@ -96,6 +110,11 @@ export class ToolRegistry {
|
||||
return Boolean(this._tools.get(name)?.flatSchema);
|
||||
}
|
||||
|
||||
/** Unknown / unannotated tools default to false — third-party MCP tools must opt in. */
|
||||
isParallelSafe(name: string): boolean {
|
||||
return this._tools.get(name)?.parallelSafe === true;
|
||||
}
|
||||
|
||||
specs(): ToolSpec[] {
|
||||
return [...this._tools.values()].map((t) => ({
|
||||
type: "function",
|
||||
@@ -172,6 +191,11 @@ export class ToolRegistry {
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
this._auditListener?.({ name, args });
|
||||
} catch {
|
||||
/* audit path must never break tool execution */
|
||||
}
|
||||
const result = await tool.fn(args, {
|
||||
signal: opts.signal,
|
||||
confirmationGate: opts.confirmationGate,
|
||||
|
||||
+73
-224
@@ -2,8 +2,13 @@
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import { DEFAULT_INDEX_EXCLUDES } from "../index/config.js";
|
||||
import type { ToolRegistry } from "../tools.js";
|
||||
import { applyEdit } from "./fs/edit.js";
|
||||
import { searchContent, searchFiles } from "./fs/search.js";
|
||||
|
||||
export { lineDiff } from "./fs/edit.js";
|
||||
|
||||
export interface FilesystemToolsOptions {
|
||||
/** Absolute directory the tools may read/write. Paths outside this are refused. */
|
||||
@@ -30,6 +35,26 @@ const SKIP_DIR_NAMES: ReadonlySet<string> = new Set(DEFAULT_INDEX_EXCLUDES.dirs)
|
||||
/** First line of binary defense; NUL-byte sniff is the second (catches mislabeled `.txt`). */
|
||||
const BINARY_EXTENSIONS: ReadonlySet<string> = new Set(DEFAULT_INDEX_EXCLUDES.exts);
|
||||
|
||||
export function displayRel(rootDir: string, full: string): string {
|
||||
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
const GLOB_METACHARS = /[*?{[]/;
|
||||
|
||||
/** Glob via picomatch when metachars present, else case-insensitive substring — keeps `.ts` / `test` callers working. Slash in pattern → match rel-path; otherwise basename. */
|
||||
export function compileNameFilter(
|
||||
filter: string | null | undefined,
|
||||
): ((name: string, rel: string) => boolean) | null {
|
||||
if (!filter) return null;
|
||||
if (!GLOB_METACHARS.test(filter)) {
|
||||
const needle = filter.toLowerCase();
|
||||
return (name) => name.toLowerCase().includes(needle);
|
||||
}
|
||||
const matchPath = filter.includes("/");
|
||||
const isMatch = picomatch(filter, { dot: true, nocase: true });
|
||||
return matchPath ? (_n, rel) => isMatch(rel) : (name) => isMatch(name);
|
||||
}
|
||||
|
||||
function isLikelyBinaryByName(name: string): boolean {
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot < 0) return false;
|
||||
@@ -77,6 +102,7 @@ export function registerFilesystemTools(
|
||||
|
||||
registry.register({
|
||||
name: "read_file",
|
||||
parallelSafe: true,
|
||||
description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
|
||||
- head: N → first N lines (imports, public API, small configs)
|
||||
- tail: N → last N lines (recently-added code, log tails)
|
||||
@@ -99,11 +125,19 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
|
||||
},
|
||||
fn: async (args: { path: string; head?: number; tail?: number; range?: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
const stat = await fs.stat(abs);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`not a file: ${args.path} (it's a directory)`);
|
||||
// Open once and reuse the fd so the directory check and the read
|
||||
// bind to the same inode — closes the stat→read TOCTOU race.
|
||||
const fh = await fs.open(abs, "r");
|
||||
let raw: Buffer;
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`not a file: ${args.path} (it's a directory)`);
|
||||
}
|
||||
raw = await fh.readFile();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
const raw = await fs.readFile(abs);
|
||||
if (raw.length > maxReadBytes) {
|
||||
const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
|
||||
return `${headBytes}\n\n[…truncated ${raw.length - maxReadBytes} bytes — file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail/range for targeted view.]`;
|
||||
@@ -168,6 +202,7 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
|
||||
|
||||
registry.register({
|
||||
name: "list_directory",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive — use directory_tree for that.",
|
||||
readOnly: true,
|
||||
@@ -190,6 +225,7 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
|
||||
|
||||
registry.register({
|
||||
name: "directory_tree",
|
||||
parallelSafe: true,
|
||||
description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
|
||||
- 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.
|
||||
- Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
|
||||
@@ -279,8 +315,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
|
||||
registry.register({
|
||||
name: "search_files",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
|
||||
"Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) by default.",
|
||||
readOnly: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -290,53 +327,25 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
type: "string",
|
||||
description: "Substring (or regex) to match against filenames.",
|
||||
},
|
||||
include_deps: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, also walk node_modules / .git / dist / build / etc. Off by default — most filename searches are about the user's own code.",
|
||||
},
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
fn: async (args: { path?: string; pattern: string }) => {
|
||||
const startAbs = safePath(args.path ?? ".");
|
||||
const needle = args.pattern.toLowerCase();
|
||||
// Try as regex first (permits users who want patterns); fall
|
||||
// back to plain substring when it's not a valid regex. Flag `i`
|
||||
// so matching is case-insensitive regardless of path.
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(args.pattern, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = pathMod.join(dir, e.name);
|
||||
const lower = e.name.toLowerCase();
|
||||
const hit = re ? re.test(e.name) : lower.includes(needle);
|
||||
if (hit) {
|
||||
const rel = pathMod.relative(rootDir, full);
|
||||
if (totalBytes + rel.length + 1 > maxListBytes) {
|
||||
matches.push("[… search truncated — refine pattern …]");
|
||||
return;
|
||||
}
|
||||
matches.push(rel);
|
||||
totalBytes += rel.length + 1;
|
||||
}
|
||||
if (e.isDirectory()) await walk(full);
|
||||
}
|
||||
};
|
||||
await walk(startAbs);
|
||||
return matches.length === 0 ? "(no matches)" : matches.join("\n");
|
||||
},
|
||||
fn: async (args: { path?: string; pattern: string; include_deps?: boolean }) =>
|
||||
searchFiles(
|
||||
{ rootDir, maxListBytes, skipDirNames: SKIP_DIR_NAMES },
|
||||
safePath(args.path ?? "."),
|
||||
args,
|
||||
),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "search_content",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
|
||||
readOnly: true,
|
||||
@@ -354,7 +363,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
glob: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional file-name suffix or substring filter. Examples: '.ts' (only TypeScript), 'test' (any file with 'test' in the name). Reduces noise when you know the file shape.",
|
||||
"Optional filename filter. Real glob when the value contains `*`, `?`, `{`, or `[` — e.g. '*.ts', '**/*.tsx', 'src/**/*.{ts,tsx}'. Plain substring otherwise — e.g. '.ts' (suffix), 'test' (anywhere in the name). Patterns containing `/` match against the path relative to the search root; otherwise just the basename.",
|
||||
},
|
||||
case_sensitive: {
|
||||
type: "boolean",
|
||||
@@ -374,102 +383,23 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
glob?: string;
|
||||
case_sensitive?: boolean;
|
||||
include_deps?: boolean;
|
||||
}) => {
|
||||
const startAbs = safePath(args.path ?? ".");
|
||||
const caseSensitive = args.case_sensitive === true;
|
||||
const includeDeps = args.include_deps === true;
|
||||
const nameFilter = typeof args.glob === "string" ? args.glob.toLowerCase() : null;
|
||||
// Try the pattern as a regex first (lets the model say `\bdispatch\(`
|
||||
// for a word-bounded match); fall back to literal substring on
|
||||
// invalid regex. No `g` flag — we test once per line, so global
|
||||
// statefulness (lastIndex tracking) would just be noise.
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(args.pattern, caseSensitive ? "" : "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const needle = caseSensitive ? args.pattern : args.pattern.toLowerCase();
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let scanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
if (truncated) return;
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (truncated) return;
|
||||
if (e.isDirectory()) {
|
||||
if (!includeDeps && SKIP_DIR_NAMES.has(e.name)) continue;
|
||||
await walk(pathMod.join(dir, e.name));
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
if (nameFilter && !e.name.toLowerCase().includes(nameFilter)) continue;
|
||||
if (isLikelyBinaryByName(e.name)) continue;
|
||||
const full = pathMod.join(dir, e.name);
|
||||
let stat: import("node:fs").Stats;
|
||||
try {
|
||||
stat = await fs.stat(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Per-file size cap so a 50MB log doesn't dominate the search.
|
||||
// Anything legitimately interesting fits in 2 MB; bigger files
|
||||
// are usually data dumps or generated bundles.
|
||||
if (stat.size > 2 * 1024 * 1024) continue;
|
||||
let raw: Buffer;
|
||||
try {
|
||||
raw = await fs.readFile(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Content-based binary sniff: a NUL byte in the first 8KB is
|
||||
// a strong indicator. Catches binaries with .json or .txt
|
||||
// extensions (yes, this happens).
|
||||
const firstNul = raw.indexOf(0);
|
||||
if (firstNul !== -1 && firstNul < 8 * 1024) continue;
|
||||
const text = raw.toString("utf8");
|
||||
const rel = pathMod.relative(rootDir, full);
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li]!;
|
||||
const lineForCheck = caseSensitive ? line : line.toLowerCase();
|
||||
const hit = re ? re.test(line) : lineForCheck.includes(needle);
|
||||
if (!hit) continue;
|
||||
// Truncate very long lines so one giant minified file
|
||||
// doesn't blow the budget on a single match.
|
||||
const display = line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
||||
const out = `${rel}:${li + 1}: ${display}`;
|
||||
if (totalBytes + out.length + 1 > maxListBytes) {
|
||||
matches.push(`[… truncated at ${maxListBytes} bytes — refine pattern or path …]`);
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
matches.push(out);
|
||||
totalBytes += out.length + 1;
|
||||
}
|
||||
scanned++;
|
||||
}
|
||||
};
|
||||
await walk(startAbs);
|
||||
if (matches.length === 0) {
|
||||
return scanned === 0
|
||||
? "(no files scanned — path empty or all files filtered out)"
|
||||
: `(no matches across ${scanned} file${scanned === 1 ? "" : "s"})`;
|
||||
}
|
||||
return matches.join("\n");
|
||||
},
|
||||
}) =>
|
||||
searchContent(
|
||||
{
|
||||
rootDir,
|
||||
maxListBytes,
|
||||
skipDirNames: SKIP_DIR_NAMES,
|
||||
isBinaryByName: isLikelyBinaryByName,
|
||||
nameMatch: compileNameFilter(typeof args.glob === "string" ? args.glob : null),
|
||||
},
|
||||
safePath(args.path ?? "."),
|
||||
args,
|
||||
),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "get_file_info",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
|
||||
readOnly: true,
|
||||
@@ -510,7 +440,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
const abs = safePath(args.path);
|
||||
await fs.mkdir(pathMod.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, args.content, "utf8");
|
||||
return `wrote ${args.content.length} chars to ${pathMod.relative(rootDir, abs)}`;
|
||||
return `wrote ${args.content.length} chars to ${displayRel(rootDir, abs)}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -527,34 +457,8 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
},
|
||||
required: ["path", "search", "replace"],
|
||||
},
|
||||
fn: async (args: { path: string; search: string; replace: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
const before = await fs.readFile(abs, "utf8");
|
||||
if (args.search.length === 0) {
|
||||
throw new Error("edit_file: search cannot be empty");
|
||||
}
|
||||
const le = before.includes("\r\n") ? "\r\n" : "\n";
|
||||
const adaptedSearch = args.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = args.replace.replace(/\r?\n/g, le);
|
||||
const firstIdx = before.indexOf(adaptedSearch);
|
||||
if (firstIdx < 0) {
|
||||
throw new Error(`edit_file: search text not found in ${pathMod.relative(rootDir, abs)}`);
|
||||
}
|
||||
const nextIdx = before.indexOf(adaptedSearch, firstIdx + 1);
|
||||
if (nextIdx >= 0) {
|
||||
throw new Error(
|
||||
`edit_file: search text appears multiple times in ${pathMod.relative(rootDir, abs)} — include more context to disambiguate`,
|
||||
);
|
||||
}
|
||||
const after =
|
||||
before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
|
||||
await fs.writeFile(abs, after, "utf8");
|
||||
const rel = pathMod.relative(rootDir, abs);
|
||||
const header = `edited ${rel} (${adaptedSearch.length}→${adaptedReplace.length} chars)`;
|
||||
const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
|
||||
const diff = renderEditDiff(adaptedSearch, adaptedReplace, startLine);
|
||||
return `${header}\n${diff}`;
|
||||
},
|
||||
fn: async (args: { path: string; search: string; replace: string }) =>
|
||||
applyEdit(rootDir, safePath(args.path), args),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
@@ -568,7 +472,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
fn: async (args: { path: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
await fs.mkdir(abs, { recursive: true });
|
||||
return `created ${pathMod.relative(rootDir, abs)}/`;
|
||||
return `created ${displayRel(rootDir, abs)}/`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -588,64 +492,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
const dst = safePath(args.destination);
|
||||
await fs.mkdir(pathMod.dirname(dst), { recursive: true });
|
||||
await fs.rename(src, dst);
|
||||
return `moved ${pathMod.relative(rootDir, src)} → ${pathMod.relative(rootDir, dst)}`;
|
||||
return `moved ${displayRel(rootDir, src)} → ${displayRel(rootDir, dst)}`;
|
||||
},
|
||||
});
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
function renderEditDiff(search: string, replace: string, startLine: number): string {
|
||||
const a = search.split(/\r?\n/);
|
||||
const b = replace.split(/\r?\n/);
|
||||
const diff = lineDiff(a, b);
|
||||
const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
|
||||
const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
|
||||
return `${hunk}\n${body}`;
|
||||
}
|
||||
|
||||
export function lineDiff(
|
||||
a: readonly string[],
|
||||
b: readonly string[],
|
||||
): Array<{ op: "-" | "+" | " "; line: string }> {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
// dp[i][j] = LCS length of a[0..i) and b[0..j).
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = 1; i <= n; i++) {
|
||||
for (let j = 1; j <= m; j++) {
|
||||
if (a[i - 1] === b[j - 1]) dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
||||
else dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
||||
}
|
||||
}
|
||||
// Backtrack to recover the op sequence.
|
||||
const out: Array<{ op: "-" | "+" | " "; line: string }> = [];
|
||||
let i = n;
|
||||
let j = m;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
out.unshift({ op: " ", line: a[i - 1]! });
|
||||
i--;
|
||||
j--;
|
||||
} else if ((dp[i - 1]![j] ?? 0) > (dp[i]![j - 1] ?? 0)) {
|
||||
out.unshift({ op: "-", line: a[i - 1]! });
|
||||
i--;
|
||||
} else {
|
||||
// Tie-break goes here (strictly less or equal): take the
|
||||
// insertion first during backtrack so the final forward order
|
||||
// renders removals BEFORE additions for a substitution —
|
||||
// matches git-diff convention of `- old / + new`.
|
||||
out.unshift({ op: "+", line: b[j - 1]! });
|
||||
j--;
|
||||
}
|
||||
}
|
||||
while (i > 0) {
|
||||
out.unshift({ op: "-", line: a[i - 1]! });
|
||||
i--;
|
||||
}
|
||||
while (j > 0) {
|
||||
out.unshift({ op: "+", line: b[j - 1]! });
|
||||
j--;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
|
||||
function displayRel(rootDir: string, full: string): string {
|
||||
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
export async function applyEdit(
|
||||
rootDir: string,
|
||||
abs: string,
|
||||
args: { search: string; replace: string },
|
||||
): Promise<string> {
|
||||
if (args.search.length === 0) {
|
||||
throw new Error("edit_file: search cannot be empty");
|
||||
}
|
||||
const before = await fs.readFile(abs, "utf8");
|
||||
const le = before.includes("\r\n") ? "\r\n" : "\n";
|
||||
const adaptedSearch = args.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = args.replace.replace(/\r?\n/g, le);
|
||||
const firstIdx = before.indexOf(adaptedSearch);
|
||||
if (firstIdx < 0) {
|
||||
throw new Error(`edit_file: search text not found in ${displayRel(rootDir, abs)}`);
|
||||
}
|
||||
const nextIdx = before.indexOf(adaptedSearch, firstIdx + 1);
|
||||
if (nextIdx >= 0) {
|
||||
throw new Error(
|
||||
`edit_file: search text appears multiple times in ${displayRel(rootDir, abs)} — include more context to disambiguate`,
|
||||
);
|
||||
}
|
||||
const after =
|
||||
before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
|
||||
await fs.writeFile(abs, after, "utf8");
|
||||
const rel = displayRel(rootDir, abs);
|
||||
const header = `edited ${rel} (${adaptedSearch.length}→${adaptedReplace.length} chars)`;
|
||||
const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
|
||||
const diff = renderEditDiff(adaptedSearch, adaptedReplace, startLine);
|
||||
return `${header}\n${diff}`;
|
||||
}
|
||||
|
||||
function renderEditDiff(search: string, replace: string, startLine: number): string {
|
||||
const a = search.split(/\r?\n/);
|
||||
const b = replace.split(/\r?\n/);
|
||||
const diff = lineDiff(a, b);
|
||||
const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
|
||||
const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
|
||||
return `${hunk}\n${body}`;
|
||||
}
|
||||
|
||||
export function lineDiff(
|
||||
a: readonly string[],
|
||||
b: readonly string[],
|
||||
): Array<{ op: "-" | "+" | " "; line: string }> {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
// dp[i][j] = LCS length of a[0..i) and b[0..j).
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = 1; i <= n; i++) {
|
||||
for (let j = 1; j <= m; j++) {
|
||||
if (a[i - 1] === b[j - 1]) dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
||||
else dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
||||
}
|
||||
}
|
||||
// Backtrack to recover the op sequence.
|
||||
const out: Array<{ op: "-" | "+" | " "; line: string }> = [];
|
||||
let i = n;
|
||||
let j = m;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
out.unshift({ op: " ", line: a[i - 1]! });
|
||||
i--;
|
||||
j--;
|
||||
} else if ((dp[i - 1]![j] ?? 0) > (dp[i]![j - 1] ?? 0)) {
|
||||
out.unshift({ op: "-", line: a[i - 1]! });
|
||||
i--;
|
||||
} else {
|
||||
// Tie-break goes here (strictly less or equal): take the
|
||||
// insertion first during backtrack so the final forward order
|
||||
// renders removals BEFORE additions for a substitution —
|
||||
// matches git-diff convention of `- old / + new`.
|
||||
out.unshift({ op: "+", line: b[j - 1]! });
|
||||
j--;
|
||||
}
|
||||
}
|
||||
while (i > 0) {
|
||||
out.unshift({ op: "-", line: a[i - 1]! });
|
||||
i--;
|
||||
}
|
||||
while (j > 0) {
|
||||
out.unshift({ op: "+", line: b[j - 1]! });
|
||||
j--;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
|
||||
export interface SearchContext {
|
||||
rootDir: string;
|
||||
maxListBytes: number;
|
||||
skipDirNames: ReadonlySet<string>;
|
||||
isBinaryByName: (name: string) => boolean;
|
||||
/** Pre-baked filename→regex/substring matcher; null when no glob filter. */
|
||||
nameMatch: ((name: string, rel: string) => boolean) | null;
|
||||
}
|
||||
|
||||
function displayRel(rootDir: string, full: string): string {
|
||||
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
export async function searchFiles(
|
||||
ctx: Pick<SearchContext, "rootDir" | "maxListBytes" | "skipDirNames">,
|
||||
startAbs: string,
|
||||
args: { pattern: string; include_deps?: boolean },
|
||||
): Promise<string> {
|
||||
const needle = args.pattern.toLowerCase();
|
||||
const includeDeps = args.include_deps === true;
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(args.pattern, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = pathMod.join(dir, e.name);
|
||||
const lower = e.name.toLowerCase();
|
||||
const hit = re ? re.test(e.name) : lower.includes(needle);
|
||||
if (hit) {
|
||||
const rel = displayRel(ctx.rootDir, full);
|
||||
if (totalBytes + rel.length + 1 > ctx.maxListBytes) {
|
||||
matches.push("[… search truncated — refine pattern …]");
|
||||
return;
|
||||
}
|
||||
matches.push(rel);
|
||||
totalBytes += rel.length + 1;
|
||||
}
|
||||
if (e.isDirectory()) {
|
||||
if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
|
||||
await walk(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(startAbs);
|
||||
return matches.length === 0 ? "(no matches)" : matches.join("\n");
|
||||
}
|
||||
|
||||
export async function searchContent(
|
||||
ctx: SearchContext,
|
||||
startAbs: string,
|
||||
args: {
|
||||
pattern: string;
|
||||
case_sensitive?: boolean;
|
||||
include_deps?: boolean;
|
||||
},
|
||||
): Promise<string> {
|
||||
const caseSensitive = args.case_sensitive === true;
|
||||
const includeDeps = args.include_deps === true;
|
||||
// Try the pattern as a regex first (lets the model say `\bdispatch\(`
|
||||
// for a word-bounded match); fall back to literal substring on
|
||||
// invalid regex. No `g` flag — we test once per line, so global
|
||||
// statefulness (lastIndex tracking) would just be noise.
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(args.pattern, caseSensitive ? "" : "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const needle = caseSensitive ? args.pattern : args.pattern.toLowerCase();
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let scanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
if (truncated) return;
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (truncated) return;
|
||||
if (e.isDirectory()) {
|
||||
if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
|
||||
await walk(pathMod.join(dir, e.name));
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
const full = pathMod.join(dir, e.name);
|
||||
if (ctx.nameMatch && !ctx.nameMatch(e.name, displayRel(ctx.rootDir, full))) continue;
|
||||
if (ctx.isBinaryByName(e.name)) continue;
|
||||
// Open once and reuse the fd so the size check and read bind to the
|
||||
// same inode — avoids the stat→readFile TOCTOU race CodeQL flags.
|
||||
let fh: import("node:fs/promises").FileHandle;
|
||||
try {
|
||||
fh = await fs.open(full, "r");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let raw: Buffer;
|
||||
try {
|
||||
const st = await fh.stat();
|
||||
// Per-file size cap so a 50MB log doesn't dominate the search.
|
||||
// Anything legitimately interesting fits in 2 MB; bigger files
|
||||
// are usually data dumps or generated bundles.
|
||||
if (st.size > 2 * 1024 * 1024) {
|
||||
await fh.close();
|
||||
continue;
|
||||
}
|
||||
raw = await fh.readFile();
|
||||
} catch {
|
||||
await fh.close().catch(() => {});
|
||||
continue;
|
||||
}
|
||||
await fh.close();
|
||||
// Content-based binary sniff: NUL byte in the first 8KB. Catches
|
||||
// binaries with .json or .txt extensions (yes, this happens).
|
||||
const firstNul = raw.indexOf(0);
|
||||
if (firstNul !== -1 && firstNul < 8 * 1024) continue;
|
||||
const text = raw.toString("utf8");
|
||||
const rel = displayRel(ctx.rootDir, full);
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li]!;
|
||||
const lineForCheck = caseSensitive ? line : line.toLowerCase();
|
||||
const hit = re ? re.test(line) : lineForCheck.includes(needle);
|
||||
if (!hit) continue;
|
||||
const display = line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
||||
const out = `${rel}:${li + 1}: ${display}`;
|
||||
if (totalBytes + out.length + 1 > ctx.maxListBytes) {
|
||||
matches.push(`[… truncated at ${ctx.maxListBytes} bytes — refine pattern or path …]`);
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
matches.push(out);
|
||||
totalBytes += out.length + 1;
|
||||
}
|
||||
scanned++;
|
||||
}
|
||||
};
|
||||
await walk(startAbs);
|
||||
if (matches.length === 0) {
|
||||
return scanned === 0
|
||||
? "(no files scanned — path empty or all files filtered out)"
|
||||
: `(no matches across ${scanned} file${scanned === 1 ? "" : "s"})`;
|
||||
}
|
||||
return matches.join("\n");
|
||||
}
|
||||
+21
-6
@@ -160,6 +160,8 @@ export class JobRegistry {
|
||||
child: null,
|
||||
readyPromise: Promise.resolve(),
|
||||
signalReady: () => {},
|
||||
closedPromise: Promise.resolve(),
|
||||
signalClosed: () => {},
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
return {
|
||||
@@ -177,6 +179,10 @@ export class JobRegistry {
|
||||
const readyPromise = new Promise<void>((res) => {
|
||||
readyResolve = res;
|
||||
});
|
||||
let closedResolve: () => void = () => {};
|
||||
const closedPromise = new Promise<void>((res) => {
|
||||
closedResolve = res;
|
||||
});
|
||||
const job: InternalJob = {
|
||||
id,
|
||||
command: trimmed,
|
||||
@@ -189,6 +195,8 @@ export class JobRegistry {
|
||||
child,
|
||||
readyPromise,
|
||||
signalReady: readyResolve,
|
||||
closedPromise,
|
||||
signalClosed: closedResolve,
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
|
||||
@@ -232,11 +240,13 @@ export class JobRegistry {
|
||||
job.running = false;
|
||||
job.spawnError = err.message;
|
||||
job.signalReady();
|
||||
job.signalClosed();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
job.running = false;
|
||||
job.exitCode = code;
|
||||
job.signalReady();
|
||||
job.signalClosed();
|
||||
});
|
||||
|
||||
const onAbort = () => this.stop(id, { graceMs: 100 });
|
||||
@@ -308,8 +318,10 @@ export class JobRegistry {
|
||||
/* already dead — fall through */
|
||||
}
|
||||
}
|
||||
// Wait for the close event or graceMs, then SIGKILL.
|
||||
await Promise.race([job.readyPromise, new Promise<void>((res) => setTimeout(res, graceMs))]);
|
||||
// closedPromise (not readyPromise) — readyPromise can have fired at
|
||||
// startup on a ready-signal regex match, which would short-circuit
|
||||
// this race even though the process is still alive.
|
||||
await Promise.race([job.closedPromise, new Promise<void>((res) => setTimeout(res, graceMs))]);
|
||||
if (job.running) {
|
||||
if (job.pid !== null) {
|
||||
killProcessTree(job.pid, "SIGKILL");
|
||||
@@ -320,10 +332,10 @@ export class JobRegistry {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Give the OS a moment to reap the tree so our exitCode field
|
||||
// catches it before we return. Windows taskkill can take up to
|
||||
// ~700ms to propagate on a three-level tree (npm → node → vite).
|
||||
await new Promise<void>((res) => setTimeout(res, 800));
|
||||
// Wait for the actual close handler — a fixed timer can return
|
||||
// before Node's `close` event fires under load (Windows taskkill
|
||||
// /T /F on a three-level tree can take ~1s to propagate).
|
||||
await Promise.race([job.closedPromise, new Promise<void>((res) => setTimeout(res, 5000))]);
|
||||
}
|
||||
return snapshot(job);
|
||||
}
|
||||
@@ -387,6 +399,9 @@ interface InternalJob extends JobRecord {
|
||||
readyPromise: Promise<void>;
|
||||
/** Fires readyPromise — called by ready-signal OR close/error handlers. */
|
||||
signalReady: () => void;
|
||||
/** Resolves only on close/error — never on ready-signal. Used by stop() to wait for actual exit. */
|
||||
closedPromise: Promise<void>;
|
||||
signalClosed: () => void;
|
||||
}
|
||||
|
||||
export interface JobReadResult {
|
||||
|
||||
@@ -132,6 +132,7 @@ export function registerMemoryTools(
|
||||
description:
|
||||
"Read the full body of a memory file when its MEMORY.md one-liner (already in the system prompt) isn't enough detail. Most of the time the index suffices — only call this when the user's question genuinely requires the full context.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { type ChildProcess, type SpawnOptions, spawn } from "node:child_process";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { killProcessTree, prepareSpawn, smartDecodeOutput } from "./shell.js";
|
||||
import { isDqEscape, killProcessTree, prepareSpawn, smartDecodeOutput } from "./shell.js";
|
||||
|
||||
export type ChainOp = "|" | "||" | "&&" | ";";
|
||||
|
||||
@@ -45,7 +45,7 @@ function splitOnChainOps(cmd: string): { segs: string[]; ops: ChainOp[] } {
|
||||
const ch = cmd[i]!;
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else if (ch === "\\" && quote === '"' && i + 1 < cmd.length) i++;
|
||||
else if (quote === '"' && isDqEscape(ch, cmd[i + 1])) i++;
|
||||
i++;
|
||||
atTokenStart = false;
|
||||
continue;
|
||||
@@ -119,7 +119,7 @@ function parseSegment(segStr: string): ChainSegment {
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (ch === "\\" && quote === '"' && i + 1 < segStr.length) {
|
||||
} else if (quote === '"' && isDqEscape(ch, segStr[i + 1])) {
|
||||
cur += segStr[++i] ?? "";
|
||||
curHasContent = true;
|
||||
} else {
|
||||
@@ -233,6 +233,18 @@ export function parseCommandChain(cmd: string): CommandChain | null {
|
||||
}
|
||||
segments.push(parseSegment(trimmed));
|
||||
}
|
||||
// Reject `cd` inside parsed chains — the executor cannot carry cwd
|
||||
// changes between segments, and silently running the wrong directory
|
||||
// is worse than rejecting early with clear guidance.
|
||||
for (const seg of segments) {
|
||||
const cmdName = seg.argv[0] ?? "";
|
||||
if (cmdName.toLowerCase() === "cd") {
|
||||
throw new UnsupportedSyntaxError(
|
||||
"cd in parsed command chains does not change cwd for later segments. Use a command-native cwd flag instead, such as `npm --prefix <dir> run <script>`, `git -C <dir> ...`, or `cargo -C <dir> ...`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (ops.length === 0 && segments[0]!.redirects.length === 0) return null;
|
||||
return { segments, ops };
|
||||
}
|
||||
|
||||
+28
-564
@@ -1,46 +1,37 @@
|
||||
/** cwd pinned to root; non-allowlisted commands throw to a UI confirm gate; spawn is `shell: false`, tokenized argv only. */
|
||||
|
||||
import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { addProjectShellAllowed } from "../config.js";
|
||||
import { pauseGate } from "../core/pause-gate.js";
|
||||
import type { ToolRegistry } from "../tools.js";
|
||||
import { JobRegistry } from "./jobs.js";
|
||||
import {
|
||||
type CommandChain,
|
||||
UnsupportedSyntaxError,
|
||||
chainAllowed,
|
||||
parseCommandChain,
|
||||
runChain,
|
||||
} from "./shell-chain.js";
|
||||
DEFAULT_MAX_OUTPUT_CHARS,
|
||||
DEFAULT_TIMEOUT_SEC,
|
||||
type RunCommandResult,
|
||||
runCommand,
|
||||
} from "./shell/exec.js";
|
||||
import { isCommandAllowed } from "./shell/parse.js";
|
||||
|
||||
/** Kill child + descendants. Windows: taskkill /T /F. Unix: SIGKILL the process group when detached, else fall back to SIGKILL on the leader. */
|
||||
export function killProcessTree(child: ChildProcess): void {
|
||||
if (!child.pid || child.killed) return;
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
/* fall through to SIGKILL */
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
return;
|
||||
} catch {
|
||||
/* not a process group leader — fall through */
|
||||
}
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
export {
|
||||
BUILTIN_ALLOWLIST,
|
||||
detectShellOperator,
|
||||
isAllowed,
|
||||
isCommandAllowed,
|
||||
isDqEscape,
|
||||
tokenizeCommand,
|
||||
} from "./shell/parse.js";
|
||||
export type { ResolveExecutableOptions, RunCommandResult } from "./shell/exec.js";
|
||||
export {
|
||||
injectPowerShellUtf8,
|
||||
killProcessTree,
|
||||
prepareSpawn,
|
||||
quoteForCmdExe,
|
||||
resolveExecutable,
|
||||
runCommand,
|
||||
smartDecodeOutput,
|
||||
withUtf8Codepage,
|
||||
} from "./shell/exec.js";
|
||||
|
||||
export interface ShellToolsOptions {
|
||||
/** Directory to run commands in. Must be an absolute path. */
|
||||
@@ -55,535 +46,6 @@ export interface ShellToolsOptions {
|
||||
jobs?: JobRegistry;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_SEC = 60;
|
||||
const DEFAULT_MAX_OUTPUT_CHARS = 32_000;
|
||||
|
||||
/** Read-only reports + test runners whose failure mode is "exit 1 with output". */
|
||||
export const BUILTIN_ALLOWLIST: ReadonlyArray<string> = [
|
||||
// Repo inspection
|
||||
"git status",
|
||||
"git diff",
|
||||
"git log",
|
||||
"git show",
|
||||
"git blame",
|
||||
"git branch",
|
||||
"git remote",
|
||||
"git rev-parse",
|
||||
"git config --get",
|
||||
// Filesystem inspection
|
||||
"ls",
|
||||
"pwd",
|
||||
"cat",
|
||||
"head",
|
||||
"tail",
|
||||
"wc",
|
||||
"file",
|
||||
"tree",
|
||||
"find",
|
||||
"grep",
|
||||
"rg",
|
||||
// Language version probes
|
||||
"node --version",
|
||||
"node -v",
|
||||
"npm --version",
|
||||
"npx --version",
|
||||
"python --version",
|
||||
"python3 --version",
|
||||
"cargo --version",
|
||||
"go version",
|
||||
"rustc --version",
|
||||
"deno --version",
|
||||
"bun --version",
|
||||
// Test runners (non-destructive by convention)
|
||||
"npm test",
|
||||
"npm run test",
|
||||
"npx vitest run",
|
||||
"npx vitest",
|
||||
"npx jest",
|
||||
"pytest",
|
||||
"python -m pytest",
|
||||
"cargo test",
|
||||
"cargo check",
|
||||
"cargo clippy",
|
||||
"go test",
|
||||
"go vet",
|
||||
"deno test",
|
||||
"bun test",
|
||||
// Linters / typecheckers (read-only by convention)
|
||||
"npm run lint",
|
||||
"npm run typecheck",
|
||||
"npx tsc --noEmit",
|
||||
"npx biome check",
|
||||
"npx eslint",
|
||||
"npx prettier --check",
|
||||
"ruff",
|
||||
"mypy",
|
||||
];
|
||||
|
||||
/** No env / glob / backtick / `$(…)` expansion — prevents bypass of allowlist via concatenation. */
|
||||
export function tokenizeCommand(cmd: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const ch = cmd[i]!;
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
|
||||
cur += cmd[++i];
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === " " || ch === "\t") {
|
||||
if (cur.length > 0) {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (quote) throw new Error(`unclosed ${quote} in command`);
|
||||
if (cur.length > 0) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Up-front detection — without it, `dir | findstr foo` quotes `|` literal and pipe silently fails. */
|
||||
export function detectShellOperator(cmd: string): string | null {
|
||||
const opPrefix = /^(?:2>&1|&>|\|{1,2}|&{1,2}|2>{1,2}|>{1,2}|<{1,2})/;
|
||||
let cur = "";
|
||||
let curQuoted = false;
|
||||
let quote: '"' | "'" | null = null;
|
||||
const check = (): string | null => {
|
||||
if (cur.length === 0 && !curQuoted) return null;
|
||||
if (!curQuoted) {
|
||||
const m = opPrefix.exec(cur);
|
||||
if (m) return m[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const ch = cmd[i]!;
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
|
||||
cur += cmd[++i];
|
||||
curQuoted = true;
|
||||
} else {
|
||||
cur += ch;
|
||||
curQuoted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
curQuoted = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === " " || ch === "\t") {
|
||||
const op = check();
|
||||
if (op) return op;
|
||||
cur = "";
|
||||
curQuoted = false;
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (quote) return null; // let tokenizeCommand throw the unclosed-quote error
|
||||
return check();
|
||||
}
|
||||
|
||||
/** Per-prefix demotion: an otherwise-allowlisted match falls back to the confirm gate when one of these tokens appears in the tail. Issue #257: `git branch -D` skipped review. Each token also matches its `--flag=value` form. */
|
||||
const RISKY_ARGS: Readonly<Record<string, ReadonlyArray<string>>> = {
|
||||
// Branch / remote mutation
|
||||
"git branch": ["-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy", "--force"],
|
||||
"git remote": ["add", "remove", "rm", "rename", "set-url", "set-head", "prune"],
|
||||
// `--output` writes to an arbitrary path; `--ext-diff` invokes user-config'd external programs.
|
||||
"git diff": ["--output", "--ext-diff"],
|
||||
"git log": ["--output"],
|
||||
"git show": ["--output"],
|
||||
// `-exec*` / `-ok*` are RCE; `-delete` and `-fprint*` / `-fls` write to arbitrary paths.
|
||||
find: [
|
||||
"-delete",
|
||||
"-exec",
|
||||
"-execdir",
|
||||
"-ok",
|
||||
"-okdir",
|
||||
"-fprint",
|
||||
"-fprint0",
|
||||
"-fprintf",
|
||||
"-fls",
|
||||
],
|
||||
// `-o FILE` writes the tree to an arbitrary path.
|
||||
tree: ["-o"],
|
||||
// Auto-fix mutates source files.
|
||||
"npx eslint": ["--fix", "--fix-dry-run"],
|
||||
"npx biome check": ["--write", "--apply", "--apply-unsafe"],
|
||||
ruff: ["--fix", "--unsafe-fixes", "format"],
|
||||
};
|
||||
|
||||
function tailHasRisky(tail: readonly string[], risky: readonly string[]): boolean {
|
||||
for (const a of tail) {
|
||||
for (const r of risky) {
|
||||
if (a === r) return true;
|
||||
if (a.startsWith(`${r}=`)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Allowlist match on leading argv tokens; demoted by `RISKY_ARGS` when a destructive flag appears in the tail. */
|
||||
export function isAllowed(cmd: string, extra: readonly string[] = []): boolean {
|
||||
let argv: string[];
|
||||
try {
|
||||
argv = tokenizeCommand(cmd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (argv.length === 0) return false;
|
||||
|
||||
const allowlist = [...BUILTIN_ALLOWLIST, ...extra];
|
||||
for (const prefix of allowlist) {
|
||||
const prefixTokens = prefix.split(" ");
|
||||
if (argv.length < prefixTokens.length) continue;
|
||||
let match = true;
|
||||
for (let i = 0; i < prefixTokens.length; i++) {
|
||||
if (argv[i] !== prefixTokens[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
const risky = RISKY_ARGS[prefix];
|
||||
if (risky && tailHasRisky(argv.slice(prefixTokens.length), risky)) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** For chain commands, every segment must individually clear the allowlist. */
|
||||
export function isCommandAllowed(cmd: string, extra: readonly string[] = []): boolean {
|
||||
let chain: CommandChain | null;
|
||||
try {
|
||||
chain = parseCommandChain(cmd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (chain === null) return isAllowed(cmd, extra);
|
||||
return chainAllowed(chain, (seg) => isAllowed(seg, extra));
|
||||
}
|
||||
|
||||
export interface RunCommandResult {
|
||||
exitCode: number | null;
|
||||
/** Combined stdout+stderr, truncated to `maxOutputChars` with a marker. */
|
||||
output: string;
|
||||
/** True when the process was killed for exceeding `timeoutSec`. */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
cmd: string,
|
||||
opts: {
|
||||
cwd: string;
|
||||
timeoutSec?: number;
|
||||
maxOutputChars?: number;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<RunCommandResult> {
|
||||
const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
|
||||
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
||||
const argv = tokenizeCommand(cmd);
|
||||
if (argv.length === 0) throw new Error("run_command: empty command");
|
||||
const chain = parseCommandChain(cmd);
|
||||
if (chain !== null) {
|
||||
return await runChain(chain, {
|
||||
cwd: opts.cwd,
|
||||
timeoutSec,
|
||||
maxOutputChars: maxChars,
|
||||
signal: opts.signal,
|
||||
});
|
||||
}
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: opts.cwd,
|
||||
shell: false, // no shell-expansion — see header comment
|
||||
windowsHide: true,
|
||||
// PYTHONIOENCODING + PYTHONUTF8 force any spawned Python child
|
||||
// (run_command running `python script.py`, etc.) to emit UTF-8
|
||||
// on stdout/stderr. Without this, Chinese-Windows defaults
|
||||
// Python's stdout encoder to GBK and `print("…")` raises
|
||||
// UnicodeEncodeError on emoji / non-GBK chars — the model then
|
||||
// sees a Python traceback instead of the script's real output
|
||||
// and goes around in circles trying to fix the wrong problem.
|
||||
// Harmless on non-Python processes (env vars they don't read).
|
||||
env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" },
|
||||
};
|
||||
|
||||
// Windows: two layered fixes on top of shell:false —
|
||||
// 1. Resolve bare command names via PATH × PATHEXT (CreateProcess
|
||||
// ignores PATHEXT, so `npm` alone misses `npm.cmd`).
|
||||
// 2. Node 21.7.3+ (CVE-2024-27980) refuses to spawn `.cmd`/`.bat`
|
||||
// directly even with shell:false and safe args — throws
|
||||
// EINVAL at invocation time. Wrap those via `cmd.exe /d /s /c`
|
||||
// with verbatim args + manual quoting, so shell metacharacters
|
||||
// in arguments stay literal.
|
||||
// Unix path is unchanged.
|
||||
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
||||
const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
|
||||
|
||||
return await new Promise<RunCommandResult>((resolve, reject) => {
|
||||
let child: import("node:child_process").ChildProcess;
|
||||
try {
|
||||
child = spawn(bin, args, effectiveSpawnOpts);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
// Collect raw Buffer chunks rather than decoding incrementally —
|
||||
// a multi-byte sequence can land split across chunks, and a naïve
|
||||
// chunk.toString() corrupts it before the second half arrives.
|
||||
// We decode once at close time, where smartDecodeOutput can also
|
||||
// sniff non-UTF-8 codepages cleanly. The byte cap mirrors the
|
||||
// prior char cap (2× maxChars worth) so a chatty process can't
|
||||
// OOM us.
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
const byteCap = maxChars * 2 * 4; // worst-case 4 bytes/char for utf-8/gbk
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
const killChildTree = () => killProcessTree(child);
|
||||
const killTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChildTree();
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
killChildTree();
|
||||
};
|
||||
// Check synchronously first — if the signal aborted before listener attach
|
||||
// (parent loop was already cancelled), addEventListener with `once:true`
|
||||
// never fires, child runs unbounded.
|
||||
if (opts.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
if (totalBytes >= byteCap) return;
|
||||
const remaining = byteCap - totalBytes;
|
||||
if (b.length > remaining) {
|
||||
chunks.push(b.subarray(0, remaining));
|
||||
totalBytes = byteCap;
|
||||
} else {
|
||||
chunks.push(b);
|
||||
totalBytes += b.length;
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(killTimer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(killTimer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
const merged = Buffer.concat(chunks);
|
||||
const buf = smartDecodeOutput(merged);
|
||||
const output =
|
||||
buf.length > maxChars
|
||||
? `${buf.slice(0, maxChars)}\n\n[… truncated ${buf.length - maxChars} chars …]`
|
||||
: buf;
|
||||
resolve({ exitCode: code, output, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** GBK fallback on Windows — cmd.exe's localized error DLL and native EXE stderr ignore chcp 65001. */
|
||||
export function smartDecodeOutput(buf: Buffer): string {
|
||||
if (buf.length === 0) return "";
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
||||
} catch {
|
||||
// Fall through to platform-specific fallback.
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
// TextDecoder supports gbk / gb18030 in Node 18+ via the WHATWG
|
||||
// Encoding spec. gb18030 is the modern superset; falling back
|
||||
// to it covers GBK byte sequences plus the rare 4-byte CJK
|
||||
// characters that appear in newer system messages.
|
||||
return new TextDecoder("gb18030").decode(buf);
|
||||
} catch {
|
||||
// Decoder unavailable in this build — fall through.
|
||||
}
|
||||
}
|
||||
// Last resort: lossy UTF-8 with replacement chars. The model still
|
||||
// gets "something happened" with the structural exit-code marker
|
||||
// intact, which is more useful than throwing away the entire output.
|
||||
return buf.toString("utf8");
|
||||
}
|
||||
|
||||
export interface ResolveExecutableOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: { PATH?: string; PATHEXT?: string };
|
||||
isFile?: (path: string) => boolean;
|
||||
pathDelimiter?: string;
|
||||
}
|
||||
|
||||
/** CreateProcess ignores PATHEXT — bare `npm` fails ENOENT under `shell:false` without this resolver. */
|
||||
export function resolveExecutable(cmd: string, opts: ResolveExecutableOptions = {}): string {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform !== "win32") return cmd;
|
||||
if (!cmd) return cmd;
|
||||
// Already a path fragment — spawn handles these natively.
|
||||
if (cmd.includes("/") || cmd.includes("\\") || pathMod.isAbsolute(cmd)) return cmd;
|
||||
// If the model wrote `npm.cmd` explicitly, respect that verbatim.
|
||||
if (pathMod.extname(cmd)) return cmd;
|
||||
|
||||
const env = opts.env ?? process.env;
|
||||
const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
const delimiter = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod.delimiter);
|
||||
const pathDirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
||||
const isFile = opts.isFile ?? defaultIsFile;
|
||||
|
||||
for (const dir of pathDirs) {
|
||||
for (const ext of pathExt) {
|
||||
// Force win32 join so CI tests that pass `platform: "win32"`
|
||||
// from a Linux runner get backslash-joined paths; the real-
|
||||
// Windows runtime path lands here too and gets the correct
|
||||
// separator regardless of where pathMod defaults.
|
||||
const full = pathMod.win32.join(dir, cmd + ext);
|
||||
if (isFile(full)) return full;
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function defaultIsFile(full: string): boolean {
|
||||
try {
|
||||
return existsSync(full) && statSync(full).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Windows workarounds: PATHEXT lookup + CVE-2024-27980 prohibition on direct `.cmd`/`.bat` spawn. */
|
||||
export function prepareSpawn(
|
||||
argv: readonly string[],
|
||||
opts: ResolveExecutableOptions = {},
|
||||
): { bin: string; args: string[]; spawnOverrides: SpawnOptions } {
|
||||
const head = argv[0] ?? "";
|
||||
const tail = argv.slice(1);
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const resolved = resolveExecutable(head, opts);
|
||||
|
||||
if (platform !== "win32") {
|
||||
return { bin: resolved, args: [...tail], spawnOverrides: {} };
|
||||
}
|
||||
|
||||
// `.cmd` / `.bat` wrappers require cmd.exe on post-CVE Node.
|
||||
if (/\.(cmd|bat)$/i.test(resolved)) {
|
||||
const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
|
||||
return {
|
||||
bin: "cmd.exe",
|
||||
args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
|
||||
// windowsVerbatimArguments prevents Node from re-quoting the /c
|
||||
// payload — we've already composed an exact cmd.exe command
|
||||
// line. Without this Node wraps our already-quoted string in
|
||||
// another round of quotes and cmd.exe can't parse it.
|
||||
spawnOverrides: { windowsVerbatimArguments: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Bare command names that PATH × PATHEXT couldn't resolve to an
|
||||
// on-disk file — these are almost always cmd.exe built-ins (`dir`,
|
||||
// `echo`, `type`, `ver`, `vol`, `where`, `help`, …) which don't
|
||||
// exist as standalone executables. Direct spawn crashes with ENOENT;
|
||||
// routing through cmd.exe lets the built-in resolve, and if it's
|
||||
// genuinely unknown the user gets the standard "'foo' is not
|
||||
// recognized" message instead of a raw spawn failure.
|
||||
if (isBareWindowsName(resolved) && resolved === head) {
|
||||
const cmdline = [head, ...tail].map(quoteForCmdExe).join(" ");
|
||||
return {
|
||||
bin: "cmd.exe",
|
||||
args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
|
||||
spawnOverrides: { windowsVerbatimArguments: true },
|
||||
};
|
||||
}
|
||||
|
||||
// PowerShell variants: chcp 65001 doesn't help here because PowerShell
|
||||
// sets its own [Console]::OutputEncoding at startup — usually system
|
||||
// codepage (CP936/CP932/CP949 on CJK Windows) or UTF-16. The result
|
||||
// is mojibake when our `chunk.toString()` UTF-8-decodes its stdout.
|
||||
// Inject a UTF-8 setup prelude into the `-Command` (or `-c`) arg so
|
||||
// any output produced thereafter is UTF-8.
|
||||
if (isPowerShellExe(resolved)) {
|
||||
const patched = injectPowerShellUtf8(tail);
|
||||
if (patched) {
|
||||
return { bin: resolved, args: patched, spawnOverrides: {} };
|
||||
}
|
||||
}
|
||||
|
||||
return { bin: resolved, args: [...tail], spawnOverrides: {} };
|
||||
}
|
||||
|
||||
/** Resolved bin path looks like Windows PowerShell or PowerShell Core. */
|
||||
function isPowerShellExe(resolved: string): boolean {
|
||||
return /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i.test(resolved);
|
||||
}
|
||||
|
||||
/** Targets `-Command` only — PowerShell quoting is finicky enough that wrapping script-file mode could break it. */
|
||||
export function injectPowerShellUtf8(args: readonly string[]): string[] | null {
|
||||
const prelude =
|
||||
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;";
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i] ?? "";
|
||||
if (/^-(?:Command|c)$/i.test(a) && i + 1 < args.length) {
|
||||
const out = [...args];
|
||||
out[i + 1] = `${prelude}${args[i + 1] ?? ""}`;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Single `&` (not `&&`) so the command still runs on Win7 where chcp can return non-zero. */
|
||||
export function withUtf8Codepage(cmdline: string): string {
|
||||
return `chcp 65001 >nul & ${cmdline}`;
|
||||
}
|
||||
|
||||
function isBareWindowsName(s: string): boolean {
|
||||
if (!s) return false;
|
||||
if (s.includes("/") || s.includes("\\")) return false;
|
||||
if (pathMod.isAbsolute(s)) return false;
|
||||
if (pathMod.extname(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Doubles embedded quotes per cmd.exe's `""` escape rule; bare alnum passes through unquoted. */
|
||||
export function quoteForCmdExe(arg: string): string {
|
||||
if (arg === "") return '""';
|
||||
if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
|
||||
return `"${arg.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Error thrown by `run_command` when the command isn't allowlisted. */
|
||||
export class NeedsConfirmationError extends Error {
|
||||
readonly command: string;
|
||||
@@ -621,7 +83,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
registry.register({
|
||||
name: "run_command",
|
||||
description:
|
||||
"Run a shell command in the project root and return its combined stdout+stderr.\n\nConstraints (read these before the first call):\n• Chain operators `|`, `||`, `&&`, `;` ARE supported — parsed natively, no shell invoked, so semantics are identical on Windows / macOS / Linux. Each chain segment is allowlist-checked individually: `git status | grep main` runs if both halves are allowed.\n• File redirects ARE supported: `>` truncate, `>>` append, `<` stdin from file, `2>` / `2>>` stderr to file, `2>&1` merge stderr→stdout, `&>` both to file. Targets resolve relative to the project root. At most one redirect per fd per segment.\n• Background `&`, heredoc `<<`, command substitution `$(…)`, subshells `(…)`, and process substitution `<(…)` are NOT supported. Wrap a literal `&` arg in quotes; for input use a `<` file or the binary's own --input flag.\n• Env-var expansion `$VAR` is NOT performed — `$VAR` is passed as a literal string. Use the binary's own --env flag or substitute the value yourself.\n• `cd` DOES NOT PERSIST between calls — each call spawns a fresh process rooted at the project. If a tool needs a subdirectory, pass it via the tool's own flag (`npm --prefix`, `cargo -C`, `git -C`, `pytest tests/…`), NOT via a preceding `cd`.\n• Glob patterns (`*.ts`) are passed through as literal arguments — no shell expansion. Use `grep -r`, `rg`, `find -name`, etc.\n• Avoid commands with unbounded output (`netstat -ano`, `find /`, etc.) — they waste tokens. Filter at source: `netstat -ano -p TCP`, `find src -name '*.ts'`, `grep -c`, `wc -l`.\n\nCommon read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually — after edits, run the project's tests to verify.",
|
||||
"Run a shell command in the project root and return its combined stdout+stderr.\n\nConstraints (read these before the first call):\n• Chain operators `|`, `||`, `&&`, `;` ARE supported — parsed natively, no shell invoked, so semantics are identical on Windows / macOS / Linux. Each chain segment is allowlist-checked individually: `git status | grep main` runs if both halves are allowed.\n• File redirects ARE supported: `>` truncate, `>>` append, `<` stdin from file, `2>` / `2>>` stderr to file, `2>&1` merge stderr→stdout, `&>` both to file. Targets resolve relative to the project root. At most one redirect per fd per segment.\n• Background `&`, heredoc `<<`, command substitution `$(…)`, subshells `(…)`, and process substitution `<(…)` are NOT supported. Wrap a literal `&` arg in quotes; for input use a `<` file or the binary's own --input flag.\n• Env-var expansion `$VAR` is NOT performed — `$VAR` is passed as a literal string. Use the binary's own --env flag or substitute the value yourself.\n• `cd` DOES NOT PERSIST between calls — each call spawns a fresh process rooted at the project. `cd` also does not persist within parsed chains like `cd dir && command`. Use a command-native cwd flag instead: `npm --prefix <dir> run <script>`, `npm --prefix <dir> exec -- <bin>`, `git -C <dir> ...`, `cargo -C <dir> ...`, `pytest <dir>/tests`.\n• Glob patterns (`*.ts`) are passed through as literal arguments — no shell expansion. Use `grep -r`, `rg`, `find -name`, etc.\n• Avoid commands with unbounded output (`netstat -ano`, `find /`, etc.) — they waste tokens. Filter at source: `netstat -ano -p TCP`, `find src -name '*.ts'`, `grep -c`, `wc -l`.\n\nCommon read-only inspection and test/lint/typecheck commands run immediately; anything that could mutate state, install dependencies, or touch the network is refused until the user confirms it in the TUI. Prefer this over asking the user to run a command manually — after edits, run the project's tests to verify.",
|
||||
// Plan-mode gate: allow allowlisted commands through (git status,
|
||||
// cargo check, ls, grep …) so the model can actually investigate
|
||||
// during planning. Anything that would otherwise trigger a
|
||||
@@ -724,6 +186,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
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.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -773,6 +236,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
description:
|
||||
"List every background job started this session — running and exited — 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.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: { type: "object", properties: {} },
|
||||
fn: async () => {
|
||||
const all = jobs.list();
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { parseCommandChain, runChain } from "../shell-chain.js";
|
||||
import { tokenizeCommand } from "./parse.js";
|
||||
|
||||
export const DEFAULT_TIMEOUT_SEC = 60;
|
||||
export const DEFAULT_MAX_OUTPUT_CHARS = 32_000;
|
||||
|
||||
/** Kill child + descendants. Windows: taskkill /T /F. Unix: SIGKILL the process group when detached, else fall back to SIGKILL on the leader. */
|
||||
export function killProcessTree(child: ChildProcess): void {
|
||||
if (!child.pid || child.killed) return;
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
/* fall through to SIGKILL */
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
return;
|
||||
} catch {
|
||||
/* not a process group leader — fall through */
|
||||
}
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunCommandResult {
|
||||
exitCode: number | null;
|
||||
/** Combined stdout+stderr, truncated to `maxOutputChars` with a marker. */
|
||||
output: string;
|
||||
/** True when the process was killed for exceeding `timeoutSec`. */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
cmd: string,
|
||||
opts: {
|
||||
cwd: string;
|
||||
timeoutSec?: number;
|
||||
maxOutputChars?: number;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<RunCommandResult> {
|
||||
const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
|
||||
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
||||
const argv = tokenizeCommand(cmd);
|
||||
if (argv.length === 0) throw new Error("run_command: empty command");
|
||||
const chain = parseCommandChain(cmd);
|
||||
if (chain !== null) {
|
||||
return await runChain(chain, {
|
||||
cwd: opts.cwd,
|
||||
timeoutSec,
|
||||
maxOutputChars: maxChars,
|
||||
signal: opts.signal,
|
||||
});
|
||||
}
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: opts.cwd,
|
||||
shell: false, // no shell-expansion — see header comment
|
||||
windowsHide: true,
|
||||
// PYTHONIOENCODING + PYTHONUTF8 force any spawned Python child
|
||||
// (run_command running `python script.py`, etc.) to emit UTF-8
|
||||
// on stdout/stderr. Without this, Chinese-Windows defaults
|
||||
// Python's stdout encoder to GBK and `print("…")` raises
|
||||
// UnicodeEncodeError on emoji / non-GBK chars — the model then
|
||||
// sees a Python traceback instead of the script's real output
|
||||
// and goes around in circles trying to fix the wrong problem.
|
||||
// Harmless on non-Python processes (env vars they don't read).
|
||||
env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" },
|
||||
};
|
||||
|
||||
// Windows: two layered fixes on top of shell:false —
|
||||
// 1. Resolve bare command names via PATH × PATHEXT (CreateProcess
|
||||
// ignores PATHEXT, so `npm` alone misses `npm.cmd`).
|
||||
// 2. Node 21.7.3+ (CVE-2024-27980) refuses to spawn `.cmd`/`.bat`
|
||||
// directly even with shell:false and safe args — throws
|
||||
// EINVAL at invocation time. Wrap those via `cmd.exe /d /s /c`
|
||||
// with verbatim args + manual quoting, so shell metacharacters
|
||||
// in arguments stay literal.
|
||||
// Unix path is unchanged.
|
||||
const { bin, args, spawnOverrides } = prepareSpawn(argv);
|
||||
const effectiveSpawnOpts = { ...spawnOpts, ...spawnOverrides };
|
||||
|
||||
return await new Promise<RunCommandResult>((resolve, reject) => {
|
||||
let child: import("node:child_process").ChildProcess;
|
||||
try {
|
||||
child = spawn(bin, args, effectiveSpawnOpts);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
// Collect raw Buffer chunks rather than decoding incrementally —
|
||||
// a multi-byte sequence can land split across chunks, and a naïve
|
||||
// chunk.toString() corrupts it before the second half arrives.
|
||||
// We decode once at close time, where smartDecodeOutput can also
|
||||
// sniff non-UTF-8 codepages cleanly. The byte cap mirrors the
|
||||
// prior char cap (2× maxChars worth) so a chatty process can't
|
||||
// OOM us.
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
const byteCap = maxChars * 2 * 4; // worst-case 4 bytes/char for utf-8/gbk
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
const killChildTree = () => killProcessTree(child);
|
||||
const killTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChildTree();
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
killChildTree();
|
||||
};
|
||||
// Check synchronously first — if the signal aborted before listener attach
|
||||
// (parent loop was already cancelled), addEventListener with `once:true`
|
||||
// never fires, child runs unbounded.
|
||||
if (opts.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
if (totalBytes >= byteCap) return;
|
||||
const remaining = byteCap - totalBytes;
|
||||
if (b.length > remaining) {
|
||||
chunks.push(b.subarray(0, remaining));
|
||||
totalBytes = byteCap;
|
||||
} else {
|
||||
chunks.push(b);
|
||||
totalBytes += b.length;
|
||||
}
|
||||
};
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(killTimer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(killTimer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
const merged = Buffer.concat(chunks);
|
||||
const buf = smartDecodeOutput(merged);
|
||||
const output =
|
||||
buf.length > maxChars
|
||||
? `${buf.slice(0, maxChars)}\n\n[… truncated ${buf.length - maxChars} chars …]`
|
||||
: buf;
|
||||
resolve({ exitCode: code, output, timedOut });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** GBK fallback on Windows — cmd.exe's localized error DLL and native EXE stderr ignore chcp 65001. */
|
||||
export function smartDecodeOutput(buf: Buffer): string {
|
||||
if (buf.length === 0) return "";
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
||||
} catch {
|
||||
// Fall through to platform-specific fallback.
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
// TextDecoder supports gbk / gb18030 in Node 18+ via the WHATWG
|
||||
// Encoding spec. gb18030 is the modern superset; falling back
|
||||
// to it covers GBK byte sequences plus the rare 4-byte CJK
|
||||
// characters that appear in newer system messages.
|
||||
return new TextDecoder("gb18030").decode(buf);
|
||||
} catch {
|
||||
// Decoder unavailable in this build — fall through.
|
||||
}
|
||||
}
|
||||
// Last resort: lossy UTF-8 with replacement chars. The model still
|
||||
// gets "something happened" with the structural exit-code marker
|
||||
// intact, which is more useful than throwing away the entire output.
|
||||
return buf.toString("utf8");
|
||||
}
|
||||
|
||||
export interface ResolveExecutableOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: { PATH?: string; PATHEXT?: string };
|
||||
isFile?: (path: string) => boolean;
|
||||
pathDelimiter?: string;
|
||||
}
|
||||
|
||||
/** CreateProcess ignores PATHEXT — bare `npm` fails ENOENT under `shell:false` without this resolver. */
|
||||
export function resolveExecutable(cmd: string, opts: ResolveExecutableOptions = {}): string {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform !== "win32") return cmd;
|
||||
if (!cmd) return cmd;
|
||||
// Already a path fragment — spawn handles these natively.
|
||||
if (cmd.includes("/") || cmd.includes("\\") || pathMod.isAbsolute(cmd)) return cmd;
|
||||
// If the model wrote `npm.cmd` explicitly, respect that verbatim.
|
||||
if (pathMod.extname(cmd)) return cmd;
|
||||
|
||||
const env = opts.env ?? process.env;
|
||||
const pathExt = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
const delimiter = opts.pathDelimiter ?? (platform === "win32" ? ";" : pathMod.delimiter);
|
||||
const pathDirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
||||
const isFile = opts.isFile ?? defaultIsFile;
|
||||
|
||||
for (const dir of pathDirs) {
|
||||
for (const ext of pathExt) {
|
||||
// Force win32 join so CI tests that pass `platform: "win32"`
|
||||
// from a Linux runner get backslash-joined paths; the real-
|
||||
// Windows runtime path lands here too and gets the correct
|
||||
// separator regardless of where pathMod defaults.
|
||||
const full = pathMod.win32.join(dir, cmd + ext);
|
||||
if (isFile(full)) return full;
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function defaultIsFile(full: string): boolean {
|
||||
try {
|
||||
return existsSync(full) && statSync(full).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Windows workarounds: PATHEXT lookup + CVE-2024-27980 prohibition on direct `.cmd`/`.bat` spawn. */
|
||||
export function prepareSpawn(
|
||||
argv: readonly string[],
|
||||
opts: ResolveExecutableOptions = {},
|
||||
): { bin: string; args: string[]; spawnOverrides: SpawnOptions } {
|
||||
const head = argv[0] ?? "";
|
||||
const tail = argv.slice(1);
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const resolved = resolveExecutable(head, opts);
|
||||
|
||||
if (platform !== "win32") {
|
||||
return { bin: resolved, args: [...tail], spawnOverrides: {} };
|
||||
}
|
||||
|
||||
// `.cmd` / `.bat` wrappers require cmd.exe on post-CVE Node.
|
||||
if (/\.(cmd|bat)$/i.test(resolved)) {
|
||||
const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
|
||||
return {
|
||||
bin: "cmd.exe",
|
||||
args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
|
||||
// windowsVerbatimArguments prevents Node from re-quoting the /c
|
||||
// payload — we've already composed an exact cmd.exe command
|
||||
// line. Without this Node wraps our already-quoted string in
|
||||
// another round of quotes and cmd.exe can't parse it.
|
||||
spawnOverrides: { windowsVerbatimArguments: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Bare command names that PATH × PATHEXT couldn't resolve to an
|
||||
// on-disk file — these are almost always cmd.exe built-ins (`dir`,
|
||||
// `echo`, `type`, `ver`, `vol`, `where`, `help`, …) which don't
|
||||
// exist as standalone executables. Direct spawn crashes with ENOENT;
|
||||
// routing through cmd.exe lets the built-in resolve, and if it's
|
||||
// genuinely unknown the user gets the standard "'foo' is not
|
||||
// recognized" message instead of a raw spawn failure.
|
||||
if (isBareWindowsName(resolved) && resolved === head) {
|
||||
const cmdline = [head, ...tail].map(quoteForCmdExe).join(" ");
|
||||
return {
|
||||
bin: "cmd.exe",
|
||||
args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
|
||||
spawnOverrides: { windowsVerbatimArguments: true },
|
||||
};
|
||||
}
|
||||
|
||||
// PowerShell variants: chcp 65001 doesn't help here because PowerShell
|
||||
// sets its own [Console]::OutputEncoding at startup — usually system
|
||||
// codepage (CP936/CP932/CP949 on CJK Windows) or UTF-16. The result
|
||||
// is mojibake when our `chunk.toString()` UTF-8-decodes its stdout.
|
||||
// Inject a UTF-8 setup prelude into the `-Command` (or `-c`) arg so
|
||||
// any output produced thereafter is UTF-8.
|
||||
if (isPowerShellExe(resolved)) {
|
||||
const patched = injectPowerShellUtf8(tail);
|
||||
if (patched) {
|
||||
return { bin: resolved, args: patched, spawnOverrides: {} };
|
||||
}
|
||||
}
|
||||
|
||||
return { bin: resolved, args: [...tail], spawnOverrides: {} };
|
||||
}
|
||||
|
||||
/** Resolved bin path looks like Windows PowerShell or PowerShell Core. */
|
||||
function isPowerShellExe(resolved: string): boolean {
|
||||
return /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i.test(resolved);
|
||||
}
|
||||
|
||||
/** Targets `-Command` only — PowerShell quoting is finicky enough that wrapping script-file mode could break it. */
|
||||
export function injectPowerShellUtf8(args: readonly string[]): string[] | null {
|
||||
const prelude =
|
||||
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;";
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i] ?? "";
|
||||
if (/^-(?:Command|c)$/i.test(a) && i + 1 < args.length) {
|
||||
const out = [...args];
|
||||
out[i + 1] = `${prelude}${args[i + 1] ?? ""}`;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Single `&` (not `&&`) so the command still runs on Win7 where chcp can return non-zero. */
|
||||
export function withUtf8Codepage(cmdline: string): string {
|
||||
return `chcp 65001 >nul & ${cmdline}`;
|
||||
}
|
||||
|
||||
function isBareWindowsName(s: string): boolean {
|
||||
if (!s) return false;
|
||||
if (s.includes("/") || s.includes("\\")) return false;
|
||||
if (pathMod.isAbsolute(s)) return false;
|
||||
if (pathMod.extname(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Doubles embedded quotes per cmd.exe's `""` escape rule; bare alnum passes through unquoted. */
|
||||
export function quoteForCmdExe(arg: string): string {
|
||||
if (arg === "") return '""';
|
||||
if (!/[\s"&|<>^%(),;!]/.test(arg)) return arg;
|
||||
return `"${arg.replace(/"/g, '""')}"`;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { type CommandChain, chainAllowed, parseCommandChain } from "../shell-chain.js";
|
||||
|
||||
/** Read-only reports + test runners whose failure mode is "exit 1 with output". */
|
||||
export const BUILTIN_ALLOWLIST: ReadonlyArray<string> = [
|
||||
// Repo inspection
|
||||
"git status",
|
||||
"git diff",
|
||||
"git log",
|
||||
"git show",
|
||||
"git blame",
|
||||
"git branch",
|
||||
"git remote",
|
||||
"git rev-parse",
|
||||
"git config --get",
|
||||
// Filesystem inspection
|
||||
"ls",
|
||||
"pwd",
|
||||
"cat",
|
||||
"head",
|
||||
"tail",
|
||||
"wc",
|
||||
"file",
|
||||
"tree",
|
||||
"find",
|
||||
"grep",
|
||||
"rg",
|
||||
// Language version probes
|
||||
"node --version",
|
||||
"node -v",
|
||||
"npm --version",
|
||||
"npx --version",
|
||||
"python --version",
|
||||
"python3 --version",
|
||||
"cargo --version",
|
||||
"go version",
|
||||
"rustc --version",
|
||||
"deno --version",
|
||||
"bun --version",
|
||||
// Test runners (non-destructive by convention)
|
||||
"npm test",
|
||||
"npm run test",
|
||||
"npx vitest run",
|
||||
"npx vitest",
|
||||
"npx jest",
|
||||
"pytest",
|
||||
"python -m pytest",
|
||||
"cargo test",
|
||||
"cargo check",
|
||||
"cargo clippy",
|
||||
"go test",
|
||||
"go vet",
|
||||
"deno test",
|
||||
"bun test",
|
||||
// Linters / typecheckers (read-only by convention)
|
||||
"npm run lint",
|
||||
"npm run typecheck",
|
||||
"npx tsc --noEmit",
|
||||
"npx biome check",
|
||||
"npx eslint",
|
||||
"npx prettier --check",
|
||||
"ruff",
|
||||
"mypy",
|
||||
];
|
||||
|
||||
/** Inside `"…"` only `\"` and `\\` are escapes — `\X` otherwise stays literal so Windows paths like `"C:\Users\foo\.bar"` survive tokenization. */
|
||||
export function isDqEscape(prev: string, next: string | undefined): boolean {
|
||||
return prev === "\\" && (next === '"' || next === "\\");
|
||||
}
|
||||
|
||||
/** No env / glob / backtick / `$(…)` expansion — prevents bypass of allowlist via concatenation. */
|
||||
export function tokenizeCommand(cmd: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const ch = cmd[i]!;
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (quote === '"' && isDqEscape(ch, cmd[i + 1])) {
|
||||
cur += cmd[++i];
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === " " || ch === "\t") {
|
||||
if (cur.length > 0) {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (quote) throw new Error(`unclosed ${quote} in command`);
|
||||
if (cur.length > 0) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Up-front detection — without it, `dir | findstr foo` quotes `|` literal and pipe silently fails. */
|
||||
export function detectShellOperator(cmd: string): string | null {
|
||||
const opPrefix = /^(?:2>&1|&>|\|{1,2}|&{1,2}|2>{1,2}|>{1,2}|<{1,2})/;
|
||||
let cur = "";
|
||||
let curQuoted = false;
|
||||
let quote: '"' | "'" | null = null;
|
||||
const check = (): string | null => {
|
||||
if (cur.length === 0 && !curQuoted) return null;
|
||||
if (!curQuoted) {
|
||||
const m = opPrefix.exec(cur);
|
||||
if (m) return m[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const ch = cmd[i]!;
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (quote === '"' && isDqEscape(ch, cmd[i + 1])) {
|
||||
cur += cmd[++i];
|
||||
curQuoted = true;
|
||||
} else {
|
||||
cur += ch;
|
||||
curQuoted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
curQuoted = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === " " || ch === "\t") {
|
||||
const op = check();
|
||||
if (op) return op;
|
||||
cur = "";
|
||||
curQuoted = false;
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (quote) return null; // let tokenizeCommand throw the unclosed-quote error
|
||||
return check();
|
||||
}
|
||||
|
||||
/** Per-prefix demotion: an otherwise-allowlisted match falls back to the confirm gate when one of these tokens appears in the tail. Issue #257: `git branch -D` skipped review. Each token also matches its `--flag=value` form. */
|
||||
const RISKY_ARGS: Readonly<Record<string, ReadonlyArray<string>>> = {
|
||||
// Branch / remote mutation
|
||||
"git branch": ["-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy", "--force"],
|
||||
"git remote": ["add", "remove", "rm", "rename", "set-url", "set-head", "prune"],
|
||||
// `--output` writes to an arbitrary path; `--ext-diff` invokes user-config'd external programs.
|
||||
"git diff": ["--output", "--ext-diff"],
|
||||
"git log": ["--output"],
|
||||
"git show": ["--output"],
|
||||
// `-exec*` / `-ok*` are RCE; `-delete` and `-fprint*` / `-fls` write to arbitrary paths.
|
||||
find: [
|
||||
"-delete",
|
||||
"-exec",
|
||||
"-execdir",
|
||||
"-ok",
|
||||
"-okdir",
|
||||
"-fprint",
|
||||
"-fprint0",
|
||||
"-fprintf",
|
||||
"-fls",
|
||||
],
|
||||
// `-o FILE` writes the tree to an arbitrary path.
|
||||
tree: ["-o"],
|
||||
// Auto-fix mutates source files.
|
||||
"npx eslint": ["--fix", "--fix-dry-run"],
|
||||
"npx biome check": ["--write", "--apply", "--apply-unsafe"],
|
||||
ruff: ["--fix", "--unsafe-fixes", "format"],
|
||||
};
|
||||
|
||||
function tailHasRisky(tail: readonly string[], risky: readonly string[]): boolean {
|
||||
for (const a of tail) {
|
||||
for (const r of risky) {
|
||||
if (a === r) return true;
|
||||
if (a.startsWith(`${r}=`)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Allowlist match on leading argv tokens; demoted by `RISKY_ARGS` when a destructive flag appears in the tail. */
|
||||
export function isAllowed(cmd: string, extra: readonly string[] = []): boolean {
|
||||
let argv: string[];
|
||||
try {
|
||||
argv = tokenizeCommand(cmd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (argv.length === 0) return false;
|
||||
|
||||
const allowlist = [...BUILTIN_ALLOWLIST, ...extra];
|
||||
for (const prefix of allowlist) {
|
||||
const prefixTokens = prefix.split(" ");
|
||||
if (argv.length < prefixTokens.length) continue;
|
||||
let match = true;
|
||||
for (let i = 0; i < prefixTokens.length; i++) {
|
||||
if (argv[i] !== prefixTokens[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
const risky = RISKY_ARGS[prefix];
|
||||
if (risky && tailHasRisky(argv.slice(prefixTokens.length), risky)) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** For chain commands, every segment must individually clear the allowlist. */
|
||||
export function isCommandAllowed(cmd: string, extra: readonly string[] = []): boolean {
|
||||
let chain: CommandChain | null;
|
||||
try {
|
||||
chain = parseCommandChain(cmd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (chain === null) return isAllowed(cmd, extra);
|
||||
return chainAllowed(chain, (seg) => isAllowed(seg, extra));
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export function registerSkillTools(
|
||||
description:
|
||||
"Invoke a playbook from the Skills index pinned in the system prompt. Each entry is a self-contained instruction block. Pass `name` as the BARE skill identifier (e.g. 'explore'), NOT the `[🧬 subagent]` tag that appears after it in the index. Entries tagged `[🧬 subagent]` spawn an isolated subagent — only the final distilled answer comes back, the model's tool calls + reasoning during the run never enter your context. Plain skills are inlined: the body becomes a tool result you read and follow. For subagent skills, supply 'arguments' describing the concrete task — they'll be the only context the subagent has.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Built-in subagent personas — system prompt + iter budget pairs picked via the `type` arg. Skills override at the run_skill level; this is the inline shortcut for parents that don't want to author one. */
|
||||
|
||||
import { NEGATIVE_CLAIM_RULE, TUI_FORMATTING_RULES } from "../prompt-fragments.js";
|
||||
|
||||
export type SubagentTypeName = "explore" | "verify";
|
||||
|
||||
export interface SubagentTypeSpec {
|
||||
system: string;
|
||||
maxToolIters: number;
|
||||
}
|
||||
|
||||
const EXPLORE_SYSTEM = `You are an exploration subagent. Wide-net read-only investigation; return one distilled answer.
|
||||
|
||||
How to operate:
|
||||
- Read-only tools only (read_file, search_files, search_content, directory_tree, list_directory, get_file_info).
|
||||
- For "find all places that call / reference / use X" — use search_content (content grep), NOT search_files (which only matches names).
|
||||
- Cast a wide net first to map the territory, then read the 3-10 most relevant files in full. Stop as soon as you can answer.
|
||||
- The parent does not see your tool calls — over-exploration is pure waste.
|
||||
|
||||
Final answer:
|
||||
- One paragraph or short bullets; lead with the conclusion.
|
||||
- Cite file:line ranges when they back the claim.
|
||||
- No follow-up offers, no "let me know if you need more" — the parent will ask again.
|
||||
|
||||
${NEGATIVE_CLAIM_RULE}
|
||||
|
||||
${TUI_FORMATTING_RULES}`;
|
||||
|
||||
const VERIFY_SYSTEM = `You are a verify subagent. Narrow check — return YES / NO / INCONCLUSIVE with evidence. Do not expand scope.
|
||||
|
||||
How to operate:
|
||||
- Read only what's needed to verify the specific claim. No exploration past the claim.
|
||||
- Use search_content / read_file to confirm the exact behavior, type, or call site in question.
|
||||
- Cap at 6-8 tool calls. If you can't verify in that, return INCONCLUSIVE plus what's missing.
|
||||
|
||||
Final answer:
|
||||
- Lead with VERIFIED / NOT VERIFIED / INCONCLUSIVE.
|
||||
- Cite file:line for the evidence.
|
||||
- One paragraph or a few bullets. No follow-up offers.
|
||||
|
||||
${NEGATIVE_CLAIM_RULE}
|
||||
|
||||
${TUI_FORMATTING_RULES}`;
|
||||
|
||||
const TYPES: Record<SubagentTypeName, SubagentTypeSpec> = {
|
||||
explore: { system: EXPLORE_SYSTEM, maxToolIters: 20 },
|
||||
verify: { system: VERIFY_SYSTEM, maxToolIters: 8 },
|
||||
};
|
||||
|
||||
export const SUBAGENT_TYPE_NAMES: readonly SubagentTypeName[] = Object.freeze(
|
||||
Object.keys(TYPES) as SubagentTypeName[],
|
||||
);
|
||||
|
||||
export function getSubagentType(name: unknown): SubagentTypeSpec | undefined {
|
||||
if (typeof name !== "string") return undefined;
|
||||
return TYPES[name as SubagentTypeName];
|
||||
}
|
||||
+113
-5
@@ -10,10 +10,13 @@ import {
|
||||
TUI_FORMATTING_RULES,
|
||||
} from "../prompt-fragments.js";
|
||||
import { ToolRegistry } from "../tools.js";
|
||||
import { SUBAGENT_TYPE_NAMES, getSubagentType } from "./subagent-types.js";
|
||||
|
||||
/** Side-channel — subagents run inside a tool-dispatch frame, can't go through parent's `LoopEvent` stream. */
|
||||
export interface SubagentEvent {
|
||||
kind: "start" | "progress" | "end" | "inner" | "phase";
|
||||
/** Stable per-spawn id; lets the UI key parallel runs apart instead of overwriting one shared row. */
|
||||
runId: string;
|
||||
task: string;
|
||||
skillName?: string;
|
||||
model?: string;
|
||||
@@ -30,6 +33,12 @@ export interface SubagentEvent {
|
||||
phase?: "exploring" | "summarising";
|
||||
}
|
||||
|
||||
let runIdCounter = 0;
|
||||
function nextRunId(): string {
|
||||
runIdCounter++;
|
||||
return `sub-${runIdCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
export interface SubagentSink {
|
||||
current: ((ev: SubagentEvent) => void) | null;
|
||||
}
|
||||
@@ -46,6 +55,8 @@ export interface SpawnSubagentOptions {
|
||||
/** Forwarded into the child loop so parent Esc cancels nested work. */
|
||||
parentSignal?: AbortSignal;
|
||||
skillName?: string;
|
||||
/** Scopes the child registry to these literal tool names; NEVER_INHERITED still wins. Driven by skill `allowed-tools` frontmatter. */
|
||||
allowedTools?: readonly string[];
|
||||
}
|
||||
|
||||
export interface SubagentResult {
|
||||
@@ -88,6 +99,8 @@ ${TUI_FORMATTING_RULES}`;
|
||||
|
||||
const DEFAULT_MAX_RESULT_CHARS = 8000;
|
||||
const DEFAULT_MAX_ITERS = 16;
|
||||
const MIN_MAX_ITERS = 1;
|
||||
const MAX_MAX_ITERS = 32;
|
||||
// Subagents default to flash — their work is read-and-synthesize
|
||||
// (explore, research), which doesn't need the 12× pro tier. Skill
|
||||
// frontmatter `model: deepseek-v4-pro` is the opt-in override for
|
||||
@@ -112,9 +125,11 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
const skillName = opts.skillName;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const runId = nextRunId();
|
||||
const taskPreview = opts.task.length > 30 ? `${opts.task.slice(0, 30)}…` : opts.task;
|
||||
sink?.current?.({
|
||||
kind: "start",
|
||||
runId,
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
@@ -122,7 +137,45 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
elapsedMs: 0,
|
||||
});
|
||||
|
||||
const childTools = forkRegistryExcluding(opts.parentRegistry, NEVER_INHERITED_TOOLS);
|
||||
if (opts.allowedTools) {
|
||||
const missing = opts.allowedTools.filter((n) => !opts.parentRegistry.has(n));
|
||||
if (missing.length > 0) {
|
||||
const errorMessage = `subagent allow-list names tool(s) not registered in the parent: ${missing.join(", ")}. Fix the skill's \`allowed-tools\` frontmatter or check spelling.`;
|
||||
sink?.current?.({
|
||||
kind: "end",
|
||||
runId,
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
iter: 0,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: errorMessage,
|
||||
turns: 0,
|
||||
costUsd: 0,
|
||||
usage: new Usage(),
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
output: "",
|
||||
error: errorMessage,
|
||||
turns: 0,
|
||||
toolIters: 0,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
costUsd: 0,
|
||||
model,
|
||||
skillName,
|
||||
usage: new Usage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const childTools = opts.allowedTools
|
||||
? forkRegistryWithAllowList(
|
||||
opts.parentRegistry,
|
||||
new Set(opts.allowedTools),
|
||||
NEVER_INHERITED_TOOLS,
|
||||
)
|
||||
: forkRegistryExcluding(opts.parentRegistry, NEVER_INHERITED_TOOLS);
|
||||
const childPrefix = new ImmutablePrefix({
|
||||
system: opts.system,
|
||||
toolSpecs: childTools.specs(),
|
||||
@@ -170,7 +223,7 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
let summarisingEmitted = false;
|
||||
try {
|
||||
for await (const ev of childLoop.step(opts.task)) {
|
||||
sink?.current?.({ kind: "inner", task: taskPreview, skillName, model, inner: ev });
|
||||
sink?.current?.({ kind: "inner", runId, task: taskPreview, skillName, model, inner: ev });
|
||||
|
||||
if (ev.role === "tool") {
|
||||
toolIter++;
|
||||
@@ -178,6 +231,7 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
summarisingEmitted = false;
|
||||
sink?.current?.({
|
||||
kind: "progress",
|
||||
runId,
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
@@ -191,6 +245,7 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
summarisingEmitted = true;
|
||||
sink?.current?.({
|
||||
kind: "phase",
|
||||
runId,
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
@@ -239,6 +294,7 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
|
||||
sink?.current?.({
|
||||
kind: "end",
|
||||
runId,
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
@@ -320,6 +376,7 @@ export function registerSubagentTool(
|
||||
|
||||
parentRegistry.register({
|
||||
name: SUBAGENT_TOOL_NAME,
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Spawn an isolated subagent to handle a self-contained subtask in a fresh context, returning only its final answer. Use for: deep codebase exploration that would flood the main context, multi-step research where you only need the conclusion, or any focused subtask whose intermediate reasoning the user does not need to see. The subagent inherits all your tools (filesystem, shell, web, MCP, etc.) but runs in its own isolated message log — its tool calls and reasoning never enter your context. Only the final assistant message comes back as this tool's result. Keep tasks focused; the subagent has a stricter iter budget than you do.",
|
||||
parameters: {
|
||||
@@ -341,31 +398,54 @@ export function registerSubagentTool(
|
||||
description:
|
||||
"Which DeepSeek model the subagent runs on. Default is 'deepseek-v4-flash' — cheap and fast, fine for explore/research-style subtasks. Override to 'deepseek-v4-pro' (~12× more expensive) when the subtask genuinely needs the stronger model: cross-file architecture, subtle bug hunts, anything where flash has empirically underperformed.",
|
||||
},
|
||||
max_iters: {
|
||||
type: "integer",
|
||||
minimum: MIN_MAX_ITERS,
|
||||
maximum: MAX_MAX_ITERS,
|
||||
description: `Cap on the subagent's tool-call iterations. Default 16 (or the type's default when 'type' is set). Hard range: ${MIN_MAX_ITERS}-${MAX_MAX_ITERS}; out-of-range values are clamped to the nearest end.`,
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
enum: [...SUBAGENT_TYPE_NAMES],
|
||||
description:
|
||||
"Optional persona shaping the system prompt and default iter budget. 'explore' = wide-net read-only investigation (20-iter budget, returns a distilled answer). 'verify' = narrow yes/no check with evidence (8-iter budget). Omit when supplying your own 'system' prompt or when the default generic persona fits. Caller-supplied 'system' / 'max_iters' override the type's defaults.",
|
||||
},
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
fn: async (args: { task?: unknown; system?: unknown; model?: unknown }, ctx) => {
|
||||
fn: async (
|
||||
args: {
|
||||
task?: unknown;
|
||||
system?: unknown;
|
||||
model?: unknown;
|
||||
max_iters?: unknown;
|
||||
type?: unknown;
|
||||
},
|
||||
ctx,
|
||||
) => {
|
||||
const task = typeof args.task === "string" ? args.task.trim() : "";
|
||||
if (!task) {
|
||||
return JSON.stringify({
|
||||
error: "spawn_subagent requires a non-empty 'task' argument.",
|
||||
});
|
||||
}
|
||||
const typeSpec = getSubagentType(args.type);
|
||||
const system =
|
||||
typeof args.system === "string" && args.system.trim().length > 0
|
||||
? args.system.trim()
|
||||
: defaultSystem;
|
||||
: (typeSpec?.system ?? defaultSystem);
|
||||
const model =
|
||||
typeof args.model === "string" && args.model.startsWith("deepseek-")
|
||||
? args.model
|
||||
: defaultModel;
|
||||
const callerIters = clampMaxIters(args.max_iters);
|
||||
const result = await spawnSubagent({
|
||||
client: opts.client,
|
||||
parentRegistry,
|
||||
system,
|
||||
task,
|
||||
model,
|
||||
maxToolIters,
|
||||
maxToolIters: callerIters ?? typeSpec?.maxToolIters ?? maxToolIters,
|
||||
maxResultChars,
|
||||
sink,
|
||||
parentSignal: ctx?.signal,
|
||||
@@ -377,6 +457,15 @@ export function registerSubagentTool(
|
||||
return parentRegistry;
|
||||
}
|
||||
|
||||
/** Floats round down; non-finite / wrong-type yields undefined so caller falls back to its default. */
|
||||
function clampMaxIters(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
|
||||
const n = Math.floor(raw);
|
||||
if (n < MIN_MAX_ITERS) return MIN_MAX_ITERS;
|
||||
if (n > MAX_MAX_ITERS) return MAX_MAX_ITERS;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Plan-mode state propagates — a subagent spawned under `/plan` MUST NOT escape it. */
|
||||
export function forkRegistryExcluding(
|
||||
parent: ToolRegistry,
|
||||
@@ -396,3 +485,22 @@ export function forkRegistryExcluding(
|
||||
if (parent.planMode) child.setPlanMode(true);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** alsoExclude wins over allow so NEVER_INHERITED still drops `spawn_subagent` even if a skill allow-list names it. */
|
||||
export function forkRegistryWithAllowList(
|
||||
parent: ToolRegistry,
|
||||
allow: ReadonlySet<string>,
|
||||
alsoExclude: ReadonlySet<string>,
|
||||
): ToolRegistry {
|
||||
const child = new ToolRegistry();
|
||||
for (const spec of parent.specs()) {
|
||||
const name = spec.function.name;
|
||||
if (!allow.has(name)) continue;
|
||||
if (alsoExclude.has(name)) continue;
|
||||
const def = parent.get(name);
|
||||
if (!def) continue;
|
||||
child.register(def);
|
||||
}
|
||||
if (parent.planMode) child.setPlanMode(true);
|
||||
return child;
|
||||
}
|
||||
+79
-20
@@ -1,5 +1,6 @@
|
||||
/** web_search uses Mojeek (DDG returns anti-bot 202 to unauthenticated POSTs); web_fetch sniffs HTML to text. */
|
||||
|
||||
import { parse as parseHtml } from "node-html-parser";
|
||||
import type { ToolRegistry } from "../tools.js";
|
||||
|
||||
export interface SearchResult {
|
||||
@@ -182,18 +183,39 @@ async function readBodyCapped(resp: Response, maxBytes: number): Promise<string>
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Hard cap so the per-request HTML budget stays linear-time even on adversarial pages. */
|
||||
const MAX_HTML_INPUT = 5 * 1024 * 1024;
|
||||
|
||||
const STRIP_BLOCK_TAGS = "script, style, noscript, nav, footer, aside, svg";
|
||||
|
||||
/** Block-level tags that should produce a paragraph break in the extracted text. */
|
||||
const BLOCK_BREAK_TAGS = new Set([
|
||||
"p",
|
||||
"div",
|
||||
"br",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"li",
|
||||
"tr",
|
||||
"section",
|
||||
"article",
|
||||
]);
|
||||
|
||||
export function htmlToText(html: string): string {
|
||||
let s = html;
|
||||
s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||
s = s.replace(/<style[\s\S]*?<\/style>/gi, "");
|
||||
s = s.replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
|
||||
s = s.replace(/<nav[\s\S]*?<\/nav>/gi, "");
|
||||
s = s.replace(/<footer[\s\S]*?<\/footer>/gi, "");
|
||||
s = s.replace(/<aside[\s\S]*?<\/aside>/gi, "");
|
||||
s = s.replace(/<svg[\s\S]*?<\/svg>/gi, "");
|
||||
// Preserve paragraph breaks by turning common block tags into newlines.
|
||||
s = s.replace(/<\/?(p|div|br|h[1-6]|li|tr|section|article)\b[^>]*>/gi, "\n");
|
||||
s = s.replace(/<[^>]+>/g, "");
|
||||
const input = html.length > MAX_HTML_INPUT ? html.slice(0, MAX_HTML_INPUT) : html;
|
||||
// Real HTML parser — sidesteps the well-known regex anti-patterns
|
||||
// (`<X[\s\S]*?</X>`, `<[^>]+>`) CodeQL flags as bad-tag-filter and
|
||||
// incomplete-multi-character-sanitization.
|
||||
const root = parseHtml(input);
|
||||
for (const node of root.querySelectorAll(STRIP_BLOCK_TAGS)) node.remove();
|
||||
|
||||
const out: string[] = [];
|
||||
walkExtract(root, out);
|
||||
let s = out.join("");
|
||||
s = decodeHtmlEntities(s);
|
||||
s = s.replace(/[ \t]+/g, " ");
|
||||
s = s.replace(/\n[ \t]+/g, "\n");
|
||||
@@ -201,18 +223,53 @@ export function htmlToText(html: string): string {
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
function stripHtml(s: string): string {
|
||||
return s.replace(/<[^>]+>/g, "");
|
||||
interface WalkableNode {
|
||||
nodeType: number;
|
||||
rawText?: string;
|
||||
text?: string;
|
||||
rawTagName?: string;
|
||||
childNodes: WalkableNode[];
|
||||
}
|
||||
|
||||
function walkExtract(node: WalkableNode, out: string[]): void {
|
||||
// nodeType 3 = TEXT_NODE; 1 = ELEMENT_NODE per node-html-parser.
|
||||
if (node.nodeType === 3) {
|
||||
out.push(node.rawText ?? node.text ?? "");
|
||||
return;
|
||||
}
|
||||
const tag = node.rawTagName?.toLowerCase();
|
||||
const isBreak = tag !== undefined && BLOCK_BREAK_TAGS.has(tag);
|
||||
if (isBreak) out.push("\n");
|
||||
for (const child of node.childNodes) walkExtract(child, out);
|
||||
if (isBreak) out.push("\n");
|
||||
}
|
||||
|
||||
function stripHtml(s: string): string {
|
||||
return parseHtml(s).text;
|
||||
}
|
||||
|
||||
const HTML_ENTITIES: Readonly<Record<string, string>> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
};
|
||||
|
||||
/** Single-pass decode — the previous chained `replace`s decoded `&lt;` into `<` because `&` ran before `<`. */
|
||||
function decodeHtmlEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
return s.replace(/&(#\d+|#x[0-9a-fA-F]+|\w+);/g, (raw, name: string) => {
|
||||
if (name.startsWith("#x") || name.startsWith("#X")) {
|
||||
const code = Number.parseInt(name.slice(2), 16);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : raw;
|
||||
}
|
||||
if (name.startsWith("#")) {
|
||||
const code = Number.parseInt(name.slice(1), 10);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : raw;
|
||||
}
|
||||
return HTML_ENTITIES[name.toLowerCase()] ?? raw;
|
||||
});
|
||||
}
|
||||
|
||||
function extractTitle(html: string): string | undefined {
|
||||
@@ -237,6 +294,7 @@ export function registerWebTools(registry: ToolRegistry, opts: WebToolsOptions =
|
||||
description:
|
||||
"Search the public web. Returns ranked results with title, url, and snippet. Call this when the answer's correctness depends on current state — anything that changes over time (events, prices, releases, status of a thing in the real world). Composing such answers from training memory invents stale numbers; search first, then ground the answer in the results. For evergreen / definitional questions you don't need this.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -262,6 +320,7 @@ export function registerWebTools(registry: ToolRegistry, opts: WebToolsOptions =
|
||||
description:
|
||||
"Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
@@ -65,6 +65,10 @@ const PHASE_RE = /\bPhase\s+\d+\b/i;
|
||||
const BANNER_RE = /\/\/\s*[─=\-*#]{3,}/;
|
||||
const INCIDENT_RE =
|
||||
/\b(user reported|screenshot showed|incident|hotfix for|fix for #?\d|introduced in v|regression from v|pre-v\d|legacy v)/i;
|
||||
const BARE_TODO_RE = /(?:^|[^A-Za-z0-9_])(TODO|HACK)(?!\(#\d+\))/;
|
||||
const FIXME_RE = /(?:^|[^A-Za-z0-9_])FIXME\b/;
|
||||
const TRANSLATOR_NOTE_RE =
|
||||
/\b(translator(?:'s)? note|english version|chinese version|英文版|中文版)\b/i;
|
||||
|
||||
describe("comment policy (CLAUDE.md)", () => {
|
||||
test("module-essay headers ≤ 2 lines", () => {
|
||||
@@ -118,6 +122,53 @@ describe("comment policy (CLAUDE.md)", () => {
|
||||
expect(offenders, format(offenders, "No section-banner separator comments.")).toEqual([]);
|
||||
});
|
||||
|
||||
test("TODO / HACK markers must carry a (#nnn) issue anchor", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { rel, src } of FILES) {
|
||||
const lines = src.split("\n");
|
||||
lines.forEach((line, idx) => {
|
||||
const c = commentText(line);
|
||||
if (c && BARE_TODO_RE.test(c))
|
||||
offenders.push(`${rel}:${idx + 1} — ${line.trim().slice(0, 100)}`);
|
||||
});
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
format(offenders, "Bare TODO/HACK is debt leakage — write `TODO(#nnn): ...`."),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("no FIXME markers — fix it or open an issue + TODO", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { rel, src } of FILES) {
|
||||
const lines = src.split("\n");
|
||||
lines.forEach((line, idx) => {
|
||||
const c = commentText(line);
|
||||
if (c && FIXME_RE.test(c))
|
||||
offenders.push(`${rel}:${idx + 1} — ${line.trim().slice(0, 100)}`);
|
||||
});
|
||||
}
|
||||
expect(offenders, format(offenders, "FIXME is banned — use TODO(#nnn) or fix now.")).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("no translator-note comments", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { rel, src } of FILES) {
|
||||
const lines = src.split("\n");
|
||||
lines.forEach((line, idx) => {
|
||||
const c = commentText(line);
|
||||
if (c && TRANSLATOR_NOTE_RE.test(c))
|
||||
offenders.push(`${rel}:${idx + 1} — ${line.trim().slice(0, 100)}`);
|
||||
});
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
format(offenders, "i18n strings are self-documenting — no translator notes."),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("no multi-line block comments > 3 lines", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { rel, src } of FILES) {
|
||||
|
||||
@@ -41,6 +41,11 @@ describe("eventsCommand", () => {
|
||||
ev(4, "tool.intent", { callId: "tc-1", name: "list_directory", args: '{"path":"src"}' }),
|
||||
ev(5, "tool.dispatched", { callId: "tc-1" }),
|
||||
ev(6, "tool.result", { callId: "tc-1", ok: true, output: "App.tsx\n", durationMs: 12 }),
|
||||
ev(7, "tool.call", {
|
||||
name: "run_command",
|
||||
args: { command: "npm test", apiKey: "[redacted]" },
|
||||
}),
|
||||
ev(8, "tool.confirm.allow", { kind: "run_command", payload: { command: "npm test" } }),
|
||||
]);
|
||||
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
@@ -55,6 +60,8 @@ describe("eventsCommand", () => {
|
||||
expect(out).toContain("prefix=abcd1234");
|
||||
expect(out).toContain("tc-1 list_directory");
|
||||
expect(out).toContain("tc-1 ok 8B"); // "App.tsx\n".length === 8
|
||||
expect(out).toContain('run_command args={"command":"npm test","apiKey":"[redacted]"}');
|
||||
expect(out).toContain('run_command "npm test"');
|
||||
});
|
||||
|
||||
it("--type filters to one event variant", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import { lineDiff, registerFilesystemTools } from "../src/tools/filesystem.js";
|
||||
import { compileNameFilter, displayRel } from "../src/tools/filesystem.js";
|
||||
|
||||
describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
let root: string;
|
||||
@@ -242,6 +243,37 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
expect(out).not.toMatch(/escapes sandbox/);
|
||||
expect(out).toContain("util.ts");
|
||||
});
|
||||
|
||||
it("returns slash-normalized paths (no backslashes)", async () => {
|
||||
await fs.mkdir(join(root, "src", "cli", "ui"), { recursive: true });
|
||||
await fs.writeFile(join(root, "src", "cli", "ui", "App.tsx"), "// app\n");
|
||||
const out = await tools.dispatch("search_files", JSON.stringify({ pattern: "App" }));
|
||||
expect(out).toContain("src/cli/ui/App.tsx");
|
||||
expect(out).not.toMatch(/src[\\]cli/);
|
||||
});
|
||||
|
||||
it("skips dependency/build/VCS dirs by default", async () => {
|
||||
await fs.mkdir(join(root, "node_modules", "lib"), { recursive: true });
|
||||
await fs.writeFile(join(root, "node_modules", "lib", "marker.ts"), "x");
|
||||
await fs.mkdir(join(root, "dist"), { recursive: true });
|
||||
await fs.writeFile(join(root, "dist", "marker.ts"), "x");
|
||||
await fs.mkdir(join(root, "src"), { recursive: true });
|
||||
await fs.writeFile(join(root, "src", "marker.ts"), "x");
|
||||
const out = await tools.dispatch("search_files", JSON.stringify({ pattern: "marker" }));
|
||||
expect(out).toContain("src/marker.ts");
|
||||
expect(out).not.toContain("node_modules");
|
||||
expect(out).not.toContain("dist/marker.ts");
|
||||
});
|
||||
|
||||
it("walks dependency dirs when include_deps:true", async () => {
|
||||
await fs.mkdir(join(root, "node_modules", "lib"), { recursive: true });
|
||||
await fs.writeFile(join(root, "node_modules", "lib", "marker.ts"), "x");
|
||||
const out = await tools.dispatch(
|
||||
"search_files",
|
||||
JSON.stringify({ pattern: "marker", include_deps: true }),
|
||||
);
|
||||
expect(out).toContain("node_modules/lib/marker.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_content", () => {
|
||||
@@ -251,8 +283,8 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "export const x" }),
|
||||
);
|
||||
// Format: path:line: text
|
||||
expect(out).toMatch(/src[\\/]index\.ts:1: export const x = 1;/);
|
||||
// Format: path:line: text (always slash-normalized)
|
||||
expect(out).toMatch(/src\/index\.ts:1: export const x = 1;/);
|
||||
});
|
||||
|
||||
it("matches across multiple files and reports each line", async () => {
|
||||
@@ -342,6 +374,106 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
);
|
||||
expect(out).toMatch(/no matches/);
|
||||
});
|
||||
|
||||
it("returns slash-normalized path prefixes (no backslashes)", async () => {
|
||||
await fs.mkdir(join(root, "src", "cli", "ui"), { recursive: true });
|
||||
await fs.writeFile(join(root, "src", "cli", "ui", "App.tsx"), "UNIQUE_MARKER_42\n");
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "UNIQUE_MARKER_42" }),
|
||||
);
|
||||
expect(out).toMatch(/src\/cli\/ui\/App\.tsx:1:/);
|
||||
expect(out).not.toMatch(/src[\\]cli/);
|
||||
});
|
||||
|
||||
describe("glob filter", () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(join(root, "src", "ui"), { recursive: true });
|
||||
await fs.writeFile(join(root, "src", "alpha.ts"), "TARGETSTRING\n");
|
||||
await fs.writeFile(join(root, "src", "beta.tsx"), "TARGETSTRING\n");
|
||||
await fs.writeFile(join(root, "src", "ui", "gamma.tsx"), "TARGETSTRING\n");
|
||||
await fs.writeFile(join(root, "notes.md"), "TARGETSTRING\n");
|
||||
});
|
||||
|
||||
it("real glob: '*.ts' matches only .ts (not .tsx)", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "TARGETSTRING", glob: "*.ts" }),
|
||||
);
|
||||
expect(out).toContain("src/alpha.ts:");
|
||||
expect(out).not.toContain("beta.tsx");
|
||||
expect(out).not.toContain("gamma.tsx");
|
||||
expect(out).not.toContain("notes.md");
|
||||
});
|
||||
|
||||
it("real glob: '**/*.tsx' matches across subdirs", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "TARGETSTRING", glob: "**/*.tsx" }),
|
||||
);
|
||||
expect(out).toContain("src/beta.tsx:");
|
||||
expect(out).toContain("src/ui/gamma.tsx:");
|
||||
expect(out).not.toContain("alpha.ts:");
|
||||
expect(out).not.toContain("notes.md");
|
||||
});
|
||||
|
||||
it("real glob: brace expansion '*.{ts,tsx}'", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "TARGETSTRING", glob: "*.{ts,tsx}" }),
|
||||
);
|
||||
expect(out).toContain("src/alpha.ts:");
|
||||
expect(out).toContain("src/beta.tsx:");
|
||||
expect(out).not.toContain("notes.md");
|
||||
});
|
||||
|
||||
it("substring fallback: '.ts' still works (matches .ts and .tsx)", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "TARGETSTRING", glob: ".ts" }),
|
||||
);
|
||||
expect(out).toContain("src/alpha.ts:");
|
||||
expect(out).toContain("src/beta.tsx:");
|
||||
expect(out).not.toContain("notes.md");
|
||||
});
|
||||
|
||||
it("substring fallback: 'beta' matches by basename substring", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "TARGETSTRING", glob: "beta" }),
|
||||
);
|
||||
expect(out).toContain("src/beta.tsx:");
|
||||
expect(out).not.toContain("alpha.ts:");
|
||||
expect(out).not.toContain("gamma.tsx:");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compileNameFilter", () => {
|
||||
it("returns null for empty / undefined input", () => {
|
||||
expect(compileNameFilter(null)).toBeNull();
|
||||
expect(compileNameFilter(undefined)).toBeNull();
|
||||
expect(compileNameFilter("")).toBeNull();
|
||||
});
|
||||
|
||||
it("substring path for plain strings (case-insensitive)", () => {
|
||||
const m = compileNameFilter(".TS")!;
|
||||
expect(m("Foo.ts", "src/Foo.ts")).toBe(true);
|
||||
expect(m("Foo.tsx", "src/Foo.tsx")).toBe(true);
|
||||
expect(m("Foo.md", "src/Foo.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("real glob path for patterns with metachars; basename match", () => {
|
||||
const m = compileNameFilter("*.ts")!;
|
||||
expect(m("alpha.ts", "src/alpha.ts")).toBe(true);
|
||||
expect(m("alpha.tsx", "src/alpha.tsx")).toBe(false);
|
||||
});
|
||||
|
||||
it("rel-path match when pattern contains '/'", () => {
|
||||
const m = compileNameFilter("src/**/*.tsx")!;
|
||||
expect(m("App.tsx", "src/cli/App.tsx")).toBe(true);
|
||||
expect(m("App.tsx", "tests/App.tsx")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_file_info", () => {
|
||||
@@ -376,6 +508,16 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
expect(disk).toBe("x");
|
||||
});
|
||||
|
||||
it("returns slash-normalized path in output", async () => {
|
||||
await fs.mkdir(join(root, "src", "cli"), { recursive: true });
|
||||
const out = await tools.dispatch(
|
||||
"write_file",
|
||||
JSON.stringify({ path: "src/cli/new.ts", content: "export {}" }),
|
||||
);
|
||||
expect(out).toContain("src/cli/new.ts");
|
||||
expect(out).not.toMatch(/src[\\]cli/);
|
||||
});
|
||||
|
||||
it("rejects writes outside the sandbox", async () => {
|
||||
const out = await tools.dispatch(
|
||||
"write_file",
|
||||
@@ -610,3 +752,21 @@ describe("lineDiff — LCS line-level diff used by edit_file", () => {
|
||||
expect(d.slice(1).every((o) => o.op === "+")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayRel — slash-normalized relative paths", () => {
|
||||
it("normalizes backslashes to forward slashes", () => {
|
||||
const root = "C:\\root";
|
||||
const full = "C:\\root\\src\\cli\\ui\\App.tsx";
|
||||
const result = displayRel(root, full);
|
||||
expect(result).not.toContain("\\");
|
||||
expect(result).toContain("/");
|
||||
});
|
||||
|
||||
it("returns forward-slash paths on POSIX systems", () => {
|
||||
const root = "/tmp/test-root";
|
||||
const full = "/tmp/test-root/src/foo.ts";
|
||||
const result = displayRel(root, full);
|
||||
expect(result).toBe("src/foo.ts");
|
||||
expect(result).not.toContain("\\");
|
||||
});
|
||||
});
|
||||
@@ -1425,4 +1425,211 @@ describe("CacheFirstLoop (streaming) — tool_call_delta emission", () => {
|
||||
expect(turn2Warns).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parallel tool dispatch", () => {
|
||||
function makeMultiToolResponse(calls: Array<{ name: string; args: string }>) {
|
||||
return {
|
||||
content: "",
|
||||
tool_calls: calls.map((c, i) => ({
|
||||
id: `call_${i}`,
|
||||
type: "function",
|
||||
function: { name: c.name, arguments: c.args },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it("runs consecutive parallelSafe calls concurrently", async () => {
|
||||
const client = makeClient([
|
||||
makeMultiToolResponse([
|
||||
{ name: "slow_read", args: '{"k":1}' },
|
||||
{ name: "slow_read", args: '{"k":2}' },
|
||||
{ name: "slow_read", args: '{"k":3}' },
|
||||
]),
|
||||
{ content: "ok" },
|
||||
]);
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "slow_read",
|
||||
parallelSafe: true,
|
||||
fn: async (args: { k: number }) => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return String(args.k);
|
||||
},
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
for await (const _ of loop.step("go")) {
|
||||
// drain
|
||||
}
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
expect(elapsed).toBeLessThan(220);
|
||||
});
|
||||
|
||||
it("unsafe call splits the chunk into serial barriers", async () => {
|
||||
const client = makeClient([
|
||||
makeMultiToolResponse([
|
||||
{ name: "slow_read", args: '{"k":1}' },
|
||||
{ name: "slow_write", args: '{"k":2}' },
|
||||
{ name: "slow_read", args: '{"k":3}' },
|
||||
]),
|
||||
{ content: "ok" },
|
||||
]);
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "slow_read",
|
||||
parallelSafe: true,
|
||||
fn: async () => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return "r";
|
||||
},
|
||||
});
|
||||
tools.register({
|
||||
name: "slow_write",
|
||||
fn: async () => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return "w";
|
||||
},
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
for await (const _ of loop.step("go")) {
|
||||
// drain
|
||||
}
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
expect(elapsed).toBeGreaterThan(220);
|
||||
});
|
||||
|
||||
it("tool yields land in declared order even when later calls finish first", async () => {
|
||||
const client = makeClient([
|
||||
makeMultiToolResponse([
|
||||
{ name: "delayed", args: '{"id":"a","ms":120}' },
|
||||
{ name: "delayed", args: '{"id":"b","ms":20}' },
|
||||
{ name: "delayed", args: '{"id":"c","ms":60}' },
|
||||
]),
|
||||
{ content: "ok" },
|
||||
]);
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "delayed",
|
||||
parallelSafe: true,
|
||||
fn: async (args: { id: string; ms: number }) => {
|
||||
await new Promise((r) => setTimeout(r, args.ms));
|
||||
return args.id;
|
||||
},
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const order: string[] = [];
|
||||
for await (const ev of loop.step("go")) {
|
||||
if (ev.role === "tool") order.push(ev.content);
|
||||
}
|
||||
expect(order).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("REASONIX_TOOL_DISPATCH=serial forces serial dispatch", async () => {
|
||||
const prev = process.env.REASONIX_TOOL_DISPATCH;
|
||||
process.env.REASONIX_TOOL_DISPATCH = "serial";
|
||||
try {
|
||||
const client = makeClient([
|
||||
makeMultiToolResponse([
|
||||
{ name: "slow_read", args: '{"k":1}' },
|
||||
{ name: "slow_read", args: '{"k":2}' },
|
||||
]),
|
||||
{ content: "ok" },
|
||||
]);
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "slow_read",
|
||||
parallelSafe: true,
|
||||
fn: async () => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return "x";
|
||||
},
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
for await (const _ of loop.step("go")) {
|
||||
// drain
|
||||
}
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
expect(elapsed).toBeGreaterThan(150);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
// biome-ignore lint/performance/noDelete: env restore must remove the key, not stringify "undefined"
|
||||
delete process.env.REASONIX_TOOL_DISPATCH;
|
||||
} else process.env.REASONIX_TOOL_DISPATCH = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("REASONIX_PARALLEL_MAX caps the chunk size", async () => {
|
||||
const prev = process.env.REASONIX_PARALLEL_MAX;
|
||||
process.env.REASONIX_PARALLEL_MAX = "2";
|
||||
try {
|
||||
const client = makeClient([
|
||||
makeMultiToolResponse([
|
||||
{ name: "slow_read", args: '{"k":1}' },
|
||||
{ name: "slow_read", args: '{"k":2}' },
|
||||
{ name: "slow_read", args: '{"k":3}' },
|
||||
{ name: "slow_read", args: '{"k":4}' },
|
||||
]),
|
||||
{ content: "ok" },
|
||||
]);
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "slow_read",
|
||||
parallelSafe: true,
|
||||
fn: async () => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return "x";
|
||||
},
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
for await (const _ of loop.step("go")) {
|
||||
// drain
|
||||
}
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
expect(elapsed).toBeGreaterThan(150);
|
||||
expect(elapsed).toBeLessThan(280);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
// biome-ignore lint/performance/noDelete: env restore must remove the key, not stringify "undefined"
|
||||
delete process.env.REASONIX_PARALLEL_MAX;
|
||||
} else process.env.REASONIX_PARALLEL_MAX = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/** inspectMcpServer — runs against the fake transport. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatMcpInspectFailure } from "../src/cli/commands/mcp-inspect.js";
|
||||
import { McpClient } from "../src/mcp/client.js";
|
||||
import { inspectMcpServer } from "../src/mcp/inspect.js";
|
||||
import type { McpTransport } from "../src/mcp/stdio.js";
|
||||
@@ -223,3 +224,41 @@ describe("McpClient: initialize records serverInfo + protocolVersion + instructi
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMcpInspectFailure", () => {
|
||||
it("adds a command-install hint for ENOENT spawn errors", () => {
|
||||
const err = Object.assign(new Error("spawn npx-typo ENOENT"), { code: "ENOENT" });
|
||||
expect(formatMcpInspectFailure(err)).toBe(
|
||||
"spawn npx-typo ENOENT — try: install or verify `npx-typo`, then check the MCP spec's command spelling",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a host/port hint for connection refused errors", () => {
|
||||
const err = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:8787"), {
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
expect(formatMcpInspectFailure(err)).toBe(
|
||||
"connect ECONNREFUSED 127.0.0.1:8787 — try: confirm 127.0.0.1:8787 is running and the host/port match the spec",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a handshake hint for initialize timeouts", () => {
|
||||
const err = new Error("MCP request initialize (id=1) timed out after 60000ms");
|
||||
expect(formatMcpInspectFailure(err)).toBe(
|
||||
"MCP request initialize (id=1) timed out after 60000ms — try: confirm the target speaks MCP and completes the handshake before the request timeout",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a spec-shape hint for malformed specs", () => {
|
||||
expect(formatMcpInspectFailure(new Error("empty MCP spec"))).toBe(
|
||||
"empty MCP spec — try: pass `name=command args` or an http(s):// URL",
|
||||
);
|
||||
expect(formatMcpInspectFailure(new Error('MCP spec "fs=" has name but no command'))).toBe(
|
||||
'MCP spec "fs=" has name but no command — try: pass `name=command args` or an http(s):// URL',
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves unknown errors unchanged", () => {
|
||||
expect(formatMcpInspectFailure(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
});
|
||||
@@ -131,4 +131,70 @@ describe("PauseGate", () => {
|
||||
await expect(p2).resolves.toEqual({ type: "deny" });
|
||||
expect(gate.current).toBeNull();
|
||||
});
|
||||
|
||||
it("emits audit events for run_once / deny / always_allow shell decisions", async () => {
|
||||
const gate = new PauseGate();
|
||||
const audit = vi.fn();
|
||||
gate.setAuditListener(audit);
|
||||
gate.on(() => {});
|
||||
|
||||
const allow = gate.ask({ kind: "run_command", payload: { command: "npm test" } });
|
||||
gate.resolve(gate.current!.id, { type: "run_once" } as ConfirmationChoice);
|
||||
await expect(allow).resolves.toEqual({ type: "run_once" });
|
||||
|
||||
const deny = gate.ask({ kind: "run_background", payload: { command: "npm run dev" } });
|
||||
gate.resolve(gate.current!.id, {
|
||||
type: "deny",
|
||||
denyContext: "too risky",
|
||||
} as ConfirmationChoice);
|
||||
await expect(deny).resolves.toEqual({ type: "deny", denyContext: "too risky" });
|
||||
|
||||
const always = gate.ask({ kind: "run_command", payload: { command: "npm run lint" } });
|
||||
gate.resolve(gate.current!.id, {
|
||||
type: "always_allow",
|
||||
prefix: "npm run",
|
||||
} as ConfirmationChoice);
|
||||
await expect(always).resolves.toEqual({ type: "always_allow", prefix: "npm run" });
|
||||
|
||||
expect(audit.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
type: "tool.confirm.allow",
|
||||
kind: "run_command",
|
||||
payload: { command: "npm test" },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "tool.confirm.deny",
|
||||
kind: "run_background",
|
||||
payload: { command: "npm run dev" },
|
||||
denyContext: "too risky",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "tool.confirm.always_allow",
|
||||
kind: "run_command",
|
||||
payload: { command: "npm run lint" },
|
||||
prefix: "npm run",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit audit events for non-tool pauses", async () => {
|
||||
const gate = new PauseGate();
|
||||
const audit = vi.fn();
|
||||
gate.setAuditListener(audit);
|
||||
gate.on(() => {});
|
||||
|
||||
const promise = gate.ask({
|
||||
kind: "plan_proposed",
|
||||
payload: { plan: "# Plan", steps: [], summary: "ship it" },
|
||||
});
|
||||
gate.resolve(gate.current!.id, { type: "approve" });
|
||||
await expect(promise).resolves.toEqual({ type: "approve" });
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { handleSettings } from "../src/server/api/settings.js";
|
||||
import type { DashboardContext } from "../src/server/context.js";
|
||||
|
||||
function makeCtx(configPath: string): DashboardContext {
|
||||
return {
|
||||
configPath,
|
||||
usageLogPath: join(configPath, "..", "usage.json"),
|
||||
mode: "standalone",
|
||||
};
|
||||
}
|
||||
|
||||
function readCfg(path: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("settings API — combined POST persistence (#274)", () => {
|
||||
let dir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "reasonix-settings-"));
|
||||
configPath = join(dir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ lang: "ZH", baseUrl: "https://orig" }), "utf8");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("preserves lang when posted alongside baseUrl", async () => {
|
||||
const res = await handleSettings(
|
||||
"POST",
|
||||
[],
|
||||
JSON.stringify({ lang: "EN", baseUrl: "https://example.com" }),
|
||||
makeCtx(configPath),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const cfg = readCfg(configPath);
|
||||
expect(cfg.lang).toBe("EN");
|
||||
expect(cfg.baseUrl).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("preserves all fields in a multi-field POST", async () => {
|
||||
const res = await handleSettings(
|
||||
"POST",
|
||||
[],
|
||||
JSON.stringify({
|
||||
lang: "EN",
|
||||
baseUrl: "https://example.com",
|
||||
preset: "pro",
|
||||
reasoningEffort: "high",
|
||||
search: false,
|
||||
}),
|
||||
makeCtx(configPath),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const cfg = readCfg(configPath);
|
||||
expect(cfg.lang).toBe("EN");
|
||||
expect(cfg.baseUrl).toBe("https://example.com");
|
||||
expect(cfg.preset).toBe("pro");
|
||||
expect(cfg.reasoningEffort).toBe("high");
|
||||
expect(cfg.search).toBe(false);
|
||||
});
|
||||
|
||||
it("does not write to disk when no fields are provided", async () => {
|
||||
const before = readFileSync(configPath, "utf8");
|
||||
const res = await handleSettings("POST", [], JSON.stringify({}), makeCtx(configPath));
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { changed: string[] }).changed).toEqual([]);
|
||||
expect(readFileSync(configPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("rejects an invalid lang without writing other fields", async () => {
|
||||
const res = await handleSettings(
|
||||
"POST",
|
||||
[],
|
||||
JSON.stringify({ lang: "XX", baseUrl: "https://changed" }),
|
||||
makeCtx(configPath),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const cfg = readCfg(configPath);
|
||||
expect(cfg.lang).toBe("ZH");
|
||||
expect(cfg.baseUrl).toBe("https://orig");
|
||||
});
|
||||
|
||||
it("persists apiKey alongside other fields without losing them", async () => {
|
||||
const res = await handleSettings(
|
||||
"POST",
|
||||
[],
|
||||
JSON.stringify({ apiKey: "sk-1234567890abcdef", lang: "EN" }),
|
||||
makeCtx(configPath),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const cfg = readCfg(configPath);
|
||||
expect(cfg.apiKey).toBe("sk-1234567890abcdef");
|
||||
expect(cfg.lang).toBe("EN");
|
||||
});
|
||||
|
||||
it("fires applyPresetLive only after the disk write succeeds", async () => {
|
||||
const calls: string[] = [];
|
||||
const ctx: DashboardContext = {
|
||||
...makeCtx(configPath),
|
||||
applyPresetLive: (n) => calls.push(`preset:${n}`),
|
||||
applyEffortLive: (e) => calls.push(`effort:${e}`),
|
||||
};
|
||||
const res = await handleSettings(
|
||||
"POST",
|
||||
[],
|
||||
JSON.stringify({ preset: "flash", reasoningEffort: "high" }),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(calls).toEqual(["preset:flash", "effort:high"]);
|
||||
const cfg = readCfg(configPath);
|
||||
expect(cfg.preset).toBe("flash");
|
||||
expect(cfg.reasoningEffort).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,30 @@ describe("parseCommandChain", () => {
|
||||
it("rejects unclosed quotes inside a chain segment", () => {
|
||||
expect(() => parseCommandChain('git status ; echo "open')).toThrow(/unclosed/);
|
||||
});
|
||||
|
||||
it("rejects `cd` in the first chain segment with cwd guidance", () => {
|
||||
expect(() => parseCommandChain('cd nested && node -e "process.cwd()"')).toThrow(
|
||||
/cd in parsed command chains does not change cwd/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects `cd` anywhere in the chain, not only the first segment", () => {
|
||||
expect(() => parseCommandChain("echo ok && cd nested")).toThrow(
|
||||
/cd in parsed command chains does not change cwd/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects `cd` with uppercase CD (case-insensitive)", () => {
|
||||
expect(() => parseCommandChain("CD .. && echo hi")).toThrow(
|
||||
/cd in parsed command chains does not change cwd/,
|
||||
);
|
||||
});
|
||||
|
||||
it("still accepts a normal chain without `cd`", () => {
|
||||
const c = parseCommandChain("echo hello && echo world");
|
||||
expect(c).not.toBeNull();
|
||||
expect(c!.segments.map((s) => s.argv[0])).toEqual(["echo", "echo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chainAllowed", () => {
|
||||
|
||||
@@ -56,6 +56,17 @@ describe("tokenizeCommand", () => {
|
||||
expect(tokenizeCommand('echo "a \\"b\\" c"')).toEqual(["echo", 'a "b" c']);
|
||||
});
|
||||
|
||||
// Issue #265 — `\` was eaten as a generic escape inside `"..."`, so
|
||||
// Windows path separators got dropped (`thron\.reasonix` → `thron.reasonix`).
|
||||
// Only `\"` and `\\` are escapes now; everything else is literal.
|
||||
it("preserves Windows path backslashes inside double quotes", () => {
|
||||
expect(tokenizeCommand('dir /b "C:\\Users\\thron\\.reasonix"')).toEqual([
|
||||
"dir",
|
||||
"/b",
|
||||
"C:\\Users\\thron\\.reasonix",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unclosed quotes", () => {
|
||||
expect(() => tokenizeCommand('grep "unclosed')).toThrow(/unclosed/);
|
||||
expect(() => tokenizeCommand("grep 'unclosed")).toThrow(/unclosed/);
|
||||
@@ -703,6 +714,18 @@ describe("prepareSpawn", () => {
|
||||
expect(out.args[3]).toBe('chcp 65001 >nul & C:\\tools\\tool.CMD "a&b" "c|d"');
|
||||
});
|
||||
|
||||
it("preserves Windows path backslashes through tokenize → prepareSpawn (issue #265)", () => {
|
||||
const argv = tokenizeCommand('dir /b "C:\\Users\\thron\\.reasonix"');
|
||||
const out = prepareSpawn(argv, {
|
||||
platform: "win32",
|
||||
env: { PATH: "C:\\nope", PATHEXT: ".EXE" },
|
||||
pathDelimiter: ";",
|
||||
isFile: () => false,
|
||||
});
|
||||
expect(out.bin).toBe("cmd.exe");
|
||||
expect(out.args[3]).toBe("chcp 65001 >nul & dir /b C:\\Users\\thron\\.reasonix");
|
||||
});
|
||||
|
||||
it("routes bare unresolved Windows commands through cmd.exe (builtins)", () => {
|
||||
// `dir`, `echo`, `type`, `ver`, … are cmd.exe built-ins — they
|
||||
// don't exist as standalone exes, so PATHEXT lookup misses and a
|
||||
|
||||
@@ -303,6 +303,38 @@ describe("Skill frontmatter — runAs", () => {
|
||||
expect(store.read("rsr")?.model).toBe("deepseek-reasoner");
|
||||
expect(store.read("wrong")?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses comma-separated allowed-tools into a trimmed list", () => {
|
||||
writeSkillDir(
|
||||
home,
|
||||
"global",
|
||||
"scoped",
|
||||
{ description: "...", runAs: "subagent", "allowed-tools": "read, search_content,write" },
|
||||
"body",
|
||||
home,
|
||||
);
|
||||
const store = new SkillStore({ homeDir: home, disableBuiltins: true });
|
||||
expect(store.read("scoped")?.allowedTools).toEqual(["read", "search_content", "write"]);
|
||||
});
|
||||
|
||||
it("treats missing allowed-tools as undefined (full inheritance)", () => {
|
||||
writeSkillDir(home, "global", "open", { description: "...", runAs: "subagent" }, "body", home);
|
||||
const store = new SkillStore({ homeDir: home, disableBuiltins: true });
|
||||
expect(store.read("open")?.allowedTools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats an allowed-tools field with only whitespace/commas as undefined", () => {
|
||||
writeSkillDir(
|
||||
home,
|
||||
"global",
|
||||
"empty",
|
||||
{ description: "...", runAs: "subagent", "allowed-tools": " , , " },
|
||||
"body",
|
||||
home,
|
||||
);
|
||||
const store = new SkillStore({ homeDir: home, disableBuiltins: true });
|
||||
expect(store.read("empty")?.allowedTools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Built-in skills", () => {
|
||||
|
||||
+1
-1
@@ -242,7 +242,7 @@ describe("handleSlash", () => {
|
||||
});
|
||||
expect(r.info).toMatch(/MCP servers \(2\)/);
|
||||
expect(r.info).toMatch(/server-filesystem/);
|
||||
expect(r.info).toMatch(/kb.example.com/);
|
||||
expect(r.info).toContain("kb.example.com");
|
||||
});
|
||||
|
||||
it("/mcp opens the hub on Marketplace when no servers are bridged (even with native tools)", () => {
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user