Compare commits

...
8 Commits
Author SHA1 Message Date
esengine c594ad71f7 feat: /permissions slash + MCP Streamable HTTP transport
- /permissions list/add/remove/clear — per-project shell allowlist
  audit + edit from inside the TUI; surfaces edit-mode banner
  (yolo bypasses allowlist), refuses to clobber the read-only
  builtin list, alias /perms.
- removeProjectShellAllowed + clearProjectShellAllowed in config.ts
  for the slash to call (literal-only match, scoped per project).
- StreamableHttpTransport (MCP 2025-03-26): single endpoint, handles
  202 / application/json / text/event-stream responses, captures
  Mcp-Session-Id and echoes on subsequent POSTs, surfaces session
  expiry as a clear error.
- Spec parser: streamable+http(s):// prefix routes to the new
  transport; plain http(s):// stays SSE for back-compat. Wired
  through chat / run / mcp inspect.

1521 tests pass (+24). Lint / typecheck / build clean.
2026-04-27 22:02:18 -07:00
esengine 23c81e6f11 chore(release): 0.11.2
Split /init into its own patch release. /init landed after the
0.11.1 commit, and it's a small but distinct addition (no fix /
no behavioral change to existing flows), so a separate version
makes the changelog story cleaner: 0.11.1 is the workspace-switching
release, 0.11.2 is the project-init release.
2026-04-27 07:23:48 -07:00
esengine 0d0cdfc54a docs: fold /init into 0.11.1 changelog, restore missing 0.11.0 header
Two CHANGELOG fixes ahead of publishing 0.11.1:

1. Add a bullet for /init under 0.11.1 Added. /init was committed
   after the 0.11.1 version bump but before publication, so the
   shipped 0.11.1 actually includes it. Update the headline to
   acknowledge the addition.

2. Restore the '## [0.11.0] — 2026-04-27' header that was clobbered
   when the 0.11.1 entry was inserted earlier — the 0.11.0 content
   was orphaned without a heading.
2026-04-27 07:20:11 -07:00
esengine 60c598527d feat: /init — model-driven REASONIX.md generator
New slash command that asks the model to scan the project and write a
concise REASONIX.md baseline, removing the friction of having to
hand-author one. The file is auto-pinned into the system prompt every
launch, so a good baseline pays back on every subsequent turn.

Mechanism: handler emits a structured user-turn prompt via `resubmit`
that hard-constrains the model to a fact-only document with sections
for Stack / Layout / Commands / Conventions / Watch out for. The
"output ≤ 80 lines / 3KB" cap and "STOP after writing" line are
load-bearing — without the cap REASONIX.md would burn cache budget
every turn; without STOP flash chains follow-up suggestions and
wastes tokens.

Reuses existing filesystem tools (read_file / directory_tree /
edit_file / write_file) — no new tool, no new pipeline. The
generated content lands as a pending edit in the normal review queue,
so the user audits it via /apply before disk writes.

Edge cases:
- Outside code mode: handler returns "/init only works in code mode"
  with the `reasonix code` invocation hint.
- REASONIX.md already exists: refused, points at `/init force` to
  regenerate. Force matching is case-insensitive.

Five tests covering: refuse outside code mode, fresh-init resubmit
shape (verifies length cap + STOP marker survive future tightenings),
existing-file refusal, force override, force case-insensitivity.

1487 tests passing.
2026-04-27 06:55:20 -07:00
esengine 494c62ef60 docs: rebuild Claude-Code-on-DeepSeek section around cache_control
DeepSeek now ships an official Anthropic-compatible endpoint at
api.deepseek.com/anthropic, so the previous 'Claude Code can't run
DeepSeek' framing is factually wrong: Claude Code talks to it
unmodified.

The defense moves from 'protocol incompatible' to 'cache mechanic
incompatible':

- Anthropic native cache is marker-based: cache_control on a message
  caches up-to-this-marker as a content-addressed unit. Claude Code's
  loop is designed around this — markers compensate for an otherwise
  non-stable prefix.
- DeepSeek's auto-cache is byte-stable: it fingerprints the literal
  byte stream from 0. No markers.
- DeepSeek's compat endpoint IGNORES cache_control. Claude Code's
  main caching strategy disappears; auto-cache misses because the
  prefix wasn't byte-stable to begin with.

Reasonix's loop was designed around byte-stable prefix from line one
(append-only invariant). That's why 94.4% on the same workload
where a cache-hostile baseline lands at 46.6%.

Restructure the section into two subheaders:
- 'But DeepSeek now has an Anthropic-compatible API...' — handles
  the Claude Code case head-on with DeepSeek's compat table and the
  marker-vs-byte-stable contrast
- 'What about Aider / Cline / Continue?' — handles the natively-
  DeepSeek tools, keeps the existing 5-row DeepSeek-specific quirks
  table (R1 thinking leak, schema flatten, repair, effort, tail-only
  compaction)

Mirror everything in README.zh-CN.md.
2026-04-27 06:37:15 -07:00
esengine c43ada74e2 docs: handle 'just point Claude Code / Aider at DeepSeek' objection
The 'Why Reasonix?' section explained why we beat generic competitors,
but didn't cover the sharper rebuttal: 'I'll just point Claude Code
(or Aider, Cline, Continue) at DeepSeek and get the same economics
without your tool.' Without an answer prospects bounce silently.

Add a third subsection to 'Why Reasonix?' that handles it head-on:

- Claude Code: not really. It's hard-bound to Anthropic's API —
  auth, tool-schema shape, <thinking> parsing all assume Claude.
  A translation proxy (claude-code-router) gets you protocol
  compatibility but every Anthropic-tuning becomes overhead and
  every DeepSeek-tuning is unreachable.
- Aider / Cline / Continue: yes, they support DeepSeek and you get
  the cheap token price. What you don't get is the DeepSeek-
  specific loop work, captured in a 6-row table contrasting how
  each layer (cache markers, reasoning blocks, tool schemas,
  malformed JSON, reasoning-effort, compaction) differs between
  Anthropic/OpenAI assumptions and DeepSeek reality, with what
  Reasonix does about each.

Then drive the cache-stability claim home with concrete numbers:
generic harnesses land at 40-60% cache hit on DeepSeek vs 94.4% for
Reasonix on the same workload, which at DeepSeek's pricing is ~2.5×
on input cost alone.

