| 1 | # Reasonix Engineering Spec |
| 2 | |
| 3 | > Reasonix is a coding agent: a thin harness driving multiple models, with **all |
| 4 | > capabilities supplied by configuration and plugins**. This document is the |
| 5 | > contract — code follows it. Change the contract first, then the code. |
| 6 | |
| 7 | The current file-operation, scheduling, and interruption contract is specified |
| 8 | in [Harness-style execution migration](DSH_EXECUTION_MIGRATION.md). |
| 9 | |
| 10 | ## 1. Design Principles |
| 11 | |
| 12 | 1. **Config- and plugin-driven core.** The core knows only interfaces. Concrete |
| 13 | models and tools are resolved by name from registries, declared in config, or |
| 14 | injected by plugins. No hardcoded `switch model`. |
| 15 | 2. **Single static binary.** `CGO_ENABLED=0`; cross-compile with one command; |
| 16 | CLI works out of the box. |
| 17 | 3. **Lean dependencies.** Standard library by default. A third-party dependency |
| 18 | must be pure-Go, lightweight, and must not compromise the single-binary / |
| 19 | cross-platform / distribution story. TOML parsing is the one accepted dependency. |
| 20 | 4. **Two extension tiers.** Compile-time built-ins (self-register via `init()`), |
| 21 | and runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible). |
| 22 | 5. **Interface-first & registry-based.** `Provider` and `Tool` are interfaces. |
| 23 | 6. **Evolve, don't over-engineer.** |
| 24 | |
| 25 | Language: **English is the primary language for all code** — comments, |
| 26 | user-facing strings, tool descriptions, system prompts, and this spec. The |
| 27 | README is bilingual (`README.md` English + `README.zh-CN.md`). |
| 28 | |
| 29 | ## 2. Layout |
| 30 | |
| 31 | ``` |
| 32 | reasonix/ |
| 33 | ├── go.mod / go.sum # module reasonix; require BurntSushi/toml |
| 34 | ├── Makefile # build / cross / vet / fmt / test |
| 35 | ├── README.md / README.zh-CN.md |
| 36 | ├── reasonix.example.toml # sample config |
| 37 | ├── docs/SPEC.md # this file |
| 38 | ├── cmd/reasonix/main.go # entry; blank-imports built-in providers/tools |
| 39 | ├── cmd/reasonix-plugin-example/ # reference MCP stdio plugin (a runnable example) |
| 40 | └── internal/ |
| 41 | ├── cli/ # subcommand routing, flags, assembly, exit codes |
| 42 | ├── config/ # TOML loading (flag > project > user > defaults) |
| 43 | ├── provider/ # Provider interface + types + kind→factory registry |
| 44 | │ └── openai/ # OpenAI-compatible impl; init() registers "openai" |
| 45 | ├── tool/ # Tool interface + Registry |
| 46 | │ └── builtin/ # read_file/write_file/edit_file/move_file/bash/ls/glob/grep |
| 47 | ├── permission/ # per-call Policy: allow/ask/deny rules → Decision |
| 48 | ├── command/ # custom slash commands loaded from .reasonix/commands/*.md |
| 49 | ├── plugin/ # stdio JSON-RPC (MCP) client; adapts remote tools |
| 50 | ├── remote/ # SSH transport for the Remote-SSH module |
| 51 | │ ├── forward/ # -L / -R port-forward lifecycle |
| 52 | │ ├── sftpfs/ # SFTP file layer (quarantines pkg/sftp) |
| 53 | │ └── bootstrap/ # detached `reasonix serve` bootstrap over SSH |
| 54 | └── agent/ # Session + harness loop |
| 55 | ``` |
| 56 | |
| 57 | Dependency direction (acyclic): `cli → {agent, plugin, config} → {tool, provider}`. |
| 58 | Built-in subpackages (`provider/openai`, `tool/builtin`) import their parent to |
| 59 | self-register; parents never import children. The Remote-SSH module layers |
| 60 | `cli → remote/bootstrap → remote → {remote/forward, remote/sftpfs, config, |
| 61 | netclient}`; `remote` and its subpackages never import `cli`, `agent`, or |
| 62 | `serve`, and all interactivity flows through callbacks (host-key / secret |
| 63 | prompts) so the desktop module consumes the same surface. See the |
| 64 | [Remote sessions](./REMOTE_SESSIONS.md) guide. |
| 65 | |
| 66 | ## 3. Core Abstractions |
| 67 | |
| 68 | ### 3.1 Provider + registry (`internal/provider`) |
| 69 | |
| 70 | ```go |
| 71 | type Provider interface { |
| 72 | Name() string |
| 73 | Stream(ctx context.Context, req Request) (<-chan Chunk, error) |
| 74 | } |
| 75 | |
| 76 | // Factory builds a Provider from a resolved config instance. |
| 77 | type Factory func(cfg Config) (Provider, error) |
| 78 | |
| 79 | // Register adds a factory under a kind (e.g. "openai"). Called from init(). |
| 80 | func Register(kind string, f Factory) |
| 81 | |
| 82 | // New instantiates the provider of the given kind. |
| 83 | func New(kind string, cfg Config) (Provider, error) |
| 84 | |
| 85 | type Config struct { |
| 86 | Name string // instance name, e.g. "deepseek" |
| 87 | BaseURL string |
| 88 | Model string |
| 89 | APIKey string |
| 90 | Extra map[string]any // kind-specific options |
| 91 | } |
| 92 | ``` |
| 93 | |
| 94 | - The `openai` kind is an OpenAI-compatible `/chat/completions` implementation. |
| 95 | - **OpenAI-compatible vendors are config instances** of `kind = "openai"`, |
| 96 | differing only in `base_url` / `model` / `api_key_env`. Adding another OpenAI- |
| 97 | compatible model is a config edit, not a code change. |
| 98 | - **A provider is a vendor endpoint** (one `base_url` + `api_key_env`) that offers |
| 99 | one or more models. `request_url`, when set, is the exact request target for |
| 100 | OpenAI-compatible, Anthropic-compatible, and Responses providers. Legacy |
| 101 | `chat_url` retains its historical OpenAI-only behavior; other legacy entries |
| 102 | derive the protocol path from `base_url`. An entry declares either a single `model = "..."` or a |
| 103 | `models = ["...", "..."]` list (with an optional `default`); the list form lets |
| 104 | one vendor expose several models without re-declaring the endpoint/key. A |
| 105 | **model reference** (`default_model`, the `--model` flag, the desktop switcher) |
| 106 | resolves via `Config.ResolveModel`, which accepts a provider name (→ its default |
| 107 | model), a bare model name, or an explicit `provider/model`. `context_window` is |
| 108 | the provider-wide fallback; `model_overrides.<model>.context_window` can replace |
| 109 | it for one model. Per-model `prices` use model IDs as keys. |
| 110 | - Streaming tool-call deltas are accumulated by index inside the provider; only |
| 111 | complete `ToolCall`s are emitted. |
| 112 | |
| 113 | ### 3.2 Tool + registry (`internal/tool`) |
| 114 | |
| 115 | ```go |
| 116 | type Tool interface { |
| 117 | Name() string |
| 118 | Description() string |
| 119 | Schema() json.RawMessage // JSON Schema for parameters |
| 120 | Execute(ctx context.Context, args json.RawMessage) (string, error) |
| 121 | } |
| 122 | ``` |
| 123 | |
| 124 | - Built-in tools self-register into a process-global builtin set via `init()` |
| 125 | (`tool.RegisterBuiltin(t)`); `tool.Builtins()` lists them. |
| 126 | - A runtime `*Registry` is assembled per run: enabled built-ins (filtered by |
| 127 | config) **plus** plugin-provided tools. The agent only sees the `*Registry`. |
| 128 | - Tool schemas are canonicalized on registry insertion. The built-in contract is |
| 129 | documented in [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md) and backed by tests that |
| 130 | compare the documented surface against the same canonical schema path. |
| 131 | - `Execute` parses raw JSON args itself. Errors are returned, not fatal — the |
| 132 | agent feeds them back so the model can self-correct. |
| 133 | |
| 134 | ### 3.3 Plugins (`internal/plugin`) — MCP client |
| 135 | |
| 136 | An external plugin is an MCP server declared in config. The wire protocol is |
| 137 | **JSON-RPC 2.0** in every case; only the transport differs. Reasonix keeps the |
| 138 | product-level client and delegates protocol negotiation, request correlation, |
| 139 | cancellation, pagination, and transport framing to the official MCP Go SDK. |
| 140 | One concurrency-safe session per configured server is shared by tools, prompts, |
| 141 | and resources. |
| 142 | |
| 143 | - **Transports** (config `type`): |
| 144 | - `stdio` (default) — a local subprocess; one JSON message per line over the |
| 145 | child's stdin/stdout (the MCP stdio convention). Declared with |
| 146 | `command` / `args` / `env`; terminated on ctx cancel / shutdown. |
| 147 | - `http` (a.k.a. `streamable-http`) — a remote server at `url`. After |
| 148 | initialize, a long-lived GET/SSE listener receives server messages while |
| 149 | POST carries client requests; POST-only and sessionless servers remain |
| 150 | supported. The `Mcp-Session-Id` response header, once seen, is echoed on |
| 151 | subsequent GET, POST, and bounded shutdown DELETE requests. Static |
| 152 | `headers` (e.g. a bearer token) are sent to the configured origin on each |
| 153 | transport method and are never forwarded cross-origin. When no static |
| 154 | `Authorization` header is configured, |
| 155 | user-initiated OAuth uses Protected Resource Metadata and Authorization |
| 156 | Server Metadata discovery, dynamic client registration, PKCE S256, a |
| 157 | loopback callback, resource indicators, and refresh-token rotation. Client |
| 158 | credentials and tokens are stored with mode `0600` in the server's private |
| 159 | Reasonix MCP state directory, outside the workspace; tokens are bound to the |
| 160 | configured resource URL and are never reused after that URL changes. OAuth |
| 161 | discovery, registration, and token requests honor Reasonix's resolved |
| 162 | network-proxy settings. Removing a declaration clears this state unless the |
| 163 | effective fallback uses the same OAuth resource. |
| 164 | - `sse` — the legacy 2024-11-05 HTTP+SSE transport. A persistent GET stream |
| 165 | receives an announced relative POST endpoint, JSON-RPC responses, and server |
| 166 | messages. Cross-origin announced endpoints are rejected so static headers |
| 167 | cannot leak. |
| 168 | - `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`, |
| 169 | and `headers` so secrets come from the environment, not the config file. |
| 170 | - Lifecycle: `initialize` → `notifications/initialized` → `tools/list`; |
| 171 | invocation via `tools/call {name, arguments}`. |
| 172 | - A per-server supervisor publishes only fully initialized/listening sessions. |
| 173 | An established session that returns 404 is rebuilt once with concurrent |
| 174 | callers joining the same rebuild; a call is replayed at most once. Ambiguous |
| 175 | disconnects never replay tool calls because the server may already have |
| 176 | executed them. Terminal background disconnects use bounded reconnect delays, |
| 177 | and stale callbacks from an older session generation cannot replace current |
| 178 | state. |
| 179 | - When a workspace root exists, initialize advertises `roots` and transports |
| 180 | answer `roots/list` with its file URI. `tools/call` includes a per-call |
| 181 | `_meta.progressToken`; matching `notifications/progress` messages stream into |
| 182 | the existing tool-progress event path. |
| 183 | - A stdio server uses one persistent transport for initialize, reads, and |
| 184 | writes, preserving state such as browser sessions across tool calls. The |
| 185 | process uses the server's process sandbox because process confinement cannot |
| 186 | change per RPC; read-only eligibility and destructive filtering remain local |
| 187 | workflow gates rather than separate process sandboxes. |
| 188 | - Configuration provenance is runtime metadata and determines persistence scope. |
| 189 | Desktop and CLI installs write the user-global `config.toml`; project |
| 190 | `reasonix.toml` and `.mcp.json` entries remain in their owning project file. |
| 191 | Every configured source is trusted without a separate launch-confirmation |
| 192 | step. Project entries override same-name global entries, and project |
| 193 | `reasonix.toml` overrides `.mcp.json`. Editing writes to the effective entry's |
| 194 | source; removing it reveals the next lower-priority declaration. |
| 195 | - Each remote tool is adapted to the `Tool` interface and injected into the run |
| 196 | registry, namespaced `mcp__<server>__<tool>` (spaces normalised to `_`) to |
| 197 | match Claude Code and avoid clashes. |
| 198 | - A tool's MCP `annotations.readOnlyHint` maps to `Tool.ReadOnly()`. It defaults |
| 199 | to false (a remote tool is opaque — we can't see its side effects), so a |
| 200 | plugin opts a tool into parallel-batch dispatch and the permission layer's |
| 201 | reader-default by declaring `readOnlyHint: true` in `tools/list`. |
| 202 | - Installation is the trust decision for tool metadata. Reasonix assumes an |
| 203 | installed server reports `readOnlyHint` and `destructiveHint` honestly; |
| 204 | planner/read-only filtering is a workflow boundary for trusted servers, not |
| 205 | containment against a malicious MCP server. Explicit deny rules and the |
| 206 | process sandbox remain host-controlled boundaries. |
| 207 | - `prompts/list` + `prompts/get` surface as `/mcp__<server>__<prompt>` slash |
| 208 | commands; `resources/list` + `resources/read` are referenced as |
| 209 | `@<server>:<uri>` in chat. All list cursors are consumed while preserving |
| 210 | server order. `/mcp` shows connected servers, counts, protocol/listening state, |
| 211 | reconnect attempts, and a redacted error category; it never exposes a session |
| 212 | identifier. |
| 213 | - `cmd/reasonix-plugin-example` is a runnable reference stdio server (`echo`, |
| 214 | `wordcount`), driven by an end-to-end test that builds the real binary. |
| 215 | |
| 216 | ### 3.4 Agent (`internal/agent`) |
| 217 | |
| 218 | - `Session` holds `[]Message`. |
| 219 | - `Run(ctx, input)` loop: build `Request` (with tool schemas) → `provider.Stream` |
| 220 | → print text deltas live, collect complete tool calls → if none, done; else |
| 221 | execute each tool (built-in or plugin) and append results → repeat, bounded by |
| 222 | `maxSteps`. `ctx` threads throughout (Ctrl-C aborts in-flight requests). |
| 223 | - A `Runner` is anything with `Run(ctx, input) error`; both `Agent` and |
| 224 | `Coordinator` satisfy it, so the CLI is agnostic to single- vs two-model mode. |
| 225 | |
| 226 | ### 3.5 Two-model collaboration (`Coordinator`) |
| 227 | |
| 228 | When `agent.planner_model` is set, a `Coordinator` runs two models in |
| 229 | **separate sessions** to keep each one's prompt prefix cache-stable. An empty |
| 230 | `planner_model` leaves the session executor-only. A configured but unusable |
| 231 | planner model is a configuration error and does not silently continue on the |
| 232 | executor: |
| 233 | |
| 234 | - The **planner** (low-frequency) runs in its own session with the same standing |
| 235 | memory context plus a filtered read-only research tool set, then produces a |
| 236 | concise plan. A deterministic host policy defaults to executor-only. It |
| 237 | invokes the dedicated planner only for an explicit plan-first / |
| 238 | plan-then-execute request, an explicit wait-for-approval boundary, an |
| 239 | explicit plan-only request, or an explicit Goal start. It does not call a |
| 240 | classifier model, does not infer complexity from wording, file count, or |
| 241 | keywords, and does not infer host state from controller-authored prompt |
| 242 | blocks. Explicit Plan Mode is an executor-driven workflow and never starts a |
| 243 | second planner. Synthetic turns, short contextual replies, and ordinary |
| 244 | requests stay executor-only. There is no Light/Full planning depth. The |
| 245 | privacy-safe route/reason decision is emitted in phase detail. |
| 246 | - The planner uses one stable system prompt. Only a small host-authored |
| 247 | `<planner-turn>` block names the explicit route. The plan distinguishes |
| 248 | verified from candidate touchpoints and records non-goals, risks, acceptance |
| 249 | criteria, and command-level verification when the evidence supports them. |
| 250 | `submit_plan` is the only delivery channel; a prose reply without a submitted |
| 251 | plan is a planner protocol error. If the planner still does not finalize after |
| 252 | the bounded research and grace round, every route fails closed and the |
| 253 | executor is not started. The incomplete planner turn is rolled back rather |
| 254 | than exposed as a broken manual continuation. |
| 255 | - A bare plan-first route hands the completed plan directly to the executor. |
| 256 | Plan-for-approval is reserved for an explicit request to wait for |
| 257 | confirmation; the host enforces that boundary even if the planner omits its |
| 258 | marker, then hands the approved plan to the executor. A headless host persists |
| 259 | the plan so a later turn can continue. Explicit plan-only requests persist the |
| 260 | plan and end the current turn without execution. A planner failure on either |
| 261 | execution boundary cannot fall back to the executor. These directives may |
| 262 | appear after the task clause; quoted examples do not change the route. |
| 263 | - The plan is handed off as structured text to the **executor** — a full |
| 264 | tool-using `Agent` in its own session — which validates candidate assumptions |
| 265 | and carries it out. |
| 266 | - The sessions never mix, so neither model's prefix is disturbed by the other's |
| 267 | turns; both grow prepend-only and stay cache-friendly. This reconciles |
| 268 | "cache-first" with "two-model collaboration": switching models *inside one |
| 269 | shared conversation* would break the prefix and tank cache hits, so we don't. |
| 270 | |
| 271 | ### 3.6 Context management (content-driven summary) |
| 272 | |
| 273 | Long tasks fill the model window. Reasonix keeps a **cache-first, append-only** |
| 274 | canonical transcript and installs a short **provider-visible checkpoint** only |
| 275 | when the sole automatic threshold is crossed. |
| 276 | |
| 277 | - Each provider declares `context_window` (tokens). The only automatic trigger is |
| 278 | `agent.compact_ratio` (default **0.80**; presets 0.70 / 0.80 / 0.85; range |
| 279 | 0.30–0.85). Lower values compact sooner and may increase summary cost or |
| 280 | reduce prompt-cache reuse. |
| 281 | `triggerTokens = floor(context_window × compact_ratio)`. |
| 282 | - **Below the trigger** ordinary requests remain append-only and no sidecar is |
| 283 | written. Every provider request uses the durable, bounded tool `Content`; |
| 284 | local `RawContent` is never promoted into sampling, retry, summary, or replay. |
| 285 | - **At the trigger** one singleflight maintenance transaction first persistently |
| 286 | prunes every tool result over 8192 Unicode code points to `4096 head + |
| 287 | "[... tool result middle pruned ...]" + 1024 tail`. If this clears pressure, |
| 288 | no summary request is made. Otherwise Reasonix summarizes the old contiguous |
| 289 | prefix and retains the newest **16%** of the context window verbatim, aligned so |
| 290 | assistant tool calls and tool results are never split. |
| 291 | - The summary request replays the original system message, the selected message |
| 292 | prefix, and the ordinary request's tool schemas, then appends one final user |
| 293 | compaction instruction. This shape can reuse provider KV cache. Output is capped |
| 294 | at **8192 tokens**, and prefix planning keeps **5%** of the window (at least 256 |
| 295 | tokens) below that cap as estimator headroom. A pressure run may make one |
| 296 | additional convergence summary (at most two successful summaries total); |
| 297 | overflow makes at most one summary and retries the original request at most |
| 298 | once after projection-version progress. An overflow rescue may also fold the |
| 299 | active turn's completed rounds, keeping its newest two rounds verbatim. |
| 300 | - Every summary reply, success or provider overflow, feeds its real prompt count |
| 301 | back into the estimator. When the provider rejects the summary request itself, |
| 302 | the fold is re-planned on the corrected estimate (at most twice), then sent once |
| 303 | as a bounded transcript (tool results cut to 2000 characters, no tool schemas); |
| 304 | a manual compact may then take the fragment path. A failed automatic attempt |
| 305 | backs off further attempts on the same turn until the view has grown by 5% of |
| 306 | the window since that attempt, which bounds the retries one turn can pay. |
| 307 | - A checkpoint must be strictly smaller than the replaced full request. Summary |
| 308 | timeout/error/empty/max-token results never produce a mechanical digest. Below |
| 309 | the hard ceiling the latest durable projection continues. At overflow or the |
| 310 | hard ceiling, when no summary can form, a lossy `truncate` projection elides the |
| 311 | oldest tool results and then drops the oldest replay units behind an explicit |
| 312 | marker until the view fits under the trigger; `ErrCompactionRequired` is |
| 313 | returned only when even that cannot reclaim enough. |
| 314 | - Users inspect or change the threshold with |
| 315 | `reasonix config compact-ratio [--local] [VALUE]`. Project config overrides the |
| 316 | user-global value used by desktop and new CLI sessions. UI always shows the |
| 317 | **effective** ratio. |
| 318 | - `max_output_tokens` is an independent **per-turn** completion ceiling and |
| 319 | never changes `triggerTokens` / `compact_ratio`. |
| 320 | - `0` is the provider auto value. Local admission uses the provider |
| 321 | capability (official DeepSeek 384K, OpenCode Go model table, or a learned |
| 322 | completion budget). It is **not** “skip the local output check”. |
| 323 | - Official DeepSeek Chat/Responses still omit the field when the remaining |
| 324 | shared window can host the 384K auto budget, and inject a clipped value |
| 325 | only when the window is tight. Official DeepSeek Anthropic always sends |
| 326 | 384K or the clipped remainder because `max_tokens` is required. |
| 327 | - Official OpenCode Go presets send `min(model max, physical remaining)` on |
| 328 | the generic `max_tokens` / `max_output_tokens` field. Third-party |
| 329 | compatible APIs do not assume a shared window until a trusted context 400. |
| 330 | - A positive value is an explicit cost cap and may still be clipped down to |
| 331 | the physical remainder. A negative value force-omits optional wire limits; |
| 332 | if the known auto budget no longer fits, Reasonix compacts instead of |
| 333 | overriding that choice. |
| 334 | - Canonical tool storage remains backward compatible: `Content` is the stable |
| 335 | provider-visible ≤32KB form and `RawContent` holds the full local original. |
| 336 | Full results are returned to the model only after an explicit paged |
| 337 | `use_capability` call to `session:tool_result`; sampling, stream retry, summary, |
| 338 | and projection replay all use the same bounded `Content`. Prune projections |
| 339 | never rewrite either canonical field. Older supported readers remain bounded. |
| 340 | - Automatic maintenance is planned once in `ContextManager.Prepare` from the |
| 341 | current projection plus the append-only canonical tail. The canonical |
| 342 | transcript is never rewritten. Subsequent thresholds merge |
| 343 | **prior digest + new history** into a single digest (no multi-span merge, no |
| 344 | application-layer retry). Failure records a generation-scoped |
| 345 | `blocked`/`failed` receipt; the same generation does not pay for another |
| 346 | automatic summary. Manual `compress` can retry. |
| 347 | - Old multi-threshold keys (`soft_compact_ratio`, `tool_result_snip_ratio`, |
| 348 | `compact_force_ratio`, `cold_resume_prune`, `context_editing`) are removed on |
| 349 | ordinary start and ignored at runtime. Native provider tool clearing is not |
| 350 | used; every provider uses the local summary checkpoint path. |
| 351 | - `keep` / `recent_keep` remain readable and round-trip for compatibility but are |
| 352 | deprecated and ignored by compaction. Old user turns, failed tool results, and |
| 353 | `[[keep]]` messages enter the summary prefix. Restart restores an existing |
| 354 | checkpoint without re-summarizing or replaying timeline cards. |
| 355 | - Full history remains in the session transcript. The read-only `history` tool |
| 356 | provides BM25 retrieval over sessions; new summary checkpoints do not create |
| 357 | prune archives. |
| 358 | - The read-only `history` tool gives the agent on-demand BM25 retrieval over |
| 359 | saved session JSONL files. `scope="project"` searches the current controller's |
| 360 | session directory; `scope="global"` also searches the user-global session |
| 361 | directory and compacted-history archives. `operation="around"` can then read a |
| 362 | bounded transcript window around a returned hit. Search keeps the best hit and |
| 363 | trims trailing common-word-only noise with a relative score floor; a 0-result |
| 364 | response tells the agent how to retry with rarer terms or widen scope. |
| 365 | - The read-only `memory` tool gives the agent on-demand search/list/read access |
| 366 | to saved auto-memory files. It complements the writer tools: `memory` checks |
| 367 | what already exists, `remember` saves or updates a fact, and `forget` removes |
| 368 | a stale one from the active index while archiving the file for traceability. |
| 369 | Archived memory files are visible in local management surfaces (`/memory`, |
| 370 | TUI, desktop panel) but are excluded from active-memory retrieval. Memory |
| 371 | search uses the same relative BM25 floor and guides the agent to fall back to |
| 372 | history when exact original wording or tool output matters. |
| 373 | - Before each real user turn, bounded BM25 recall selects relevant active facts |
| 374 | from the raw user message and appends them as a low-authority user-turn suffix. |
| 375 | Generic turns are suppressed, project facts override equivalent global |
| 376 | fallbacks, stale facts are down-ranked, and recall is bounded by result/character |
| 377 | budgets. This never mutates the stable system prompt or tool schemas. |
| 378 | - The owning controller may auto-allow only a bounded, non-sensitive, |
| 379 | create-only project/reference `remember`, including in a top-level headless |
| 380 | run. Other memory writes follow the active permission preset and preserve |
| 381 | explicit `ask` and `deny` rules. Full access bypasses ordinary prompts unless |
| 382 | an explicit deny rule matches. |
| 383 | Guardian/safety review cannot answer these prompts on the user's |
| 384 | behalf. Sub-agents and headless surfaces without the owning scoped |
| 385 | controller fail closed, including headless execution except for the create-only |
| 386 | path above. The approval request includes a compact preview, while |
| 387 | external notification hooks only receive the tool name. |
| 388 | - Facts carry immutable IDs, monotonic revisions, timestamps, type, and scope. |
| 389 | Updates snapshot the previous revision; restore and archive recovery create a |
| 390 | higher revision and reject path escapes, symlinks, collisions, and overwrites. |
| 391 | User-initiated memory edits in the local UI are already explicit user actions. |
| 392 | See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the |
| 393 | detailed implementation contract. |
| 394 | |
| 395 | **What survives a fold.** The system prompt and newest 16% tail survive verbatim. |
| 396 | Every older model-visible message forms one contiguous summary prefix, including |
| 397 | user turns, failed tool results, prior digests, and `[[keep]]` messages. Exact |
| 398 | older wording remains available in the canonical transcript and through the |
| 399 | read-only `history` tool. `keep` and `recent_keep` are compatibility-only fields. |
| 400 | |
| 401 | Subsequent folds merge the current digest with newer old history into one digest. |
| 402 | Compaction only writes a projection: canonical storage keeps every original, so |
| 403 | a missed detail stays recoverable through `history`. |
| 404 | |
| 405 | Prune and summary commits are deliberate cache-reset points. Between maintenance |
| 406 | runs the session remains append-only and cache-friendly. `context_window = 0` |
| 407 | disables automatic compaction for an instance. |
| 408 | |
| 409 | ### 3.7 Permissions (`internal/permission`) — per-call gating |
| 410 | |
| 411 | A coding agent runs shell commands and edits files autonomously. The permission |
| 412 | layer decides, **per tool call**, whether to allow it, deny it, or ask the user |
| 413 | first. It is independent of the model and of the CLI — the agent consults a |
| 414 | `Gate` interface at execute time; the gate is built from a static `Policy` plus |
| 415 | an optional interactive `Approver`. |
| 416 | |
| 417 | ```go |
| 418 | type Decision int // permission package |
| 419 | const (Allow Decision = iota; Ask; Deny) |
| 420 | |
| 421 | // Policy evaluates static rules against a tool call. Pure, no I/O. |
| 422 | type Policy struct { Mode Decision; Allow, Ask, Deny []Rule } |
| 423 | func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision |
| 424 | ``` |
| 425 | |
| 426 | - **Rule syntax.** A rule is `Tool` (matches any call in that tool family) or |
| 427 | `Tool(specifier)` (matches when the call's *subject* matches the specifier). |
| 428 | Bash and file mutation approvals use Claude Code-style families such as |
| 429 | `Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`. Built-in |
| 430 | file mutations include writes, edits, notebook edits, symbol/range deletes, |
| 431 | and `move_file` renames/moves. Legacy lowercase tool IDs still load for |
| 432 | compatibility. `Bash=<literal>` is the exact-command form: metacharacters in |
| 433 | the literal are ordinary characters and only the identical complete command |
| 434 | matches. The |
| 435 | `:*` suffix marks a Bash command-prefix approval; generated prefix rules also |
| 436 | reject later commands that introduce shell operators, so `Bash(go test:*)` |
| 437 | does not cover `go test ./... && rm -rf tmp`. |
| 438 | Legacy `Bash(go test *)` prefix rules still load, but new rules are saved as |
| 439 | `Bash(go test:*)`. The subject is extracted generically from the call's JSON |
| 440 | args by a small set of |
| 441 | known keys — `command` (bash), `path` / `file_path` (file tools), `pattern` |
| 442 | (grep/glob) — so tools need not change. A rule whose subject the args don't |
| 443 | expose only matches in its bare `Tool` form. |
| 444 | - **Shell syntax.** Pipes, substitutions, redirects, shell `-c`, and runtime |
| 445 | inline-code flags follow the active permission preset and OS sandbox. Syntax |
| 446 | never creates a separate approval rule. Explicit `deny` rules and exact |
| 447 | session grants continue to match the canonical command subject. |
| 448 | - **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for |
| 449 | read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so |
| 450 | a broad `allow = ["Bash"]` can still be carved by `deny = ["Bash(rm -rf*)"]`; |
| 451 | conversely `ask` overrides a broad `allow` to force a prompt on a risky subset. |
| 452 | - **Resolving authorization.** The interactive frontend offers allow once, |
| 453 | allow the displayed scope for this session, or deny. Session grants bind an |
| 454 | exact command, canonical directory, or server capability and are never written |
| 455 | to project configuration. A non-interactive run cannot prompt and therefore |
| 456 | fails closed when its preset does not cover the operation. A `Deny` is a hard |
| 457 | block in every preset. |
| 458 | - **MCP authorization.** Installing an MCP server authorizes all of its tools; |
| 459 | there is no second server, raw-tool, writer, or destructive approval policy. |
| 460 | Project configuration is trusted the same way and requires no separate launch |
| 461 | confirmation. Explicit global deny rules still win. `readOnlyHint` and |
| 462 | `destructiveHint` remain internal |
| 463 | facts for scheduling, Plan/read-only restrictions, and cached-to-live safety |
| 464 | reclassification. Strict read-only sub-agent registries expose only |
| 465 | authorized tools with `readOnlyHint: true` and no `destructiveHint`. The |
| 466 | two-model Planner uses the fixed `use_capability` proxy (never direct |
| 467 | `mcp__*` schemas) for authorized, non-destructive MCP without requiring |
| 468 | `readOnlyHint`; destructive tools are left for the Executor. In Balanced |
| 469 | two-model sessions the Executor owns an isolated frontend for the same proxy, |
| 470 | so Planner-discovered capability IDs remain executable after handoff. Schema-only |
| 471 | changes refresh the next-session cache without adding an execution approval |
| 472 | or retry. Immediately before dispatch, the proxy re-checks the current |
| 473 | controller's enablement, authorization, and complete runtime connection |
| 474 | identity; a same-name client on a shared Host is never sufficient authority. |
| 475 | - **Relationship to plan mode.** Plan mode (§3.4) is a plan-first collaboration |
| 476 | workflow, not an all-tools read-only mode. Before Permissions/Sandbox, the |
| 477 | host enforces explicit phase opt-outs. The dedicated two-model Planner may call authorized, |
| 478 | non-destructive MCP even when `readOnlyHint` is absent; it hard-blocks |
| 479 | destructive targets and readers from unauthorized servers for the entire |
| 480 | planning phase. A single-model Plan without the dedicated Planner continues |
| 481 | to block MCP writer/destructive targets while Plan is active. |
| 482 | Ordinary built-in and Bash calls then use the same Read only, Workspace |
| 483 | access, or Full access preset, explicit `ask`/`deny`, and OS sandbox path as |
| 484 | Standard mode. A third-party MCP |
| 485 | `readOnlyHint` affects dispatch classification and strict-child eligibility, |
| 486 | but not the dedicated Planner's non-destructive trust path. Once the server is |
| 487 | installed or declared in project configuration, all non-destructive |
| 488 | capabilities enter the dedicated Planner proxy; only hinted readers enter |
| 489 | strict read-only sub-agent execution. `plan_mode_read_only_commands` is |
| 490 | retained for config/session round trips and does not grant or revoke calls in |
| 491 | the main Plan workflow. `read_only_task` and `read_only_skill` remain strict |
| 492 | read-only capabilities with their own tool registry and safe foreground Bash; |
| 493 | writer-capable `task` and skill execution remain permission-gated instead of |
| 494 | Plan-blocked, and their child turns inherit the Plan workflow marker and |
| 495 | explicit phase opt-outs. |
| 496 | - **User decisions are separate from tool approvals.** Runtime permission has |
| 497 | three presets: `read-only`, `workspace-write`, and `danger-full-access`. |
| 498 | Workspace write is the default and confines local mutations to the workspace |
| 499 | and private session temporary directory. Full access skips ordinary prompts |
| 500 | and Reasonix filesystem/network confinement. Explicit host deny rules still |
| 501 | run before launch, but Reasonix does not constrain the launched process. |
| 502 | Shell syntax does not alter the selected preset. |
| 503 | Neither posture answers `ask` questions or approves `exit_plan_mode` plans. |
| 504 | Plan Mode is entered only through an explicit user choice and remains |
| 505 | independent of the active tool-approval posture. After a user approves a |
| 506 | plan, the controller opens a short `approvedPlanAutoApproveTools` execution |
| 507 | window so the model can perform the approved writes without re-prompting; that |
| 508 | transient window still does not auto-approve future plans. In headless `ask` |
| 509 | execution, any fallback answer is labelled as a model assumption, not as a |
| 510 | user decision. |
| 511 | |
| 512 | - **Collaboration mode is separate from tool approval.** The desktop composer |
| 513 | presents collaboration as `normal` ("正常模式"), `plan` ("计划模式"), and |
| 514 | `goal` ("目标模式"). `/goal <objective>` starts an autonomous, session-scoped |
| 515 | active goal: stable lifecycle-tool rules stay in the cacheable system prefix, |
| 516 | while each automatic round carries the escaped objective and exact goal |
| 517 | identity as dynamic user input. A runtime-idle driver admits one normal |
| 518 | top-level turn at a time until the model completes or blocks the goal, the |
| 519 | user pauses or clears it, or an explicit resource boundary is reached. An |
| 520 | automatic blocked transition is rejected before three admitted goal rounds; |
| 521 | deciding whether the same blocker persisted is the model's responsibility, |
| 522 | not a second host detector. A |
| 523 | goal is treated as a task contract: if the objective includes Context, |
| 524 | Request, Output format, Constraints, or Pause policy sections, those sections |
| 525 | define the autonomous work boundary. When they are absent, the model infers a |
| 526 | lightweight contract from the conversation and workspace. The injected goal |
| 527 | block tells the model to pause only for irreversible or externally visible |
| 528 | operations, scope changes, or information only the user can provide; ordinary |
| 529 | uncertainty should be handled with sensible defaults and reported as an |
| 530 | assumption. Completion requires the concrete request, output format, |
| 531 | constraints, and relevant verification expectations to be satisfied or |
| 532 | explicitly reported as unverified. |
| 533 | Goal has no default model-round, cross-Run turn, wall-clock, or numeric |
| 534 | no-progress boundary. Exact consecutive tool calls receive bounded reminders |
| 535 | and still execute. The model-facing lifecycle is `get_goal`, `create_goal` |
| 536 | and versioned `update_goal(edit|pause|resume|complete|blocked)`. There is no |
| 537 | `continue` action: `active + armed` is sufficient for the idle driver. There |
| 538 | is no host readiness evaluator, second research protocol or writable sidecar |
| 539 | runtime. Legacy `.reasonix/autoresearch/...` |
| 540 | archives remain read-only and explicit old paths recover as ordinary Goals. |
| 541 | Ordinary prompts never force collaboration mode at the host level, although |
| 542 | the model may create a long-running goal from a directly authorized human |
| 543 | request when its semantics require autonomous continuation. |
| 544 | Turns, |
| 545 | tokens, provider requests, and active work duration remain observational when |
| 546 | the corresponding budget is not configured. Positive user-selected |
| 547 | `[agent].goal_token_budget`, `max_steps`, time, and cost budgets remain |
| 548 | explicit resumable boundaries. The Goal token budget defaults to `0` (off); |
| 549 | resuming a `budget_spend` pause grants a fresh slice without clearing |
| 550 | cumulative Goal statistics. `task_time_budget_minutes = 0` (and legacy |
| 551 | negative values) disables the time boundary. |
| 552 | `/goal clear` removes the active goal. Switching into plan/normal mode clears |
| 553 | the active goal in the desktop UI so the collaboration mode remains one of |
| 554 | the three choices, while the underlying tool approval posture is preserved. |
| 555 | |
| 556 | | Permission preset | Tool authorization | Plan approval | `ask` questions | |
| 557 | | --- | --- | --- | --- | |
| 558 | | Read only / `read-only` | Reads are allowed; writes and external side effects require a scoped authorization | Waits for user | Waits for user | |
| 559 | | Workspace access / `workspace-write` | Workspace and private session temp writes run inside the OS sandbox; boundary crossings require authorization | Waits for user | Waits for user | |
| 560 | | Full access / `danger-full-access` | Ordinary prompts and Reasonix process sandboxing are skipped; explicit host deny rules still run before launch | Waits for user | Waits for user | |
| 561 | | Approved-plan execution window | The approved plan may execute only within the active preset; explicit `ask` / `deny` rules remain | Future plans still wait | Waits for user | |
| 562 | |
| 563 | Out of the box, new sessions use `workspace-write`: workspace and private |
| 564 | session-temp operations run in the OS sandbox without prompting. Use |
| 565 | `--permission-mode read-only` for inspection-only automation or explicitly |
| 566 | select `--permission-mode danger-full-access` when unrestricted local access is |
| 567 | required. Explicit `ask` rules still require authorization, and `deny` rules |
| 568 | harden every preset. |
| 569 | |
| 570 | ### 3.8 Slash commands (`internal/command`) |
| 571 | |
| 572 | The chat TUI accepts `/command` input. Three kinds share one dispatch: |
| 573 | |
| 574 | - **Built-in actions** (`/compact`, `/new`, `/clear`, `/effort`, `/mcp`, `/help`) manipulate session |
| 575 | state locally and never reach the model. `/new` starts a new session while |
| 576 | saving the previous transcript for resume/history. `/clear` requires |
| 577 | confirmation, then discards the current context without saving it; it does not |
| 578 | delete project memory. |
| 579 | - **Custom commands** are Markdown files under `.reasonix/commands/` (project) and |
| 580 | the user config dir, e.g. `~/.reasonix/commands/` on macOS/Linux; the project dir overrides the user dir on a |
| 581 | name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it |
| 582 | (`git/commit.md` → `/git:commit`). Invoking one renders its body and sends the |
| 583 | result as the next user turn. |
| 584 | - **MCP prompts** (§3.3) appear as `/mcp__<server>__<prompt>`. |
| 585 | |
| 586 | ```markdown |
| 587 | --- |
| 588 | description: Review the staged diff |
| 589 | argument-hint: [focus-area] |
| 590 | --- |
| 591 | Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line. |
| 592 | ``` |
| 593 | |
| 594 | - Frontmatter is an optional `---`-fenced block of simple `key: value` lines; |
| 595 | `description` and `argument-hint` are recognised (no YAML dependency — Reasonix |
| 596 | stays lean). The remainder is the body template. |
| 597 | - Substitution in the body: `$ARGUMENTS` (all args, space-joined), `$1`…`$N` |
| 598 | (positional, empty when absent), `$$` (a literal `$`). Arguments are the |
| 599 | space-separated tokens after the command. |
| 600 | - Loading is pure (`command.Load(dirs...)`) and tested; a malformed file is |
| 601 | skipped, not fatal. Custom and MCP-prompt commands both resolve to text and |
| 602 | reuse the same "start a turn" path as a typed message. |
| 603 | |
| 604 | #### CLI modal/composer ownership |
| 605 | |
| 606 | The Bubble Tea chat TUI has one bottom composer. A slash-command overlay must |
| 607 | declare whether it owns keyboard input: |
| 608 | |
| 609 | - **Modal overlays** own navigation/confirm/cancel keys and must hide the |
| 610 | composer while open. Examples: `/mcp`, `/resume`, `/rewind`, approval prompts, |
| 611 | and non-typing `ask` choice cards. |
| 612 | - **Input-owned overlays** are attached to the textarea and must keep the |
| 613 | composer visible. Examples: slash/@ autocomplete and `ask` free-text mode. |
| 614 | |
| 615 | New CLI overlays must update `chat_tui.hideComposer()` and add/extend layout |
| 616 | tests so `bottomRows()` accounts for either `panel + status` or |
| 617 | `panel + composer + status`. This prevents inactive chat input boxes from being |
| 618 | rendered under modal panels. |
| 619 | |
| 620 | ### 3.9 Chat references (`@`) |
| 621 | |
| 622 | A chat message may embed `@` references; before the turn is sent, each is |
| 623 | resolved and prepended to the message as a tagged block the model can read. |
| 624 | |
| 625 | - `@<server>:<uri>` where `<server>` is a connected MCP server → an MCP |
| 626 | resource (`resources/read`), wrapped `<resource ref="…">…</resource>`. |
| 627 | - `@<path>` otherwise → a **local file or directory**, but only when the path |
| 628 | actually exists on disk. This existence gate is the disambiguator: an ordinary |
| 629 | `@mention` or an email address resolves to no file and stays literal text. A |
| 630 | file is wrapped `<file path="…">…</file>` (size-capped, binary files noted not |
| 631 | dumped); a directory becomes a recursive listing (depth-first, skipping common |
| 632 | noise like `.git` and `node_modules`). |
| 633 | - Resolution is asynchronous (off the TUI event loop); a fetch failure surfaces |
| 634 | as a notice but doesn't block the turn. Reads are user-initiated and read-only |
| 635 | — they do **not** pass the permission gate (§3.7). |
| 636 | - Typing `/` or `@` opens an autocomplete menu above the input. The `@` menu |
| 637 | navigates **one directory level at a time** (`os.ReadDir`, never a recursive |
| 638 | walk — bounded for huge directories): a directory entry descends, a file |
| 639 | completes, and MCP resources appear alongside top-level entries. The |
| 640 | bottom-region menu changes height only on these discrete actions, never per |
| 641 | streamed token, so scrollback stays clean (§ rendering). |
| 642 | |
| 643 | ### 3.10 Subagent profiles and explicit CLI execution |
| 644 | |
| 645 | A subagent profile is a Skill with `runAs: subagent` and, for profiles managed |
| 646 | by the desktop or CLI editors, `invocation: manual`. Profiles reuse the existing |
| 647 | project/global Skill files; they do not introduce another state format or |
| 648 | database. Manual invocation excludes a profile from the `session-context` |
| 649 | Skills catalog so |
| 650 | the model cannot discover it implicitly, while explicit `/<name> <task>` |
| 651 | invocation remains available. |
| 652 | |
| 653 | Interactive slash invocation and `Controller.RunSubagentProfile` both execute |
| 654 | the profile with the Boot-wired Skill runners. Each run gets an isolated child |
| 655 | session and returns only its final answer to the caller. The headless contract is |
| 656 | explicit: |
| 657 | |
| 658 | - `reasonix subagent try <name> ... <task>` uses the read-only Skill runner; |
| 659 | - `reasonix subagent run <name> ... <task>` uses the normal permission and |
| 660 | sandbox path; and |
| 661 | - ordinary `Controller.Run` / `reasonix run` remains unchanged and does not |
| 662 | reinterpret slash-prefixed input as a subagent command. |
| 663 | |
| 664 | Desktop and CLI profile mutations share |
| 665 | `skill.ValidateEditableSubagentProfile`. Only simple manual project/global |
| 666 | profiles can be rewritten or deleted. Custom-scope Skills, unmanaged |
| 667 | frontmatter, and Skill directories containing `references/` or `scripts/` are |
| 668 | refused so an editor cannot silently flatten or discard rich Skill content. |
| 669 | Built-in profiles support configuration overrides but have no writable file. |
| 670 | |
| 671 | Effective model and effort precedence is: per-profile |
| 672 | `agent.subagent_models` / `agent.subagent_efforts`, this call's `model` / |
| 673 | `effort` on `task`/`fleet`, profile frontmatter, `agent.subagent_model` / |
| 674 | `agent.subagent_effort`, then executor/default model configuration. |
| 675 | |
| 676 | `task` accepts optional `profile` and `write_paths`. `fleet` dispatches 2–64 |
| 677 | profile-aware tasks under a session scheduler |
| 678 | (`agent.max_subagent_concurrency`, default 6; `agent.max_parallel_writers`, |
| 679 | default 3). Profile names are resolved at runtime from the Skill store and |
| 680 | must never enter tool schemas or the parent system prompt. Custom and named |
| 681 | built-in profile bodies are the full child system prompt (no implicit |
| 682 | concise default). `parallel_tasks` remains the compatible read-only batch |
| 683 | API on the same scheduler. In a persisted parent session, parallel/fleet |
| 684 | children save independent transcripts; the aggregate carries bounded previews |
| 685 | and stable refs, and `read_subagent_result` pages a referenced final answer by |
| 686 | UTF-8 byte offset under the current conversation-lineage/workspace boundary. |
| 687 | Headless runs remain ephemeral and return fair bounded previews without refs. |
| 688 | See [Subagent profiles](./SUBAGENT_PROFILES.md) |
| 689 | for the user-facing command and file-format contract. |
| 690 | |
| 691 | A profile describes a worker, not a run. Delegation is five separate concepts: |
| 692 | the profile says how a worker thinks, `TaskSpec` what this call wants, |
| 693 | `CapabilityGrant` what it may touch, `ContextCapsule` what it starts from, and |
| 694 | `SchedulerPolicy` when it runs. A field belongs to whichever member decides its |
| 695 | value, so a profile may carry a capability *ceiling* (`allowed-tools`, |
| 696 | `read-only`) but never a per-call value such as `max_turns`, `write_paths`, or a |
| 697 | retry or verification policy — those are decided by the task or the scheduler. |
| 698 | Skill frontmatter may keep growing; `agent.ProfileFromSkill` is the single |
| 699 | narrowing point, and routing metadata (triggers, auto-use, cost, freshness) |
| 700 | stops there because it decides *when* a worker is chosen, not how it thinks. |
| 701 | `internal/agent/profile_boundary_test.go` fails on any widening. |
| 702 | |
| 703 | ### 3.11 Sub-agents close with a host-adjudicated claim |
| 704 | |
| 705 | A writer sub-agent ends its run by calling `complete_subtask` with a `status`, |
| 706 | a `summary`, the `acceptance_criteria` it was held to (each with the command it |
| 707 | ran or the paths it changed), and whatever it left `unresolved`. Prose alone is |
| 708 | still accepted, but it is no longer the interface the parent reasons over. |
| 709 | |
| 710 | The submitted status is a claim, not a verdict. Before the parent sees it, the |
| 711 | host checks every citation against its own receipts: a `verification` criterion |
| 712 | must name a command the host recorded as run, `diff`/`files` must name paths the |
| 713 | host observed written or read, and a `manual` note is never self-backing. Any |
| 714 | criterion the receipts cannot back is lowered to `unsatisfied`, a report holding |
| 715 | one cannot stay `complete`, and the downgrade is printed with its reason. The |
| 716 | host never raises a status. |
| 717 | |
| 718 | The parent therefore receives, in order: the adjudicated status and criteria, |
| 719 | the child's own prose, and the host's own receipts of what it changed and ran. |
| 720 | |
| 721 | ### 3.12 Write claims are enforced, not advisory |
| 722 | |
| 723 | A declared `write_paths` is one truth source used for both scheduling and |
| 724 | enforcement. When a writer sub-agent declares explicit paths, the host binds its |
| 725 | registry to that claim before the child runs: |
| 726 | |
| 727 | - path-aware built-in writers (`write_file`, `edit_file`, `multi_edit`, |
| 728 | `move_file`, `notebook_edit`, `delete_range`, `delete_symbol`) reject any |
| 729 | argument path outside the claim, with both ends of a `move_file` checked; |
| 730 | - paths are compared after symlink resolution against the deepest existing |
| 731 | ancestor, so neither `..` traversal nor a symlink inside the claim can launder |
| 732 | a write out of it; |
| 733 | - `bash` is kept only if the OS sandbox can rebind its write roots to the claim, |
| 734 | and is otherwise removed from the child's registry entirely; |
| 735 | - MCP goes through `use_capability`, which refuses at resolve time — before any |
| 736 | MCP process runs — every target not proven read-only; |
| 737 | - writers the host cannot path-scope (custom, unknown) are dropped; |
| 738 | - after the run, the host compares the mutations it recorded against the claim |
| 739 | and reports any outside path to the parent in the sub-agent's host receipts. |
| 740 | |
| 741 | Omitting `write_paths` is not an unscoped writer: the run starts by claiming |
| 742 | the whole workspace, so it cannot start beside another writer. After it has |
| 743 | only performed path-bound writes, the scheduler reservation shrinks to those |
| 744 | files and a parent (or sibling) may write elsewhere. A `bash` or MCP workspace |
| 745 | mutation makes the claim whole-workspace again. Directory claims may start |
| 746 | together; they serialize only when they realize the same file. Enforcement |
| 747 | still uses the declared bound — sandbox/`AllowsPath` do not shrink. Writes |
| 748 | that leave the workspace are still reported as claim violations. |
| 749 | |
| 750 | Declaring paths is what buys parallelism; it costs `bash` on hosts where the OS |
| 751 | sandbox cannot enforce write roots. |
| 752 | |
| 753 | ### 3.13 Sub-agent context inheritance is explicit |
| 754 | |
| 755 | A child inherits nothing implicitly. What it receives is exactly this: |
| 756 | |
| 757 | | Given to the child | Where it comes from | |
| 758 | | --- | --- | |
| 759 | | System prompt | `DefaultTaskSystemPrompt`, `DefaultReadOnlyTaskSystemPrompt`, or the profile body — nothing else is composed into it | |
| 760 | | Workspace root | `<workspace-context>` on the first user turn | |
| 761 | | The task text | the user turn itself | |
| 762 | | Completion contract | appended to a writer's task turn (§3.11) | |
| 763 | | Delegation guidance | `<subagent-context>` on a nested child's fresh session | |
| 764 | | Plan-mode marker, reasoning/response language | run options, when set | |
| 765 | | A prior transcript | only via `continue_from` / `fork_from` | |
| 766 | |
| 767 | Not inherited, by construction: `REASONIX.md`, `AGENTS.md`, `CLAUDE.md`, project |
| 768 | and global memory (the memory queue is disabled, so a child cannot record memory |
| 769 | either), the parent conversation, the current Goal, planner output, and sibling |
| 770 | sub-agent results. A constraint that must reach a child today has to be in its |
| 771 | profile body or in the task text — there is no ambient channel. |
| 772 | |
| 773 | Every run records a `ContextCapsule` in its transcript sidecar: the workspace, |
| 774 | the system-prompt source and hash, the resolved tool scope and schema hash, the |
| 775 | model and effort, the parent session and tool-call id, any resumed transcript, |
| 776 | and an `inherited` block whose fields are all false. `capsuleHash` is its stable |
| 777 | identity, so *why did this reviewer not see that constraint* is answered from |
| 778 | the record, and two runs that behaved differently can be diffed instead of |
| 779 | guessed at. The capsule holds references and digests only — never copied parent |
| 780 | context, which is what keeps delegation cheap and the child prefix cacheable. |
| 781 | |
| 782 | ### 3.14 Fleet is a small dependency graph |
| 783 | |
| 784 | A fleet item may declare `id` and `depends_on`. That is the whole graph |
| 785 | vocabulary: no conditions, no expressions, no dynamic fan-out. It is enough for |
| 786 | |
| 787 | ``` |
| 788 | research ──▶ implement backend ──┐ |
| 789 | └──▶ implement frontend ─┴──▶ integration test ──▶ review |
| 790 | ``` |
| 791 | |
| 792 | Ids default to the 1-based position. A duplicate id, an id no task declares, a |
| 793 | self-edge, or a cycle fails preflight, so a fleet that cannot finish never |
| 794 | starts. Items run as soon as their dependencies complete; items with no ordering |
| 795 | between them run in parallel under the same session scheduler as before. |
| 796 | |
| 797 | Dependencies are a property of the graph, never of a task: they live in the |
| 798 | fleet plan and never reach `ProfileExecSpec`, which is what keeps `depends_on` |
| 799 | from becoming the first keyword of a workflow language. |
| 800 | |
| 801 | The graph relaxes the write-claim preflight in the one place it should. Only |
| 802 | items that can run at the same time need disjoint `write_paths`; an |
| 803 | `implement → review` pair is serialised by its edge and may share paths, which |
| 804 | a flat fleet could not express. |
| 805 | |
| 806 | Failure handling has one knob. A failed or skipped task always skips its whole |
| 807 | downstream branch — running a dependent on a broken input only buys a result the |
| 808 | parent must discard. Independent branches keep going unless `fail_fast` is set, |
| 809 | which stops *starting* new tasks; tasks already running are left to finish so a |
| 810 | writer is never abandoned mid-write. |
| 811 | |
| 812 | ### 3.15 One child-construction primitive |
| 813 | |
| 814 | The APIs that spawn a child are many — `task`, `read_only_task`, `fleet`, |
| 815 | `parallel_tasks`, `run_skill`, `/<profile>`, `reasonix subagent run|try`, |
| 816 | desktop preview. The execution primitive behind them must stay one. Each entry |
| 817 | point compiles its request into a `ProfileExecSpec` and hands it to |
| 818 | `TaskTool.RunProfileSpec`, which is the only place that resolves depth, tool |
| 819 | scope, permissions, sandbox, write claims, scheduler slots, the MCP frontend, |
| 820 | the transcript and capsule, the evidence ledger, and the completion contract. |
| 821 | |
| 822 | This is not a style preference. A safety boundary spread across several |
| 823 | construction paths only has to be forgotten once: past regressions where a |
| 824 | preview path built unconfined file tools, and where a profile editor dropped |
| 825 | `read-only` on save, were both one entry point missing one layer. |
| 826 | |
| 827 | An entry point that must not persist a transcript says so with |
| 828 | `ContextRequest.Ephemeral` rather than building its own session, so its promise |
| 829 | is a field on the spec instead of a second construction path. |
| 830 | |
| 831 | `internal/agent/spawn_boundary_test.go` enumerates the files that still call the |
| 832 | low-level runners directly and fails on any new one. The remaining entries — |
| 833 | `internal/boot` (skill runners), `internal/cli/review.go`, and |
| 834 | `desktop/subagents_app.go` — are known debt, not precedent. |
| 835 | |
| 836 | ### 3.16 MCP concurrency: read-only is not stateless |
| 837 | |
| 838 | Sub-agents share one session Host and its connections while each keeps its own |
| 839 | `use_capability` frontend and ledger. For a stdio server that means they share |
| 840 | one process, and therefore its session state. |
| 841 | |
| 842 | Read-only does not imply stateless. A browser server opens a page, selects a |
| 843 | tab, scrolls; every one of those tools may honestly declare `readOnly` because |
| 844 | nothing reaches the filesystem, yet two children calling it concurrently |
| 845 | interleave on state neither of them can see. Write claims do not help — there is |
| 846 | nothing to claim. |
| 847 | |
| 848 | A configured server therefore carries a concurrency policy: |
| 849 | |
| 850 | ```toml |
| 851 | [[mcp.servers]] |
| 852 | name = "browser" |
| 853 | concurrency = "serial" # parallel (default) | serial |
| 854 | ``` |
| 855 | |
| 856 | `serial` means the runtime never runs two calls to that server at once across |
| 857 | the whole session, whichever child issues them. The gate lives on the shared |
| 858 | runtime because the process being interleaved on is shared at exactly that |
| 859 | scope, and a call waiting on it still honours its own cancellation. Servers |
| 860 | whose names look known-stateful (browser, playwright, puppeteer, chrome, |
| 861 | chromium, selenium) default to `serial`; explicit configuration always wins, and |
| 862 | everything else stays parallel so the shared-Host tradeoff is unchanged. |
| 863 | |
| 864 | This is deliberately the conservative first version: one policy per server, not |
| 865 | per capability. Per-tool `parallel_safe` / `exclusive` hints and explicit |
| 866 | `concurrency_key` grouping are the later refinement, once real servers show |
| 867 | which tools within one server genuinely differ. |
| 868 | |
| 869 | ### 3.17 Measuring whether delegation pays |
| 870 | |
| 871 | Orchestration is easy to add and hard to justify: more agents always cost more |
| 872 | tokens, and the extra tokens alone can look like an improvement. Comparing arms |
| 873 | therefore has to hold the model fixed and read host-recorded facts, not prose. |
| 874 | |
| 875 | `reasonix run --json` emits per-run delegation counters alongside the existing |
| 876 | token, cache, cost, and duration totals: |
| 877 | |
| 878 | | Counter | Answers | |
| 879 | | --- | --- | |
| 880 | | `subagent_runs`, `subagent_nested_runs` | which shape actually ran, not which was configured | |
| 881 | | `tool_calls` − `subagent_tool_calls` | parent versus child work split | |
| 882 | | `subagent_mutations`, `duplicate_work_paths` | did two children redo the same file | |
| 883 | | `completion_reports`, `completions_prose_only` | how much of the run ended in a checkable claim | |
| 884 | | `false_completions`, `criterion_downgrades` | claims the host refused to back | |
| 885 | | `write_scope_violations` | writes that escaped a declared claim | |
| 886 | |
| 887 | The control axis is partial, and the counters are what revealed it. |
| 888 | `--ablate subagent` removes `task`, `read_only_task`, `fleet`, and |
| 889 | `parallel_tasks`, but a run can still delegate through a `runAs=subagent` |
| 890 | profile skill: a measured `no-subagent` arm spent a child run on `explore`. |
| 891 | Treat that arm as "no task-tool delegation", not "single agent", and read |
| 892 | `subagent_runs` to see what actually happened rather than trusting the label. |
| 893 | Nested depth is `agent.max_subagent_depth`. |
| 894 | |
| 895 | `false_completions` is the counter that matters most. It comes from the |
| 896 | adjudication in §3.11, so it measures claims the host refused rather than a |
| 897 | reviewer's opinion, and it is the one number that separates "the fleet finished |
| 898 | faster" from "the fleet said it finished". |
| 899 | |
| 900 | Read these against the measured noise floor. Running the same arm twice over |
| 901 | the same tasks moved per-task token use by a median of 19% and up to 54%, while |
| 902 | the whole between-arm difference in that experiment was 2.5%. A single run per |
| 903 | cell therefore proves nothing about delegation: the effect has to clear the |
| 904 | variance before it is an effect. Budget repetitions, or restrict the comparison |
| 905 | to tasks where `subagent_runs` shows delegation actually happened — in that |
| 906 | experiment it happened on one task in six. |
| 907 | |
| 908 | What the counters have measured so far, on one model over four task shapes, |
| 909 | each comparing a neutral prompt against a forced-delegation twin over identical |
| 910 | work: three one-line fixes in separate modules cost 3.8x the tokens; a 24-file |
| 911 | search 1.5x tokens and 2.2x wall; a 36-file three-package migration 2.6x tokens |
| 912 | and 4.1x wall; three genuinely heterogeneous branches, the shape with the best |
| 913 | theoretical case, 2.4x tokens and 3.7x wall over three repetitions. Success rate |
| 914 | was 100% everywhere, and the forced arm's spread was about twice the neutral |
| 915 | arm's, so delegation also buys variance. |
| 916 | |
| 917 | Read a child's token figure carefully: 27 measured child runs averaged 134k |
| 918 | tokens each, but that is cumulative prompt tokens over 9.3 model calls with the |
| 919 | same ~14k context re-sent each time, not 134k tokens of new material. At ~90% |
| 920 | cache hit the real price of a child averaged ¥0.017. The 2-4x above is the |
| 921 | number that matters, because both arms are counted the same way; the per-child |
| 922 | total is not a threshold to compare a branch's size against. |
| 923 | |
| 924 | Why delegation is rare is answerable from the same runs, and the answer is not |
| 925 | that the model weighs it and declines. Across 33 runs with delegation available, |
| 926 | 15% delegated and bash outnumbered every delegation-class call 10:1. The |
| 927 | recorded reasoning shows the model deliberating over how to read efficiently — |
| 928 | "that's 25 files... read them in parallel batches... I can read multiple files |
| 929 | at once" — on a task built for `explore`, without delegation entering the |
| 930 | decision at all. |
| 931 | |
| 932 | Three things explain that, and only one of them is a defect. The base system |
| 933 | prompt never mentions delegation; every mention lives in the skills index, and |
| 934 | each is a brake ("the heavy path... only when the task genuinely needs |
| 935 | context-heavy work, not on weak relevance") next to an accelerator for inline |
| 936 | skills ("even plausibly relevant... cheap"). The `task` tool description says |
| 937 | what the tool does and never when to reach for it. And the model already has |
| 938 | cheaper parallelism — several tool calls in one round trip, with no context |
| 939 | duplicated — which is what it reasons in terms of. |
| 940 | |
| 941 | Given the measured 2.4-4.5x, a brake is the correct default; the gap is that |
| 942 | nothing recognises the rare case where delegation would pay. Forcing it does not |
| 943 | close that gap: in the forced fleet run the parent worked out all three fixes in |
| 944 | its own reasoning before dispatching, so the children re-read the code to apply |
| 945 | edits the parent had already derived. Delegation moved the typing, not the |
| 946 | thinking. |
| 947 | |
| 948 | One hypothesis remains untested rather than disproved: delegation's isolation |
| 949 | should pay when the parent is actually hurt by what it read. It could not be |
| 950 | provoked here. Pinning a workspace `compact_ratio` down to 0.5% still produced |
| 951 | zero compactions, because the agent keeps its session small by writing a script |
| 952 | instead of reading — the same behaviour that wins it the comparisons. Context |
| 953 | pressure needs a task that cannot be scripted away, which this corpus does not |
| 954 | yet contain. |
| 955 | |
| 956 | The migration is the instructive one. Left alone the agent read a single file, |
| 957 | wrote a script and changed 108 call sites in 28 seconds; split across three |
| 958 | packages, no branch could see the transformation that solved all three. A task |
| 959 | looking parallel-shaped is not evidence that splitting it is cheaper. |
| 960 | |
| 961 | Not yet measured, and deliberately not faked: rework-after-handoff needs |
| 962 | mutation ordering across a whole run, which belongs to the harness driving the |
| 963 | arms rather than the instrument recording one. |
| 964 | |
| 965 | ## 4. Data Types (`internal/provider`) |
| 966 | |
| 967 | ```go |
| 968 | type Role string |
| 969 | const (RoleSystem Role = "system"; RoleUser Role = "user" |
| 970 | RoleAssistant Role = "assistant"; RoleTool Role = "tool") |
| 971 | |
| 972 | type Message struct { |
| 973 | Role Role `json:"role"` |
| 974 | Content string `json:"content,omitempty"` |
| 975 | ToolCalls []ToolCall `json:"tool_calls,omitempty"` |
| 976 | ToolCallID string `json:"tool_call_id,omitempty"` |
| 977 | Name string `json:"name,omitempty"` |
| 978 | } |
| 979 | |
| 980 | type ToolCall struct { ID, Name, Arguments string } // Arguments: raw JSON |
| 981 | type ToolSchema struct { Name, Description string; Parameters json.RawMessage } |
| 982 | type Request struct { Messages []Message; Tools []ToolSchema; Temperature float64; MaxTokens int } |
| 983 | |
| 984 | type ChunkType int |
| 985 | const (ChunkText ChunkType = iota; ChunkToolCall; ChunkDone; ChunkError) |
| 986 | |
| 987 | type Chunk struct { |
| 988 | Type ChunkType |
| 989 | Text string // ChunkText |
| 990 | ToolCall *ToolCall // ChunkToolCall |
| 991 | Err error // ChunkError |
| 992 | } |
| 993 | ``` |
| 994 | |
| 995 | ## 5. Configuration (TOML) |
| 996 | |
| 997 | Resolution order: **flag > project `./reasonix.toml` > the user config file |
| 998 | > built-in defaults**. Starting with **Reasonix v1.8.1**, the user config lives |
| 999 | at `~/.reasonix/config.toml` on macOS/Linux and |
| 1000 | `%AppData%\reasonix\config.toml` on Windows. See |
| 1001 | [Configuration paths](./CONFIG_PATHS.md) for migration and related data paths. |
| 1002 | Fields marked user/global only are not overridden by project `reasonix.toml`. |
| 1003 | Provider entries name secrets with `api_key_env`; saved key values live in |
| 1004 | Reasonix's global `<Reasonix home>/.env`, shared by CLI and desktop. Project |
| 1005 | `.env`, home `.env`, inherited shell environment variables, legacy credentials, |
| 1006 | and the OS keyring are not provider-key runtime fallbacks. Project `.env` still |
| 1007 | feeds workspace-scoped, non-provider `${VAR}` expansion for MCP/plugin settings |
| 1008 | without importing provider keys or Reasonix control variables. |
| 1009 | |
| 1010 | ```toml |
| 1011 | default_model = "deepseek" # provider name (→ its default model) or "provider/model" |
| 1012 | # language = "zh" # ui language tag; empty = auto-detect from $LANG / $REASONIX_LANG |
| 1013 | |
| 1014 | [ui] |
| 1015 | # shortcut_layout = "desktop" # classic|desktop; compatibility setting |
| 1016 | # cursor_shape = "bar" # CLI/TUI textarea cursor: underline|block|bar |
| 1017 | show_turn_usage = false # hide per-request token/cost receipts in the TUI; default true |
| 1018 | |
| 1019 | [agent] |
| 1020 | system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..." |
| 1021 | temperature = 0.0 |
| 1022 | reasoning_language = "auto" # visible reasoning text: auto|zh|en |
| 1023 | # plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash uses Permissions |
| 1024 | # planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner) |
| 1025 | # subagent_model = "deepseek-pro" # optional default for runAs=subagent skills |
| 1026 | # subagent_effort = "high" # optional default reasoning effort for subagents |
| 1027 | # subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } |
| 1028 | # subagent_efforts = { review = "max", security_review = "high" } |
| 1029 | |
| 1030 | # A vendor endpoint exposing several models under one base_url/key. |
| 1031 | [[providers]] |
| 1032 | name = "deepseek" |
| 1033 | kind = "anthropic" |
| 1034 | base_url = "https://api.deepseek.com/anthropic" |
| 1035 | # request_url = "https://proxy.example.com/anthropic/v1/messages" # optional exact provider request URL |
| 1036 | # models_url = "https://proxy.example.com/v1/models" # optional model discovery URL |
| 1037 | models = ["deepseek-flash", "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"] |
| 1038 | default = "deepseek-v4-flash" # optional; defaults to models[0] |
| 1039 | # vision_models = ["deepseek-v4-flash-vision-exp"] # legacy compatibility; Settings derives image support from model metadata |
| 1040 | # Official DeepSeek vision accepts inline base64, http(s) image URLs, and Files API file_id. |
| 1041 | api_key_env = "DEEPSEEK_API_KEY" |
| 1042 | web_search = true |
| 1043 | context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables) |
| 1044 | # max_output_tokens = 0 # auto: provider capability; official DeepSeek omits until the window is tight |
| 1045 | # max_output_tokens = 32768 # optional cost cap; still clipped to physical remaining |
| 1046 | # max_output_tokens = 65536 # optional cost cap |
| 1047 | # max_output_tokens = -1 # force-omit optional wire limits; compact if the auto budget no longer fits |
| 1048 | # max_output_tokens never changes compact_ratio |
| 1049 | # model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } } |
| 1050 | |
| 1051 | # A single-model entry still works for custom OpenAI-compatible endpoints. |
| 1052 | |
| 1053 | [environment] |
| 1054 | enabled = true # inject a stable startup summary of OS, shell, and common tool versions |
| 1055 | offline = false # set true when outbound network access is unavailable; prevents futile retries |
| 1056 | |
| 1057 | # Optional trusted executable paths shown to the model when PATH probing is not enough. |
| 1058 | # Workspace-local paths are listed but not auto-executed during startup probing. |
| 1059 | # [environment.tools] |
| 1060 | # go = "/opt/homebrew/bin/go" |
| 1061 | |
| 1062 | [tools] |
| 1063 | enabled = [] # omit/empty = all built-ins |
| 1064 | bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap |
| 1065 | mcp_startup_timeout_seconds = 30 # background initialize + tools/list safety cap |
| 1066 | mcp_call_timeout_seconds = 300 # default MCP call safety cap; plugin/tool overrides may raise it |
| 1067 | |
| 1068 | [tools.shell] |
| 1069 | prefer = "auto" # auto (default) | bash | powershell | pwsh — force the shell tool's interpreter |
| 1070 | # path = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" # explicit executable for the chosen shell |
| 1071 | |
| 1072 | [skills] |
| 1073 | # paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots |
| 1074 | # excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders |
| 1075 | # disabled_skills = ["review"] # hidden from prompt, slash invocation, and skill tools |
| 1076 | |
| 1077 | [permissions] |
| 1078 | mode = "ask" # writer fallback when no rule matches: ask|allow|deny |
| 1079 | deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode |
| 1080 | allow = ["Bash(go test:*)", "Bash(git status:*)"] # never prompted |
| 1081 | ask = [] # force a prompt even if otherwise allowed |
| 1082 | |
| 1083 | [sandbox] |
| 1084 | # workspace_root = "" # file-writers confined here; empty = cwd |
| 1085 | # allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit/move_file may modify |
| 1086 | # forbid_read = ["${HOME}/.ssh"] # paths read/list/search tools and sandboxed bash may not inspect |
| 1087 | |
| 1088 | [serve] |
| 1089 | auth_mode = "none" # none|token|password; use auth before binding beyond localhost |
| 1090 | # token = "" # optional fixed token; empty token mode generates one at startup |
| 1091 | # password_hash = "" # bcrypt hash generated with reasonix serve --hash-password --password '...' |
| 1092 | # behind_proxy = false # trust X-Forwarded-* only behind a trusted reverse proxy |
| 1093 | |
| 1094 | [[plugins]] |
| 1095 | name = "example" # type defaults to "stdio" |
| 1096 | command = "reasonix-plugin-example" |
| 1097 | args = [] |
| 1098 | # env = { FOO = "bar" } |
| 1099 | # startup_timeout_seconds = 60 # initialize + tools/list cap; 0 = global/default cap |
| 1100 | # call_timeout_seconds = 600 # per-server MCP call timeout; 0 = global/default cap |
| 1101 | # tool_timeout_seconds = { "generate_video" = 1800 } # raw MCP tool names |
| 1102 | # [[plugins]] # a remote MCP server over Streamable HTTP |
| 1103 | # name = "stripe" |
| 1104 | # type = "http" # "stdio" (default) | "http" | "sse" |
| 1105 | # url = "https://mcp.stripe.com" |
| 1106 | # headers = { Authorization = "Bearer ${STRIPE_KEY}" } # ${VAR} / ${VAR:-default} expanded |
| 1107 | ``` |
| 1108 | |
| 1109 | The native CLI updater always installs the latest strict `vX.Y.Z` official |
| 1110 | release. Legacy channel configuration and arguments remain parseable during |
| 1111 | 1.x, resolve to the official release, and are omitted on subsequent writes. |
| 1112 | |
| 1113 | The executor tracks an adaptive progress lease while a todo is active. A new |
| 1114 | completion, unique successful read, command, or mutation renews the lease; |
| 1115 | exact repeats do not. After 8 no-progress tool-call rounds the host appends a |
| 1116 | one-shot reassessment nudge. In Goal mode, the later threshold forces a re-plan |
| 1117 | and continues; outside Goal it may end the current attempt. The serial contract is level-aware while preserving the |
| 1118 | single-in_progress rule: in a two-level list the active level-1 sub-step is |
| 1119 | the only `in_progress` item and its level-0 phase stays `pending`; sub-steps |
| 1120 | complete in order, and the phase becomes `in_progress` — and signs off — only |
| 1121 | after all of its sub-steps have completed. A level-1 item with no phase above |
| 1122 | it is rejected. Retired `[agent].max_steps` and `planner_max_steps` keys remain |
| 1123 | parseable for upgrade compatibility, but are ignored and removed by a one-time |
| 1124 | migration. The CLI `--max-steps` flag and `[bot].max_steps` remain separate, |
| 1125 | explicit controls for one-off and unattended execution; bot `0` means continuous. |
| 1126 | |
| 1127 | `reasonix setup` writes this default config so the CLI is usable out of the box. |
| 1128 | |
| 1129 | `[ui].cursor_shape` is normalized to `underline`, `block`, or `bar`; empty or |
| 1130 | unknown values fall back to `bar`. It applies to the Bubble Tea CLI/TUI |
| 1131 | textarea only, while desktop and browser inputs keep their platform-native |
| 1132 | cursor behavior. |
| 1133 | |
| 1134 | `[serve]` controls the HTTP browser frontend used by `reasonix serve`. The |
| 1135 | default `auth_mode = "none"` is intended for the loopback default |
| 1136 | `127.0.0.1:8787`; deployments reachable from another machine must use `token` or |
| 1137 | `password`. Password mode requires either a startup `--password` or a stored |
| 1138 | bcrypt `password_hash`. `behind_proxy` must stay false unless the server is |
| 1139 | behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto` |
| 1140 | headers. |
| 1141 | |
| 1142 | MCP servers may also be declared in a project-root `.mcp.json` using Claude |
| 1143 | Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`, |
| 1144 | `${VAR}` expansion). It is read after the TOML files and merged into |
| 1145 | `[[plugins]]`; on a name collision `reasonix.toml` wins (it is the more explicit, |
| 1146 | Reasonix-specific source). This lets a server already configured for Claude work in |
| 1147 | Reasonix unchanged. |
| 1148 | |
| 1149 | MCP startup has a separate lifecycle from an individual tool call. A caller |
| 1150 | waits briefly for cold startup, while the shared launch/authorization/ |
| 1151 | `initialize`/`tools/list` sequence may continue in the background up to |
| 1152 | `mcp_startup_timeout_seconds` (default `30`). A per-server |
| 1153 | `startup_timeout_seconds` overrides that cap. MCP call timeouts begin only after |
| 1154 | the connection is ready. |
| 1155 | |
| 1156 | ```json |
| 1157 | { "mcpServers": { |
| 1158 | "stripe": { "type": "http", "url": "https://mcp.stripe.com", |
| 1159 | "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } } |
| 1160 | } } |
| 1161 | ``` |
| 1162 | |
| 1163 | `[sandbox]` is the *enforcement* layer beneath permissions (which are *policy*). |
| 1164 | They stay two layers: a permitted call still cannot write outside the approved |
| 1165 | roots. Interactive sessions can extend those roots with a write-access approval |
| 1166 | (once / session / project `reasonix.toml` / deny). File tools request the target |
| 1167 | parent directory automatically. Bash must declare `additional_write_dirs` and a |
| 1168 | `justification`; the host does not infer paths from the command text. Headless |
| 1169 | `reasonix run` fails closed unless the directory is already in |
| 1170 | `[sandbox].allow_write` or `--add-dir`. Granting `${HOME}` is allowed with a |
| 1171 | high-risk warning; the filesystem root and Reasonix session/state paths are not. |
| 1172 | Phase 0 confines the file-writing built-ins (`write_file`, `edit_file`, |
| 1173 | `multi_edit`, `move_file`) to `workspace_root` (default cwd), the Reasonix user |
| 1174 | config dir, plus `allow_write`: a write whose target — resolved to an absolute, |
| 1175 | symlink-free path so a symlinked dir or `..` cannot tunnel out — falls outside |
| 1176 | every root is refused, and the error is fed back to the model. Confinement is on |
| 1177 | by default (root = cwd), so edits stay in the project while the agent can still |
| 1178 | update its own global config. `forbid_read` lists files or directories the agent should |
| 1179 | not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansion |
| 1180 | and should be absolute, or use `${HOME}` for home-relative secrets such as |
| 1181 | `${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is |
| 1182 | available (`[sandbox] bash = "enforce"`: Seatbelt on macOS and bubblewrap on |
| 1183 | Linux): each command is allowed to write only |
| 1184 | the same roots plus platform-specific command temp/cache roots, denied reads |
| 1185 | under `forbid_read`, and allowed to reach the network only when |
| 1186 | `network = true`. |
| 1187 | **Windows status:** Reasonix does not ship an OS-level Bash sandbox on Windows. |
| 1188 | The effective mode is fixed to `off`; an older config containing |
| 1189 | `bash = "enforce"` remains readable but resolves to `off`, `reasonix doctor` |
| 1190 | reports the ignored value, and the desktop control is read-only. Bash therefore |
| 1191 | runs unconfined on Windows. The in-process file tools continue to enforce |
| 1192 | `workspace_root`, `allow_write`, and `forbid_read`. |
| 1193 | When no OS sandbox is available, `bash = "enforce"` refuses bash execution |
| 1194 | instead of running unconfined. Install the platform sandbox backend |
| 1195 | (bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set |
| 1196 | `[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell |
| 1197 | behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9). |
| 1198 | |
| 1199 | ## 6. Error Handling |
| 1200 | |
| 1201 | - Library code wraps with `fmt.Errorf("...: %w", err)` and returns; it never |
| 1202 | prints or calls `os.Exit`. |
| 1203 | - Only `cli` / `main` decide exit codes and user-facing messages. |
| 1204 | - Tool execution errors are fed back to the model, not fatal. |
| 1205 | - Network layer should apply bounded exponential backoff on 429 / 5xx |
| 1206 | (interface reserved; implementation may follow). |
| 1207 | |
| 1208 | ## 7. Code Style |
| 1209 | |
| 1210 | - `gofmt` + `go vet` must be clean; package names lowercase; exported |
| 1211 | identifiers documented; comments explain *why*, not *what*. |
| 1212 | - No premature generalization. Prefer clear and direct. |
| 1213 | |
| 1214 | ## 8. Distribution |
| 1215 | |
| 1216 | - Build: `CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$(VERSION)" -o reasonix ./cmd/reasonix` |
| 1217 | - Cross matrix: `darwin|linux|windows` × `amd64|arm64`. |
| 1218 | - Version injected via ldflags (`git describe --tags --always`). |
| 1219 | - Install: prebuilt binary / `go install` / future `brew tap`. |
| 1220 | |
| 1221 | ## 9. Roadmap (not in current scope) |
| 1222 | |
| 1223 | - Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the |
| 1224 | file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on |
| 1225 | macOS and bubblewrap on Linux ship, on by default when available** (see §5). |
| 1226 | Restricted presets fail closed when the platform sandbox cannot be established; |
| 1227 | Reasonix never offers an unconfined retry as a fallback. |
| 1228 | - MCP long tail (deferred deliberately): `headersHelper` auth for remote |
| 1229 | servers; the remaining `.mcp.json` scopes |
| 1230 | (local / user — project scope shipped, see §5); tool-search deferral; |
| 1231 | `list_changed` live updates; channels / elicitation / roots; plugins that |
| 1232 | provide *providers*, not just tools. |
| 1233 | - An Anthropic-native provider `kind` (native prompt-cache control), proving the |
| 1234 | registry generalises beyond one wire format. |
| 1235 |