Compare commits

...
44 Commits
Author SHA1 Message Date
YHH 3efa4cc374 chore(release): 0.29.0 (#329) 2026-05-06 08:35:38 -07:00
YHH 26ca84dd8f docs(architecture): parallel tool dispatch + env knobs (#325) (#328)
Document the chunked-parallel dispatcher and the two knobs that govern
it (REASONIX_PARALLEL_MAX, REASONIX_TOOL_DISPATCH=serial), plus the
list of built-in tools that opt in via parallelSafe.

Closes the docs portion of #325. Observability-event work (chunk-start
in events.jsonl) is deferred — the existing test suite already validates
the timing, ordering, and barrier guarantees, and the kernel event log
is a heavier surface that can wait until a real ops need surfaces.
2026-05-06 08:18:59 -07:00
YHH e91d136dbc feat(loop): chunked parallel tool dispatch + multi-subagent UI (#325) (#327)
Replace the serial `for...of + await` tool dispatch with chunked
parallel scheduling: consecutive `parallelSafe` calls race together;
unsafe calls form serial barriers. Tool yields and history append
preserve declared order regardless of which call settles first.

- Loop dispatch chunks consecutive parallelSafe calls; unsafe call
  ends the current chunk and runs alone
- runOneToolCall extracts per-call lifecycle (PreToolUse + dispatch +
  PostToolUse) so chunks can fan out via Promise.allSettled while the
  loop body keeps yielding events in declared order
- New env knobs: REASONIX_PARALLEL_MAX (cap, default 3, hard max 16)
  and REASONIX_TOOL_DISPATCH=serial (escape hatch)
- SubagentEvent gains a stable runId per spawn; useSubagent keeps an
  array of in-flight activities; SubagentLiveStack renders 1 → rich
  card, 2..max → compact rows, >max → "+N more" fold

Tests cover the four guarantees: parallel timing, serial barrier on
mixed safe/unsafe, declared-order yields under racey completion, and
both env-knob overrides.
2026-05-06 08:15:24 -07:00
YHH 12a3dab7e9 feat(tools): parallelSafe annotation on ToolDefinition (#325) (#326)
Plumbing for chunked parallel dispatch. No behavior change.

- ToolDefinition.parallelSafe?: boolean (default false; opt-in only)
- ToolRegistry.isParallelSafe(name): unknown / unannotated → false
- 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), memory recall, semantic_search, isolated
  child loops (run_skill, spawn_subagent), in-memory job queries
  (job_output, list_jobs)
- Mutating / side-effecting tools stay default (write_file, edit_file,
  shell, plan state, memory writes, ask_choice)
- MCP-bridged tools default false; safe surface only ships when an MCP
  server explicitly declares its tools parallel-safe
2026-05-06 08:07:04 -07:00
YHH deefafed10 chore(release): 0.28.0 (#323) 2026-05-06 03:16:17 -07:00
YHH 882a7ad2d9 feat(subagent): built-in subagent types (explore, verify) via the type arg (#322)
Two task shapes repeat often enough to warrant first-class support
without needing a skill:

- explore — wide-net read-only investigation (20-iter budget)
- verify — narrow yes/no check with evidence (8-iter budget)

Add an optional `type` field to the spawn_subagent schema. When set,
the type supplies the system prompt and default iter budget. Caller-
supplied `system` / `max_iters` override the type's defaults.

Prompts live in src/tools/subagent-types.ts so subagent.ts stays under
the 500-line target.

Closes #319
2026-05-06 01:59:01 -07:00
YHH b248a87455 feat(subagent): expose max_iters on spawn_subagent tool schema (#321)
Subagents have always had a configurable maxToolIters internally, but
the public spawn_subagent schema exposed only task/system/model. Every
spawn paid the 16-iter default, even narrow verify-style tasks that
want fewer or broad explore tasks that want more.

Add an optional `max_iters` integer field to the schema, clamp at the
boundary to 1-32 so a stray prompt can't request thousands, and pipe
through to spawnSubagent. Non-numeric / missing values fall back to
the registration-time default (still 16).

Closes #318
2026-05-06 01:55:12 -07:00
YHH ff858ed744 feat(subagent): honor skill allowed-tools frontmatter when forking child registry (#320)
Skill frontmatter `allowed-tools` was parsed but ignored — every subagent
inherited the parent's full registry minus spawn_subagent / submit_plan,
which gave skill authors no way to scope a child to read-only or
task-specific tools.

Plumb the parsed list through SpawnSubagentOptions; when present, fork
via new forkRegistryWithAllowList helper instead of forkRegistryExcluding.
NEVER_INHERITED still wins so spawn_subagent stays out even if the
skill's allow-list names it. Validate at spawn time — if the allow-list
names a tool the parent doesn't have, return a structured error result
naming the missing tool, no API call burned.

Direct (non-skill) spawn_subagent calls still see full inheritance —
the tool schema is unchanged.

Closes #317
2026-05-06 01:50:08 -07:00
YHH a5adca41ac chore(release): 0.27.3 (#315) 2026-05-06 01:12:27 -07:00
YHH aed4d431cb chore(ui): remove orphan ChromeBar.tsx (#314)
No live render path — `App.tsx` mounts `StatsPanel`, which has its own
`ChromeRow` that diverged from `ChromeBar` and is the bar users actually
see. Only references are a CHANGELOG history line and one test
description string ("'w \$0.91' style").

The two formatter helpers `ChromeBar` once owned (`formatBalance` /
`formatBalanceLabel`) already live in `theme/tokens.ts` after #272 / #313.
2026-05-06 01:06:20 -07:00
YHH 0acfcefb89 fix(ui): top bar cost + subagent end-cost follow wallet currency (#313)
After #272 fixed StatusRow / UsageCard / ChromeBar BalancePill, three
display sites still hardcoded `$` and ignored the wallet currency:

- StatsPanel.ChromeRow (the live top bar) — cost label `[$0.0058]`
- StatsPanel.ChromeRow — balance label inline-formatted, bypassing the
  shared formatBalance helper from #272
- useSubagent end-event summary — ` · $0.0123` cost suffix

Now all three route through formatCost / formatBalance and follow the
live wallet symbol. Render tests added in ui-stats-panel-currency.test.tsx
covering USD / CNY / no-wallet variants.

useSubagent gets a `getWalletCurrency` thunk (not a snapshot value)
because subagent runs can outlive a balance refresh — the thunk reads
the latest currency at end-event time.

Out of scope: ChromeBar.tsx (orphan, no live render path), DiffApp.tsx
(dev tool comparing historical USD numbers), BudgetRow (USD by definition,
user sets `--budget` in USD). SessionPicker tracked separately in #312.
2026-05-06 00:58:47 -07:00
Wesley Vianaandesengine 9cb0bdbc5f Fix: usd currency on status bar (#272)
* fix: Currency for USD wrong afeter TUI redesign.

* red: tests for the currency bug

* Fix implementation

* fixup: collapse tokens helpers + fix UsageCard cost bug

Addresses the two review comments on this PR:

1. Undefined-currency fallback now defaults to CNY for all three helpers
   (formatBalance, formatCost, balanceColor) — matches pre-fix unconditional ¥.
2. Seven helpers (formatCNY, formatBalance, formatBalanceLabel,
   formatWalletDisplay, formatCost, balanceColorCny, balanceColorForBalance)
   collapsed to three: formatBalance, formatCost, balanceColor.

Also fixes a separate bug surfaced while collapsing: UsageCard turn cost
(header) and session cost (body) used formatCNY, which was unconditional ¥
regardless of wallet currency. They now use formatCost(…, balanceCurrency)
and follow the wallet symbol like StatusRow does.

Render tests added for UsageCard turn/session cost in USD wallet mode.

---------

Co-authored-by: esengine <[email protected]>
2026-05-06 00:44:35 -07:00
YHH 2ca403670d refactor(loop): extract message builders, failure tracker, forced-summary helper (#311)
loop.ts 1331 → 1219 (−112). Three sibling files under src/loop/:
- messages.ts: pure ChatMessage builders (assistant + synthetic)
- turn-failure-tracker.ts: per-turn count/types + threshold tipping
- force-summary.ts: forced-summary generator behind a small DI context

Drops two now-unused imports (errorLabelFor, reasonPrefixFor) from loop.ts.
2026-05-06 00:06:31 -07:00
YHH c6e5bf4b02 refactor(loop): move LoopEvent / BranchSummary types + hook helpers out (#309)
PR2 under #307. Three more pieces that didn't depend on `this`:

- new `src/loop/types.ts` — `EventRole`, `BranchSummary`, `BranchProgress`,
  `LoopEvent`. The public-API re-export from `loop.ts` is preserved
  (`export type { ... } from "./loop/types.js"`), so consumers importing
  from `reasonix` see no change.
- new `src/loop/branch.ts` — `summarizeBranch` lives next to the types
  it produces.
- new `src/loop/hook-events.ts` — `safeParseToolArgs` + `hookWarnings`.
  These format hook outcomes as `LoopEvent`s and were the only callers
  of `formatHookOutcomeMessage` / `HookOutcome` from `./hooks.js`, so
  those imports leave `loop.ts` too.

`loop.ts`: 1414 → 1332 lines. No public API change. `npm run verify`
green (2336 pass / 1 skip).
2026-05-05 21:07:20 -07:00
YHH 08c7f2be81 refactor(loop): extract escalation marker + summary-reason helpers (#308)
PR1 under #307. Six functions and three constants that don't touch
`this` had no business living inside the `CacheFirstLoop` class file.

- new `src/loop/escalation.ts` — `parseEscalationMarker`,
  `isEscalationRequest`, `looksLikePartialEscalationMarker` plus
  `NEEDS_PRO_MARKER_PREFIX` / `NEEDS_PRO_MARKER_RE` /
  `NEEDS_PRO_BUFFER_CHARS`. The streaming chunk consumer + post-call
  retry path call these as plain functions now.
- `src/loop/errors.ts` gains `reasonPrefixFor` + `errorLabelFor` next
  to `formatLoopError`. They share the same `"budget" | "aborted" |
  "context-guard" | "stuck"` reason union and were the only callers.

`loop.ts`: 1463 → 1414 lines. No public API change — these were all
private / file-local. `npm run verify` green (2336 pass / 1 skip).
2026-05-05 21:02:53 -07:00
YHH 8befb44e5e fix(web): rewrite htmlToText with a real HTML parser (#306)
The chain-of-regex approach was tripping CodeQL across three rules
(bad-tag-filter, incomplete-multi-character-sanitization,
polynomial-redos) on the same set of patterns:

- `<tag[\s\S]*?</tag>` misses `</tag >` (whitespace before `>`) and
  re-exposes `<tag` after one removal pass on `<scr<tag>ipt>`-shaped
  input.
- `<[^>]+>` doesn't handle attribute values containing `>` (rare but
  legal in unquoted contexts) and is the canonical "incomplete
  sanitization" footgun.

Replaces both with a `node-html-parser` walk:

- `htmlToText` parses the input, removes `script / style / noscript /
  nav / footer / aside / svg` blocks via `querySelectorAll`, then
  walks the surviving tree and emits text with `\n` breaks at the
  block-level tags we care about (`p`, `div`, `br`, `h1-6`, `li`,
  `tr`, `section`, `article`).
- `stripHtml` (used by `parseMojeekResults`) becomes `parseHtml(s).text`.
- `decodeHtmlEntities` stays — kept for the post-parser pass since
  parser-decoded text can still contain numeric entities in edge
  attribute contexts.
- 5 MB input cap retained.

`node-html-parser` 7.x: 25 KB gzip, zero deps, ~30k packages
depend on it, active maintenance. Lighter than `parse5` for our
use case (we don't need full HTML5 conformance — just the tree).

21 web-tools tests pass unchanged; 2336 total tests green.
2026-05-05 20:38:23 -07:00
zerone0x b65ed747c9 fix(events): emit tool audit trail events (#260) (#304) 2026-05-05 20:28:24 -07:00
zerone0x 86be00299e fix(mcp): make inspect errors actionable (#101) (#303) 2026-05-05 20:24:10 -07:00
YHH b634b5e171 fix(codeql): drain 3 real bugs flagged by CodeQL (#305)
- src/cli/ui/Wizard.tsx — quoteIfNeeded escaped `"` but not `\`. A
  trailing backslash on the input would consume the closing quote
  when a downstream parser un-escaped the result. Escape `\` first
  (CodeQL js/incomplete-sanitization).
- src/cli/ui/clipboard.ts — temp filename was `reasonix-clip-${Date.now()}.txt`,
  predictable + collides under sub-millisecond bursts. Switched to
  `randomUUID()` (CodeQL js/insecure-temporary-file).
- tests/slash.test.ts — `/kb.example.com/` matched too broadly (`.`
  matched any char, no anchors). Tightened to
  `/\bkb\.example\.com\b/` (CodeQL js/incomplete-hostname-regexp +
  js/regex/missing-regexp-anchor).

136 wizard + slash tests pass unchanged.
2026-05-05 20:13:59 -07:00
YHH 3e91082456 fix(edit-blocks): close exists→write and read→write races (CodeQL) (#301)
Drains the last 2 file-system-race warnings in code/edit-blocks.ts.
Replaces the existsSync precheck + separate read/write pair with a
single fd-scoped flow:

- Try `openSync(path, "r+")` first. ENOENT → file doesn't exist:
  bail with `file-missing` (when SEARCH is non-empty) or fall through
  to atomic create.
- Atomic create is `openSync(path, "wx")` — fails on EEXIST so a
  concurrent writer that filled the slot between our `r+` ENOENT and
  our create can't be silently clobbered.
- Modify path keeps one fd open through fstat → read → ftruncate →
  write. ftruncate sets the new length first (so a shorter result
  doesn't leave stale tail bytes), then we overwrite from offset 0.

The 22 edit-blocks tests pass unchanged.

1 file-system-race warning still open (`usage.ts:103` compaction);
needs temp-file + rename, separate PR.
2026-05-05 20:01:04 -07:00
YHH 638eaeeb46 fix(memory): close exists→write race in hash-memory append (CodeQL) (#300)
Drains the last 2 file-system-race warnings in cli/ui that were
exists→write rather than stat→read.

The original `appendBulletToFile` had two races:

1. `existsSync(path)` then `writeFileSync(path, header)` — between the
   check and the write a concurrent appender could create the file,
   and our writeFileSync would clobber it with a fresh header (losing
   their bullet).
2. `readFileSync(path)` then `appendFileSync(path, …)` — peeking the
   trailing byte to decide whether we need a leading `\n`, then a
   separate append. A concurrent writer that added their own newline
   in between would cause us to insert a redundant blank line.

Fix:

- Atomic create-or-fail via `openSync(path, "ax")`. EEXIST means
  another process won the race; we fall through to the append branch
  rather than clobbering.
- For the append branch, open with `a+` once and do the trailing-byte
  peek + write on the same fd. `O_APPEND` means each `writeSync`
  atomically lands at end-of-file regardless of concurrent appenders.

20 hash-memory tests pass unchanged.

3 file-system-race warnings still open: edit-blocks.ts (×2) and
usage.ts:103. They need different surgery (`r+` + ftruncate /
read-modify-write or temp-file + rename); separate PR.
2026-05-05 20:01:00 -07:00
YHH 1a7b88c72f fix(fs): close 4 more stat→read TOCTOU races (CodeQL js/file-system-race) (#299)
Same fix shape as #298 — open once, do the size check and the read
against the same fd. The read on filtered semantic indexes also gets
to enforce its size threshold against the actual bytes we're about
to load, not a stat result that could be stale by the time we look.

- src/index/semantic/chunker.ts:209  per-file read in the indexer —
  re-checks `maxFileBytes` against `fh.stat()` before reading
- src/server/api/semantic.ts:200    `/api/semantic/status` index
  summariser — was `statSync().size` then `readFileSync`
- src/cli/ui/slash/handlers/semantic.ts:78  `/semantic` slash status —
  was `fs.stat` then `fs.readFile`; estimate fallback for >10MB stays
  the same, just bound to the fd's stat
- src/telemetry/usage.ts:74        compactUsageLogIfLarge — folds the
  separate stat / read try-blocks into one fd-scoped sequence

5 file-system-race warnings remain — those are exists→write or
read→write patterns (`hash-memory.ts`, `edit-blocks.ts`,
`usage.ts:103`); separate PR with `openSync(path, "ax" | "r+")`.
2026-05-05 20:00:55 -07:00
YHH 6ee2428802 fix(fs): close 3 stat→read TOCTOU races (CodeQL js/file-system-race) (#298)
Same fix shape across three files: open once, use fstat + read against
the same fd. The fd binding pins the inode for the lifetime of the
operation so a concurrent rename / replace / unlink during the call
can't desync the size or mtime we just observed from the bytes we're
about to read.

- src/tools/filesystem.ts:127  read_file body — was `fs.stat` then
  `fs.readFile`; switches to async `fs.open` + `fh.stat` + `fh.readFile`
- src/server/assets.ts:36      mtime-keyed asset cache — was `statSync`
  then `readFileSync`; switches to `openSync` + `fstatSync` + chunked
  `readSync`
- src/server/api/skills.ts:72  per-skill listing — same shape; also
  drops the redundant `existsSync` precheck that was a separate race
  in the same chain

9 file-system-race warnings remain — those are exists→write or
read→write patterns that need a different fix (open with `ax` / `r+`
flags); separate PR.
2026-05-05 19:28:21 -07:00
YHH febecb3d6f refactor(app): extract codeApply / codeDiscard into useCodeMode hook (#290) (#296)
Phase-2 PR under #290 (start of the feature-cluster phase). The
/apply and /discard slash callbacks move into one hook (93 lines).
The hook owns the partition / snapshot / save / sync sequence and
returns thin string-builders that AppInner forwards to its log.

Drops `partitionEdits` (unused after extraction) and `savePendingEdits`
(only used by the extracted callbacks) from App.tsx's imports.

App.tsx: 3361 → 3304 lines.
2026-05-05 19:18:08 -07:00
YHH 795a1e2f35 refactor(app): extract /loop machinery into useLoopMode hook (#290) (#295)
Phase-1 PR under #290 — biggest single extraction so far. The /loop
slash command's entire lifecycle moves into one hook:

- the activeLoop state + the four refs (mirror, timer handle, firing
  flag, and the timer scheduler effect itself)
- startLoop / stopLoop / getLoopStatus
- the timer scheduler useEffect (35 lines)

External consumers no longer poke at refs directly. The hook exposes:

- startLoop / stopLoop / getLoopStatus (forwarded to slash handlers)
- isLoopActive() — replaces all `activeLoopRef.current` checks (Esc
  handlers, apply-slash-result)
- isLoopFiring() / clearFiringFlag() — replace the cross-component
  `loopFiringRef.current` reads in handleSubmit's cancel guard
- activeLoop — reactive state for the LoopStatusRow render

apply-slash-result.ts's context shape switches `activeLoopRef:
MutableRefObject<...>` to `isLoopActive: () => boolean`. Cleaner —
slash handlers don't need to know about the ref shape.

App.tsx: 3499 → 3400 lines.
2026-05-05 19:07:11 -07:00
YHH 77cd4f7f66 refactor(app): extract quitProcess into useQuit hook (#290) (#293)
Phase-1 PR under #290. The Ctrl+C / SIGINT exit handler now lives in
its own hook: it returns the `quitProcess` callback and registers /
deregisters the SIGINT listener on its own. AppInner just calls
`useQuit(transcriptRef)` and forwards the returned function to the
slash dispatcher and Ctrl+C interceptor.

The "why process.exit, not Ink exit" rationale moves with the code
into the hook's docstring — that's the only place future callers
need to read it.

App.tsx: 3499 → 3483 lines.
2026-05-05 19:01:13 -07:00
YHH 3ec46356f5 refactor(app): extract writeTranscript into useTranscriptWriter hook (#290) (#294)
Phase-1 PR under #290. The transcript writer becomes its own hook —
takes the transcript ref + the model/prefix metadata it needs to
stamp on each record, returns the LoopEvent writer that AppInner
forwards into stream subscribers and slash handlers.

Drops `recordFromLoopEvent` + `writeRecord` from App.tsx's import
block since the hook now owns those.

App.tsx: 3499 → 3493 lines.
2026-05-05 18:58:53 -07:00
YHH 825c6e9608 refactor(app): extract input-recall into useInputRecall hook (#290) (#292)
First Phase-1 PR under #290. Lifts the bash-style ↑/↓ history recall
out of AppInner: the two `useRef`s (`promptHistory`, `historyCursor`)
and the two `useCallback`s (`recallPrev`, `recallNext`) are now
co-located in `src/cli/ui/hooks/useInputRecall.ts`.

The hook returns four functions — `recallPrev` / `recallNext` for
PromptInput, plus `pushHistory` (called on every successful turn) and
`resetCursor` (called once on submit). Existing callers that wrote
`promptHistory.current.push(text)` switch to `pushHistory(text)`;
`historyCursor.current = -1` becomes `resetCursor()`.

`apply-slash-result.ts` had the only out-of-component reference to
the ref — its context shape now accepts `pushHistory: (s: string)
=> void` instead of the raw `MutableRefObject`. Cleaner contract.

App.tsx: 3499 → 3479 lines.
2026-05-05 18:58:47 -07:00
YHH affa03b235 fix: drain CodeQL warnings — polynomial redos + web.ts htmlToText (#291)
* fix(redos): close polynomial backtracking in 4 regexes

CodeQL js/polynomial-redos flagged four regexes whose backtracking
shape is O(n²) on adversarial input. None are reachable from a real
attacker today (the inputs are model output / our own URL constants),
but the patterns are easy to fix and the alerts were noise.

- at-mentions.ts:319 — `/\.+$/` on path-like tokens. Loop-slice instead.
- client.ts:95 — `/\/+$/` on baseUrl. Loop-slice instead.
- scavenge.ts:67/75/86 — three DSML matchers (`<X>[\s\S]*?</X>` shape)
  on reasoning_content. Adds a 100 KB input cap with an early-bail
  note; the matchers themselves stay readable.

Per CodeQL's third remediation example, bounding the input length is
a valid fix when the regex is hard to rewrite — scavenge is best-effort
and the cap is well above any plausible legitimate reasoning_content.

* fix(web): drain CodeQL warnings on htmlToText

htmlToText leaned on a chain of regex replacements that CodeQL flagged
across six rules — bad-tag-filter, incomplete-multi-character-
sanitization, polynomial-redos (×2), double-escaping. None were
exploitable in our pipeline (output goes to the LLM, not a browser),
but the patterns had real correctness bugs visible to the model:

- `<script[\s\S]*?</script>` missed `</script >` (whitespace before
  `>`) and could leave `<script` behind on the second pass when an
  outer match revealed a previously-hidden inner opening.
- Chained `decodeHtmlEntities` replacements ran `&amp;` before `&lt;`,
  so `&amp;lt;` decoded to `<` instead of literal `&lt;`.

Rewrites both:

- `stripTagBlock(s, tag)` — indexOf-based scan with explicit tag-
  boundary check (avoids matching `<scriptfoo>`) and tolerant close
  matching (`</tag …>` with whitespace).
- `decodeHtmlEntities` — single regex pass with an entity map plus
  `&#nnn;` / `&#xHH;` numeric-entity handling. No more order bugs.
- 5 MB input cap on htmlToText so any residual quadratic patterns
  (e.g. the kept `<[^>]+>` strip) stay bounded.

21 web-tools tests still pass.
2026-05-05 18:58:43 -07:00
YHH 4ba0199c42 chore(tsconfig): add @/* path alias for src; migrate slash handlers (#289)
* chore(tsconfig): add @/* path alias for src; migrate slash handlers

The 16 files under src/cli/ui/slash/handlers/ sit four levels deep, so
every import to a top-level module read "../../../../i18n/index.js" or
the equivalent. The depth was load-bearing for nothing — it just made
moves and reviews painful. Adds a single \`@/* → src/*\` alias to
tsconfig.json and mirrors it in vitest.config.ts (esbuild via tsup
already picks up tsconfig paths transparently).

Migrates only the four-level offenders for now. Three-level imports
(siblings within cli/ that resolve to "@/cli/...") are left alone —
the relative form is arguably clearer when the import target is a
neighbour. A follow-up can tighten that if it's worth the churn.

Build, typecheck, and full test suite all pass on the migrated paths.

* fix(jobs): wait for actual close event after SIGKILL, not a fixed timer

stop() had two timing races. Both manifested as the same flake — the
returned snapshot's `running` flag could still be true even though the
test (and the user) just told us to stop the job.

1. SIGKILL phase used a fixed 800ms timer, then returned regardless of
   whether the OS had reaped the process tree. Under Windows scheduler
   load, taskkill /T /F on a 3-level tree (npm → node → vite) can take
   over a second to propagate before Node's `close` event fires.
2. SIGTERM phase awaited `readyPromise`, which is dual-purpose: it
   fires on either a startup ready-signal regex match OR child exit.
   If the job had already matched a ready signal, `readyPromise` was
   already resolved, so the SIGTERM grace race short-circuited
   immediately and we'd jump to SIGKILL with zero pause.

Adds `closedPromise` — fires only on close/error, never on ready
signal — and uses it for both the SIGTERM grace race and the post-
SIGKILL wait, with a 5s ceiling on the latter so a wedged kernel
can't hang us indefinitely.
2026-05-05 18:25:09 -07:00
YHH 5f80b252e8 refactor(filesystem): extract edit + search bodies into src/tools/fs/ (#287)
* refactor(filesystem): extract edit + search bodies into src/tools/fs/

filesystem.ts had grown to 681 lines, with three of the registered
tools — edit_file, search_files, search_content — carrying most of
the weight in their inline `fn:` bodies. The diff helpers (renderEditDiff,
lineDiff) were also trailing the registration code with no good reason
to live in the same file.

- src/tools/fs/edit.ts — applyEdit + lineDiff + renderEditDiff (the LCS-based diff renderer is now in the same file as its only caller).
- src/tools/fs/search.ts — searchFiles + searchContent share a SearchContext shape so each tool's `fn:` becomes a one-line dispatch.
- src/tools/filesystem.ts — registration entries kept; bodies are now thin pass-throughs.

`lineDiff` is re-exported from filesystem.ts so the existing test import
(`tests/filesystem-tools.test.ts`) doesn't move.

No public API change. No behavior change. filesystem.ts: 681 → 486 lines.

* fix(filesystem): close stat→read race in search_content (CodeQL)

Replaces the separate `fs.stat(full)` + `fs.readFile(full)` pair with a
single `fs.open` → `fh.stat` → `fh.readFile` sequence so the size check
and the actual read bind to the same fd / inode. No TOCTOU window.

Functionally equivalent: oversized files still skip cheaply (we never
read past the size check), small files still read in one shot.
2026-05-05 18:24:51 -07:00
YHH 4b77671103 refactor(shell): split tools/shell.ts into parse + exec submodules (#286)
shell.ts had grown to 847 lines, mixing three unrelated concerns:
allowlist parsing (which is pure string work and the security gate),
process spawning (which carries all the Windows / PowerShell / GBK
workarounds), and tool registration with the ToolRegistry. Each lives
in its own file now:

- src/tools/shell/parse.ts — tokenizer, allowlist, RISKY_ARGS demotion
- src/tools/shell/exec.ts — runCommand, prepareSpawn, killProcessTree, all the Windows quirks
- src/tools/shell.ts — registerShellTools, NeedsConfirmationError, format helpers, plus re-exports

No public API change — every name still importable from ./shell.js.
No behavior change. shell.ts: 847 → 304 lines.
2026-05-05 18:24:46 -07:00
YHH 900eae2a3a refactor(loop): extract heal/shrink/thinking/errors helpers into src/loop/ (#284)
loop.ts had grown to 1724 lines, mixing the CacheFirstLoop streaming
class with a dozen helper functions across four unrelated concerns:
message healing, oversized-result shrinking, thinking-mode flags, and
DeepSeek error formatting. Each concern now lives in its own module
under src/loop/, and loop.ts re-exports every public name so external
callers don't move.

No behavior change. loop.ts: 1724 → 1459 lines.
2026-05-05 18:24:42 -07:00
YHH 94047eadf3 fix(jobs): close stop() race condition; drop useless \$ escapes (#288)
* fix(benchmark): drop useless \$ escapes in tdd-eval prompt string

The h1 task description string used `\${file}::\${fullName}` inside a
regular `"..."` literal. The backslashes are no-ops (JS quietly drops
unrecognized escapes in regular strings) but trip CodeQL's
js/useless-regexp-character-escape rule. Identical resulting string,
cleaner source.

* fix(jobs): wait for actual close event after SIGKILL, not a fixed timer

stop() had two timing races. Both manifested as the same flake — the
returned snapshot's `running` flag could still be true even though the
test (and the user) just told us to stop the job.

1. SIGKILL phase used a fixed 800ms timer, then returned regardless of
   whether the OS had reaped the process tree. Under Windows scheduler
   load, taskkill /T /F on a 3-level tree (npm → node → vite) can take
   over a second to propagate before Node's `close` event fires.
2. SIGTERM phase awaited `readyPromise`, which is dual-purpose: it
   fires on either a startup ready-signal regex match OR child exit.
   If the job had already matched a ready signal, `readyPromise` was
   already resolved, so the SIGTERM grace race short-circuited
   immediately and we'd jump to SIGKILL with zero pause.

Adds `closedPromise` — fires only on close/error, never on ready
signal — and uses it for both the SIGTERM grace race and the post-
SIGKILL wait, with a 5s ceiling on the latter so a wedged kernel
can't hang us indefinitely.
2026-05-05 18:24:17 -07:00
YHH 0945a1189e test(comment-policy): ban bare TODO, FIXME, and translator notes (#285)
TODO/HACK markers without a (#nnn) issue anchor are debt leakage —
they accumulate without ever being tracked. Require the anchor; bare
markers fail the comment-policy gate.

FIXME is banned outright: either fix it now or open an issue and turn
it into a TODO(#nnn). The marker without follow-through is just noise.

Translator-note comments in i18n files ("english version", "中文版",
etc.) restate what the strings already say — i18n entries are
self-documenting.

src/ currently has zero violations of any of these rules, so this is a
floor-keeper, not a cleanup.
2026-05-05 18:24:13 -07:00
YHH bba8199ba4 chore(test): retry once on flaky tests (#282)
jobs.test.ts / loop.test.ts / bundle-smoke spawn real processes and
load the tokenizer cold; on Windows under load these intermittently
miss their wall-clock budget. One retry absorbs the scheduler
hiccup; a real failure still re-fails on the second run.
2026-05-05 16:05:58 -07:00
esengine fc8639a697 chore(release): 0.27.2 2026-05-05 15:51:25 -07:00
YHH 8c51dd6f0f fix(settings): preserve fields when settings POST writes more than one (#281)
handleSettings read cfg once up front, then per-field either called a
self-contained helper (setLanguage / saveApiKey / saveReasoningEffort)
or mutated the snapshot and called writeConfig — so a combined POST
like { lang, baseUrl } would write lang via the helper, then clobber
it with the stale snapshot in the writeConfig branch.

Single read up top, all field updates accumulate into one cfg, single
writeConfig at the end. Runtime side-effects (setLanguage,
applyPresetLive, applyEffortLive) fire after the disk write succeeds
so disk + runtime state can't diverge mid-request.

Closes #274
2026-05-05 15:48:51 -07:00
YHH 56f1cbb835 fix(filesystem): search_content.glob accepts real glob patterns (#280)
The `glob` arg was always a `name.toLowerCase().includes(...)` substring
filter, so `*.ts` matched the literal three-char filename instead of
TypeScript files. Param name advertised glob, behavior didn't.

Now glob via picomatch when the value contains `*`, `?`, `{`, or `[`;
case-insensitive substring otherwise so existing `.ts` / `test` callers
keep working. Patterns containing `/` match against the rel-path
(enabling `src/**/*.tsx`), basename otherwise.

Closes #273
2026-05-05 15:42:25 -07:00
YHH abb1b1ec7a fix(filesystem): search_files skips dependency/build dirs by default (#279)
Aligns search_files with search_content + directory_tree: skip the
existing SKIP_DIR_NAMES set (node_modules / .git / dist / build /
.next / target / .venv) by default, accept include_deps:true to opt
back into walking them. Fixes the surprising mismatch where
search_content("foo") and search_files("foo") had different default
scopes.

Closes #267
2026-05-05 15:32:18 -07:00
ctharveyandCharles H 4f1e65a629 fix(filesystem): slash-normalize tool output paths (#270)
Co-authored-by: Charles H <[email protected]>
2026-05-05 14:54:57 -07:00
ctharveyandCharles H 7096e43895 fix(shell): reject cd in parsed chains (#277)
Co-authored-by: Charles H <[email protected]>
2026-05-05 14:54:45 -07:00
esengine 8543c88034 chore(release): 0.27.1 2026-05-05 09:05:19 -07:00
YHH bdfb04c52b fix(shell): preserve Windows path backslashes inside quoted args (#266)
Inside `"..."` the parser treated any `\X` as a generic escape and
dropped the backslash, so `dir /b "C:\Users\foo\.bar"` ran against
`C:\Users\foo.bar`. Restrict the escape to `\"` and `\` only —
everything else stays literal so Windows path separators survive.

Single `isDqEscape` helper now shared by `tokenizeCommand`,
`detectShellOperator`, `splitOnChainOps`, and `parseSegment`.

Closes #265
2026-05-05 09:04:23 -07:00
108 changed files with 5182 additions and 2305 deletions

No files matched your search

+135
View File
@@ -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
+1 -1
View File
@@ -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." },
];
+23
View File
@@ -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
+135 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+8
View File
@@ -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":
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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 &&
-259
View File
@@ -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);
}
+3 -2
View File
@@ -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}` : "";
+4 -1
View File
@@ -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;
}
+10 -6
View File
@@ -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>
);
+6 -2
View File
@@ -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
View File
@@ -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 };
}
+5 -12
View File
@@ -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" };
}
+93
View File
@@ -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 };
}
+41
View File
@@ -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 };
}
+143
View File
@@ -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,
};
}
+19
View File
@@ -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;
}
+2
View File
@@ -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;
+20
View File
@@ -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],
);
}
+75 -14
View File
@@ -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;
}
+10 -12
View File
@@ -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 -4
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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) => {
+3 -3
View File
@@ -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 -1
View File
@@ -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 = [
+2 -2
View File
@@ -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 {
+2 -7
View File
@@ -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> = {
+2 -2
View File
@@ -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";
+3 -7
View File
@@ -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";
+2 -2
View File
@@ -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";
+4 -4
View File
@@ -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 -3
View File
@@ -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) => {
+2 -2
View File
@@ -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) => {
+19 -10
View File
@@ -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")) {
+2 -2
View File
@@ -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 });
+2 -2
View File
@@ -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) => {
+1
View File
@@ -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;
+1
View File
@@ -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. */
+2
View File
@@ -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(),
});
+1
View File
@@ -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,
});
+1
View File
@@ -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 };
+28 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
+21
View File
@@ -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;
}
+65
View File
@@ -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,
+30
View File
@@ -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
+65
View File
@@ -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. */
+24 -16
View File
@@ -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" };
}
}
+1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -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),
};
}
+71
View File
@@ -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;
}
+31
View File
@@ -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;
}
+79
View File
@@ -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: "" };
}
}
+99
View File
@@ -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,
};
}
+18
View File
@@ -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) };
}
}
+28
View File
@@ -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, "");
}
+126
View File
@@ -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);
}
+26
View File
@@ -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();
}
+38
View File
@@ -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)`;
}
}
+59
View File
@@ -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;
}
+9
View File
@@ -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[] = [];
+22 -4
View File
@@ -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
View File
@@ -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 } };
+22 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
}
}
}
+24
View File
@@ -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
View File
@@ -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;
}
+93
View File
@@ -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;
}
+164
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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: {
+15 -3
View File
@@ -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
View File
@@ -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();
+337
View File
@@ -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, '""')}"`;
}
+230
View File
@@ -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));
}
+1
View File
@@ -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: {
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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 `&amp;lt;` into `<` because `&amp;` ran before `&lt;`. */
function decodeHtmlEntities(s: string): string {
return s
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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: {
+51
View File
@@ -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) {
+7
View File
@@ -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", () => {
+162 -2
View File
@@ -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("\\");
});
});
+207
View File
@@ -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;
}
});
});
});
+39
View File
@@ -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");
});
});
+66
View File
@@ -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();
});
});
+122
View File
@@ -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");
});
});
+24
View File
@@ -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", () => {
+23
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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