Mirror in README.zh-CN.md.
2026-04-27 06:22:26 -07:00
esengine 18b8d0d8df docs: add 'Why Reasonix?' positioning section to README
The README read like a feature manual — "here's everything we have"
without ever telling a new visitor why they'd pick Reasonix over
Cursor / Claude Code / Cline / Aider. Most prospects bounce because
the unique value (DeepSeek's price × cache-first prefix engineering)
isn't visible above the fold.

Insert a "Why Reasonix?" section between Quick start and the code-
mode walkthrough, with three pieces:

1. Three short pitches: cost economics that actually land in your
   bill (~30× cheaper than Claude Sonnet, with 94% live cache hit
   verified in benchmarks/), terminal-native (no Electron / IDE
   plugin), MIT-licensed and hackable end-to-end.
2. A side-by-side comparison table — Reasonix vs the four
   alternatives, on backend / cost / runtime / license /
   cache-hit / MCP / plan mode / skills / edit review / workspace
   switch / cost dashboard / sandbox enforcement.
3. An honest "Pick something else when" section pointing at Aider,
   Cline, Cursor, and Claude Code's IDE mode for the cases where
   Reasonix's tradeoffs don't fit (multi-provider, IDE integration,
   PhD-level reasoning benchmarks, fully-local).

Also tighten the subtitle under # Reasonix to lead with the cost
angle instead of "no LangChain" (audience-correct, but doesn't
help conversion). Mirror everything in README.zh-CN.md. Bump the
test-count claim in Development from 1007 to 1482.
2026-04-27 04:37:38 -07:00
esengine 855b91c7ed feat: workspace switching (/cwd + change_workspace) + 4 fixes
Add two ways to pivot to another project mid-session:
- /cwd <path> — user-typed slash; validates target, applies via
  applyCwdChange (re-registers filesystem/shell/memory tools,
  reloads hooks, syncs loop.hookCwd, re-registers run_skill).
  MCP servers stay on the launch root (no reconnect handshake).
- change_workspace tool — model-callable, gated on a
  WorkspaceConfirm modal. Tool fn always throws
  WorkspaceConfirmationError; App detects the marker, mounts
  Switch/Deny modal; approval drives the same applyCwdChange path.
  No always-allow option — switches are per-target.

Fixes that surfaced from real-use workspace-switching attempts:
- loop: reset _turnAbort after Esc-abort branch returns. Prior
  carry-abort logic re-aborted at iter 0 on every subsequent
  step(), poisoning the session until restart.
- shell: collect Buffer chunks instead of decoding per-chunk;
  smartDecodeOutput tries strict UTF-8, falls back to GB18030 on
  Windows when invalid sequences detected. Fixes mojibake on
  Chinese-Windows cmd.exe error messages (chcp 65001 doesn't
  cover them — they come from a localized resource DLL).
- markdown: gate stripMath pipeline on a math-marker pre-check.
  The catch-all \[a-zA-Z]+ → "" had been wiping \TEST out of
  Windows paths like F:\TEST1, rendering them as F:1.
- prompt: tell the model to call change_workspace (or surface
  /cwd) when the user asks to change projects, instead of
  fumbling with cd /d / pushd / 2>&1 shell hacks.

Bump 0.11.0 → 0.11.1, CHANGELOG entry covers all four fixes plus
the two new entry points. 1482 tests passing.
2026-04-27 04:29:31 -07:00
35 changed files with 2723 additions and 100 deletions

No files matched your search

+170
View File
@@ -3,6 +3,176 @@
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.11.3] — 2026-04-27
**Headline:** Two long-deferred items land — `/permissions` makes the
shell allowlist auditable and editable from inside the TUI, and
Streamable HTTP MCP transport (2025-03-26 spec) clears the last debt
from the v0.3 deferred queue.
### Added
- **`/permissions`** — list / add / remove / clear the shell
allowlist without leaving the session. Bare `/permissions` shows
the current edit mode (review / auto / yolo with a yolo-bypasses-
allowlist banner), the per-project entries with 1-based indices,
and the read-only builtin list grouped by leading verb. Subcommands:
`/permissions add <prefix>` (multi-token OK), `/permissions remove
<prefix-or-N>` (literal match or list index), `/permissions clear
confirm`. Refuses to add a prefix that's already in the builtin
list (no redundant project entry) and refuses to remove a builtin
(read-only). Mutating subcommands require code mode. `perms`
registered as alias.
- **`removeProjectShellAllowed` + `clearProjectShellAllowed`**
exported from `src/config.ts`. The remove helper does literal-
prefix match (not prefix-of-prefix), so dropping `git` doesn't
accidentally remove `git push origin main` if both were stored.
- **MCP Streamable HTTP transport (2025-03-26 spec)** —
`src/mcp/streamable-http.ts` implements the new single-endpoint
protocol. POSTs JSON-RPC frames, handles all three response shapes
(202 Accepted for notifications, `application/json` for single
responses, `text/event-stream` for multi-frame streams covering
progress + response). Captures `Mcp-Session-Id` from the first
response that hands one out and echoes it on every subsequent
request; surfaces 404-with-session as a "session expired" error
so callers know to reinitialize. Long-lived GET stream for
unsolicited server-initiated frames is deliberately deferred —
POST-only handles full request/response/notification traffic
for every server we'd realistically point at today.
- **Spec parser** — `streamable+http(s)://` prefix routes to the
new transport (`{ transport: "streamable-http", url, name }`).
Plain `http(s)://` still routes to SSE (2024-11-05) so existing
`--mcp` config entries keep working without surprise upgrades.
Wired through `chat.tsx`, `run.ts`, and `reasonix mcp inspect`.
Public API gains `StreamableHttpTransport` + the
`StreamableHttpMcpSpec` type re-export.
### Tests
- `tests/permissions-slash.test.ts` — 16 tests covering listing,
add, remove (by prefix and by 1-based index), clear, mode banner,
builtin-collision rejection, codeRoot guard, alias.
- `tests/config.test.ts` — 6 new tests for `removeProjectShellAllowed`
/ `clearProjectShellAllowed` (literal-only matching, scoping per
project, idempotent counts).
- `tests/mcp-streamable-http.test.ts` — 8 tests against an in-process
`http.Server` fake that speaks the 2025-03-26 wire shape: JSON
response delivery, 202 ack as no-op, session-id capture+echo,
multi-frame SSE ordering (progress → response), full McpClient
initialize → tools/list round-trip, 404+session = "expired",
500-as-error from `send()`, `close()` unblocks idle iterators.
- `tests/mcp-spec.test.ts` — 4 tests for the new prefix parsing.
1521 tests pass (+24). Lint / typecheck / build clean.
## [0.11.2] — 2026-04-27
**Headline:** `/init` synthesizes a baseline REASONIX.md so a new
project starts with context instead of cold. Closes the gap with
Claude Code's `/init`, scoped to the structure REASONIX expects.
### Added
- **`/init`** — model-driven REASONIX.md generator. The slash
emits a structured user-turn prompt (via the `resubmit` channel)
that hard-constrains the model to a fact-only document with
Stack / Layout / Commands / Conventions / Watch out for sections,
capped at 80 lines / 3KB so REASONIX.md doesn't bloat the system
prompt every launch. Reuses the existing filesystem tools (no new
pipeline) and the result lands as a pending edit in the normal
review queue, so the user audits before it hits disk. Refuses to
overwrite an existing REASONIX.md without `/init force`. Removes
the friction of having to hand-author a project memory file.
## [0.11.1] — 2026-04-27
**Headline:** Workspace-switching, end to end. Four real-use bugs
that all hit the same scenario — `Esc` poisoned the next turn,
Chinese-Windows shell errors came back as mojibake, the markdown
renderer ate `\TEST` out of `F:\TEST1`, and the model had no idea
how to change directories. Plus two new ways to do it: `/cwd <path>`
the user types, and `change_workspace` the model calls (always
gated on an explicit confirmation modal — no auto-switching).
### Fixed
- **`Esc` poisoned the next turn.** The loop's user-Esc abort branch
processed the cancel correctly but left `_turnAbort` in an aborted
state on its way out. The carry-abort logic at `step()` entry then
re-aborted at iter 0 on every subsequent turn, so the user typed
a fresh prompt and saw "stopped without producing a summary"
before any model call ran. The session was effectively dead until
restart. Fix: reset `_turnAbort` to a fresh controller before
returning from the abort branch — the across-turn race that the
carry logic guards against still works because a new `abort()`
fired between turns aborts the new controller. Regression test
added (`tests/loop.test.ts`).
- **Mojibake on Chinese / Japanese / Korean Windows shell errors.**
`runCommand` decoded child output as UTF-8 incrementally per
chunk. Two failure modes:
1. `cmd.exe`'s OWN error messages (e.g. "'sed' is not recognized
as an internal or external command") come from a localized
resource DLL and ignore `chcp 65001`, so on Chinese Windows
the bytes are CP936/GBK and decoded as UTF-8 produced
unreadable garbage.
2. Multi-byte sequences could split across chunk boundaries and
corrupt before the second half arrived.
Fix: collect raw `Buffer[]` chunks and decode once at close via
a new `smartDecodeOutput` — strict UTF-8 first; on Windows fall
back to GB18030 (GBK superset) when UTF-8 rejects the bytes;
last resort lossy UTF-8 keeps the structural exit-code marker
intact. PowerShell's existing `injectPowerShellUtf8` prelude
still covers the PS path; this fixes the path where the model
invokes a native EXE directly (`run_command sed …`).
- **Markdown renderer ate `\TEST` out of `F:\TEST1`.** `stripMath`'s
catch-all LaTeX command stripper (`\\[a-zA-Z]+` → `""`) deleted any
backslash-followed-by-letters sequence — fine for an invented
`\textbf{…}` the model emitted, catastrophic for Windows paths in
prose. `F:\TEST1` rendered as `F:1`. Fix: gate the entire
`stripMath` pipeline on a math-marker pre-check (`$`, `\(`, `\[`,
known LaTeX commands, `^{…}`/`_{…}`, Pandoc super/subscripts). When
none are present we return the string untouched. Mixed inputs (a
path AND real math in the same message) still run the pipeline —
math correctness wins over path preservation in that rare collision.
- **Model didn't know `/cwd` existed.** When asked to switch to a
project on another drive, the model fumbled with `pwd`,
`cd /d F:\TEST1`, and `2>&1` shell tricks (none of which work —
`cd` doesn't carry across `run_command` calls and `2>&1` is rejected
as a shell operator by design). The code-mode system prompt now has
a "When the user wants to switch project / working directory"
section telling the model to surface `/cwd <path>` once and stop,
instead of trying to do it itself.
### Added
- **`change_workspace` tool** — model-callable workspace switching,
gated on a confirmation modal. The tool fn validates the target,
resolves it (absolute / `~`-expanded / relative-to-launch-cwd), then
always throws a `WorkspaceConfirmationError` with the absolute
path. App.tsx detects the marker and mounts a Switch / Deny modal;
on approval it calls the same `applyCwdChange` path that drives
`/cwd` (re-registers filesystem / shell / memory tools, reloads
hooks, syncs the loop's hookCwd). On denial the model gets a
synthetic "user refused, continue without it" message. No
"always allow" option — workspace switches are per-target by
nature. The code-mode system prompt now tells the model to call
this tool (rather than fumble with `cd /d`) when the user asks
to change projects, and to STOP after the call instead of chaining
more tools before the user has confirmed.
- **`/cwd <path>`** — switch the session's working directory mid-
session. Validates the target (must exist, must be a directory),
expands `~`, then atomically: updates the hook cwd, memory root,
project shell allowlist, `@file` mention root, and re-registers
filesystem / shell / memory / `run_skill` tools against the new
path so file reads, edits, and shell commands all land in the
new sandbox. MCP servers stay anchored to the original cwd
(their stdio child was spawned with the launch root and there's
no standard reconnect handshake) — the slash output flags this
explicitly when MCP servers are present. The system prompt's
gitignore-aware project tour is also frozen at launch so the
prefix cache stays valid; the slash output notes it for users
switching to a structurally different project.
## [0.11.0] — 2026-04-27
**Headline:** Local semantic search lands as an opt-in pillar — Ollama-
+136 -4
View File
@@ -18,9 +18,10 @@
[![downloads](https://img.shields.io/npm/dm/reasonix.svg)](https://www.npmjs.com/package/reasonix)
[![node](https://img.shields.io/node/v/reasonix.svg)](./package.json)
**A DeepSeek-native AI coding agent in your terminal.** Edits files as
reviewable SEARCH/REPLACE blocks. Ink TUI. MCP first-class. No
LangChain.
**A DeepSeek-native AI coding agent in your terminal.** ~30× cheaper
per task than Claude Code, with a cache-first loop engineered for
DeepSeek's pricing model. Edits as reviewable SEARCH/REPLACE blocks.
MIT-licensed. No IDE lock-in. MCP first-class.
---
@@ -71,6 +72,137 @@ command list.
---
## Why Reasonix? (vs Cursor / Claude Code / Cline / Aider)
Three things you'd come to Reasonix for, that nothing else combines:
- **The cost economics actually land in your bill.** DeepSeek V4 is
~30× cheaper than Claude Sonnet per token. Cheaper tokens alone
isn't the win — *cheap tokens with a 90%+ prefix-cache hit* is.
Reasonix's loop is engineered around append-only prompt growth so
the cache-stable prefix survives every tool call, which the
benchmarks section below verifies end-to-end (94.4% live, vs 46.6%
for a generic harness against the same workload). The `/stats`
panel tracks "vs Claude Sonnet 4.6" savings every turn so you can
watch your bill not happen.
- **It lives in your terminal.** Pure CLI — no Electron, no VS Code
extension, no IDE plugin to wedge into your editor. Sits next to
git, tmux, and your shell history. macOS / Linux / Windows
(PowerShell, Git Bash, Windows Terminal all tested). The only
network call is to the DeepSeek API itself; no vendor server in
the middle.
- **Open source and hackable, end to end.** MIT-licensed TypeScript.
The entire loop, tool registry, cache-stable prefix, TUI, MCP
bridge — all in `src/` under 30k lines. Fork it, ship a private
build, drop it into CI. No SaaS layer, no enterprise tier, no
feature gates.
| | Reasonix | Claude Code | Cursor | Cline | Aider |
|---|---|---|---|---|---|
| Backend | DeepSeek V4 only | Anthropic only | OpenAI / Anthropic | any (OpenRouter) | any (OpenRouter) |
| Cost / typical task | **~$0.001–$0.005** | ~$0.05–$0.50 | $20/mo + usage | varies | varies |
| Where it runs | terminal | terminal + IDE | IDE (Electron) | VS Code only | terminal |
| License | **MIT** | closed | closed | Apache 2 | Apache 2 |
| Cache-first prefix loop | **engineered (94% hit)** | basic | n/a | n/a | basic |
| MCP servers | **first-class** | first-class | — | beta | — |
| Plan mode (read-only audit gate) | **yes** | yes | — | yes | — |
| User-authored skills | **yes** | yes | — | — | — |
| Edit review (no auto-write) | **yes** (`/apply`) | yes | partial | yes | yes |
| Workspace switch (`/cwd`, `change_workspace`) | **yes** | — | n/a (per-window) | — | — |
| Cross-session cost dashboard | **yes** (`/stats`) | — | — | — | — |
| Sandbox boundary enforcement | **strict** (refuses `..` escape) | yes | partial | yes | partial |
### Pick something else when
- **You want multi-provider flexibility** (mix Claude / GPT / Gemini /
local Llama in one tool). Try [Aider](https://aider.chat) or
[Cline](https://cline.bot). Reasonix is DeepSeek-only on purpose —
every layer (cache-first loop, R1 harvesting, JSON-mode tool repair,
reasoning-effort cap) is tuned against DeepSeek-specific behavior
and economics. Coupling to one backend is the feature, not a
limitation we'll grow out of.
- **You want IDE integration** (inline diff in your gutter,
multi-cursor, ghost text, refactor previews). Try
[Cursor](https://cursor.com) or Claude Code's IDE mode. Reasonix
is terminal-first; the diff lives in `git diff`, the file tree
lives in `ls`, the chat lives in your shell.
- **You're chasing the hardest reasoning benchmarks.** Claude Opus
4.6 still wins some leaderboards. DeepSeek V4-pro is competitive
on most coding tasks but doesn't lead every benchmark. If your
task is "solve this PhD-level proof" rather than "fix this auth
bug," start with Claude.
- **You need fully-local / fully-free**. DeepSeek's API has free
credit on signup, but isn't free forever. For air-gapped or
always-free, look at Aider + Ollama or [Continue](https://continue.dev).
### "But DeepSeek now has an Anthropic-compatible API — can't I just point Claude Code at it?"
You can. DeepSeek ships an official Anthropic-compatible endpoint at
`https://api.deepseek.com/anthropic`, and Claude Code (or any Anthropic
SDK client) talks to it without modification. The protocol works. The
**caching economics** don't transfer, and that's the whole point.
Look at DeepSeek's [own compatibility table](https://api-docs.deepseek.com/guides/anthropic_api):
| Field | Status on DeepSeek's compat endpoint |
|---|---|
| `cache_control` markers | **Ignored** |
| `mcp_servers` (API-level) | Ignored |
| `thinking.budget_tokens` | Ignored |
| Images / documents / citations | Not supported |
`cache_control: Ignored` is the load-bearing line. Two completely
different cache mechanics are colliding here:
| | Anthropic native | DeepSeek auto-cache |
|---|---|---|
| Model | **Marker-based.** You put `cache_control` on a message; Anthropic caches "everything up to this marker" as a content-addressed unit. Multiple markers = multiple independent breakpoints. | **Byte-stable prefix.** The cache fingerprints the literal byte stream from byte 0. |
| Claude Code's design | Built around this. Markers on system prompt + tool defs let the loop reorder, compact, or insert metadata after the markers without losing the cache. | n/a — Claude Code wasn't designed for byte-stable prefixes. |
| What happens when Claude Code → DeepSeek compat | Markers stripped (ignored). Claude Code's main caching strategy disappears. | Falls back to auto-cache. But Claude Code's prefix isn't byte-stable (markers were the *substitute* for byte-stability), so auto-cache misses too. |
Net effect: **Claude Code's loop, redirected at DeepSeek, gets the
cheap tokens and loses the cache hit it depended on.** A loop running
at 80%+ cache hit on Anthropic's marker cache lands somewhere in the
40-60% range on DeepSeek's auto-cache (matches the generic-harness
baseline in our benchmarks). Same model, same API, same workload —
the loop's invariants don't fit the cache mechanic it's now talking
to.
Reasonix's loop was designed around byte-stable prefix from line one.
No markers, no breakpoints — append-only is the invariant. That's why
the same τ-bench workload lands at **94.4% cache hit** on Reasonix
and **46.6%** on a cache-hostile baseline (committed transcripts;
benchmarks section below). At DeepSeek's pricing — $0.07/Mtok
uncached, ~$0.014/Mtok cached — the difference between 50% and 94%
hit is **roughly 2.5× on input cost alone**.
### "What about Aider / Cline / Continue?"
They support DeepSeek natively (no compat layer needed) and you do
get the cheap token price. What you don't get is the DeepSeek-
specific loop work — those tools' loops support every backend
generically (OpenAI / Anthropic / local Llama / ...) and use
compaction + summarization patterns that destroy byte-stability. They
land in the same 40-60% cache-hit range as the baseline. Plus a
handful of DeepSeek-specific quirks generic loops don't handle:
| Generic loops assume | DeepSeek actually does | Reasonix's fix |
|---|---|---|
| Reasoning emitted as a structured `thinking` block | R1 sometimes leaks tool-call JSON inside `<think>` tags | a `scavenge` pass that pulls escaped tool calls back out, otherwise the model thinks it called and waits for output that never comes |
| Tool schemas validated strictly | DeepSeek silently drops deeply-nested object/array params | auto-flatten — nested params get rewritten to single-level prefixed names so the model sees them at all |
| Tool-call args are well-formed JSON | DeepSeek occasionally produces `string="false"` and other malformed fragments | dedicated `ToolCallRepair` heals the common shapes before they hit dispatch |
| Reasoning depth tuned via system-level switches | V4 exposes a `reasoning_effort` knob (`max` / `high`) | `/effort` slash + `--effort` flag, so users can step down for cheap turns |
| Old tool results kept in full forever | 1M context — don't compact pre-emptively, but most agents do | call-storm breaker + result token cap, but the prefix is *never* rewritten; compaction lands as new turns at the tail |
> Cache-stability isn't a feature you turn on; it's an invariant
> the loop is designed around. Reasonix isn't yet-another agent
> CLI — it's an agent CLI built around DeepSeek's specific cache
> mechanic and pricing model.
---
## `reasonix code` — pair programmer in your terminal
Scoped to the directory you launch from. The model has native
@@ -771,7 +903,7 @@ cd reasonix
npm install
npm run dev code # run CLI from source via tsx
npm run build # tsup to dist/
npm test # vitest (1007 tests)
npm test # vitest (1482 tests)
npm run lint # biome
npm run typecheck # tsc --noEmit
```
+118 -3
View File
@@ -18,8 +18,9 @@
[![downloads](https://img.shields.io/npm/dm/reasonix.svg)](https://www.npmjs.com/package/reasonix)
[![node](https://img.shields.io/node/v/reasonix.svg)](./package.json)
**DeepSeek 原生的终端 AI 编程代理。** 编辑以可审查的 SEARCH/REPLACE 块呈现,
落盘前必须确认。Ink TUI、原生 MCP、不依赖 LangChain。
**DeepSeek 原生的终端 AI 编程代理。** 单次任务成本约为 Claude Code 的
1/30,缓存优先的循环是为 DeepSeek 的定价模型量身打造的。编辑以可审查的
SEARCH/REPLACE 块呈现,落盘前必须确认。MIT 许可、不绑 IDE、原生 MCP。
---
@@ -68,6 +69,120 @@ Windows Terminal)。任何时候按 `Esc` 中断;`/help` 查看完整命令
---
## 为什么选 Reasonix?(vs Cursor / Claude Code / Cline / Aider)
三件事,别家不会同时都给你:
- **成本节省落到账单上。** DeepSeek V4 的 token 单价大约是 Claude Sonnet
的 1/30。光便宜还不够 —— *便宜的 token 配上 90%+ 的前缀缓存命中*才是关键。
Reasonix 的循环按 append-only 增长设计,缓存稳定的前缀在每次工具调用之间
都活着,下面的 benchmark 章节端到端验证过:实测 94.4% 缓存命中,对照组通用
框架只有 46.6%。`/stats` 面板每轮都跟踪 "vs Claude Sonnet 4.6" 的节省额,
你可以亲眼看着账单不涨。
- **它住在终端里。** 纯 CLI —— 没有 Electron,没有 VS Code 插件,没有要
塞进编辑器的 IDE 插件。和 git、tmux、shell 历史并排。macOS / Linux /
Windows(PowerShell、Git Bash、Windows Terminal 都测过)。唯一的网络
请求就是 DeepSeek API 本身,中间没有厂商服务器。
- **开源且彻底可改。** MIT 许可的 TypeScript。整个循环、工具注册表、
缓存稳定前缀、TUI、MCP 桥接 —— 全部在 `src/` 下,不到 3 万行。Fork
它、做私有构建、塞进 CI 都可以。没有 SaaS 层,没有企业版,没有功能闸门。
| | Reasonix | Claude Code | Cursor | Cline | Aider |
|---|---|---|---|---|---|
| 后端 | 仅 DeepSeek V4 | 仅 Anthropic | OpenAI / Anthropic | 任意(OpenRouter)| 任意(OpenRouter)|
| 单次任务成本 | **~$0.001–$0.005** | ~$0.05–$0.50 | $20/月 + 用量 | 视情况 | 视情况 |
| 运行环境 | 终端 | 终端 + IDE | IDE(Electron)| 仅 VS Code | 终端 |
| 开源协议 | **MIT** | 闭源 | 闭源 | Apache 2 | Apache 2 |
| 缓存优先前缀循环 | **工程化(94% 命中)** | 基础 | n/a | n/a | 基础 |
| MCP 服务器 | **原生支持** | 原生支持 | — | 测试中 | — |
| 计划模式(只读审计闸门)| **支持** | 支持 | — | 支持 | — |
| 用户编写的 skills | **支持** | 支持 | — | — | — |
| 编辑审阅(不自动落盘)| **支持**(`/apply`)| 支持 | 部分 | 支持 | 支持 |
| 工作区切换(`/cwd`、`change_workspace`)| **支持** | — | n/a(每窗一项目)| — | — |
| 跨会话成本面板 | **支持**(`/stats`)| — | — | — | — |
| 沙箱边界强制 | **严格**(拒绝 `..` 逃逸)| 支持 | 部分 | 支持 | 部分 |
### 这些情况下应该选别的
- **你想要多模型混用**(在一个工具里同时切 Claude / GPT / Gemini / 本地 Llama)。
试试 [Aider](https://aider.chat) 或 [Cline](https://cline.bot)。Reasonix
故意只绑 DeepSeek —— 每一层(缓存优先循环、R1 harvest、JSON 模式的工具
调用修复、reasoning_effort 上限)都是为 DeepSeek 的具体行为和经济模型
调出来的。绑死后端是设计选择,不是早晚要解决的限制。
- **你想要 IDE 集成**(编辑器侧边栏 inline diff、多光标、ghost text、重构
预览)。试试 [Cursor](https://cursor.com) 或 Claude Code 的 IDE 模式。
Reasonix 是终端优先的:diff 在 `git diff` 里、文件树在 `ls` 里、对话
在 shell 里。
- **你在追最难的推理 benchmark**。Claude Opus 4.6 还是赢一些榜单的。
DeepSeek V4-pro 在大多数编程任务上都很有竞争力,但不是每个 benchmark
都领先。如果你的任务是"证明这个 PhD 级别的数学命题"而不是"修这个
auth bug",从 Claude 起步更合适。
- **你需要完全本地 / 永远免费**。DeepSeek API 注册送额度,但不是永久
免费。要真正离线/永久免费,看看 Aider + Ollama 或者
[Continue](https://continue.dev)。
### "DeepSeek 现在有 Anthropic 兼容 API 了,我直接拿 Claude Code 接上不就行?"
可以接。DeepSeek 官方提供了 Anthropic 兼容端点
`https://api.deepseek.com/anthropic`,Claude Code(或任何 Anthropic SDK
客户端)不改一行代码就能连上去。**协议跑得通,缓存经济学跑不通** ——
而后者才是关键。
看 [DeepSeek 自己的兼容性表](https://api-docs.deepseek.com/guides/anthropic_api):
| 字段 | 在 DeepSeek 兼容端点上的状态 |
|---|---|
| `cache_control` 标记 | **Ignored(被忽略)** |
| `mcp_servers`(API 层)| Ignored |
| `thinking.budget_tokens` | Ignored |
| 图像 / 文档 / 引用 | 不支持 |
`cache_control: Ignored` 就是杀手级的那一行。这里有**两套完全不同的缓存
机制在打架**:
| | Anthropic 原生 | DeepSeek 自动缓存 |
|---|---|---|
| 模型 | **Marker 驱动。** 你在某条消息上打 `cache_control`,Anthropic 把"到此 marker 为止"的内容做内容寻址缓存。多个 marker = 多个独立断点。 | **Byte-stable prefix。** 缓存对字面字节流从第 0 字节起做指纹。 |
| Claude Code 的设计 | 围绕这个设计的。在 system prompt + tool 定义上插 marker,让 loop 在 marker 之后做重排、压缩、插元数据都不丢缓存。 | n/a —— Claude Code 不是为 byte-stable prefix 设计的。 |
| Claude Code 接 DeepSeek 兼容端点之后 | Marker 被 strip(忽略)。Claude Code 的主缓存策略消失。 | Fallback 到 auto-cache。但 Claude Code 的 prefix 不是 byte-stable 的(marker 本来就是 byte-stability 的*替代*),auto-cache 也命中不了。 |
净效果:**Claude Code 的 loop 重定向到 DeepSeek 之后,便宜 token 拿到了,
原本依赖的缓存命中没了**。一个在 Anthropic marker cache 上 80%+ 命中的
loop,到 DeepSeek 的 auto-cache 上大概率掉到 40-60%(跟我们 benchmark 里
通用 harness 的 baseline 同区间)。同一个模型、同一个 API、同一个负载 ——
loop 的 invariant 跟它现在对话的缓存机制不匹配。
Reasonix 的 loop 从第一行起就是按 byte-stable prefix 的不变量设计的。没有
marker、没有断点 —— append-only 就是 invariant。这就是为什么同一份 τ-bench
负载在 Reasonix 上是 **94.4% 缓存命中**、在 cache-hostile baseline 上是
**46.6%**(已 commit 的 transcript,见下面 benchmark 段)。按 DeepSeek 的
单价 —— $0.07/Mtok 非缓存、约 $0.014/Mtok 缓存命中 —— 50% 和 94% 命中之间
**仅 input 这一侧就大约是 2.5× 的差距**。
### "那 Aider / Cline / Continue 呢?"
它们原生支持 DeepSeek(不需要兼容层),便宜 token 单价你确实拿到了。
但你拿不到 DeepSeek-specific 的循环工程 —— 这些工具的 loop 是为**通用支持**
所有后端(OpenAI / Anthropic / 本地 Llama / ...)设计的,用的是那种会破坏
byte-stability 的通用压缩 / 摘要模式。命中率落在和 baseline 同样的 40-60%
区间。再加上一堆 DeepSeek 怪癖通用 loop 不处理:
| 通用 loop 假定 | DeepSeek 实际行为 | Reasonix 怎么处理 |
|---|---|---|
| 推理通过结构化 `thinking` 块产出 | R1 偶尔把 tool-call JSON 漏到 `<think>` 标签里 | `scavenge` 扫描把漏出的 tool call 拣回,否则模型以为自己已经调用,等不到结果 |
| tool schema 严格校验 | DeepSeek 静默丢弃深嵌套 object/array 参数 | auto-flatten:嵌套参数被重写成单层 prefixed name,模型才看得见 |
| tool-call args 是良构 JSON | DeepSeek 偶发 `string="false"` 之类的破碎片段 | 专用 `ToolCallRepair` 在 dispatch 之前修好常见形状 |
| 推理深度靠系统级开关 | V4 直接暴露 `reasoning_effort` 旋钮(`max` / `high`) | `/effort` slash + `--effort` flag,简单任务可以降到 high 省钱 |
| 老 tool result 永久保留 | 1M context 不需要主动 compact,但通用工具都会做 | call-storm breaker + 结果 token cap,但前缀**永不重写** —— 压缩作为新 turn 追加在尾部 |
> 缓存稳定性不是一个开关,是 loop 设计之初就要建立的不变量。Reasonix
> 不是"又一个 agent CLI",是**围绕 DeepSeek 具体的缓存机制和定价模型
> 设计的 agent CLI**。
---
## `reasonix code` — 终端里的结对编程
作用域为启动目录。模型自带 `read_file` / `write_file` / `edit_file` /
@@ -709,7 +824,7 @@ cd reasonix
npm install
npm run dev code # 用 tsx 直接从源码跑 CLI
npm run build # tsup 打包到 dist/
npm test # vitest(1007 个测试)
npm test # vitest(1482 个测试)
npm run lint # biome
npm run typecheck # tsc --noEmit
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.11.0",
"version": "0.11.3",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
+18 -3
View File
@@ -9,6 +9,7 @@ import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
import {
loadSessionMessages,
rewriteSession,
@@ -53,7 +54,17 @@ export interface ChatOptions {
* Enable SEARCH/REPLACE edit-block processing after each assistant turn.
* Set by `reasonix code`; plain `reasonix chat` leaves this off.
*/
codeMode?: { rootDir: string; jobs?: import("../../tools/jobs.js").JobRegistry };
codeMode?: {
rootDir: string;
jobs?: import("../../tools/jobs.js").JobRegistry;
/**
* `/cwd <path>` callback — re-registers every rootDir-dependent
* native tool against the new path. Optional so embedders that
* don't want live cwd switching can omit it (the slash command
* then falls back to non-tool updates only).
*/
reregisterTools?: (rootDir: string) => void;
};
/** Skip the session picker — assume "Resume" (backwards-compatible auto-continue). */
forceResume?: boolean;
/** Skip the session picker — assume "New" (wipe the session file and start fresh). */
@@ -182,7 +193,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
: spec.transport === "streamable-http"
? new StreamableHttpTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
const mcp = new McpClient({ transport });
await mcp.initialize();
const bridge = await bridgeMcpTools(mcp, {
@@ -212,7 +225,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
}
const label = spec.name ?? "anon";
const source =
spec.transport === "sse" ? spec.url : `${spec.command} ${spec.args.join(" ")}`;
spec.transport === "sse" || spec.transport === "streamable-http"
? spec.url
: `${spec.command} ${spec.args.join(" ")}`;
process.stderr.write(
`▸ MCP[${label}]: ${bridge.registeredNames.length} tool(s) from ${source}\n`,
);
+29 -20
View File
@@ -68,26 +68,39 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// shape instead of the `string="false"` JSON-in-string array that
// triggered R1's DSML hallucinations all through 0.4.x.
const tools = new ToolRegistry();
registerFilesystemTools(tools, { rootDir });
// Background-process registry shared between the shell tools and the
// TUI's /jobs + /kill slashes + exit cleanup. One per `reasonix code`
// run — orphan prevention on SIGINT / process exit kills everything
// it owns, so dev servers don't outlive the Reasonix process.
const jobs = new JobRegistry();
registerShellTools(tools, {
rootDir,
// Per-project "always allow" list persisted from prior ShellConfirm
// choices; merged on top of the built-in allowlist in shell.ts.
// GETTER form — re-read every dispatch so a prefix the user adds
// via ShellConfirm mid-session takes effect on the next shell call
// instead of waiting for `/new` or a relaunch.
extraAllowed: () => loadProjectShellAllowed(rootDir),
// `yolo` edit-mode disables shell confirmations entirely. Re-read
// from config on each dispatch so /mode yolo (or Shift+Tab cycling
// through to it) flips the gate live without forcing a relaunch.
allowAll: () => loadEditMode() === "yolo",
jobs,
});
// Bundled re-registration so `/cwd <path>` can swap every rootDir-
// dependent tool atomically. ToolRegistry.register is keyed by name
// and overwrites in-place, so re-calling these against the existing
// registry replaces the closures cleanly without disturbing tool
// specs (names/descriptions/params don't reference rootDir, so the
// prefix cache survives).
const registerRootedTools = (root: string): void => {
registerFilesystemTools(tools, { rootDir: root });
registerShellTools(tools, {
rootDir: root,
// Per-project "always allow" list persisted from prior ShellConfirm
// choices; merged on top of the built-in allowlist in shell.ts.
// GETTER form — re-read every dispatch so a prefix the user adds
// via ShellConfirm mid-session takes effect on the next shell call
// instead of waiting for `/new` or a relaunch.
extraAllowed: () => loadProjectShellAllowed(root),
// `yolo` edit-mode disables shell confirmations entirely. Re-read
// from config on each dispatch so /mode yolo (or Shift+Tab cycling
// through to it) flips the gate live without forcing a relaunch.
allowAll: () => loadEditMode() === "yolo",
jobs,
});
// `remember` / `forget` / `recall_memory` — cross-session user memory.
// Project scope hashes off rootDir so switching projects gets a fresh
// per-project memory store; the global scope is shared across runs.
registerMemoryTools(tools, { projectRoot: root });
};
registerRootedTools(rootDir);
// `submit_plan` is always in the spec list so the prefix cache stays
// stable across plan-mode toggles (Pillar 1). The tool itself is a
// no-op outside plan mode and throws `PlanProposedError` when the
@@ -99,10 +112,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// menu into a submit_plan body. Keeping it always-registered
// preserves the prefix cache across plan-mode toggles.
registerChoiceTool(tools);
// `remember` / `forget` / `recall_memory` — cross-session user memory.
// Project scope hashes off rootDir so switching projects gets a fresh
// per-project memory store; the global scope is shared across runs.
registerMemoryTools(tools, { projectRoot: rootDir });
// `run_skill` is intentionally NOT registered here — App.tsx wires it
// up with the subagent runner attached, so `runAs: subagent` skills
// can spawn isolated child loops. Doing it here would mean the App's
@@ -141,7 +150,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
transcript: opts.transcript,
session,
seedTools: tools,
codeMode: { rootDir, jobs },
codeMode: { rootDir, jobs, reregisterTools: registerRootedTools },
forceResume: opts.forceResume,
forceNew: opts.forceNew,
});
+4 -1
View File
@@ -15,6 +15,7 @@ import type { InspectionReport } from "../../mcp/inspect.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
export interface McpInspectOptions {
/** The raw --mcp spec string (e.g. `fs=npx -y @modelcontextprotocol/server-filesystem .`). */
@@ -28,7 +29,9 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
: spec.transport === "streamable-http"
? new StreamableHttpTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
const client = new McpClient({ transport });
try {
await client.initialize();
+7 -2
View File
@@ -9,6 +9,7 @@ import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
import { ToolRegistry } from "../../tools.js";
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
import { appendUsage } from "../../usage.js";
@@ -86,12 +87,16 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
: spec.transport === "streamable-http"
? new StreamableHttpTransport({ url: spec.url })
: new StdioTransport({ command: spec.command, args: spec.args });
const mcp = new McpClient({ transport });
await mcp.initialize();
const bridge = await bridgeMcpTools(mcp, { registry: tools, namePrefix: prefix });
const source =
spec.transport === "sse" ? spec.url : `${spec.command} ${spec.args.join(" ")}`;
spec.transport === "sse" || spec.transport === "streamable-http"
? spec.url
: `${spec.command} ${spec.args.join(" ")}`;
process.stderr.write(
`▸ MCP[${spec.name ?? "anon"}]: ${bridge.registeredNames.length} tool(s) from ${source}\n`,
);
+253 -42
View File
@@ -1,4 +1,5 @@
import type { WriteStream } from "node:fs";
import * as pathMod from "node:path";
import { Box, Static, Text, useApp, useStdout } from "ink";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type AtUrlExpansion, expandAtMentions, expandAtUrls } from "../../at-mentions.js";
@@ -40,6 +41,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 { registerWorkspaceTool } from "../../tools/workspace.js";
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
import { appendUsage } from "../../usage.js";
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
@@ -57,6 +59,7 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
import { SlashSuggestions } from "./SlashSuggestions.js";
import { StatsPanel } from "./StatsPanel.js";
import { WelcomeBanner } from "./WelcomeBanner.js";
import { WorkspaceConfirm, type WorkspaceConfirmChoice } from "./WorkspaceConfirm.js";
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
import {
describeRepair,
@@ -114,7 +117,17 @@ export interface AppProps {
* optional `jobs` registry enables /jobs + /kill slashes in the TUI
* and the status-bar "N jobs running" indicator.
*/
codeMode?: { rootDir: string; jobs?: import("../../tools/jobs.js").JobRegistry };
codeMode?: {
rootDir: string;
jobs?: import("../../tools/jobs.js").JobRegistry;
/**
* `/cwd <path>` callback — re-registers every rootDir-dependent
* native tool against the new path. Optional: when omitted the
* slash command degrades to updating hook cwd / memory root only,
* with file/shell tools still pointing at the original root.
*/
reregisterTools?: (rootDir: string) => void;
};
}
/**
@@ -281,17 +294,23 @@ export function App({
// summary). Rendered as a dim spinner row; auto-cleared on the next
// primary event.
const [statusLine, setStatusLine] = useState<string | null>(null);
// Live working directory for every rootDir-dependent surface:
// hook cwd, memory root, project shell allowlist root, `@file`
// mention root, `applyEditBlocks` base, run_command cwd, project-
// settings hook loader. `/cwd <path>` mutates this state to swap
// the workspace mid-session; the prop `codeMode.rootDir` stays as
// the original launch root so it can't accidentally drift (it's
// used purely for "is this a code-mode session?" checks now).
const [currentRootDir, setCurrentRootDir] = useState<string>(
() => codeMode?.rootDir ?? process.cwd(),
);
// Loaded user hooks (project + global settings.json). Stays mutable
// so `/hooks reload` can rescan disk without reconstructing the
// loop. The loop holds a parallel reference for its tool-event
// dispatch; we keep them in sync via the effect below.
// so `/hooks reload` and `/cwd` can rescan disk without
// reconstructing the loop. The loop holds a parallel reference for
// its tool-event dispatch; we keep them in sync via the effect below.
const [hookList, setHookList] = useState<ResolvedHook[]>(() =>
loadHooks({ projectRoot: codeMode?.rootDir }),
);
// Working directory reported in every hook's stdin payload. Hook
// scripts that `cd $REASONIX_CWD` (or read `cwd` from the JSON
// envelope) land in the project root, not the user's shell home.
const hookCwd = codeMode?.rootDir ?? process.cwd();
// Session-scoped edit history + undo banner + /undo, /history, /show
// handlers. Kept in a custom hook so App.tsx only sees the small API
// it needs — append an edit, arm the banner, answer the slash
@@ -389,6 +408,13 @@ export function App({
*/
kind: "run_command" | "run_background";
} | null>(null);
// The latest `change_workspace` request awaiting user approval.
// Populated from the WorkspaceConfirmationError surfaced in the
// tool result; cleared once the user picks Switch / Deny in
// WorkspaceConfirm. Mutually exclusive with pendingShell — only
// one modal can be open at a time, and we gate input + render on
// both flags wherever pendingShell is gated.
const [pendingWorkspace, setPendingWorkspace] = useState<{ path: string } | null>(null);
// Plan text the model submitted via `submit_plan` while plan mode
// was active. Non-null renders PlanConfirm; user picks Approve /
// Refine / Cancel and we drive the loop from there. Separate from
@@ -592,13 +618,14 @@ export function App({
}, []);
const loopRef = useRef<CacheFirstLoop | null>(null);
// hookList + hookCwd intentionally NOT in deps — they seed the loop
// on first construction (loopRef guards a single instantiation), and
// later edits flow in through the mutable `loop.hooks = hookList`
// effect below. Putting them in deps would tear down the loop on
// every reload, wiping the append-only log mid-session.
// hookList + currentRootDir intentionally NOT in deps — they seed
// the loop on first construction (loopRef guards a single
// instantiation), and later edits flow in through the mutable
// `loop.hooks = hookList` / `loop.hookCwd = currentRootDir` effects
// below. Putting them in deps would tear down the loop on every
// reload, wiping the append-only log mid-session.
// biome-ignore lint/correctness/useExhaustiveDependencies: hookList — see comment above
// biome-ignore lint/correctness/useExhaustiveDependencies: hookCwd — see comment above
// biome-ignore lint/correctness/useExhaustiveDependencies: currentRootDir — see comment above
const loop = useMemo(() => {
if (loopRef.current) return loopRef.current;
const client = new DeepSeekClient();
@@ -634,6 +661,16 @@ export function App({
},
});
}
// `change_workspace` — model-callable workspace switching, gated
// on a confirmation modal driven by App.tsx (see pendingWorkspace
// state below). Tool fn validates the path and throws
// WorkspaceConfirmationError; the actual swap happens when the
// user approves. Registered here (not in code.tsx) so chat-mode
// sessions also expose it — `setCwd` works in chat mode too,
// just doesn't have a tool sandbox to re-register.
if (tools && !tools.has("change_workspace")) {
registerWorkspaceTool(tools);
}
const prefix = new ImmutablePrefix({
system,
toolSpecs: tools?.specs(),
@@ -647,7 +684,7 @@ export function App({
branch,
session,
hooks: hookList,
hookCwd,
hookCwd: currentRootDir,
// Restore the user's last-chosen effort cap. Without this a
// `/effort high` silently reverted to `max` on relaunch — the
// loop's constructor default wins over persisted state.
@@ -664,6 +701,77 @@ export function App({
loop.hooks = hookList;
}, [loop, hookList]);
// Shared cwd-switch implementation — drives BOTH the `/cwd` slash
// command and the `change_workspace` tool's confirmation handler.
// Returns a multi-line info string the caller can surface to the
// user (slash) or fold into a synthetic message (tool). Idempotent
// on the same path.
const applyCwdChange = useCallback(
(newRoot: string): string => {
// Update the React state — every memoized derivation that reads
// `currentRootDir` (memoryRoot, hookCwd via useEffect,
// applyEditBlocks paths, mention root, run_command cwd) picks
// up the new path on the next render.
setCurrentRootDir(newRoot);
// Reload hooks against the new project (different
// .reasonix/settings.json may exist there).
const fresh = loadHooks({ projectRoot: codeMode ? newRoot : undefined });
setHookList(fresh);
// Re-register every rootDir-dependent native tool so file
// reads/writes and shell calls land in the new sandbox. Only
// available in code mode (chat mode has no rootDir-bound tools
// beyond memory, which we re-register inline below).
const codeRebound = codeMode?.reregisterTools !== undefined;
if (codeMode?.reregisterTools) {
codeMode.reregisterTools(newRoot);
}
// Keep `run_skill`'s closured projectRoot in sync too. It's
// owned by App.tsx (the subagent runner needs in-process refs),
// so the codeMode reregister hook above doesn't touch it. Safe
// to re-register: the registry overwrites by tool name, the
// spec is identical, prefix cache stays.
if (tools) {
registerSkillTools(tools, {
projectRoot: codeMode ? newRoot : undefined,
subagentRunner: async (skill, task) => {
const result = await spawnSubagent({
client: loop.client,
parentRegistry: tools,
system: skill.body,
task,
model: skill.model,
sink: subagentSinkRef.current,
skillName: skill.name,
});
return formatSubagentResult(result);
},
});
}
const lines = [`▸ cwd → ${newRoot}`, ` hooks reloaded (${fresh.length} active)`];
if (codeMode) {
lines.push(
codeRebound
? " filesystem / shell / memory tools rebound to new root"
: " warning: reregisterTools callback missing — tool sandbox unchanged",
);
lines.push(
" note: system prompt context (gitignore, REASONIX.md stack) was",
" baked at session start and still references the original root.",
);
}
return lines.join("\n");
},
[codeMode, loop, tools, subagentSinkRef],
);
// Mirror `currentRootDir` into the loop's mutable `hookCwd` so
// `/cwd` switches the path threaded into every hook's stdin
// envelope without reconstructing the loop. Same shape as the
// hookList sync above — the loop holds a parallel mutable copy.
useEffect(() => {
loop.hookCwd = currentRootDir;
}, [loop, currentRootDir]);
// Ambient session info (balance, model catalog, latest published
// version) — three independent mount-time fetches behind one hook
// so the refresh callbacks can be wired into handleSubmit's finally
@@ -699,7 +807,14 @@ export function App({
slashArgSelected,
setSlashArgSelected,
pickSlashArg,
} = useCompletionPickers({ input, setInput, codeMode, models, mcpServers });
} = useCompletionPickers({
input,
setInput,
codeMode,
rootDir: currentRootDir,
models,
mcpServers,
});
// Wire the shared progressSink so the bridge's onProgress → us.
// Only updates progress when the frame belongs to the currently-
@@ -927,6 +1042,7 @@ export function App({
key.shift &&
key.tab &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -966,6 +1082,7 @@ export function App({
input.length === 0 &&
(chKey === "u" || chKey === "U") &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -1122,7 +1239,7 @@ export function App({
// the queued block is a literal whole-file overwrite. For new
// files SEARCH stays empty — applyEditBlock's create-new sentinel.
const content = typeof args.content === "string" ? args.content : "";
block = toWholeFileEditBlock(relPath, content, codeMode.rootDir);
block = toWholeFileEditBlock(relPath, content, currentRootDir);
}
// Helper: apply the current block + record into history + arm
@@ -1138,8 +1255,8 @@ export function App({
// the "result shown twice" bug reported in 0.6 (one dim info
// row, then a nearly identical tool row directly below).
const applyNow = (): string => {
const snaps = snapshotBeforeEdits([block], codeMode.rootDir);
const results = applyEditBlocks([block], codeMode.rootDir);
const snaps = snapshotBeforeEdits([block], currentRootDir);
const results = applyEditBlocks([block], currentRootDir);
const good = results.some((r) => r.status === "applied" || r.status === "created");
if (good) {
recordEdit("auto", [block], results, snaps);
@@ -1233,8 +1350,8 @@ export function App({
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, codeMode.rootDir);
const results = applyEditBlocks(selected, codeMode.rootDir);
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;
@@ -1247,7 +1364,7 @@ export function App({
: "";
return formatEditResults(results) + tail;
},
[codeMode, session, syncPendingCount, recordEdit],
[codeMode, currentRootDir, session, syncPendingCount, recordEdit],
);
/**
@@ -1514,7 +1631,7 @@ export function App({
const hashParse = detectHashMemory(text);
if (hashParse?.kind === "memory" || hashParse?.kind === "memory-global") {
const isGlobal = hashParse.kind === "memory-global";
const memRoot = codeMode?.rootDir ?? process.cwd();
const memRoot = currentRootDir;
promptHistory.current.push(text);
try {
const result = isGlobal
@@ -1557,7 +1674,7 @@ export function App({
// happened AND the bang exchange survives session resume.
const bangCmd = detectBangCommand(text);
if (bangCmd !== null) {
const bangRoot = codeMode?.rootDir ?? process.cwd();
const bangRoot = currentRootDir;
promptHistory.current.push(text);
setHistorical((prev) => [
...prev,
@@ -1627,10 +1744,10 @@ export function App({
codeDiscard: codeMode ? codeDiscard : undefined,
codeHistory: codeMode ? codeHistory : undefined,
codeShowEdit: codeMode ? codeShowEdit : undefined,
codeRoot: codeMode?.rootDir,
codeRoot: codeMode ? currentRootDir : undefined,
pendingEditCount: codeMode ? pendingEdits.current.length : undefined,
toolHistory: () => toolHistoryRef.current,
memoryRoot: codeMode?.rootDir ?? process.cwd(),
memoryRoot: currentRootDir,
planMode,
setPlanMode: codeMode ? togglePlanMode : undefined,
clearPendingPlan: codeMode ? clearPendingPlan : undefined,
@@ -1655,10 +1772,11 @@ export function App({
{ id: `sys-late-${Date.now()}-${Math.random()}`, role: "info", text },
]),
reloadHooks: () => {
const fresh = loadHooks({ projectRoot: codeMode?.rootDir });
const fresh = loadHooks({ projectRoot: codeMode ? currentRootDir : undefined });
setHookList(fresh);
return fresh.length;
},
setCwd: (newRoot: string) => applyCwdChange(newRoot),
latestVersion,
refreshLatestVersion,
models,
@@ -1764,7 +1882,7 @@ export function App({
if (hookList.some((h) => h.event === "UserPromptSubmit")) {
const promptReport = await runHooks({
hooks: hookList,
payload: { event: "UserPromptSubmit", cwd: hookCwd, prompt: text },
payload: { event: "UserPromptSubmit", cwd: currentRootDir, prompt: text },
});
if (promptReport.outcomes.length > 0) {
setHistorical((prev) => [
@@ -1870,8 +1988,8 @@ export function App({
// block; the Historical row above keeps the user's verbatim text
// so the display doesn't balloon.
let modelInput = text;
if (codeMode?.rootDir) {
const expanded = expandAtMentions(text, codeMode.rootDir);
if (codeMode) {
const expanded = expandAtMentions(text, currentRootDir);
if (expanded.expansions.length > 0) {
modelInput = expanded.text;
const inlined = expanded.expansions
@@ -2046,8 +2164,8 @@ export function App({
const blocks = parseEditBlocks(finalText);
if (blocks.length > 0) {
if (editModeRef.current === "auto" || editModeRef.current === "yolo") {
const snaps = snapshotBeforeEdits(blocks, codeMode.rootDir);
const results = applyEditBlocks(blocks, codeMode.rootDir);
const snaps = snapshotBeforeEdits(blocks, currentRootDir);
const results = applyEditBlocks(blocks, currentRootDir);
const good = results.some(
(r) => r.status === "applied" || r.status === "created",
);
@@ -2167,6 +2285,34 @@ export function App({
/* malformed args — skip the prompt */
}
}
// change_workspace surfaced its WorkspaceConfirmationError —
// the resolved absolute path is on the error message between
// the `"` markers (`switching to "/abs/path" needs ...`). We
// re-derive it from the args rather than parsing the message
// so a future error-text rewording doesn't break the modal.
if (
ev.toolName === "change_workspace" &&
ev.content.includes('"WorkspaceConfirmationError:') &&
ev.toolArgs
) {
try {
const parsed = JSON.parse(ev.toolArgs) as { path?: unknown };
if (typeof parsed.path === "string" && parsed.path.trim()) {
// Re-resolve the same way the tool fn did so the modal
// shows the canonical destination even when the model
// passed a relative or `~`-prefixed path.
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const expanded =
parsed.path.startsWith("~") && home
? pathMod.join(home, parsed.path.slice(1))
: parsed.path;
const abs = pathMod.resolve(expanded);
setPendingWorkspace({ path: abs });
}
} catch {
/* malformed args — skip the prompt */
}
}
// submit_plan fired while plan mode was on — the registry
// serialized `{ error, plan, steps? }` via PlanProposedError's
// toToolResult(). Extract the plan and mount PlanConfirm.
@@ -2385,7 +2531,7 @@ export function App({
hooks: hookList,
payload: {
event: "Stop",
cwd: hookCwd,
cwd: currentRootDir,
lastAssistantText: streamRef.text,
turn: loop.stats.summary().turns,
},
@@ -2426,8 +2572,8 @@ export function App({
codeMode,
codeShowEdit,
codeUndo,
currentRootDir,
exit,
hookCwd,
hookList,
loop,
latestVersion,
@@ -2463,6 +2609,7 @@ export function App({
startLoop,
getLoopStatus,
startWalkthrough,
applyCwdChange,
],
);
@@ -2550,13 +2697,13 @@ export function App({
} else {
if (choice === "always_allow") {
const prefix = derivePrefix(cmd);
addProjectShellAllowed(codeMode.rootDir, prefix);
addProjectShellAllowed(currentRootDir, prefix);
setHistorical((prev) => [
...prev,
{
id: `sh-allow-${Date.now()}`,
role: "info",
text: `▸ always allowed "${prefix}" for ${codeMode.rootDir}`,
text: `▸ always allowed "${prefix}" for ${currentRootDir}`,
},
]);
}
@@ -2577,7 +2724,7 @@ export function App({
let jobId: number | null = null;
let preview = "";
try {
const res = await codeMode.jobs.start(cmd, { cwd: codeMode.rootDir });
const res = await codeMode.jobs.start(cmd, { cwd: currentRootDir });
startedOk = true;
jobId = res.jobId;
preview = res.preview;
@@ -2606,7 +2753,7 @@ export function App({
// Foreground (run_command) — synchronous; waits for exit.
let body: string;
try {
const res = await runCommand(cmd, { cwd: codeMode.rootDir });
const res = await runCommand(cmd, { cwd: currentRootDir });
body = formatCommandResult(cmd, res);
} catch (err) {
body = `$ ${cmd}\n[failed to spawn] ${(err as Error).message}`;
@@ -2630,7 +2777,7 @@ export function App({
await handleSubmit(synthetic);
}
},
[pendingShell, codeMode, handleSubmit, busy, loop],
[pendingShell, codeMode, currentRootDir, handleSubmit, busy, loop],
);
// Drain the shell-confirm queue after the in-flight turn tears down.
@@ -2644,6 +2791,56 @@ export function App({
}
}, [busy, queuedSubmit, handleSubmit]);
/**
* WorkspaceConfirm callback. Two outcomes, both ending with a
* synthetic user message so the model sees what happened on its
* next turn:
* - deny → tell the model the user refused, continue without it.
* - switch → call applyCwdChange (same path as `/cwd`), surface
* the cwd-change info row to scrollback, hand the model a
* short confirmation so it can resume the user's request
* against the new sandbox.
*/
const handleWorkspaceConfirm = useCallback(
async (choice: WorkspaceConfirmChoice) => {
const pending = pendingWorkspace;
if (!pending) return;
const target = pending.path;
setPendingWorkspace(null);
let synthetic: string;
if (choice === "deny") {
setHistorical((prev) => [
...prev,
{
id: `ws-deny-${Date.now()}`,
role: "info",
text: `▸ denied workspace switch: ${target}`,
},
]);
synthetic = `I denied switching the workspace to \`${target}\`. Please continue without changing directories.`;
} else {
const info = applyCwdChange(target);
setHistorical((prev) => [
...prev,
{ id: `ws-switch-${Date.now()}`, role: "info", text: info },
]);
synthetic = `I approved the workspace switch. The session is now rooted at \`${target}\` — your filesystem / shell / memory tools resolve against that path on every subsequent call. Continue with my original request from this new root.`;
}
// Same race protection as handleShellConfirm: if the prior
// turn is still streaming, abort it and queue the synthetic
// for the busy=false edge detector below.
if (busy) {
loop.abort();
setQueuedSubmit(synthetic);
} else {
await handleSubmit(synthetic);
}
},
[pendingWorkspace, applyCwdChange, busy, loop, handleSubmit],
);
/**
* PlanConfirm callback. Three outcomes, all ending with a synthetic
* user message so the model sees the verdict on its next turn:
@@ -3045,6 +3242,7 @@ export function App({
isResizing ||
!!pendingPlan ||
!!pendingShell ||
!!pendingWorkspace ||
!!pendingEditReview ||
walkthroughActive ||
!!pendingCheckpoint ||
@@ -3071,7 +3269,7 @@ export function App({
escalated={turnOnPro}
/>
<Static items={historical}>
{(item) => <EventRow key={item.id} event={item} projectRoot={hookCwd} />}
{(item) => <EventRow key={item.id} event={item} projectRoot={currentRootDir} />}
</Static>
{/*
Welcome card on the empty state. Visible only when nothing
@@ -3093,6 +3291,7 @@ export function App({
*/}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3100,11 +3299,12 @@ export function App({
!stagedCheckpointRevise &&
streaming ? (
<Box marginY={1}>
<EventRow event={streaming} projectRoot={hookCwd} />
<EventRow event={streaming} projectRoot={currentRootDir} />
</Box>
) : null}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3115,6 +3315,7 @@ export function App({
) : null}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3125,6 +3326,7 @@ export function App({
) : null}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3137,6 +3339,7 @@ export function App({
{!PLAIN_UI &&
undoBanner &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3157,6 +3360,7 @@ export function App({
*/}
{!PLAIN_UI &&
!pendingShell &&
!pendingWorkspace &&
!pendingPlan &&
!stagedInput &&
!pendingEditReview &&
@@ -3219,7 +3423,7 @@ export function App({
steps={planStepsRef.current ?? undefined}
summary={planSummaryRef.current ?? undefined}
onChoose={stableHandlePlanConfirm}
projectRoot={hookCwd}
projectRoot={currentRootDir}
/>
) : pendingShell ? (
<ShellConfirm
@@ -3228,6 +3432,13 @@ export function App({
kind={pendingShell.kind}
onChoose={handleShellConfirm}
/>
) : pendingWorkspace ? (
<WorkspaceConfirm
path={pendingWorkspace.path}
currentRoot={currentRootDir}
mcpServerCount={mcpServers?.length ?? 0}
onChoose={handleWorkspaceConfirm}
/>
) : pendingEditReview ? (
<EditConfirm
block={pendingEditReview}
+69
View File
@@ -0,0 +1,69 @@
import { Box, Text } from "ink";
import React from "react";
import { ModalCard } from "./ModalCard.js";
import { SingleSelect } from "./Select.js";
export type WorkspaceConfirmChoice = "switch" | "deny";
export interface WorkspaceConfirmProps {
/** Resolved absolute path the model wants to switch to. */
path: string;
/** Current session root, shown above the target so the user sees the diff. */
currentRoot: string;
/** Number of MCP servers still attached — surfaced so the user knows
* those won't follow the switch (their child processes were spawned
* with the original cwd). 0 means no warning. */
mcpServerCount: number;
onChoose: (choice: WorkspaceConfirmChoice) => void;
}
/**
* Modal-style approval for a `change_workspace` tool call. Two
* choices, Enter / Esc bindings. No "always allow" — workspace
* switches are per-target by nature.
*/
export function WorkspaceConfirm({
path,
currentRoot,
mcpServerCount,
onChoose,
}: WorkspaceConfirmProps) {
const subtitle =
mcpServerCount > 0
? `MCP servers (${mcpServerCount}) stay anchored to the original launch root.`
: "Re-registers filesystem / shell / memory tools at the new path.";
return (
<ModalCard accent="#f59e0b" icon="⇄" title="switch workspace" subtitle={subtitle}>
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text dimColor>{"from "}</Text>
<Text color="#a3a3a3">{currentRoot}</Text>
</Box>
<Box>
<Text dimColor>{"to "}</Text>
<Text color="#67e8f9" bold>
{path}
</Text>
</Box>
</Box>
<SingleSelect
initialValue="switch"
items={[
{
value: "switch",
label: "Switch",
hint: "Re-register filesystem / shell / memory tools against the new root.",
},
{
value: "deny",
label: "Deny",
hint: "Tell the model the user refused; it will continue without changing directories.",
},
]}
onSubmit={(v) => onChoose(v as WorkspaceConfirmChoice)}
onCancel={() => onChoose("deny")}
footer="[↑↓] navigate · [Enter] select · [Esc] deny"
/>
</ModalCard>
);
}
+32
View File
@@ -63,7 +63,39 @@ function toSubscript(s: string): string {
return out;
}
/**
* Math indicator regex — gates the LaTeX-stripping pipeline so plain
* prose with literal backslashes (Windows paths, escape sequences in
* code prose) doesn't get mauled by the catch-all `\[a-zA-Z]+ → ""`
* fallback at the bottom of stripMath.
*
* Without this guard, "F:\TEST1" got rendered as "F:1" because the
* catch-all interpreted `\TEST` as an unknown LaTeX command and
* deleted it. We only run the pipeline when the text actually
* contains math markers we recognize: $-delimited spans, `\(`/`\[`
* paren-delimiters, or one of the explicit LaTeX command names the
* pipeline knows how to translate. Anything else passes through
* untouched.
*/
const HAS_MATH_RE = new RegExp(
[
"\\$", // dollar-delimited (block or inline)
"\\\\[([]", // \( or \[
"\\\\[a-zA-Z]+\\s*\\{", // \anyCommand{...} — covers catch-all braced transforms
// Bare (no-brace) LaTeX commands the pipeline knows how to handle.
// Listed explicitly because a generic `\\[a-zA-Z]+` would also match
// Windows paths (`F:\TEST1`) and re-introduce the bug we're fixing.
"\\\\(?:cdot|times|div|pm|mp|leq|geq|neq|approx|in|notin|infty|sum|prod|int|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|phi|omega|implies|iff|to|rightarrow|leftarrow|Rightarrow|Leftarrow|ldots|cdots|quad|qquad)(?![a-zA-Z])",
"[\\^_]\\{", // LaTeX braced super/subscript: ^{2}, _{ij}
"\\^[0-9+\\-n](?![A-Za-z])", // LaTeX single-char super: ^2, ^-, ^n
"_[0-9+\\-](?![A-Za-z])", // LaTeX single-char sub: _1, _+, _-
"\\^[A-Za-z0-9+\\-]+\\^", // Pandoc super: ^2^, ^abc^
"(?<!~)~[A-Za-z0-9+\\-]+~(?!~)", // Pandoc sub: ~2~ (lookarounds avoid ~~strike~~)
].join("|"),
);
export function stripMath(s: string): string {
if (!HAS_MATH_RE.test(s)) return s;
return (
s
// Dollar-delimited math (KaTeX / MathJax convention). Block
+21
View File
@@ -69,6 +69,19 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "[reload]",
summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk",
},
{
cmd: "permissions",
argsHint: "[list|add <prefix>|remove <prefix|N>|clear confirm]",
summary:
"show / edit shell allowlist (builtin read-only · per-project: ~/.reasonix/config.json)",
argCompleter: ["list", "add", "remove", "clear"],
},
{
cmd: "cwd",
argsHint: "<path>",
summary:
"switch session working directory (re-registers code tools, reloads hooks; MCP servers stay)",
},
{
cmd: "update",
summary: "show current vs latest version + the shell command to upgrade",
@@ -113,6 +126,14 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
},
{ cmd: "exit", summary: "quit the TUI" },
// Code-mode only
{
cmd: "init",
argsHint: "[force]",
summary:
"scan the project and synthesize a baseline REASONIX.md (model writes; review with /apply). `force` overwrites an existing file.",
contextual: "code",
argCompleter: ["force"],
},
{
cmd: "apply",
argsHint: "[N|N,M|N-M]",
+4
View File
@@ -2,11 +2,13 @@ import type { CacheFirstLoop } from "../../../loop.js";
import { handlers as adminHandlers } from "./handlers/admin.js";
import { handlers as basicHandlers } from "./handlers/basic.js";
import { handlers as editsHandlers } from "./handlers/edits.js";
import { handlers as initHandlers } from "./handlers/init.js";
import { handlers as jobsHandlers } from "./handlers/jobs.js";
import { handlers as mcpHandlers } from "./handlers/mcp.js";
import { handlers as memoryHandlers } from "./handlers/memory.js";
import { handlers as modelHandlers } from "./handlers/model.js";
import { handlers as observabilityHandlers } from "./handlers/observability.js";
import { handlers as permissionsHandlers } from "./handlers/permissions.js";
import { handlers as plansHandlers } from "./handlers/plans.js";
import { handlers as semanticHandlers } from "./handlers/semantic.js";
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
@@ -33,11 +35,13 @@ const HANDLERS: Record<string, SlashHandler> = {
...adminHandlers,
...basicHandlers,
...editsHandlers,
...initHandlers,
...jobsHandlers,
...mcpHandlers,
...memoryHandlers,
...modelHandlers,
...observabilityHandlers,
...permissionsHandlers,
...plansHandlers,
...semanticHandlers,
...sessionsHandlers,
+62
View File
@@ -1,3 +1,5 @@
import { existsSync, statSync } from "node:fs";
import * as pathMod from "node:path";
import {
HOOK_EVENTS,
type HookEvent,
@@ -150,9 +152,69 @@ const stats: SlashHandler = () => {
return { info: renderDashboard(agg, path) };
};
/**
* `/cwd <path>` — switch the session working directory mid-session.
* Validates the target (must exist, must be a directory), normalizes
* to an absolute path with `~` expansion, then defers the actual swap
* to the App-supplied `setCwd` callback. The callback updates hook
* cwd, memory root, project shell allowlist root, `@file` mention
* root, and (in code mode) re-registers filesystem / shell / memory /
* skill tools — see App.tsx for the wiring.
*
* MCP servers do NOT follow the cwd switch — their stdio child was
* spawned with the original cwd at session start, and there's no
* standard "reconnect with new cwd" handshake. The handler surfaces
* a one-line warning when MCP servers are present so users aren't
* surprised by tools still resolving paths against the old root.
*/
const cwd: SlashHandler = (args, _loop, ctx) => {
if (!ctx.setCwd) {
return {
info: "/cwd is not available in this context (no setCwd callback wired).",
};
}
const raw = (args[0] ?? "").trim();
if (!raw) {
return {
info: "usage: /cwd <path> (absolute or relative, ~ expands to home)",
};
}
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const expanded = raw.startsWith("~") && home ? pathMod.join(home, raw.slice(1)) : raw;
const abs = pathMod.resolve(expanded);
if (!existsSync(abs)) {
return { info: `▸ /cwd: path does not exist — ${abs}` };
}
let isDir = false;
try {
isDir = statSync(abs).isDirectory();
} catch {
// Permission denied or transient FS error — treat as not-a-dir
// and let the user see the exact path so they can investigate.
}
if (!isDir) {
return { info: `▸ /cwd: not a directory — ${abs}` };
}
let info: string;
try {
info = ctx.setCwd(abs);
} catch (err) {
return { info: `▸ /cwd failed: ${(err as Error).message}` };
}
const lines = [info];
if (ctx.mcpServers && ctx.mcpServers.length > 0) {
lines.push(
` note: ${ctx.mcpServers.length} MCP server(s) still anchored to the original cwd —`,
" their tools won't follow this switch. Restart the session for full reset.",
);
}
return { info: lines.join("\n") };
};
export const handlers: Record<string, SlashHandler> = {
hook: hooks,
hooks,
cwd,
update,
stats,
};
+126
View File
@@ -0,0 +1,126 @@
/**
* `/init` — auto-generate a baseline REASONIX.md by asking the model
* to scan the project and synthesize a concise summary.
*
* Mechanism: the slash handler injects a structured user-turn prompt
* (via `resubmit`) that hard-constrains the model to write a short,
* fact-only document and stop. It uses the same filesystem tools the
* model already has — no new tool, no new pipeline. The model's output
* lands as an `edit_file` (empty SEARCH = create new) or `write_file`
* SEARCH/REPLACE block, which goes through the normal /apply review
* gate so the user can audit before the file hits disk.
*
* Why this shape rather than a CLI command: REASONIX.md goes into
* the system prompt every session, so its content has to fit. A
* model-driven generator that runs inside the same loop the user
* will use later gives the most consistent output, and reusing the
* existing edit-review flow means we don't have to invent a
* separate "review the generated doc" UI.
*/
import { existsSync } from "node:fs";
import * as pathMod from "node:path";
import type { SlashHandler } from "../dispatch.js";
/**
* The init brief. Sent verbatim as a user turn (via resubmit). Hard
* constraints up top because flash will skim and the structure of
* the document matters for system-prompt budget. The "STOP after
* writing" line is load-bearing — without it the model will follow
* up with "want me to also …" and burn turns on noise.
*/
const INIT_PROMPT = [
"# Task: Initialize REASONIX.md",
"",
"I want you to generate a REASONIX.md at the project root that captures",
"the working knowledge a future Reasonix session needs to be productive",
"here. This file is auto-pinned into your system prompt every launch,",
"so its size and accuracy matter.",
"",
"## Hard constraints (do NOT relax these)",
"",
"- **Length cap: ≤ 80 lines / 3KB total.** Be concise. If you can't fit a",
" section, drop it.",
"- **Only document things you can verify by reading files.** Do NOT",
" speculate about architectural intent, future roadmap, or design",
" rationale. If it isn't obvious from the code, leave it out.",
"- **No placeholder text.** No 'TODO: describe X', no 'Add more here'.",
" Either state a fact or omit the section.",
"",
"## Procedure",
"",
"1. Read the top of any existing README* file.",
"2. Read the manifest (package.json / Cargo.toml / pyproject.toml /",
" go.mod / etc.) — pick whichever exists.",
"3. `directory_tree` 1-2 levels deep on the project root, skipping",
" common build/dependency dirs (node_modules, dist, target, .git,",
" venv, __pycache__).",
"4. Identify: primary language + framework, top-level layout, test",
" runner, lint/format setup, build/run/test scripts, any non-obvious",
" convention with visible evidence (commit message format, import",
" order, naming pattern).",
"5. Write REASONIX.md with the sections below, skipping any you can't",
" fill from evidence.",
"",
"## Sections to use (skip ones with no evidence)",
"",
"- **Stack** — language + framework + 3-5 key deps. One line each.",
"- **Layout** — top-level dirs and what lives in each. One line each.",
"- **Commands** — verbatim from `scripts` block (or equivalent):",
" build / test / lint / typecheck / dev / format. Whatever exists.",
"- **Conventions** — only things visible in the code. Examples:",
" '*.test.ts colocated with source', 'named exports only',",
" 'commits use Conventional Commits prefix'. If you can't find any",
" CONVENTION evidence, omit the whole section.",
"- **Watch out for** — gotchas a new contributor would benefit from",
" knowing BEFORE editing. Examples: 'edit_file SEARCH must match",
" byte-for-byte', 'this dir is generated, don't edit by hand'.",
" Omit if you find nothing concrete.",
"",
"## Output",
"",
"Write the result to `REASONIX.md` in the project root using the",
"filesystem tools (edit_file with empty SEARCH if creating new,",
"write_file if overwriting). After writing, STOP — do not summarize",
"what you did, do not propose follow-up tasks. The user will review",
"the pending edit via /apply.",
"",
"Start now.",
].join("\n");
const init: SlashHandler = (args, _loop, ctx) => {
if (!ctx.codeRoot) {
return {
info: [
"/init only works in code mode (it needs filesystem tools).",
"Run `reasonix code [path]` to start a session rooted at the",
"project you want to initialize, then run /init.",
].join("\n"),
};
}
const force = (args[0] ?? "").toLowerCase() === "force";
const target = pathMod.join(ctx.codeRoot, "REASONIX.md");
if (existsSync(target) && !force) {
return {
info: [
`▸ REASONIX.md already exists at ${target}`,
"",
" /init force regenerate from scratch (overwrites)",
"",
" Or edit it by hand — it's just markdown. The current file is",
" pinned into the system prompt every launch as-is.",
].join("\n"),
};
}
return {
info: [
"▸ /init — model will scan the project and synthesize REASONIX.md.",
" The result lands as a pending edit; review with /apply or /walk.",
].join("\n"),
resubmit: INIT_PROMPT,
};
};
export const handlers: Record<string, SlashHandler> = {
init,
};
+196
View File
@@ -0,0 +1,196 @@
import {
addProjectShellAllowed,
clearProjectShellAllowed,
loadProjectShellAllowed,
removeProjectShellAllowed,
} from "../../../../config.js";
import { BUILTIN_ALLOWLIST } from "../../../../tools/shell.js";
import type { SlashHandler } from "../dispatch.js";
/**
* `/permissions` family. Mirrors /memory's shape — bare lists state,
* subcommands manage it. Two layers exist:
*
* 1. Builtin allowlist — baked into shell.ts (`BUILTIN_ALLOWLIST`),
* always on. Read-only here.
* 2. Per-project allowlist — `~/.reasonix/config.json`, populated by
* "always allow" picks on ShellConfirm. Add / remove / clear here.
*
* Edit-mode (review / auto / yolo) is also surfaced at the top so users
* see "yolo skips all of this" without having to re-derive it from /mode.
*
* Subcommands:
* add <prefix...> — append a prefix (multi-token OK: "git push origin")
* remove <prefix|N> — drop by exact prefix or 1-based project index
* clear confirm — wipe every project entry (typed literal "confirm")
*/
const permissions: SlashHandler = (args, _loop, ctx) => {
const sub = (args[0] ?? "").toLowerCase();
const root = ctx.codeRoot;
const mode = ctx.editMode ?? null;
// Bare → status + listing.
if (sub === "" || sub === "list" || sub === "ls") {
return { info: renderListing(root, mode) };
}
// Mutating subcommands need a code-mode root.
if (!root) {
return {
info: "/permissions add / remove / clear are only available inside `reasonix code` — they edit the project-scoped allowlist (`~/.reasonix/config.json` projects[<root>].shellAllowed).",
};
}
if (sub === "add") {
const prefix = args.slice(1).join(" ").trim();
if (!prefix) {
return {
info: 'usage: /permissions add <prefix> (multi-token OK: /permissions add "git push origin")',
};
}
const before = loadProjectShellAllowed(root);
if (before.includes(prefix)) {
return { info: `▸ already allowed: ${prefix}` };
}
if (BUILTIN_ALLOWLIST.includes(prefix)) {
return {
info: `▸ \`${prefix}\` is already in the builtin allowlist — no per-project entry needed. (Builtin entries are always on.)`,
};
}
addProjectShellAllowed(root, prefix);
return {
info: `▸ added: ${prefix}\n → next \`${prefix}\` invocation runs without prompting in this project.`,
};
}
if (sub === "remove" || sub === "rm" || sub === "delete") {
const target = args.slice(1).join(" ").trim();
if (!target) {
return {
info: "usage: /permissions remove <prefix-or-index> (e.g. /permissions remove 3, or /permissions remove npm)",
};
}
const existing = loadProjectShellAllowed(root);
let prefix: string | null = null;
// Index form: "/permissions remove 3" → 1-based project index.
if (/^\d+$/.test(target)) {
const idx = Number.parseInt(target, 10);
if (idx < 1 || idx > existing.length) {
return {
info:
existing.length === 0
? "▸ no project allowlist entries to remove."
: `▸ index out of range: ${idx} (project list has ${existing.length} entries)`,
};
}
prefix = existing[idx - 1] ?? null;
} else {
prefix = target;
}
if (prefix === null) return { info: "▸ nothing to remove." };
// Builtin entries can't be touched — surface that explicitly.
if (BUILTIN_ALLOWLIST.includes(prefix) && !existing.includes(prefix)) {
return {
info: `▸ \`${prefix}\` is in the builtin allowlist (read-only). Builtin entries can't be removed at runtime — they're baked into the binary.`,
};
}
const ok = removeProjectShellAllowed(root, prefix);
return {
info: ok
? `▸ removed: ${prefix}`
: `▸ no such project entry: ${prefix} (try /permissions list to see what's stored)`,
};
}
if (sub === "clear") {
if ((args[1] ?? "").toLowerCase() !== "confirm") {
const count = loadProjectShellAllowed(root).length;
return {
info:
count === 0
? "▸ project allowlist is already empty."
: `about to drop ${count} project allowlist entr${count === 1 ? "y" : "ies"} for ${root}. Re-run with the word 'confirm' to proceed: /permissions clear confirm`,
};
}
const dropped = clearProjectShellAllowed(root);
return {
info:
dropped === 0
? "▸ project allowlist was already empty — nothing changed."
: `▸ cleared ${dropped} project allowlist entr${dropped === 1 ? "y" : "ies"}.`,
};
}
return {
info: [
"usage: /permissions [list] show current state",
' /permissions add <prefix> persist (e.g. "npm run build")',
" /permissions remove <prefix-or-N> drop one entry",
" /permissions clear confirm wipe every project entry",
].join("\n"),
};
};
function renderListing(root: string | undefined, mode: string | null): string {
const lines: string[] = [];
// Mode banner — edit-gate is the dial that decides whether the
// allowlist matters for the next call.
if (mode === "yolo") {
lines.push(
"▸ edit mode: YOLO — every shell command auto-runs, allowlist is bypassed. /mode review to re-enable prompts.",
);
} else if (mode === "auto") {
lines.push(
"▸ edit mode: auto — edits auto-apply, shell still gated by allowlist (or ShellConfirm prompt for non-allowlisted).",
);
} else if (mode === "review") {
lines.push(
"▸ edit mode: review — both edits and non-allowlisted shell commands ask before running.",
);
}
lines.push("");
// Project list.
if (root) {
const project = loadProjectShellAllowed(root);
lines.push(`Project allowlist (${project.length}) — ${root}`);
if (project.length === 0) {
lines.push(' (none — pick "always allow" on a ShellConfirm prompt to add one,');
lines.push(" or `/permissions add <prefix>` directly.)");
} else {
project.forEach((p, i) => {
lines.push(` ${String(i + 1).padStart(2)}. ${p}`);
});
}
} else {
lines.push("Project allowlist — (no project root; chat mode shows builtin entries only)");
}
lines.push("");
// Builtin list — grouped by leading verb so 40 lines stay scannable.
lines.push(`Builtin allowlist (${BUILTIN_ALLOWLIST.length}) — read-only, baked in`);
const grouped = new Map<string, string[]>();
for (const entry of BUILTIN_ALLOWLIST) {
const head = entry.split(" ")[0] ?? entry;
if (!grouped.has(head)) grouped.set(head, []);
grouped.get(head)!.push(entry);
}
for (const [head, items] of grouped) {
if (items.length === 1 && items[0] === head) {
lines.push(` · ${head}`);
} else {
const tail = items.map((i) => i.slice(head.length).trim() || "(bare)").join(", ");
lines.push(` · ${head}: ${tail}`);
}
}
lines.push("");
lines.push(
"Subcommands: /permissions add <prefix> · /permissions remove <prefix-or-N> · /permissions clear confirm",
);
return lines.join("\n");
}
export const handlers: Record<string, SlashHandler> = {
permissions,
perms: permissions,
};
+10
View File
@@ -174,6 +174,16 @@ export interface SlashContext {
* confirmation. Absent → `/hooks reload` replies "not available".
*/
reloadHooks?: () => number;
/**
* Switch the session working directory. Called by `/cwd <path>`.
* Updates the hook cwd, memory root, project shell allowlist root,
* `@file` mention root, and (in code mode) re-registers filesystem /
* shell / memory / skill tools against the new path. Returns a
* human-readable info line describing what changed; throws on
* validation errors so the handler can surface the message.
* Absent → `/cwd` replies "not available in this context".
*/
setCwd?: (path: string) => string;
/**
* Latest published version if App's background registry check
* has completed, `null` otherwise (still in flight OR offline).
+13 -5
View File
@@ -17,6 +17,13 @@ export interface UseCompletionPickersParams {
input: string;
setInput: (v: string) => void;
codeMode: { rootDir: string } | undefined;
/**
* Live working directory for the @-mention file picker. May differ
* from `codeMode.rootDir` after a `/cwd` switch — `codeMode` keeps
* the launch root for "is this a code-mode session?" checks while
* this drives the actual file listing.
*/
rootDir: string;
models: string[] | null;
mcpServers: McpServerSummary[] | undefined;
}
@@ -57,6 +64,7 @@ export function useCompletionPickers({
input,
setInput,
codeMode,
rootDir,
models,
mcpServers,
}: UseCompletionPickersParams): UseCompletionPickersResult {
@@ -81,13 +89,13 @@ export function useCompletionPickers({
// created mid-session via tool edits won't appear until restart —
// rare, and the user can always type the full path.
const atFiles = useMemo<readonly FileWithStats[]>(() => {
if (!codeMode?.rootDir) return [];
if (!codeMode) return [];
try {
return listFilesWithStatsSync(codeMode.rootDir, { maxResults: 500 });
return listFilesWithStatsSync(rootDir, { maxResults: 500 });
} catch {
return [];
}
}, [codeMode?.rootDir]);
}, [codeMode, rootDir]);
// LRU of files touched by recent tool calls. Seeds the picker with
// "stuff I just looked at" at the top, so a user typing `@` in the
// same file they were just discussing gets it instantly.
@@ -100,12 +108,12 @@ export function useCompletionPickers({
if (list.length > 20) list.length = 20;
}, []);
const atPicker = useMemo(() => {
if (!codeMode?.rootDir) return null;
if (!codeMode) return null;
// Slash prefix wins — avoids the picker confusingly surfacing on
// `/@wat`-style edge inputs.
if (slashMatches !== null) return null;
return detectAtPicker(input);
}, [codeMode?.rootDir, input, slashMatches]);
}, [codeMode, input, slashMatches]);
const atMatches = useMemo<readonly string[] | null>(() => {
if (!atPicker) return null;
return rankPickerCandidates(atFiles, atPicker.query, {
+10
View File
@@ -156,6 +156,16 @@ Two different rules depending on which tool:
- **Filesystem tools** (\`read_file\`, \`list_directory\`, \`search_files\`, \`edit_file\`, etc.): paths are sandbox-relative. \`/\` means the project root, \`/src/foo.ts\` means \`<project>/src/foo.ts\`. Both relative (\`src/foo.ts\`) and POSIX-absolute (\`/src/foo.ts\`) forms work.
- **\`run_command\`**: the command runs in a real OS shell with cwd pinned to the project root. Paths inside the shell command are interpreted by THAT shell, not by us. **Never use leading \`/\` in run_command arguments** — Windows treats \`/tests\` as drive-root \`F:\\tests\` (non-existent), POSIX shells treat it as filesystem root. Use plain relative paths (\`tests\`, \`./tests\`, \`src/loop.ts\`) instead.
# When the user wants to switch project / working directory
If the user asks to switch / change / open a different directory or project ("切换到...", "switch to ...", "let's work in ...", "open the X project"), call **\`change_workspace\`** with the absolute target path. The tool always requires the user's explicit approval via a TUI modal — your call surfaces a "switch / deny" prompt, and STOPS your turn until they pick. After approval the filesystem / shell / memory tools re-register against the new root and your subsequent calls land there.
Hard rules:
- Do NOT try to switch via \`run_command\` (\`cd\`, \`pushd\`, etc.) — your tool sandbox is pinned and \`cd\` inside one shell call doesn't carry to the next.
- Do NOT chain other tool calls in the same turn as \`change_workspace\` — wait for the user's confirmation. Their next message will tell you whether the switch happened.
- Do NOT call \`change_workspace\` to "preview" a sibling directory; only when the user explicitly asked to change projects.
- The user can also type \`/cwd <path>\` themselves — fine, you'll see the new root take effect on the next turn either way.
# Foreground vs. background commands
You have TWO tools for running shell commands, and picking the right one is non-negotiable:
+44
View File
@@ -193,6 +193,50 @@ export function addProjectShellAllowed(
writeConfig(cfg, path);
}
/**
* Drop one prefix from the project's shell-allowed list. Returns true
* when the prefix was present (and removed), false when it wasn't —
* lets `/permissions remove` distinguish "removed" from "no such entry"
* without a separate read. Match is exact after trim; we deliberately
* do NOT prefix-match here (e.g. removing `git` shouldn't also drop
* `git push origin main` if the user added both).
*/
export function removeProjectShellAllowed(
rootDir: string,
prefix: string,
path: string = defaultConfigPath(),
): boolean {
const trimmed = prefix.trim();
if (!trimmed) return false;
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
if (!existing.includes(trimmed)) return false;
const next = existing.filter((p) => p !== trimmed);
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = next;
writeConfig(cfg, path);
return true;
}
/**
* Wipe every persisted shell-allow entry for a project. Returns the
* number of prefixes that were dropped — zero if nothing was stored.
*/
export function clearProjectShellAllowed(
rootDir: string,
path: string = defaultConfigPath(),
): number {
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
if (existing.length === 0) return 0;
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = [];
writeConfig(cfg, path);
return existing.length;
}
/**
* Read the persisted edit-mode. Unknown values fall back to "review" so
* a user who hand-edits the file into an invalid state still gets the
+8 -1
View File
@@ -200,6 +200,8 @@ export { StdioTransport } from "./mcp/stdio.js";
export type { McpTransport, StdioTransportOptions } from "./mcp/stdio.js";
export { SseTransport } from "./mcp/sse.js";
export type { SseTransportOptions } from "./mcp/sse.js";
export { StreamableHttpTransport } from "./mcp/streamable-http.js";
export type { StreamableHttpTransportOptions } from "./mcp/streamable-http.js";
export {
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RESULT_TOKENS,
@@ -210,7 +212,12 @@ export {
} from "./mcp/registry.js";
export type { BridgeOptions, BridgeResult, FlattenOptions } from "./mcp/registry.js";
export { parseMcpSpec } from "./mcp/spec.js";
export type { McpSpec, StdioMcpSpec, SseMcpSpec } from "./mcp/spec.js";
export type {
McpSpec,
SseMcpSpec,
StdioMcpSpec,
StreamableHttpMcpSpec,
} from "./mcp/spec.js";
export { inspectMcpServer } from "./mcp/inspect.js";
export type { InspectionReport, SectionResult } from "./mcp/inspect.js";
+17 -2
View File
@@ -294,8 +294,12 @@ export class CacheFirstLoop {
* tool call is one array length check.
*/
hooks: ResolvedHook[];
/** `cwd` reported to hook stdin. Resolved once at construction. */
readonly hookCwd: string;
/**
* `cwd` reported to hook stdin. Mutable so `/cwd` can switch the
* working directory mid-session — the App keeps it in sync with
* the same currentRootDir that drives tool re-registration.
*/
hookCwd: string;
/** Number of messages that were pre-loaded from the session file. */
readonly resumedMessageCount: number;
@@ -937,6 +941,17 @@ export class CacheFirstLoop {
};
this.autoCompactToolResultsOnTurnEnd();
yield { turn: this._turn, role: "done", content: stoppedMsg };
// Reset to a fresh, non-aborted controller before returning.
// Without this the carry-abort logic above sees the still-
// aborted controller on the NEXT step() entry and immediately
// re-aborts at iter 0, locking the session: every subsequent
// user message produces "stopped without producing a summary"
// before any work happens. A user-initiated Esc is a discrete
// event tied to ONE turn; it must not bleed into the next.
// (The race scenario the carry-abort handles — abort fired in
// the async window before step() entry — still works: a fresh
// abort() between turns aborts the new controller below.)
this._turnAbort = new AbortController();
return;
}
// Bridge the silence between the PREVIOUS iter's tool result and
+30 -9
View File
@@ -2,11 +2,13 @@
* Parse the `--mcp` CLI argument into a transport-tagged spec.
*
* Accepted forms:
* "name=command args..." → stdio, namespaced (tools prefixed with `name_`)
* "command args..." → stdio, anonymous
* "name=https://host/sse" → SSE, namespaced
* "https://host/sse" → SSE, anonymous
* ("http://" is also honored — useful for local dev servers.)
* "name=command args..." → stdio, namespaced (tools prefixed with `name_`)
* "command args..." → stdio, anonymous
* "name=https://host/sse" → HTTP+SSE (2024-11-05), namespaced
* "https://host/sse" → HTTP+SSE (2024-11-05), anonymous
* "name=streamable+https://host/mcp" → Streamable HTTP (2025-03-26), namespaced
* "streamable+https://host/mcp" → Streamable HTTP (2025-03-26), anonymous
* ("http://" / "streamable+http://" also honored — useful for local dev.)
*
* The identifier regex before `=` is deliberately narrow
* (`[a-zA-Z_][a-zA-Z0-9_]*`) so Windows drive letters ("C:\\...") and
@@ -15,9 +17,15 @@
* with `foo=...` as a bare command, they can wrap it in quotes inside the
* shell command string.
*
* Transport is selected solely by whether the body begins with `http://`
* or `https://`. Anything else is stdio — including ws:// (unsupported)
* which will surface later as a spawn error, keeping the rule local.
* Transport selection:
* - body starts with `streamable+http(s)://` → Streamable HTTP. The
* `streamable+` prefix is stripped from the URL we hand the transport.
* - body starts with `http(s)://` → HTTP+SSE (2024-11-05).
* Default for plain http URLs to preserve back-compat with users who
* already have `--mcp https://...` config entries pointed at SSE
* servers; opt into Streamable HTTP explicitly.
* - anything else → stdio (including ws://,
* which will surface later as a spawn error).
*/
import { shellSplit } from "./shell-split.js";
@@ -39,10 +47,18 @@ export interface SseMcpSpec {
url: string;
}
export type McpSpec = StdioMcpSpec | SseMcpSpec;
export interface StreamableHttpMcpSpec {
transport: "streamable-http";
name: string | null;
/** Fully qualified Streamable HTTP endpoint URL (no `streamable+` prefix). */
url: string;
}
export type McpSpec = StdioMcpSpec | SseMcpSpec | StreamableHttpMcpSpec;
const NAME_PREFIX = /^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/;
const HTTP_URL = /^https?:\/\//i;
const STREAMABLE_PREFIX = /^streamable\+(https?:\/\/.+)$/i;
export function parseMcpSpec(input: string): McpSpec {
const trimmed = input.trim();
@@ -58,6 +74,11 @@ export function parseMcpSpec(input: string): McpSpec {
throw new Error(`MCP spec has name but no command: ${input}`);
}
const streamMatch = STREAMABLE_PREFIX.exec(body);
if (streamMatch) {
return { transport: "streamable-http", name, url: streamMatch[1]! };
}
if (HTTP_URL.test(body)) {
return { transport: "sse", name, url: body };
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Streamable HTTP transport for MCP (spec version 2025-03-26).
*
* Wire shape (single endpoint, no separate POST URL handshake):
*
* 1. Client POSTs each outgoing JSON-RPC frame to the endpoint with
* `Accept: application/json, text/event-stream`. The server picks
* ONE of three responses:
* a. `202 Accepted`, no body → notification or response
* was accepted; nothing more to deliver.
* b. `200 OK`, `Content-Type: application/json` → body is a
* single JSON-RPC response (or batch). Connection closes.
* c. `200 OK`, `Content-Type: text/event-stream` → an SSE
* stream of `event: message` frames carrying responses,
* server-initiated requests, and notifications. Stream may
* close after the matching response or stay open longer.
* 2. The server may include `Mcp-Session-Id: <opaque>` on the response
* to `initialize`. Client echoes that header on every subsequent
* request. A 404 on a request with a session id means the session
* expired — caller must reinitialize.
*
* Compared to 2024-11-05 HTTP+SSE:
* - No two-endpoint dance (no `event: endpoint` handshake).
* - Replies arrive on the POST response, not on a separate GET stream.
* - Session continuity is explicit (`Mcp-Session-Id`), not implicit.
*
* Not yet implemented in this transport (acceptable for v1):
* - Long-lived GET stream for unsolicited server-initiated frames
* (sampling requests, etc.). Most MCP servers we care about today
* don't issue server-initiated requests, and POST-only handles
* full request/response/notification traffic. Add when a real
* server we're integrating against needs it.
* - Resumability via `Last-Event-ID` on reconnect.
*/
import { createParser } from "eventsource-parser";
import type { McpTransport } from "./stdio.js";
import type { JsonRpcMessage } from "./types.js";
export interface StreamableHttpTransportOptions {
/** Streamable HTTP endpoint URL, e.g. `https://mcp.example.com/mcp`. */
url: string;
/** Extra headers sent on every request (e.g. `Authorization`). */
headers?: Record<string, string>;
}
const SESSION_HEADER = "mcp-session-id";
export class StreamableHttpTransport implements McpTransport {
private readonly url: string;
private readonly extraHeaders: Record<string, string>;
private readonly queue: JsonRpcMessage[] = [];
private readonly waiters: Array<(m: JsonRpcMessage | null) => void> = [];
private readonly controller = new AbortController();
/** Session id minted by server on (typically) the initialize response. */
private sessionId: string | null = null;
private closed = false;
/** Background SSE read-loops kicked off by send(); awaited on close(). */
private readonly streams = new Set<Promise<void>>();
constructor(opts: StreamableHttpTransportOptions) {
this.url = opts.url;
this.extraHeaders = opts.headers ?? {};
}
async send(message: JsonRpcMessage): Promise<void> {
if (this.closed) throw new Error("MCP Streamable HTTP transport is closed");
const headers: Record<string, string> = {
"content-type": "application/json",
// Both accepted — server picks. application/json first signals a
// mild preference for the simpler shape when the response is a
// single message.
accept: "application/json, text/event-stream",
...this.extraHeaders,
};
if (this.sessionId !== null) headers["mcp-session-id"] = this.sessionId;
let res: Response;
try {
res = await fetch(this.url, {
method: "POST",
headers,
body: JSON.stringify(message),
signal: this.controller.signal,
});
} catch (err) {
throw new Error(`MCP Streamable HTTP POST ${this.url} failed: ${(err as Error).message}`);
}
// Capture session id the first time the server hands one out.
const serverSessionId = res.headers.get(SESSION_HEADER);
if (serverSessionId && this.sessionId === null) {
this.sessionId = serverSessionId;
}
if (res.status === 404 && this.sessionId !== null) {
// Session expired / unknown to the server. Surface as an error so
// McpClient can recreate; drain the body so the socket goes back
// to the pool.
await res.body?.cancel().catch(() => undefined);
throw new Error(
`MCP Streamable HTTP session expired (server returned 404 with Mcp-Session-Id "${this.sessionId}"). Reinitialize the client.`,
);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`MCP Streamable HTTP POST ${this.url} → ${res.status} ${res.statusText}${body ? `: ${body}` : ""}`,
);
}
// 202 Accepted: request was a notification or pure ack — no body.
if (res.status === 202) {
await res.body?.cancel().catch(() => undefined);
return;
}
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
if (ct.includes("application/json")) {
let parsed: unknown;
try {
parsed = await res.json();
} catch (err) {
throw new Error(`MCP Streamable HTTP body wasn't valid JSON: ${(err as Error).message}`);
}
if (Array.isArray(parsed)) {
for (const item of parsed) this.pushMessage(item as JsonRpcMessage);
} else {
this.pushMessage(parsed as JsonRpcMessage);
}
return;
}
if (ct.includes("text/event-stream")) {
// Stream may carry multiple events (progress notifications +
// the eventual response). Read it concurrently with subsequent
// sends — return as soon as the stream is wired so callers can
// pipeline more requests.
if (!res.body) {
throw new Error("MCP Streamable HTTP SSE response had no body");
}
const stream = this.consumeStream(res.body as AsyncIterable<Uint8Array>);
this.streams.add(stream);
stream.finally(() => this.streams.delete(stream));
return;
}
// Unknown content type — drain and treat as a no-op rather than
// hanging. Servers that want to extend the protocol should not
// wedge older clients with an unexpected MIME.
await res.body?.cancel().catch(() => undefined);
}
async *messages(): AsyncIterableIterator<JsonRpcMessage> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
continue;
}
if (this.closed) return;
const next = await new Promise<JsonRpcMessage | null>((resolve) => {
this.waiters.push(resolve);
});
if (next === null) return;
yield next;
}
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
while (this.waiters.length > 0) this.waiters.shift()!(null);
try {
this.controller.abort();
} catch {
/* already aborted */
}
// Wait for any in-flight SSE streams to wind down so a subsequent
// process.exit() doesn't trip on a hanging socket. Cap at "done";
// controller.abort() above unblocks them.
await Promise.allSettled(Array.from(this.streams));
}
/** Visible for tests — confirm session header round-trip. */
getSessionId(): string | null {
return this.sessionId;
}
// ---------- internals ----------
private async consumeStream(body: AsyncIterable<Uint8Array>): Promise<void> {
const parser = createParser({
onEvent: (ev) => {
// Per spec, server-side events use the `message` event type
// (default if `event:` line is missing). Other event types
// (server pings, custom extensions) we silently ignore.
const type = ev.event ?? "message";
if (type !== "message") return;
try {
const parsed = JSON.parse(ev.data) as JsonRpcMessage;
this.pushMessage(parsed);
} catch {
/* malformed JSON — drop, mirror SSE behavior */
}
},
});
const decoder = new TextDecoder();
try {
for await (const chunk of body) {
if (this.closed) break;
parser.feed(decoder.decode(chunk, { stream: true }));
}
} catch (err) {
if (!this.closed) {
this.pushMessage({
jsonrpc: "2.0",
id: null,
error: {
code: -32000,
message: `Streamable HTTP stream error: ${(err as Error).message}`,
},
});
}
}
}
private pushMessage(msg: JsonRpcMessage): void {
const waiter = this.waiters.shift();
if (waiter) waiter(msg);
else this.queue.push(msg);
}
}
+73 -7
View File
@@ -83,7 +83,7 @@ const DEFAULT_MAX_OUTPUT_CHARS = 32_000;
* is "exit 1 with output." Nothing that can rewrite state, escalate,
* or touch the network.
*/
const BUILTIN_ALLOWLIST: ReadonlyArray<string> = [
export const BUILTIN_ALLOWLIST: ReadonlyArray<string> = [
// Repo inspection
"git status",
"git diff",
@@ -328,7 +328,16 @@ export async function runCommand(
reject(err);
return;
}
let buf = "";
// Collect raw Buffer chunks rather than decoding incrementally —
// a multi-byte sequence can land split across chunks, and a naïve
// chunk.toString() corrupts it before the second half arrives.
// We decode once at close time, where smartDecodeOutput can also
// sniff non-UTF-8 codepages cleanly. The byte cap mirrors the
// prior char cap (2× maxChars worth) so a chatty process can't
// OOM us.
const chunks: Buffer[] = [];
let totalBytes = 0;
const byteCap = maxChars * 2 * 4; // worst-case 4 bytes/char for utf-8/gbk
let timedOut = false;
const killTimer = setTimeout(() => {
timedOut = true;
@@ -338,11 +347,16 @@ export async function runCommand(
opts.signal?.addEventListener("abort", onAbort, { once: true });
const onData = (chunk: Buffer | string) => {
buf += chunk.toString();
// Soft cap: we let the process keep running (killing early could
// hide a real failure), but we stop growing the buffer past 2×
// the cap so a chatty test can't OOM us.
if (buf.length > maxChars * 2) buf = `${buf.slice(0, maxChars * 2)}`;
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
if (totalBytes >= byteCap) return;
const remaining = byteCap - totalBytes;
if (b.length > remaining) {
chunks.push(b.subarray(0, remaining));
totalBytes = byteCap;
} else {
chunks.push(b);
totalBytes += b.length;
}
};
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
@@ -354,6 +368,8 @@ export async function runCommand(
child.on("close", (code) => {
clearTimeout(killTimer);
opts.signal?.removeEventListener("abort", onAbort);
const merged = Buffer.concat(chunks);
const buf = smartDecodeOutput(merged);
const output =
buf.length > maxChars
? `${buf.slice(0, maxChars)}\n\n[… truncated ${buf.length - maxChars} chars …]`
@@ -363,6 +379,56 @@ export async function runCommand(
});
}
/**
* Decode a child-process output buffer with codepage fallback. The
* default UTF-8 path handles 99% of Linux / Mac and any process we
* already coerced to UTF-8 (Python via PYTHONIOENCODING, PowerShell
* via the OutputEncoding prelude, cmd.exe builtins via chcp 65001).
*
* On Windows, two cases still leak non-UTF-8 bytes through that
* pipeline:
*
* 1. `cmd.exe`'s OWN error messages (e.g. "'sed' is not recognized
* as an internal or external command") come from a localized
* resource DLL and ignore the active code page, so chcp 65001
* doesn't help. On Chinese Windows the bytes are CP936/GBK.
*
* 2. Native Windows EXEs that hardcode the system locale for stderr
* (older sed/grep ports, MS toolchain prompts).
*
* We try UTF-8 strictly; if the bytes don't form valid UTF-8 we fall
* back to GBK on Windows (covers Simplified Chinese and most CJK
* Windows installs because GBK is a strict superset of ASCII and
* decodes Latin text identically). Better than mojibake; not a full
* locale-detector, but matches where the actual bug reports come
* from.
*
* Exported for tests.
*/
export function smartDecodeOutput(buf: Buffer): string {
if (buf.length === 0) return "";
try {
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
} catch {
// Fall through to platform-specific fallback.
}
if (process.platform === "win32") {
try {
// TextDecoder supports gbk / gb18030 in Node 18+ via the WHATWG
// Encoding spec. gb18030 is the modern superset; falling back
// to it covers GBK byte sequences plus the rare 4-byte CJK
// characters that appear in newer system messages.
return new TextDecoder("gb18030").decode(buf);
} catch {
// Decoder unavailable in this build — fall through.
}
}
// Last resort: lossy UTF-8 with replacement chars. The model still
// gets "something happened" with the structural exit-code marker
// intact, which is more useful than throwing away the entire output.
return buf.toString("utf8");
}
/**
* Test/override hooks for {@link resolveExecutable}. Omitting any field
* falls through to the real process globals — the runtime call path
+99
View File
@@ -0,0 +1,99 @@
/**
* `change_workspace` — model-callable workspace switching, gated on
* an explicit user confirmation in the TUI.
*
* The tool function itself never performs the switch. It validates
* the requested path and throws {@link WorkspaceConfirmationError},
* which the loop serializes into the tool result. App.tsx detects the
* marker, mounts a confirmation modal, and on approval drives the
* actual `setCwd` callback (the same one the `/cwd` slash uses).
*
* Why this shape (mirrors `run_command`):
* - The tool fn is pure / synchronous — no React or App-state
* coupling lives in tools/.
* - The model gets a clear "stop and wait" message in the tool
* result, which prevents a chain of switches before the user
* has had a chance to confirm even one.
* - Approval / denial flow back through a synthetic user message,
* so the model sees outcome on its next turn.
*
* Why no `always_allow`: workspace switches are per-target by nature
* (each target is a different project root with different secrets,
* different .gitignore, different memory); there's no useful "I trust
* the model to switch to ANY directory it picks" allowlist semantics.
* The modal offers Switch / Deny only.
*/
import { existsSync, statSync } from "node:fs";
import * as pathMod from "node:path";
import type { ToolRegistry } from "../tools.js";
export class WorkspaceConfirmationError extends Error {
readonly path: string;
constructor(path: string) {
super(
`change_workspace: switching to "${path}" needs the user's approval before it takes effect. STOP calling tools now — the TUI has already prompted the user to press Enter (switch) or Esc (deny). Wait for their next message; it will either confirm the switch (and your subsequent file/shell tools will resolve against the new root) or tell you to continue without changing directories.`,
);
this.name = "WorkspaceConfirmationError";
this.path = path;
}
}
export interface ChangeWorkspaceArgs {
path: string;
}
/**
* Register `change_workspace` on `registry`. The tool always throws
* `WorkspaceConfirmationError(absolutePath)` after path validation —
* the actual swap happens in App.tsx when the user approves the
* modal. Path validation matches `/cwd`'s: must exist, must be a
* directory, supports `~` expansion and relative paths (resolved
* against `process.cwd()` at call time, NOT the session root, so
* a model emitting `~/projects/foo` lands where the user expects).
*/
export function registerWorkspaceTool(registry: ToolRegistry): ToolRegistry {
registry.register({
name: "change_workspace",
description:
"Switch the session's working directory to a different project root. Re-registers filesystem / shell / memory tools against the new path so subsequent file reads, edits, and run_command calls all land there. EVERY switch requires explicit user approval via a modal — do NOT batch switches or chain a switch with subsequent tool calls before the user has confirmed. Use ONLY when the user explicitly asked to change directory or open a different project; never use to 'preview' a sibling repo. MCP servers stay anchored to the original launch root (their child processes can't be reconnected mid-session); the modal warns the user about this.",
parameters: {
type: "object",
required: ["path"],
properties: {
path: {
type: "string",
description:
"Target directory. Absolute paths land verbatim. Leading `~` expands to the user's home. Relative paths resolve against the user's launch cwd (not the current session root, so paths the user typed in chat resolve where they expect).",
},
},
},
fn: (rawArgs) => {
const args = (rawArgs ?? {}) as Partial<ChangeWorkspaceArgs>;
if (typeof args.path !== "string" || args.path.trim() === "") {
throw new Error("change_workspace: `path` must be a non-empty string");
}
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const expanded =
args.path.startsWith("~") && home ? pathMod.join(home, args.path.slice(1)) : args.path;
const abs = pathMod.resolve(expanded);
if (!existsSync(abs)) {
throw new Error(`change_workspace: path does not exist — ${abs}`);
}
try {
if (!statSync(abs).isDirectory()) {
throw new Error(`change_workspace: not a directory — ${abs}`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`change_workspace: path does not exist — ${abs}`);
}
throw err;
}
// Always defer to the user. The tool itself does not switch —
// approval drives the swap in App.tsx.
throw new WorkspaceConfirmationError(abs);
},
});
return registry;
}
+42
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
addProjectShellAllowed,
clearProjectShellAllowed,
editModeHintShown,
isPlausibleKey,
loadApiKey,
@@ -13,6 +14,7 @@ import {
markEditModeHintShown,
readConfig,
redactKey,
removeProjectShellAllowed,
saveApiKey,
saveEditMode,
saveReasoningEffort,
@@ -173,6 +175,46 @@ describe("config", () => {
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
});
it("removeProjectShellAllowed drops one entry by exact match", () => {
addProjectShellAllowed("/a", "npm install", path);
addProjectShellAllowed("/a", "git commit", path);
expect(removeProjectShellAllowed("/a", "npm install", path)).toBe(true);
expect(loadProjectShellAllowed("/a", path)).toEqual(["git commit"]);
});
it("removeProjectShellAllowed returns false when prefix isn't stored", () => {
addProjectShellAllowed("/a", "npm install", path);
expect(removeProjectShellAllowed("/a", "git commit", path)).toBe(false);
expect(loadProjectShellAllowed("/a", path)).toEqual(["npm install"]);
});
it("removeProjectShellAllowed doesn't prefix-match (literal only)", () => {
addProjectShellAllowed("/a", "git push origin main", path);
expect(removeProjectShellAllowed("/a", "git push", path)).toBe(false);
expect(loadProjectShellAllowed("/a", path)).toEqual(["git push origin main"]);
});
it("removeProjectShellAllowed scoped to project (doesn't leak across roots)", () => {
addProjectShellAllowed("/a", "lint", path);
addProjectShellAllowed("/b", "lint", path);
expect(removeProjectShellAllowed("/a", "lint", path)).toBe(true);
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
expect(loadProjectShellAllowed("/b", path)).toEqual(["lint"]);
});
it("clearProjectShellAllowed wipes one project, returns count, leaves others alone", () => {
addProjectShellAllowed("/a", "lint", path);
addProjectShellAllowed("/a", "test", path);
addProjectShellAllowed("/b", "build", path);
expect(clearProjectShellAllowed("/a", path)).toBe(2);
expect(loadProjectShellAllowed("/a", path)).toEqual([]);
expect(loadProjectShellAllowed("/b", path)).toEqual(["build"]);
});
it("clearProjectShellAllowed returns 0 when nothing stored", () => {
expect(clearProjectShellAllowed("/empty", path)).toBe(0);
});
it("loadEditMode defaults to 'review' when unset", () => {
expect(loadEditMode(path)).toBe("review");
});
+78
View File
@@ -0,0 +1,78 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleSlash } from "../src/cli/ui/slash/dispatch.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../src/index.js";
import { ToolRegistry } from "../src/tools.js";
/**
* Build a minimal loop instance for the slash dispatcher to consume.
* The handler under test never actually calls the loop — it returns
* a SlashResult — so this is just structural plumbing.
*/
function makeLoop(): CacheFirstLoop {
const tools = new ToolRegistry();
return new CacheFirstLoop({
client: new DeepSeekClient({ apiKey: "sk-test" }),
prefix: new ImmutablePrefix({ system: "s", toolSpecs: [] }),
tools,
maxToolIters: 1,
stream: false,
});
}
describe("/init slash handler", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "reasonix-init-slash-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("refuses outside code mode (no codeRoot)", () => {
const loop = makeLoop();
const result = handleSlash("init", [], loop, {});
expect(result.info).toMatch(/only works in code mode/i);
expect(result.resubmit).toBeUndefined();
});
it("emits the structured init prompt as resubmit when REASONIX.md does not exist", () => {
const loop = makeLoop();
const result = handleSlash("init", [], loop, { codeRoot: tmp });
expect(result.resubmit).toBeDefined();
expect(result.resubmit).toMatch(/Initialize REASONIX.md/);
// The hard length cap is the most important constraint — pin it.
expect(result.resubmit).toMatch(/≤\s*80\s*lines/);
// The "STOP after writing" line is load-bearing for flash; pin it
// so a future tightening pass doesn't accidentally drop it.
expect(result.resubmit).toMatch(/STOP/);
expect(result.info).toMatch(/scan the project/);
});
it("refuses overwriting an existing REASONIX.md without `force`", () => {
writeFileSync(join(tmp, "REASONIX.md"), "# pre-existing");
const loop = makeLoop();
const result = handleSlash("init", [], loop, { codeRoot: tmp });
expect(result.resubmit).toBeUndefined();
expect(result.info).toMatch(/already exists/);
expect(result.info).toMatch(/\/init force/);
});
it("`/init force` proceeds even when REASONIX.md exists", () => {
writeFileSync(join(tmp, "REASONIX.md"), "# pre-existing");
const loop = makeLoop();
const result = handleSlash("init", ["force"], loop, { codeRoot: tmp });
expect(result.resubmit).toBeDefined();
expect(result.resubmit).toMatch(/Initialize REASONIX.md/);
expect(result.info).toMatch(/scan the project/);
});
it("`force` matching is case-insensitive", () => {
writeFileSync(join(tmp, "REASONIX.md"), "# pre-existing");
const loop = makeLoop();
const result = handleSlash("init", ["FORCE"], loop, { codeRoot: tmp });
expect(result.resubmit).toBeDefined();
});
});
+57
View File
@@ -327,6 +327,63 @@ describe("CacheFirstLoop (non-streaming)", () => {
void fetchSpy;
});
it("does not bleed the prior turn's abort into the next step", async () => {
// Regression: a user pressing Esc once would put _turnAbort into
// an aborted state; the iter-0 abort branch handled it but didn't
// reset the controller. Every subsequent step() then carried the
// stale aborted state forward and bailed out with another
// "stopped without producing a summary" before any model call ran.
// The session was effectively dead until restart.
const reg = new ToolRegistry();
reg.register({
name: "probe",
description: "no-op",
parameters: { type: "object", properties: {} },
fn: async () => "ok",
});
const chainingToolCall = {
content: "",
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
};
const finalAnswer = { content: "second turn ran cleanly", tool_calls: [] };
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: fakeFetch([chainingToolCall, finalAnswer]) as unknown as typeof fetch,
});
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
tools: reg,
stream: false,
maxToolIters: 16,
});
// Turn 1 — abort mid-flight.
let aborted = false;
for await (const ev of loop.step("first")) {
if (!aborted && ev.role === "tool") {
aborted = true;
loop.abort();
}
}
// Turn 2 — fresh user input; should reach the second model call
// and yield its output. If the bug is back, we see iter-0 abort
// again and never see "second turn ran cleanly".
const turn2Events: { role: string; content?: string }[] = [];
for await (const ev of loop.step("second")) {
turn2Events.push({ role: ev.role, content: ev.content });
}
const finals = turn2Events.filter((e) => e.role === "assistant_final");
expect(finals).toHaveLength(1);
expect(finals[0]!.content).toBe("second turn ran cleanly");
// No "aborted at iter 0" warning on turn 2.
expect(
turn2Events.some((e) => e.role === "warning" && /aborted at iter/.test(e.content ?? "")),
).toBe(false);
});
it("forces a summary when maxToolIters is exhausted, instead of stopping silently", async () => {
// Give a registered tool so the repair layer doesn't strip the fake
// tool_calls for referring to an unknown name.
+27
View File
@@ -72,6 +72,33 @@ describe("stripMath", () => {
expect(stripMath("\\sqrt{49}")).toBe("√(49)");
});
it("preserves Windows-style backslash paths in plain prose", () => {
// Regression: the catch-all `\\[a-zA-Z]+` LaTeX fallback wiped
// five characters from "F:\TEST1" — `\TEST` matched as an
// unknown LaTeX command and was deleted, leaving "F:1". The
// early-exit guard skips the math pipeline entirely when no
// math indicator is present.
expect(stripMath("F:\\TEST1")).toBe("F:\\TEST1");
expect(stripMath("F:\\TEST1\\report_output")).toBe("F:\\TEST1\\report_output");
expect(stripMath("see C:\\Users\\name\\Documents\\foo.py")).toBe(
"see C:\\Users\\name\\Documents\\foo.py",
);
// Literal backslash-letter in prose (not a path) also survives.
expect(stripMath("the regex \\d+ matches digits")).toBe("the regex \\d+ matches digits");
});
it("still processes math when both a path and an equation appear together", () => {
// Mixed input: the math indicator (`\frac`) IS present, so the
// pipeline runs. The guard is "all-or-nothing" by design — once
// we know there's math we transform the whole string. Path
// chars in this case may collide with the catch-all, which is
// acceptable (no real chat output mixes Windows paths with
// LaTeX math, and even if it did, surfacing the math correctly
// matters more than preserving the path verbatim).
const out = stripMath("save to F:\\out then compute \\frac{a}{b}");
expect(out).toContain("(a)/(b)");
});
it("the full user-reported line no longer leaks raw LaTeX", () => {
const input =
"总路程:2d km 总时间:t_1 + t_2 = \\dfrac{d}{30} + \\dfrac{d}{60} = \\dfrac{2d}{60} + \\dfrac{d}{60} = \\dfrac{3d}{60} = \\dfrac{d}{20} 小时 平均速度:v_{avg} = \\frac{总路程}{总时间} = 40 km/h";
+27
View File
@@ -95,3 +95,30 @@ describe("parseMcpSpec: sse", () => {
expect(spec.transport).toBe("stdio");
});
});
describe("parseMcpSpec: streamable-http", () => {
it("parses an anonymous streamable+https URL and strips the prefix", () => {
const spec = parseMcpSpec("streamable+https://example.com/mcp");
expect(spec.transport).toBe("streamable-http");
if (spec.transport !== "streamable-http") throw new Error("unreachable");
expect(spec.name).toBeNull();
expect(spec.url).toBe("https://example.com/mcp");
});
it("parses a namespaced streamable+http URL (localhost dev)", () => {
const spec = parseMcpSpec("local=streamable+http://127.0.0.1:9000/mcp");
if (spec.transport !== "streamable-http") throw new Error("unreachable");
expect(spec.name).toBe("local");
expect(spec.url).toBe("http://127.0.0.1:9000/mcp");
});
it("is case-insensitive on the streamable+ prefix", () => {
const spec = parseMcpSpec("STREAMABLE+HTTPS://example.com/mcp");
expect(spec.transport).toBe("streamable-http");
});
it("plain https without `streamable+` still routes to SSE for back-compat", () => {
const spec = parseMcpSpec("https://example.com/mcp");
expect(spec.transport).toBe("sse");
});
});
+379
View File
@@ -0,0 +1,379 @@
/**
* Streamable HTTP transport tests.
*
* Mirrors `mcp-sse.test.ts`'s in-process http.Server pattern. The fake
* server speaks the 2025-03-26 Streamable HTTP wire shape directly —
* it accepts POSTs and responds with one of:
* - 202 Accepted (notification ack)
* - 200 + application/json (single response)
* - 200 + text/event-stream (one or more SSE `event: message` frames)
* It also mints a `Mcp-Session-Id` on the first response so the client
* has to round-trip the header on subsequent requests.
*/
import { type IncomingMessage, type ServerResponse, createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { McpClient } from "../src/mcp/client.js";
import { StreamableHttpTransport } from "../src/mcp/streamable-http.js";
import { MCP_PROTOCOL_VERSION } from "../src/mcp/types.js";
interface FakeServer {
url: string;
requests: Array<{
method: string;
url: string;
body?: string;
headers: Record<string, string | string[] | undefined>;
}>;
stop: () => Promise<void>;
}
interface FakeOptions {
/** Override path (default `/mcp`). */
path?: string;
/** Hand back this session id on the initialize response. Default "sess-1". */
sessionId?: string;
/**
* Return a JSON-RPC reply for an incoming request.
* - If the value is `{ stream: [...] }`, the server writes those frames
* on an SSE response.
* - If `undefined`, the server replies 202 Accepted (notification ack).
* - Otherwise, the value is sent as a single application/json body.
*/
reply?: (body: unknown) => unknown | { stream: unknown[] } | undefined;
/**
* Sites that need to inject failures for a specific request shape.
* Lookup happens after `reply` returns so the failure can short-circuit
* the normal path.
*/
forceStatus?: (body: unknown) => { status: number; body?: string } | undefined;
}
function startFakeServer(opts: FakeOptions = {}): Promise<FakeServer> {
const path = opts.path ?? "/mcp";
const sessionId = opts.sessionId ?? "sess-1";
const requests: FakeServer["requests"] = [];
let mintedSession = false;
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (!req.url || !req.url.startsWith(path)) {
res.writeHead(404);
res.end("not found");
return;
}
if (req.method === "POST") {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
requests.push({
method: "POST",
url: req.url ?? "",
body,
headers: req.headers,
});
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400);
res.end("bad json");
return;
}
const forced = opts.forceStatus?.(parsed);
if (forced) {
res.writeHead(forced.status);
res.end(forced.body ?? "");
return;
}
const reply = opts.reply?.(parsed);
// The session id is minted on the first response that has a
// body — i.e. the initialize response. Notifications (202) and
// unknown methods don't get a session header until then.
const responseHeaders: Record<string, string> = {};
const isInitialize =
typeof parsed === "object" &&
parsed !== null &&
(parsed as { method?: string }).method === "initialize";
if (isInitialize && !mintedSession) {
responseHeaders["mcp-session-id"] = sessionId;
mintedSession = true;
}
if (reply === undefined) {
// notification → 202 Accepted, no body
res.writeHead(202, responseHeaders);
res.end();
return;
}
if (
typeof reply === "object" &&
reply !== null &&
Array.isArray((reply as { stream?: unknown[] }).stream)
) {
const frames = (reply as { stream: unknown[] }).stream;
res.writeHead(200, {
...responseHeaders,
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
for (const frame of frames) {
res.write(`event: message\ndata: ${JSON.stringify(frame)}\n\n`);
}
res.end();
return;
}
res.writeHead(200, {
...responseHeaders,
"content-type": "application/json",
});
res.end(JSON.stringify(reply));
});
return;
}
res.writeHead(405);
res.end("method not allowed");
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
resolve({
url: `http://127.0.0.1:${port}${path}`,
requests,
stop: () =>
new Promise<void>((r) => {
server.close(() => r());
server.closeAllConnections?.();
}),
});
});
});
}
describe("StreamableHttpTransport: POST round-trip", () => {
let fake: FakeServer | null = null;
beforeEach(() => {
fake = null;
});
afterEach(async () => {
await fake?.stop();
});
it("delivers a single application/json response back through messages()", async () => {
fake = await startFakeServer({
reply: (req) => ({
jsonrpc: "2.0",
id: (req as { id: number }).id,
result: { ok: true },
}),
});
const transport = new StreamableHttpTransport({ url: fake.url });
await transport.send({ jsonrpc: "2.0", id: 1, method: "ping" });
const iter = transport.messages();
const first = await iter.next();
expect(first.done).toBe(false);
expect(first.value).toMatchObject({ id: 1, result: { ok: true } });
await transport.close();
});
it("treats 202 Accepted as a no-op (notification ack, no message yielded)", async () => {
fake = await startFakeServer({
reply: (req) => {
// Notifications have no id — return undefined → 202.
if ((req as { id?: number }).id === undefined) return undefined;
return { jsonrpc: "2.0", id: (req as { id: number }).id, result: {} };
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
// Now send a real request so we can prove the iterator only got
// the response (one message), not the notification (no message).
await transport.send({ jsonrpc: "2.0", id: 42, method: "ping" });
const iter = transport.messages();
const first = await iter.next();
expect(first.value).toMatchObject({ id: 42 });
await transport.close();
});
it("captures Mcp-Session-Id from the initialize response and echoes it on subsequent POSTs", async () => {
fake = await startFakeServer({
sessionId: "alpha-session",
reply: (req) => {
const r = req as { id?: number; method: string };
if (r.method === "initialize") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
serverInfo: { name: "fake", version: "0.0.0" },
capabilities: {},
},
};
}
if (r.id === undefined) return undefined;
return { jsonrpc: "2.0", id: r.id, result: { echoed: r.method } };
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
const client = new McpClient({ transport });
await client.initialize();
expect(transport.getSessionId()).toBe("alpha-session");
await client.listTools().catch(() => undefined);
await client.close();
// First POST = initialize: no session header yet (we don't have one).
// Second POST = notifications/initialized: should have session id.
// Third POST = tools/list: should have session id.
const posts = fake.requests.filter((r) => r.method === "POST");
expect(posts.length).toBeGreaterThanOrEqual(2);
expect(posts[0]!.headers["mcp-session-id"]).toBeUndefined();
for (let i = 1; i < posts.length; i++) {
expect(posts[i]!.headers["mcp-session-id"]).toBe("alpha-session");
}
});
it("delivers SSE-streamed responses with multiple frames in order", async () => {
fake = await startFakeServer({
reply: (req) => {
const r = req as { id: number };
return {
stream: [
// a progress notification first
{ jsonrpc: "2.0", method: "notifications/progress", params: { progress: 50 } },
// then the real response
{ jsonrpc: "2.0", id: r.id, result: { done: true } },
],
};
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
await transport.send({ jsonrpc: "2.0", id: 7, method: "slow_tool" });
const iter = transport.messages();
const first = await iter.next();
expect(first.value).toMatchObject({ method: "notifications/progress" });
const second = await iter.next();
expect(second.value).toMatchObject({ id: 7, result: { done: true } });
await transport.close();
});
it("drives a full McpClient initialize → tools/list round-trip", async () => {
fake = await startFakeServer({
reply: (raw) => {
const r = raw as { id?: number; method: string };
if (r.id === undefined) return undefined;
if (r.method === "initialize") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
serverInfo: { name: "fake-streamable", version: "0.0.0" },
capabilities: { tools: { listChanged: false } },
},
};
}
if (r.method === "tools/list") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
tools: [
{
name: "ping",
description: "responds pong",
inputSchema: { type: "object", properties: {} },
},
],
},
};
}
return {
jsonrpc: "2.0",
id: r.id,
error: { code: -32601, message: `no such method ${r.method}` },
};
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
const client = new McpClient({ transport });
const info = await client.initialize();
expect(info.serverInfo.name).toBe("fake-streamable");
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0]!.name).toBe("ping");
await client.close();
// initialize + notifications/initialized + tools/list = 3 POSTs.
const posts = fake.requests.filter((r) => r.method === "POST");
expect(posts).toHaveLength(3);
const methods = posts.map((p) => (JSON.parse(p.body!) as { method: string }).method);
expect(methods).toEqual(["initialize", "notifications/initialized", "tools/list"]);
});
it("surfaces a 404 with an existing session id as 'session expired'", async () => {
fake = await startFakeServer({
reply: (req) => {
const r = req as { id?: number; method: string };
if (r.method === "initialize") {
return {
jsonrpc: "2.0",
id: r.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
serverInfo: { name: "fake", version: "0.0.0" },
capabilities: {},
},
};
}
if (r.id === undefined) return undefined;
return { jsonrpc: "2.0", id: r.id, result: {} };
},
forceStatus: (req) => {
const r = req as { method?: string };
if (r.method === "tools/list") {
return { status: 404, body: "session expired" };
}
return undefined;
},
});
const transport = new StreamableHttpTransport({ url: fake.url });
const client = new McpClient({ transport });
await client.initialize();
expect(transport.getSessionId()).not.toBeNull();
// Once the session id is set, a 404 should surface as a clear error.
await expect(client.listTools()).rejects.toThrow(/session expired/i);
await client.close();
});
it("surfaces a non-OK response as an error from send()", async () => {
fake = await startFakeServer({
forceStatus: () => ({ status: 500, body: "boom" }),
});
const transport = new StreamableHttpTransport({ url: fake.url });
await expect(transport.send({ jsonrpc: "2.0", id: 1, method: "ping" })).rejects.toThrow(/500/);
await transport.close();
});
it("close() unblocks an idle messages() iterator", async () => {
fake = await startFakeServer({ reply: () => undefined });
const transport = new StreamableHttpTransport({ url: fake.url });
const iter = transport.messages();
const next = iter.next();
await transport.close();
const settled = await next;
expect(settled.done).toBe(true);
});
});
+191
View File
@@ -0,0 +1,191 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleSlash } from "../src/cli/ui/slash/dispatch.js";
import { addProjectShellAllowed, loadProjectShellAllowed } from "../src/config.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../src/index.js";
import { ToolRegistry } from "../src/tools.js";
function makeLoop(): CacheFirstLoop {
return new CacheFirstLoop({
client: new DeepSeekClient({ apiKey: "sk-test" }),
prefix: new ImmutablePrefix({ system: "s", toolSpecs: [] }),
tools: new ToolRegistry(),
maxToolIters: 1,
stream: false,
});
}
describe("/permissions slash handler", () => {
let dir: string;
let cfgPath: string;
let projectRoot: string;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "reasonix-perms-slash-"));
cfgPath = join(dir, "config.json");
projectRoot = join(dir, "project");
// Redirect ~/.reasonix → temp dir so the handler's calls (which use
// defaultConfigPath) land in `cfgPath`. config.test.ts skips this by
// passing `path` explicitly to every helper, but the slash handler
// hardcodes the default — so we have to redirect HOME instead.
process.env.HOME = dir;
process.env.USERPROFILE = dir;
});
afterEach(() => {
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
if (originalHome === undefined) {
// biome-ignore lint/performance/noDelete: the string "undefined" leaks into process.env otherwise
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalUserProfile === undefined) {
// biome-ignore lint/performance/noDelete: same reason
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
}
});
it("bare /permissions lists builtin entries (always shown)", () => {
const result = handleSlash("permissions", [], makeLoop(), {});
expect(result.info).toMatch(/Builtin allowlist/);
expect(result.info).toMatch(/git/);
expect(result.info).toMatch(/Project allowlist/);
});
it("bare /permissions surfaces yolo-mode banner when active", () => {
const result = handleSlash("permissions", [], makeLoop(), {
codeRoot: projectRoot,
editMode: "yolo",
});
expect(result.info).toMatch(/YOLO/);
expect(result.info).toMatch(/bypassed/);
});
it("bare /permissions lists project entries with 1-based indices", () => {
addProjectShellAllowed(projectRoot, "npm run build", join(dir, ".reasonix", "config.json"));
addProjectShellAllowed(projectRoot, "deploy.sh", join(dir, ".reasonix", "config.json"));
const result = handleSlash("permissions", [], makeLoop(), {
codeRoot: projectRoot,
editMode: "review",
});
expect(result.info).toMatch(/1\.\s+npm run build/);
expect(result.info).toMatch(/2\.\s+deploy\.sh/);
});
it("/permissions add persists a new prefix", () => {
const result = handleSlash("permissions", ["add", "npm", "run", "build"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/added.*npm run build/);
expect(loadProjectShellAllowed(projectRoot, join(dir, ".reasonix", "config.json"))).toContain(
"npm run build",
);
});
it("/permissions add rejects an empty prefix with a usage hint", () => {
const result = handleSlash("permissions", ["add"], makeLoop(), { codeRoot: projectRoot });
expect(result.info).toMatch(/usage:\s+\/permissions add/);
});
it("/permissions add notes when the target is already in the builtin list", () => {
const result = handleSlash("permissions", ["add", "git", "status"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/builtin allowlist/i);
// Should NOT have written a redundant project entry.
expect(loadProjectShellAllowed(projectRoot, join(dir, ".reasonix", "config.json"))).toEqual([]);
});
it("/permissions remove drops by exact prefix", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "npm run build", cfgFile);
addProjectShellAllowed(projectRoot, "deploy.sh", cfgFile);
const result = handleSlash("permissions", ["remove", "deploy.sh"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/removed.*deploy\.sh/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual(["npm run build"]);
});
it("/permissions remove drops by 1-based project index", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
addProjectShellAllowed(projectRoot, "beta", cfgFile);
addProjectShellAllowed(projectRoot, "gamma", cfgFile);
const result = handleSlash("permissions", ["remove", "2"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/removed.*beta/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual(["alpha", "gamma"]);
});
it("/permissions remove flags an out-of-range index", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
const result = handleSlash("permissions", ["remove", "5"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/index out of range/);
});
it("/permissions remove of an unknown prefix replies 'no such project entry'", () => {
const result = handleSlash("permissions", ["remove", "ghost"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/no such project entry/);
});
it("/permissions remove on a builtin entry refuses (read-only)", () => {
const result = handleSlash("permissions", ["remove", "git", "status"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/builtin/i);
expect(result.info).toMatch(/can't be removed/i);
});
it("/permissions clear without 'confirm' asks for confirmation", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
addProjectShellAllowed(projectRoot, "beta", cfgFile);
const result = handleSlash("permissions", ["clear"], makeLoop(), { codeRoot: projectRoot });
expect(result.info).toMatch(/about to drop 2 project allowlist/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual(["alpha", "beta"]);
});
it("/permissions clear confirm wipes the project list", () => {
const cfgFile = join(dir, ".reasonix", "config.json");
addProjectShellAllowed(projectRoot, "alpha", cfgFile);
addProjectShellAllowed(projectRoot, "beta", cfgFile);
const result = handleSlash("permissions", ["clear", "confirm"], makeLoop(), {
codeRoot: projectRoot,
});
expect(result.info).toMatch(/cleared 2 project allowlist/);
expect(loadProjectShellAllowed(projectRoot, cfgFile)).toEqual([]);
});
it("mutating subcommands refuse without a codeRoot", () => {
const r1 = handleSlash("permissions", ["add", "lint"], makeLoop(), {});
expect(r1.info).toMatch(/only available inside `reasonix code`/);
const r2 = handleSlash("permissions", ["remove", "lint"], makeLoop(), {});
expect(r2.info).toMatch(/only available inside `reasonix code`/);
const r3 = handleSlash("permissions", ["clear", "confirm"], makeLoop(), {});
expect(r3.info).toMatch(/only available inside `reasonix code`/);
});
it("'perms' is registered as an alias for 'permissions'", () => {
const result = handleSlash("perms", [], makeLoop(), {});
expect(result.info).toMatch(/Builtin allowlist/);
});
it("unknown subcommand surfaces the usage block", () => {
const result = handleSlash("permissions", ["wat"], makeLoop(), { codeRoot: projectRoot });
expect(result.info).toMatch(/usage: \/permissions/);
});
});
+43
View File
@@ -14,6 +14,7 @@ import {
registerShellTools,
resolveExecutable,
runCommand,
smartDecodeOutput,
tokenizeCommand,
} from "../src/tools/shell.js";
@@ -669,3 +670,45 @@ describe("NeedsConfirmationError", () => {
expect(e.message).not.toMatch(/apply-shell/);
});
});
describe("smartDecodeOutput", () => {
it("decodes valid UTF-8 cleanly", () => {
const buf = Buffer.from("hello 世界", "utf8");
expect(smartDecodeOutput(buf)).toBe("hello 世界");
});
it("returns empty string for empty input", () => {
expect(smartDecodeOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK on Windows for non-UTF-8 bytes", () => {
// "'sed' 不是内部或外部命令" — encoded in GBK (Chinese Windows
// cmd.exe error message). UTF-8 strict decode rejects it; on
// win32 we re-decode as GBK and recover the Chinese text. On
// other platforms we expect the lossy UTF-8 fallback string,
// which is fine — the bug only manifests on Chinese Windows
// anyway.
const gbk = Buffer.from([
0x27, 0x73, 0x65, 0x64, 0x27, 0x20, 0xb2, 0xbb, 0xca, 0xc7, 0xc4, 0xda, 0xb2, 0xbf, 0xc3,
0xfc, 0xc1, 0xee,
]);
const decoded = smartDecodeOutput(gbk);
if (process.platform === "win32") {
expect(decoded).toBe("'sed' 不是内部命令");
} else {
// Non-Windows: takes the lossy UTF-8 path; assert at least
// the ASCII portion survives unmangled.
expect(decoded.startsWith("'sed' ")).toBe(true);
}
});
it("does not split a multi-byte UTF-8 sequence across decode calls", () => {
// The full 6-byte sequence for "你好" decodes cleanly when
// handed to smartDecodeOutput as a single Buffer — this is the
// post-collection contract. (The chunk-aware accumulator in
// runCommand defers decoding until close, so this case can't
// arise there; the test pins the single-buffer contract.)
const buf = Buffer.from("你好", "utf8");
expect(smartDecodeOutput(buf)).toBe("你好");
});
});
+96
View File
@@ -0,0 +1,96 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToolRegistry } from "../src/tools.js";
import { WorkspaceConfirmationError, registerWorkspaceTool } from "../src/tools/workspace.js";
describe("change_workspace tool", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "reasonix-workspace-tool-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("registers a tool named change_workspace", () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
expect(reg.has("change_workspace")).toBe(true);
});
it("throws WorkspaceConfirmationError with the resolved absolute path on a valid directory", async () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", { path: tmp });
// ToolRegistry.dispatch serializes thrown errors into a JSON
// result with the error name + message. The model sees this; the
// App detects the marker and mounts the modal.
expect(out).toContain("WorkspaceConfirmationError");
// Path appears inside a JSON string, so backslashes (Windows) are
// doubled. Parse the result and compare against the canonical
// form rather than substring-checking the raw JSON.
const parsed = JSON.parse(out) as { error: string };
expect(parsed.error).toContain(tmp);
});
it("rejects empty / missing path with a normal error (no confirmation)", async () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out1 = await reg.dispatch("change_workspace", {});
expect(out1).toContain("non-empty string");
expect(out1).not.toContain("WorkspaceConfirmationError");
const out2 = await reg.dispatch("change_workspace", { path: "" });
expect(out2).toContain("non-empty string");
});
it("rejects a non-existent path", async () => {
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", {
path: join(tmp, "does", "not", "exist"),
});
expect(out).toContain("does not exist");
expect(out).not.toContain("WorkspaceConfirmationError");
});
it("rejects a path that exists but is a regular file", async () => {
const file = join(tmp, "marker.txt");
writeFileSync(file, "hi");
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", { path: file });
expect(out).toContain("not a directory");
expect(out).not.toContain("WorkspaceConfirmationError");
});
it("never performs an actual switch — only ever throws confirmation", () => {
// The tool fn must be a pure validator → confirmation-error
// pump. App.tsx owns the actual cwd swap. This test pins the
// contract so a future "let's just switch directly" refactor
// gets caught.
const e = new WorkspaceConfirmationError("/some/abs/path");
expect(e.name).toBe("WorkspaceConfirmationError");
expect(e.path).toBe("/some/abs/path");
expect(e.message).toMatch(/needs the user's approval/);
expect(e.message).toMatch(/STOP calling tools/);
});
it("expands a leading ~ to the home directory when one is set", async () => {
// We can't assume HOME points at our tmp dir, but we CAN assert
// that `~` triggers expansion (the resulting absolute path won't
// equal `~/...` literally and the validator will run against the
// expanded form). Use a known-bad subpath so the validator
// surfaces a clear "does not exist" rather than passing through.
const reg = new ToolRegistry();
registerWorkspaceTool(reg);
const out = await reg.dispatch("change_workspace", {
path: "~/__reasonix_definitely_not_a_real_dir_xyz__",
});
// The error message must contain the resolved absolute path,
// not the literal `~` prefix — otherwise expansion didn't happen.
expect(out).not.toContain("~");
expect(out).toContain("does not exist");
});
});