Compare commits

...
51 Commits
Author SHA1 Message Date
YHH 6379bc0628 chore(release): 0.30.0 (#349) 2026-05-06 18:06:28 -07:00
YHH 82c5627123 fix(renderer): use CHA absolute for column moves in cell-diff (#346) (#348)
Switch the X-axis branch of `moveTo()` from CUF (relative cursor right,
`\x1b[NC`) to CHA (absolute column, `\x1b[N+1G`). Y-axis stays on
CUU/CUD since we don't track absolute terminal rows.

Relative column moves accumulate drift across frames: if any earlier
write miscounted cell width — wide-char disagreement (`▸` U+25B8 in
"narrow" fonts that render 2-cell), `▎` ambiguous-width on terminals
that font-detect East Asian width, OSC8 hyperlinks parsed as visible
chars, etc. — the cursor's actual column desyncs from what the
renderer thinks. The next CUF lands at the wrong column, ghost-rows
get written into hint lines, and the modal "shifts" as users navigate
(symptom in #346 issue screenshots).

Anthropic's Claude Code TUI hit the same class of bug
(anthropics/claude-code#14208) and the fix was the same: prefer CHA
over CUF/CUB so column targeting is immune to per-cell width
miscounts. Fix is invisible on terminals where our width math was
already correct; on terminals where it wasn't, no more accumulating
drift.
2026-05-06 18:02:08 -07:00
YHH e991644416 feat(slash): first-class command aliases (#332) (#347)
Add `aliases?: readonly string[]` to SlashCommandSpec so /quit + /q
resolve to /exit, /? to /help, /reset to /new, /lang to /language —
all from a single declaration on the spec instead of ad-hoc handler
mirrors scattered across handlers/basic.ts + handlers/language.ts.

dispatch, suggestSlashCommands and detectSlashArgContext all route
through one resolveSlashAlias() built from SLASH_COMMANDS at module
init. Suggestion rows surface aliases dimly (` · /quit /q`) so they
stay discoverable without polluting the autocomplete list with
duplicates. Dashboard /api/slash response now exposes the field too.
2026-05-06 18:01:54 -07:00
YHH 39dbe19bcd chore(release): 0.29.1 (#345) 2026-05-06 17:07:23 -07:00
YHH c1e8a933d3 fix(markdown): row-group the table fallback layout (#340) (#344)
When a markdown table is too wide for the viewport, FallbackTable
flattens it into key/value lines. The previous implementation
iterated headers first, then rows-within-each-header — so a 3×N
table came out as N "Component:" lines, then N "What:" lines,
then N "Manual TCs:" lines. The user couldn't tell which value
belonged to which row.

Reproduction (issue #340): a planning table for 9 components
rendered as 9 grouped Component lines, 9 grouped What lines,
9 grouped Manual TCs lines — visually a list, not a table.

Swap loop order: iterate rows first, then write all columns
inside each row block, separated by a blank row between rows.
Output now reads top-to-bottom as one logical row at a time.
2026-05-06 16:55:20 -07:00
YHH b401913910 fix(plan): show plan body in PlanConfirm modal (#336) (#343)
When the model called submit_plan with a markdown body but no
structured steps, the Approve plan modal showed only the
warning banner + the choice list — the actual plan content
was hidden behind the collapsed tool-call card in scrollback.
Users had no way to see what they were approving without
hunting up through the transcript.

- Render the plan body via MarkdownView when no steps are
  supplied. Steps still take precedence when present (they
  are the canonical structured view).
- Cap inline preview at 24 lines; surface "… N more lines
  above in scrollback" when the body exceeds that. Reserve
  budget grows with whichever body section is being shown.

Tests cover the three branches: body-only, steps-present,
and truncated-body-with-overflow-hint.
2026-05-06 16:44:59 -07:00
YHH 5662b57a4b fix(stats): carry session cost / turn count across resume (#333) (#342)
The TUI's session-cost figure ($X session) reset to $0 on every
resume because SessionStats was rebuilt empty even when the disk
meta still held the cumulative totalCostUsd from prior runs.

Reproduction (issue #333): hello → exit → resume → expense
accumulator displays 0 instead of the prior session total.

The persistence path was already correct — App.tsx writes
`{ totalCostUsd, turnCount }` into the session meta after every
turn. Only the read-back was missing.

- SessionStats gains a `seedCarryover()` once-at-construction hook
  that adds prior totals to the in-memory totalCost / summary.turns
  without polluting the live `turns[]` array (which intentionally
  holds only this run's per-turn records).
- CacheFirstLoop now calls `loadSessionMeta()` on resume and seeds
  the carryover when prior messages exist. Fresh sessions are
  unaffected — undefined meta fields short-circuit the seed.

Tests cover the carryover math (telemetry.test.ts) and the
end-to-end loop wiring (session.test.ts).
2026-05-06 16:38:13 -07:00
YHH e40e1911cc fix: issue #330 — markdown osc8 garbage + cell-diff shrink-row leak (#341)
* fix(markdown): stop linkifying "e.g" / "i.e" / drop OSC 8 garbage (#330)

Two coupled bugs in the markdown file-ref linkifier:

1. FILE_REF_RE matched English abbreviations because the extension
   class was `{1,6}` — `e.g`, `i.e`, `a.m` all qualified.
2. `osc8()` baked OSC 8 escape bytes (\x1b]8;;...\x07) into Text
   content. The cell-diff renderer's wrapLine strips zero-width chars
   (ESC, BEL) but keeps the printable body, so terminals received
   plain `]8;;file://e.ge.g]8;;` characters — visible garbage on
   any terminal, with no working hyperlink anywhere.

The fix:

- Tighten FILE_REF_RE post-match: require either path-shape (slash),
  line-number suffix, or extension >= 2 chars. `src/foo.ts`,
  `package.json`, `util.c:42` still match; bare `util.c` no longer
  does (acceptable — rare in prose).
- Strip the OSC 8 escape from `osc8()` — keep color + underline
  styling so file refs stand out, but do not emit hyperlink bytes.
  The renderer would need per-span hyperlink propagation through
  nested HostText flattening to support real OSC 8; that's a
  separate refactor. Until then, no link beats broken link.
- Plumb a `hyperlink` prop on ink-compat Text so the future
  per-span fix has a place to land.

Regression test mounts Markdown through the reconciler and asserts
the byte stream never contains `]8;;` for `e.g` / `i.e` samples.

* fix(renderer): trail-clear shrunk rows in cell-diff (#330)

Defensive against terminal-state desync. The cell-diff renderer
skips cells where prev and next are byte-equal — including the
trailing EMPTY cells of a row that shrank. If the terminal's
actual state diverged from prev (e.g. a stray ESC body slipped
through earlier rendering), those skipped trailing cells retain
stale chars from prior frames.

This was the second half of issue #330: with the markdown OSC 8
garbage previously sourcing the desync, the option-list modal
rendered "allow always" + "mand, ask again next time" leftover
on rows that originally held a longer hint.

- New `clearToEOL` patch type → serializes to `\x1b[K`
- After diffEach, sweep rows: if next's row ends earlier than
  prev's content extent, emit clearToEOL past the last visible
  cell. Bounded — at most one clearToEOL per row that shrank.
- Two regression tests cover the modal-shrink case and verify
  no clearToEOL is emitted for same-width swaps.
2026-05-06 16:32:00 -07:00
YHH fdea3bb837 fix(dashboard): wire up filter chips + mark static indicators (#331)
The .chip-f class has cursor:pointer + a hover style, so every chip
looked clickable. In skills, plans, and hooks the chips were laid out
as filter buttons (one hardcoded "active") but had no onClick — clicks
did nothing. Other panels reused chip-f for non-interactive count
indicators, which had the same misleading hover affordance.

Real filters wired:

- skills: All / Project / Global / Builtin filters the list by scope
- plans: All / Active / Done filters by completion status
- hooks: Resolved + each event filters the matrix to rows whose hooks
  fire on the selected event (matrix columns still show all events)

Static indicators marked with the new `chip-f.static` modifier (cursor
default, no hover lift). Sessions count, permissions Project/Builtin
counts, usage records/log size, tools total + plan-mode flag, chat
mode label, semantic index status, and semantic config-tag chips all
no longer pretend to be clickable.

CSS: new `.chip-f.static` + `.chip-f.static:hover` rules disable the
interactive affordances without duplicating the chip styling.
2026-05-06 08:58:58 -07:00
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
135 changed files with 5748 additions and 2392 deletions

No files matched your search

+227
View File
@@ -3,6 +3,233 @@
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.30.0] — 2026-05-06
**Headline:** slash commands grow first-class aliases, and the
cell-diff renderer hardens column targeting against per-cell width
miscounts. `/quit` and `/q` now resolve to `/exit` from a single
declaration on the spec instead of ad-hoc handler mirrors; `/?` →
`/help`, `/reset` → `/new`, `/lang` → `/language` follow the same
path. The renderer's `moveTo()` now uses CHA absolute (`\x1b[N+1G`)
for column targeting instead of CUF relative (`\x1b[NC`), making the
diff stream immune to the cursor-drift class of bug Anthropic
documented in `claude-code#14208`.
**Features:**
- feat(slash): `aliases?: readonly string[]` on `SlashCommandSpec`.
Adding a new alias is now a one-line edit to the canonical command
— dispatch, autocomplete, arg-context resolution, and the
dashboard `/api/slash` response all route through one
`resolveSlashAlias()` map built from `SLASH_COMMANDS` at module
init. Suggestion rows display aliases dimly (` · /quit /q`) so
they stay discoverable without doubling the autocomplete list.
Removes the per-handler alias mirrors that used to live in
`handlers/basic.ts` and `handlers/language.ts`. (#332, PR #347)
**Bug fixes:**
- fix(renderer): switch the X-axis branch of `moveTo()` from CUF
relative (`\x1b[NC`) to CHA absolute (`\x1b[N+1G`). Y-axis stays
on CUU/CUD since we don't track absolute terminal rows. Relative
column moves accumulate drift across frames whenever an earlier
write miscounts cell width — `▸` (U+25B8) rendered 2-cell on
fonts with East Asian fallback, ambiguous-width chars on
terminals that font-detect width, OSC8 hyperlinks parsed as
visible chars, etc. The next CUF lands at the wrong column,
ghost rows leak into adjacent hint lines, and the modal "shifts"
as users navigate. CHA targets the absolute column regardless of
what the terminal thinks — immune to the desync chain. Same fix
Anthropic shipped in claude-code per their issue #14208
post-mortem. (#346, PR #348)
## [0.29.1] — 2026-05-06
**Headline:** four user-reported bugs from the 0.29.0 release window.
The markdown renderer no longer turns English abbreviations like
`e.g.` into broken hyperlinks (which on cmd.exe / non-OSC-8 terminals
showed up as visible `]8;;file://e.g…` garbage and on the cell-diff
side desynced the renderer's prev-frame model). The cell-diff
renderer now defensively trail-clears any row whose content shrank
between frames. Resumed sessions keep their cumulative session cost
instead of resetting to `$0`. The Approve plan modal now shows the
plan body inline when the model didn't supply structured steps.
Wide markdown tables fall back to row-grouped key/value lines
instead of the previous column-grouped output.
**Bug fixes:**
- fix(markdown): stop linkifying English abbreviations + drop OSC 8
escape emission. The `FILE_REF_RE` extension class was too loose
(`{1,6}`), so `e.g`, `i.e`, `a.m` matched as file paths; `osc8()`
baked OSC 8 escape bytes into Text content, which the cell-diff
renderer's wrapLine stripped of zero-width chars but kept the
printable body — producing visible `]8;;file://e.ge.g]8;;` garbage
on every terminal. Tightened the regex (now requires path-shape,
line-number suffix, or extension >= 2 chars) and removed the OSC 8
escape — file refs still stand out via color + underline. (#330,
PR #341)
- fix(renderer): trail-clear rows that shrank between frames in the
cell-diff diff. The diff skipped cells where prev and next were
byte-equal (including trailing EMPTY cells), so any earlier ANSI
desync left stale chars in shrunken rows — manifested as the
shell-confirm modal showing `allow always` + `mand, ask again next
time` after Up/Down navigation. New `clearToEOL` patch type and a
per-row sweep after `diffEach`. (#330, PR #341)
- fix(stats): carry session cost / turn count across resume. The
TUI's `$X session` figure reset to `$0` on every resume even
though the disk meta still held the cumulative `totalCostUsd`.
`SessionStats` gains `seedCarryover()`; `CacheFirstLoop` reads the
meta on resume and seeds the carryover when prior messages exist.
(#333, PR #342)
- fix(plan): show the plan body in the Approve plan modal. When the
model called `submit_plan` with a markdown body but no structured
`steps`, the modal showed only the choice list — users had no way
to see what they were approving without scrolling back. The modal
now renders the body via `MarkdownView`, capped at 24 lines with
an overflow hint. (#336, PR #343)
- fix(markdown): row-group the table fallback layout. When a table
was too wide for the viewport, the fallback flattened it as N
"Component:" lines, then N "What:" lines, then N "Manual TCs:"
lines — the reader couldn't tell which value belonged to which
row. Swapped to row-first iteration with a blank separator
between rows. (#340, PR #344)
## [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." },
];
+3
View File
@@ -812,6 +812,9 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.chip-f:hover { background: var(--bg-hover); color: var(--fg-1); }
.chip-f.active { color: var(--c-brand); border-color: var(--c-brand); background: rgba(121,192,255,.08); }
.chip-f.static { cursor: default; }
.chip-f.static:hover { background: var(--bg-elev); color: var(--fg-2); }
.chip-f.static.active:hover { color: var(--c-brand); background: rgba(121,192,255,.08); }
.chip-f .ct { color: var(--fg-4); font-size: 10px; }
.chip-f.active .ct { color: var(--c-brand); }
.chip-f .x { color: var(--fg-4); padding: 0 2px; }
+1 -1
View File
@@ -559,7 +559,7 @@ export function ChatPanel() {
<div class="chat-shell">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
<div class="chips" style="padding:0">
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
<span class="chip-f static active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
</div>
<div class="header-pickers" style="margin-left:auto">
${
+17 -4
View File
@@ -68,6 +68,7 @@ export function HooksPanel() {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string | null>(null);
const [eventFilter, setEventFilter] = useState<string>("all");
const load = useCallback(async () => {
try {
@@ -129,19 +130,31 @@ export function HooksPanel() {
data.events.length > 0
? data.events
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
const visibleRows =
eventFilter === "all"
? matrixRows
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div class="chips">
<span class="chip-f active">${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
${data.events.map((ev) => html`<span class="chip-f">${ev}</span>`)}
<span
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
onClick=${() => setEventFilter("all")}
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
${data.events.map(
(ev) => html`<span
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
onClick=${() => setEventFilter(ev)}
>${ev}</span>`,
)}
</div>
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
${error ? html`<div class="card accent-err">${error}</div>` : null}
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
matrixRows.length === 0
visibleRows.length === 0
? html`<div class="card" style="color:var(--fg-3)">
${t("hooks.noHooks")}
</div>`
@@ -152,7 +165,7 @@ export function HooksPanel() {
<div>${t("hooks.colScript")}</div>
${events.map((ev) => html`<div>${ev}</div>`)}
</div>
${matrixRows.map(
${visibleRows.map(
(r) => html`
<div class="row" style=${`grid-template-columns:${gridCols}`}>
<div class="cell" title=${r.command}>
+2 -2
View File
@@ -122,8 +122,8 @@ export function PermissionsPanel() {
}
<div class="chips">
<span class="chip-f active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
<span class="chip-f">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
</div>
${
+21 -5
View File
@@ -37,6 +37,7 @@ export function PlansPanel() {
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
const [openIdx, setOpenIdx] = useState<number | null>(null);
const [filter, setFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
if (loading && !data)
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
@@ -48,13 +49,19 @@ export function PlansPanel() {
${t("plans.noPlans")}
</div>`;
const statusFiltered =
statusFilter === "all"
? plans
: statusFilter === "active"
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
: plans.filter((p) => p.completionRatio >= 1);
const filtered = filter.trim()
? plans.filter(
? statusFiltered.filter(
(p) =>
p.session.toLowerCase().includes(filter.toLowerCase()) ||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
)
: plans;
: statusFiltered;
const open = openIdx !== null ? plans[openIdx] : null;
@@ -71,12 +78,21 @@ export function PlansPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
<span class="chip-f">
<span
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
onClick=${() => setStatusFilter("all")}
>${t("common.all")} <span class="ct">${plans.length}</span></span>
<span
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
onClick=${() => setStatusFilter("active")}
>
${t("plans.active")}
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
</span>
<span class="chip-f">
<span
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
onClick=${() => setStatusFilter("done")}
>
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
</span>
</div>
+4 -4
View File
@@ -179,13 +179,13 @@ export function SemanticPanel() {
<div style="display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:14px;align-items:start">
<div style="display:flex;flex-direction:column;gap:10px;min-width:0">
<div class="chips">
<span class=${`chip-f ${idx?.exists ? "active" : ""}`}>
<span class=${`chip-f static ${idx?.exists ? "active" : ""}`}>
${idx?.exists ? t("semantic.indexBuilt") : t("semantic.noIndex")}
</span>
${
ready
? html`<span class="chip-f" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
? html`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
: html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
}
</div>
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
@@ -730,7 +730,7 @@ function ChipFormRow({
<div style="display:flex;flex-wrap:wrap;gap:4px">
${value.map(
(e) => html`
<span class="chip-f">
<span class="chip-f static">
<span>${e}</span>
<span class="x" style="cursor:pointer" onClick=${() => remove(e)} title="remove">×</span>
</span>
+1 -1
View File
@@ -68,7 +68,7 @@ export function SessionsPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
</div>
<div class="ssl-rows">
${filtered.map(
+21 -6
View File
@@ -29,6 +29,7 @@ export function SkillsPanel() {
const [newName, setNewName] = useState("");
const [newScope, setNewScope] = useState<"global" | "project">("global");
const [filter, setFilter] = useState("");
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
const load = useCallback(async () => {
try {
@@ -120,13 +121,15 @@ export function SkillsPanel() {
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
];
const scopeFiltered =
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
const filtered = filter.trim()
? allWith.filter(
? scopeFiltered.filter(
(s) =>
s.name.toLowerCase().includes(filter.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
)
: allWith;
: scopeFiltered;
return html`
<div class="sessions-grid">
@@ -141,10 +144,22 @@ export function SkillsPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${allWith.length}</span></span>
<span class="chip-f">${t("skills.project")} <span class="ct">${data.project.length}</span></span>
<span class="chip-f">${t("skills.global")} <span class="ct">${data.global.length}</span></span>
<span class="chip-f">${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
onClick=${() => setScopeFilter("all")}
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
onClick=${() => setScopeFilter("project")}
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
onClick=${() => setScopeFilter("global")}
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
onClick=${() => setScopeFilter("builtin")}
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
</div>
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
+2 -2
View File
@@ -37,8 +37,8 @@ export function ToolsPanel() {
return html`
<div style="display:flex;flex-direction:column;gap:14px">
<div class="chips">
<span class="chip-f active">${t("common.all")} <span class="ct">${d.total}</span></span>
${d.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
</div>
${
+2 -2
View File
@@ -174,8 +174,8 @@ export function UsagePanel() {
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div class="chips">
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
<span class="chip-f">${u.logSize}</span>
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
<span class="chip-f static">${u.logSize}</span>
</div>
${
+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.1",
"version": "0.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reasonix",
"version": "0.27.1",
"version": "0.30.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.1",
"version": "0.30.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);
}
+26 -4
View File
@@ -8,7 +8,8 @@ import { PlanStepList } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { CARD, TONE } from "./theme/tokens.js";
import { MarkdownView } from "./markdown-view.js";
import { CARD, FG, TONE } from "./theme/tokens.js";
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
@@ -21,9 +22,19 @@ export interface PlanConfirmProps {
projectRoot?: string;
}
const PLAN_BODY_PREVIEW_LINES = 24;
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
const stepRows = steps?.length ?? 0;
useReserveRows("modal", { min: 10, max: Math.max(16, stepRows + 14) });
const hasSteps = stepRows > 0;
const planLines = plan.split("\n");
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
const previewRows = truncatedBody
? PLAN_BODY_PREVIEW_LINES
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
const reservedFor = hasSteps ? stepRows : previewRows;
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
const hasOpenQuestions =
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
@@ -45,9 +56,20 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
</Text>
</Box>
) : null}
{steps && steps.length > 0 ? (
{hasSteps ? (
<Box marginBottom={1} flexDirection="column">
<PlanStepList steps={steps} />
<PlanStepList steps={steps!} />
</Box>
) : plan.trim().length > 0 ? (
<Box marginBottom={1} flexDirection="column">
<MarkdownView text={previewBody} />
{truncatedBody ? (
<Text color={FG.faint}>
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
} above in scrollback`}
</Text>
) : null}
</Box>
) : null}
<SingleSelect
+2
View File
@@ -86,6 +86,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
const key = `slash.${spec.cmd}.description`;
const translated = t(key);
const summary = translated === key ? spec.summary : translated;
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
@@ -99,6 +100,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
{summary}
</Text>
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
</Box>
);
}
+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 />
+33 -29
View File
@@ -328,31 +328,29 @@ function FallbackTable({
}): React.ReactElement {
return (
<Box flexDirection="column">
{headerCells.map((h, ci) => {
const label = `${padToCells(h, labelPad - 2)}: `;
const values = bodyCells.map((r) => r[ci] ?? "");
return (
// biome-ignore lint/suspicious/noArrayIndexKey: header cells positional
<Box key={`fk-${ci}`} flexDirection="column">
{values.map((v, ri) => {
const lines = wrapToCells(v, valueCells);
return lines.map((line, li) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
<Box key={`fv-${ri}-${li}`}>
{li === 0 ? (
<Text bold color={FG.sub}>
{label}
</Text>
) : (
<Text>{padToCells("", labelPad)}</Text>
)}
<Text color={FG.body}>{line}</Text>
</Box>
));
})}
</Box>
);
})}
{bodyCells.map((row, ri) => (
// biome-ignore lint/suspicious/noArrayIndexKey: body rows positional
<Box key={`fr-${ri}`} flexDirection="column">
{ri > 0 ? <Text> </Text> : null}
{headerCells.map((h, ci) => {
const label = `${padToCells(h, labelPad - 2)}: `;
const lines = wrapToCells(row[ci] ?? "", valueCells);
return lines.map((line, li) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
<Box key={`fc-${ri}-${ci}-${li}`}>
{li === 0 ? (
<Text bold color={FG.sub}>
{label}
</Text>
) : (
<Text>{padToCells("", labelPad)}</Text>
)}
<Text color={FG.body}>{line}</Text>
</Box>
));
})}
</Box>
))}
</Box>
);
}
@@ -370,12 +368,17 @@ function Inline({ tokens }: { tokens: Token[] }): React.ReactElement {
const FILE_REF_RE = /\b([A-Za-z0-9_./@\-]+\.[A-Za-z0-9]{1,6})(?::(\d+)(?:-(\d+))?)?\b/g;
const MENTION_RE = /(?<![A-Za-z0-9_])@([A-Za-z0-9_./\-]+\.[A-Za-z0-9]{1,6})/g;
function osc8(label: string, target: string, color: string): React.ReactElement {
const open = `\x1b]8;;${target}\x07`;
const close = "\x1b]8;;\x07";
function looksLikeFileRef(path: string, hasLine: boolean): boolean {
if (hasLine) return true;
if (path.includes("/")) return true;
const ext = path.split(".").pop() ?? "";
return ext.length >= 2;
}
function osc8(label: string, _target: string, color: string): React.ReactElement {
return (
<Text color={color} underline>
{`${open}${label}${close}`}
{label}
</Text>
);
}
@@ -402,6 +405,7 @@ function renderInlineText(raw: string): React.ReactElement {
if (hits.some((h) => start < h.end && end > h.start)) continue;
const path = m[1]!;
const line = m[2];
if (!looksLikeFileRef(path, line !== undefined)) continue;
const target = line ? `file://${path}:${line}` : `file://${path}`;
hits.push({ start, end, node: osc8(m[0], target, TONE.brand) });
}
+25 -5
View File
@@ -1,7 +1,7 @@
import type { SlashArgContext, SlashCommandSpec } from "./types.js";
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{ cmd: "help", summary: "show the full command reference" },
{ cmd: "help", summary: "show the full command reference", aliases: ["?"] },
{ cmd: "status", summary: "current model, flags, context, session" },
{
cmd: "preset",
@@ -52,6 +52,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
{
@@ -147,13 +148,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
summary: "show semantic_search status — built? Ollama installed? how to enable",
},
{ cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
{ cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
{
cmd: "new",
summary: "start a fresh conversation (clear context + scrollback)",
aliases: ["reset"],
},
{
cmd: "loop",
argsHint: "<5s..6h> <prompt> · stop · (no args = status)",
summary: "auto-resubmit <prompt> every <interval> until you type something / Esc / /loop stop",
},
{ cmd: "exit", summary: "quit the TUI" },
{ cmd: "exit", summary: "quit the TUI", aliases: ["quit", "q"] },
// Code-mode only
{
cmd: "init",
@@ -253,16 +258,31 @@ export function suggestSlashCommands(prefix: string, codeMode = false): SlashCom
const p = prefix.toLowerCase();
return SLASH_COMMANDS.filter((c) => {
if (c.contextual === "code" && !codeMode) return false;
return c.cmd.startsWith(p);
if (c.cmd.startsWith(p)) return true;
return c.aliases?.some((a) => a.startsWith(p)) ?? false;
});
}
/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */
const ALIAS_TO_CMD: Readonly<Record<string, string>> = (() => {
const m: Record<string, string> = {};
for (const spec of SLASH_COMMANDS) {
if (!spec.aliases) continue;
for (const a of spec.aliases) m[a] = spec.cmd;
}
return m;
})();
export function resolveSlashAlias(name: string): string {
return ALIAS_TO_CMD[name] ?? name;
}
/** Picker fires only when arg tail has no internal whitespace; past that it's a usage hint. */
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
if (!m) return null;
const cmdName = m[1]!.toLowerCase();
const cmdName = resolveSlashAlias(m[1]!.toLowerCase());
const tail = m[2] ?? "";
const spec = SLASH_COMMANDS.find(
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
+2 -1
View File
@@ -1,4 +1,5 @@
import type { CacheFirstLoop } from "../../../loop.js";
import { resolveSlashAlias } from "./commands.js";
import { handlers as adminHandlers } from "./handlers/admin.js";
import { handlers as basicHandlers } from "./handlers/basic.js";
import { handlers as dashboardHandlers } from "./handlers/dashboard.js";
@@ -45,7 +46,7 @@ export function handleSlash(
loop: CacheFirstLoop,
ctx: SlashContext = {},
): SlashResult {
const h = HANDLERS[cmd];
const h = HANDLERS[resolveSlashAlias(cmd)];
if (h) return h(args, loop, ctx);
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
}
+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 -4
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";
@@ -189,13 +189,10 @@ const loop: SlashHandler = (args, _loop, ctx) => {
export const handlers: Record<string, SlashHandler> = {
exit,
quit: exit,
clear,
new: resetLog,
reset: resetLog,
keys,
help,
"?": help,
retry,
loop,
};
+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 -8
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> = {
@@ -30,5 +25,4 @@ export const handlers: Record<string, SlashHandler> = {
return { info: t("slash.language.success") };
},
lang: (args, loop, ctx) => handlers.language!(args, loop, ctx),
};
+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) => {
+3
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;
@@ -131,6 +132,8 @@ export interface SlashCommandSpec {
argsHint?: string;
/** First-arg picker source — file paths intentionally absent (use `@path` mentions instead). */
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
/** Alternate names — typing any of these resolves to `cmd` for dispatch / suggestion / arg-context. */
aliases?: readonly string[];
}
export interface SlashArgContext {
+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. */
+1 -1
View File
@@ -365,7 +365,7 @@ export const EN: TranslationSchema = {
" /clear clear displayed scrollback only (context kept — model still sees it)",
helpLoop:
" /loop <interval> <prompt> auto-resubmit <prompt> every <interval> (5s..6h). /loop stop · type anything to cancel.",
helpExit: " /exit quit",
helpExit: " /exit quit (aliases: /quit, /q)",
helpShellTitle: "Shell shortcut:",
helpShell: " !<cmd> run <cmd> in the sandbox root; output goes into",
helpShellDetail:
+1 -1
View File
@@ -345,7 +345,7 @@ export const zhCN: TranslationSchema = {
helpClear: " /clear 仅清除显示的滚动回放(上下文保留 — 模型仍能看到)",
helpLoop:
" /loop <interval> <prompt> 每 <interval> 自动重新提交 <prompt>(5秒..6小时)。/loop stop · 输入任何内容取消。",
helpExit: " /exit 退出",
helpExit: " /exit 退出(别名:/quit, /q)",
helpShellTitle: "Shell 快捷方式:",
helpShell: " !<cmd> 在沙箱根目录运行 <cmd>;输出进入对话",
helpShellDetail: " 以便模型在下一轮看到。无允许列表限制。",
+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: {
+224 -665
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;
}
+30 -1
View File
@@ -38,6 +38,8 @@ export function diffFrames(prev: Frame, next: Frame, pools: DiffPools): Patch[]
return undefined;
});
appendTrailClears(out, cursor, prev, next);
if (next.cursor.x !== cursor.x || next.cursor.y !== cursor.y) {
moveTo(out, cursor, next.cursor.x, next.cursor.y, next.viewportWidth);
}
@@ -89,7 +91,7 @@ function moveTo(
}
if (cursor.x !== targetX) {
out.push({ type: "cursorMove", dx: targetX - cursor.x, dy: 0 });
out.push({ type: "cursorTo", col: targetX });
cursor.x = targetX;
}
}
@@ -158,3 +160,30 @@ function resetTrailingState(out: Patch[], cursor: CursorState, pools: DiffPools)
transitionStyle(out, cursor, pools.style.none, pools);
transitionHyperlink(out, cursor, 0, pools);
}
/** Per-row trail clear when prev had content past next's last visible cell — defends shrinking rows against terminal-state desync. */
function appendTrailClears(out: Patch[], cursor: CursorState, prev: Frame, next: Frame): void {
const w = Math.min(prev.screen.width, next.viewportWidth);
const h = Math.max(prev.screen.height, next.screen.height);
for (let y = 0; y < h; y++) {
let lastNextCol = -1;
for (let x = next.screen.width - 1; x >= 0; x--) {
const c = next.screen.cellAt(x, y);
if (c && c.charId !== 0) {
lastNextCol = x;
break;
}
}
let prevHasTrail = false;
for (let x = lastNextCol + 1; x < w; x++) {
const c = prev.screen.cellAt(x, y);
if (c && c.charId !== 0) {
prevHasTrail = true;
break;
}
}
if (!prevHasTrail) continue;
moveTo(out, cursor, Math.max(0, lastNextCol + 1), y, next.viewportWidth);
out.push({ type: "clearToEOL" });
}
}
+1
View File
@@ -7,6 +7,7 @@ export type Patch =
| { readonly type: "styleStr"; readonly str: string }
| { readonly type: "hyperlink"; readonly uri: string }
| { readonly type: "clear"; readonly count: number }
| { readonly type: "clearToEOL" }
| { readonly type: "clearTerminal" };
export type Diff = ReadonlyArray<Patch>;
+2
View File
@@ -30,6 +30,8 @@ function serializeOne(patch: Patch): string {
return `${ESC}]8;;${patch.uri}${ST}`;
case "clear":
return clearLines(patch.count);
case "clearToEOL":
return `${CSI}K`;
case "clearTerminal":
return `${CSI}2J${CSI}H`;
}
+7 -1
View File
@@ -15,6 +15,8 @@ export interface InkTextProps {
readonly strikethrough?: boolean;
readonly inverse?: boolean;
readonly wrap?: "wrap" | "truncate" | "truncate-start" | "truncate-middle" | "truncate-end";
/** OSC 8 hyperlink target — emitted as proper escape bytes, never inlined into cell content. */
readonly hyperlink?: string;
}
const SGR = {
@@ -38,5 +40,9 @@ export function Text(props: InkTextProps): React.ReactElement {
if (props.underline) codes.push(SGR.underline);
if (props.inverse) codes.push(SGR.inverse);
if (props.strikethrough) codes.push(SGR.strikethrough);
return <RsxText style={codes.length > 0 ? codes : undefined}>{props.children}</RsxText>;
return (
<RsxText style={codes.length > 0 ? codes : undefined} hyperlink={props.hyperlink}>
{props.children}
</RsxText>
);
}
+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,
+1
View File
@@ -15,6 +15,7 @@ export async function handleSlash(
summary: c.summary,
argsHint: c.argsHint,
contextual: c.contextual,
aliases: c.aliases,
}));
return { status: 200, body: { commands, codeMode } };
}
+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,
};
+16 -2
View File
@@ -96,6 +96,20 @@ export interface SessionSummary {
export class SessionStats {
readonly turns: TurnStats[] = [];
/** Cost from prior runs of a resumed session, restored from session meta. */
private _carryoverCost = 0;
/** Turn count from prior runs of a resumed session. */
private _carryoverTurns = 0;
/** Seed totals from a resumed session's persisted meta — only call once at construction. */
seedCarryover(opts: { totalCostUsd?: number; turnCount?: number }): void {
if (typeof opts.totalCostUsd === "number" && opts.totalCostUsd > 0) {
this._carryoverCost = opts.totalCostUsd;
}
if (typeof opts.turnCount === "number" && opts.turnCount > 0) {
this._carryoverTurns = opts.turnCount;
}
}
record(turn: number, model: string, usage: Usage): TurnStats {
const cost = costUsd(model, usage);
@@ -111,7 +125,7 @@ export class SessionStats {
}
get totalCost(): number {
return this.turns.reduce((sum, t) => sum + t.cost, 0);
return this._carryoverCost + this.turns.reduce((sum, t) => sum + t.cost, 0);
}
get totalClaudeEquivalent(): number {
@@ -145,7 +159,7 @@ export class SessionStats {
summary(): SessionSummary {
const last = this.turns[this.turns.length - 1];
return {
turns: this.turns.length,
turns: this.turns.length + this._carryoverTurns,
totalCostUsd: round(this.totalCost, 6),
totalInputCostUsd: round(this.totalInputCost, 6),
totalOutputCostUsd: round(this.totalOutputCost, 6),
+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;
}
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more