mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c594ad71f7 | ||
|
|
23c81e6f11 | ||
|
|
0d0cdfc54a | ||
|
|
60c598527d | ||
|
|
494c62ef60 | ||
|
|
c43ada74e2 | ||
|
|
18b8d0d8df | ||
|
|
855b91c7ed |
No files matched your search
+170
@@ -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-
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
[](./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
@@ -18,8 +18,9 @@
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
[](./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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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("你好");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user