| 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 | ## 1. Design Principles |
| 8 | |
| 9 | 1. **Config- and plugin-driven core.** The core knows only interfaces. Concrete |
| 10 | models and tools are resolved by name from registries, declared in config, or |
| 11 | injected by plugins. No hardcoded `switch model`. |
| 12 | 2. **Single static binary.** `CGO_ENABLED=0`; cross-compile with one command; |
| 13 | CLI works out of the box. |
| 14 | 3. **Lean dependencies.** Standard library by default. A third-party dependency |
| 15 | must be pure-Go, lightweight, and must not compromise the single-binary / |
| 16 | cross-platform / distribution story. TOML parsing is the one accepted dependency. |
| 17 | 4. **Two extension tiers.** Compile-time built-ins (self-register via `init()`), |
| 18 | and runtime external plugins (stdio JSON-RPC subprocesses, MCP-compatible). |
| 19 | 5. **Interface-first & registry-based.** `Provider` and `Tool` are interfaces. |
| 20 | 6. **Evolve, don't over-engineer.** |
| 21 | |
| 22 | Language: **English is the primary language for all code** — comments, |
| 23 | user-facing strings, tool descriptions, system prompts, and this spec. The |
| 24 | README is bilingual (`README.md` English + `README.zh-CN.md`). |
| 25 | |
| 26 | ## 2. Layout |
| 27 | |
| 28 | ``` |
| 29 | reasonix/ |
| 30 | ├── go.mod / go.sum # module reasonix; require BurntSushi/toml |
| 31 | ├── Makefile # build / cross / vet / fmt / test |
| 32 | ├── README.md / README.zh-CN.md |
| 33 | ├── reasonix.example.toml # sample config |
| 34 | ├── docs/SPEC.md # this file |
| 35 | ├── cmd/reasonix/main.go # entry; blank-imports built-in providers/tools |
| 36 | ├── cmd/reasonix-plugin-example/ # reference MCP stdio plugin (a runnable example) |
| 37 | └── internal/ |
| 38 | ├── cli/ # subcommand routing, flags, assembly, exit codes |
| 39 | ├── config/ # TOML loading (flag > project > user > defaults) |
| 40 | ├── provider/ # Provider interface + types + kind→factory registry |
| 41 | │ └── openai/ # OpenAI-compatible impl; init() registers "openai" |
| 42 | ├── tool/ # Tool interface + Registry |
| 43 | │ └── builtin/ # read_file/write_file/edit_file/move_file/bash/ls/glob/grep |
| 44 | ├── permission/ # per-call Policy: allow/ask/deny rules → Decision |
| 45 | ├── command/ # custom slash commands loaded from .reasonix/commands/*.md |
| 46 | ├── plugin/ # stdio JSON-RPC (MCP) client; adapts remote tools |
| 47 | ├── remote/ # SSH transport for the Remote-SSH module |
| 48 | │ ├── forward/ # -L / -R port-forward lifecycle |
| 49 | │ ├── sftpfs/ # SFTP file layer (quarantines pkg/sftp) |
| 50 | │ └── bootstrap/ # detached `reasonix serve` bootstrap over SSH |
| 51 | └── agent/ # Session + harness loop |
| 52 | ``` |
| 53 | |
| 54 | Dependency direction (acyclic): `cli → {agent, plugin, config} → {tool, provider}`. |
| 55 | Built-in subpackages (`provider/openai`, `tool/builtin`) import their parent to |
| 56 | self-register; parents never import children. The Remote-SSH module layers |
| 57 | `cli → remote/bootstrap → remote → {remote/forward, remote/sftpfs, config, |
| 58 | netclient}`; `remote` and its subpackages never import `cli`, `agent`, or |
| 59 | `serve`, and all interactivity flows through callbacks (host-key / secret |
| 60 | prompts) so the desktop module consumes the same surface. See §Remote below. |
| 61 | |
| 62 | ## 3. Core Abstractions |
| 63 | |
| 64 | ### 3.1 Provider + registry (`internal/provider`) |
| 65 | |
| 66 | ```go |
| 67 | type Provider interface { |
| 68 | Name() string |
| 69 | Stream(ctx context.Context, req Request) (<-chan Chunk, error) |
| 70 | } |
| 71 | |
| 72 | // Factory builds a Provider from a resolved config instance. |
| 73 | type Factory func(cfg Config) (Provider, error) |
| 74 | |
| 75 | // Register adds a factory under a kind (e.g. "openai"). Called from init(). |
| 76 | func Register(kind string, f Factory) |
| 77 | |
| 78 | // New instantiates the provider of the given kind. |
| 79 | func New(kind string, cfg Config) (Provider, error) |
| 80 | |
| 81 | type Config struct { |
| 82 | Name string // instance name, e.g. "deepseek" |
| 83 | BaseURL string |
| 84 | Model string |
| 85 | APIKey string |
| 86 | Extra map[string]any // kind-specific options |
| 87 | } |
| 88 | ``` |
| 89 | |
| 90 | - The `openai` kind is an OpenAI-compatible `/chat/completions` implementation. |
| 91 | - **OpenAI-compatible vendors are config instances** of `kind = "openai"`, |
| 92 | differing only in `base_url` / `model` / `api_key_env`. Adding another OpenAI- |
| 93 | compatible model is a config edit, not a code change. |
| 94 | - **A provider is a vendor endpoint** (one `base_url` + `api_key_env`) that offers |
| 95 | one or more models. OpenAI-compatible chat normally posts to |
| 96 | `base_url + "/chat/completions"`; set `chat_url` only for gateways that require a |
| 97 | full request URL. An entry declares either a single `model = "..."` or a |
| 98 | `models = ["...", "..."]` list (with an optional `default`); the list form lets |
| 99 | one vendor expose several models without re-declaring the endpoint/key. A |
| 100 | **model reference** (`default_model`, the `--model` flag, the desktop switcher) |
| 101 | resolves via `Config.ResolveModel`, which accepts a provider name (→ its default |
| 102 | model), a bare model name, or an explicit `provider/model`. `context_window` is |
| 103 | the provider-wide fallback; `model_overrides.<model>.context_window` can replace |
| 104 | it for one model. Per-model `prices` use model IDs as keys. |
| 105 | - Streaming tool-call deltas are accumulated by index inside the provider; only |
| 106 | complete `ToolCall`s are emitted. |
| 107 | |
| 108 | ### 3.2 Tool + registry (`internal/tool`) |
| 109 | |
| 110 | ```go |
| 111 | type Tool interface { |
| 112 | Name() string |
| 113 | Description() string |
| 114 | Schema() json.RawMessage // JSON Schema for parameters |
| 115 | Execute(ctx context.Context, args json.RawMessage) (string, error) |
| 116 | } |
| 117 | ``` |
| 118 | |
| 119 | - Built-in tools self-register into a process-global builtin set via `init()` |
| 120 | (`tool.RegisterBuiltin(t)`); `tool.Builtins()` lists them. |
| 121 | - A runtime `*Registry` is assembled per run: enabled built-ins (filtered by |
| 122 | config) **plus** plugin-provided tools. The agent only sees the `*Registry`. |
| 123 | - Tool schemas are canonicalized on registry insertion. The built-in contract is |
| 124 | documented in [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md) and backed by tests that |
| 125 | compare the documented surface against the same canonical schema path. |
| 126 | - `Execute` parses raw JSON args itself. Errors are returned, not fatal — the |
| 127 | agent feeds them back so the model can self-correct. |
| 128 | |
| 129 | ### 3.3 Plugins (`internal/plugin`) — MCP client |
| 130 | |
| 131 | An external plugin is an MCP server declared in config. The wire protocol is |
| 132 | **JSON-RPC 2.0** in every case; only the transport differs. A `transport` |
| 133 | interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic |
| 134 | (handshake, `tools/list`, `tools/call`, …) is written once. |
| 135 | |
| 136 | - **Transports** (config `type`): |
| 137 | - `stdio` (default) — a local subprocess; one JSON message per line over the |
| 138 | child's stdin/stdout (the MCP stdio convention). Declared with |
| 139 | `command` / `args` / `env`; terminated on ctx cancel / shutdown. |
| 140 | - `http` (a.k.a. `streamable-http`) — a remote server at `url`. Each request |
| 141 | is an HTTP POST; the server replies with either `application/json` (one |
| 142 | response) or `text/event-stream` (an SSE stream carrying the response plus |
| 143 | any server notifications). The `Mcp-Session-Id` response header, once seen, |
| 144 | is echoed on subsequent requests. Static `headers` (e.g. a bearer token) are |
| 145 | sent on every request. OAuth is out of scope for now (see §9). |
| 146 | - `sse` — the legacy 2024-11-05 HTTP+SSE transport. A persistent GET stream |
| 147 | receives an announced relative POST endpoint, JSON-RPC responses, and server |
| 148 | messages. Cross-origin announced endpoints are rejected so static headers |
| 149 | cannot leak. |
| 150 | - `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`, |
| 151 | and `headers` so secrets come from the environment, not the config file. |
| 152 | - Lifecycle: `initialize` → `notifications/initialized` → `tools/list`; |
| 153 | invocation via `tools/call {name, arguments}`. |
| 154 | - When a workspace root exists, initialize advertises `roots` and transports |
| 155 | answer `roots/list` with its file URI. `tools/call` includes a per-call |
| 156 | `_meta.progressToken`; matching `notifications/progress` messages stream into |
| 157 | the existing tool-progress event path. |
| 158 | - A stdio server uses one persistent transport for initialize, reads, and |
| 159 | writes, preserving state such as browser sessions across tool calls. The |
| 160 | process uses the server's process sandbox because process confinement cannot |
| 161 | change per RPC; read-only eligibility and destructive filtering remain local |
| 162 | workflow gates rather than separate process sandboxes. |
| 163 | - Configuration provenance is runtime metadata and determines persistence scope. |
| 164 | Desktop and CLI installs write the user-global `config.toml`; project |
| 165 | `reasonix.toml` and `.mcp.json` entries remain in their owning project file. |
| 166 | Every configured source is trusted without a separate launch-confirmation |
| 167 | step. Project entries override same-name global entries, and project |
| 168 | `reasonix.toml` overrides `.mcp.json`. Editing writes to the effective entry's |
| 169 | source; removing it reveals the next lower-priority declaration. |
| 170 | - Each remote tool is adapted to the `Tool` interface and injected into the run |
| 171 | registry, namespaced `mcp__<server>__<tool>` (spaces normalised to `_`) to |
| 172 | match Claude Code and avoid clashes. |
| 173 | - A tool's MCP `annotations.readOnlyHint` maps to `Tool.ReadOnly()`. It defaults |
| 174 | to false (a remote tool is opaque — we can't see its side effects), so a |
| 175 | plugin opts a tool into parallel-batch dispatch and the permission layer's |
| 176 | reader-default by declaring `readOnlyHint: true` in `tools/list`. |
| 177 | - Installation is the trust decision for tool metadata. Reasonix assumes an |
| 178 | installed server reports `readOnlyHint` and `destructiveHint` honestly; |
| 179 | planner/read-only filtering is a workflow boundary for trusted servers, not |
| 180 | containment against a malicious MCP server. Explicit deny rules and the |
| 181 | process sandbox remain host-controlled boundaries. |
| 182 | - `prompts/list` + `prompts/get` surface as `/mcp__<server>__<prompt>` slash |
| 183 | commands; `resources/list` + `resources/read` are referenced as |
| 184 | `@<server>:<uri>` in chat. `/mcp` shows connected servers and their counts. |
| 185 | - `cmd/reasonix-plugin-example` is a runnable reference stdio server (`echo`, |
| 186 | `wordcount`), driven by an end-to-end test that builds the real binary. |
| 187 | |
| 188 | ### 3.4 Agent (`internal/agent`) |
| 189 | |
| 190 | - `Session` holds `[]Message`. |
| 191 | - `Run(ctx, input)` loop: build `Request` (with tool schemas) → `provider.Stream` |
| 192 | → print text deltas live, collect complete tool calls → if none, done; else |
| 193 | execute each tool (built-in or plugin) and append results → repeat, bounded by |
| 194 | `maxSteps`. `ctx` threads throughout (Ctrl-C aborts in-flight requests). |
| 195 | - A `Runner` is anything with `Run(ctx, input) error`; both `Agent` and |
| 196 | `Coordinator` satisfy it, so the CLI is agnostic to single- vs two-model mode. |
| 197 | |
| 198 | ### 3.5 Two-model collaboration (`Coordinator`) |
| 199 | |
| 200 | When `agent.planner_model` names a provider different from the executor, a |
| 201 | `Coordinator` runs two models in **separate sessions** to keep each one's prompt |
| 202 | prefix cache-stable: |
| 203 | |
| 204 | - The **planner** (low-frequency) runs in its own session with the same standing |
| 205 | memory context plus a filtered read-only research tool set, then produces a |
| 206 | concise plan. A deterministic host policy chooses executor-only, light |
| 207 | planning, full planning, plan-for-approval, or explicit plan-only from |
| 208 | pristine user text plus trusted turn metadata. It does not call a classifier |
| 209 | model and does not infer host state from controller-authored prompt blocks. |
| 210 | Explicit Plan Mode, synthetic turns, short contextual replies, atomic edits, |
| 211 | and bounded read-only actions avoid a second planner; cross-surface, |
| 212 | structured, ambiguous, and high-risk work uses the full contract. Active Goal |
| 213 | and Delivery turns upgrade non-atomic mutation work, while bounded read-only |
| 214 | actions remain executor-only. The privacy-safe |
| 215 | route/depth/reason decision is emitted in phase detail. |
| 216 | - Light plans use a small per-turn research-round budget and return a compact |
| 217 | objective, 1-4 ordered steps, likely touchpoints, and primary verification. |
| 218 | Full plans use a larger bounded budget and distinguish verified from candidate |
| 219 | touchpoints, with risks, acceptance criteria, command-level verification, and |
| 220 | rollback when relevant. The depth contract stays in one stable system prompt; |
| 221 | only a small host-authored `<planner-turn>` block changes per user turn. If |
| 222 | the planner still does not finalize after the bounded research and grace |
| 223 | round, plan-and-execute falls back to the executor with the pristine task; |
| 224 | plan-only and plan-for-approval remain fail-closed. The incomplete planner |
| 225 | turn is rolled back rather than exposed as a broken manual continuation. |
| 226 | - A bare plan-first route hands the completed plan directly to the executor. |
| 227 | Plan-for-approval is reserved for an explicit request to wait for |
| 228 | confirmation; the host enforces that boundary even if the planner omits its |
| 229 | marker, then hands the approved plan to the executor. A headless host persists |
| 230 | the plan so a later turn can continue. Explicit plan-only requests persist the |
| 231 | plan and end the current turn without execution. A planner failure on either |
| 232 | execution boundary cannot fall back to the executor. These directives may |
| 233 | appear after the task clause; quoted examples do not change the route. |
| 234 | - The plan is handed off as structured text to the **executor** — a full |
| 235 | tool-using `Agent` in its own session — which validates candidate assumptions |
| 236 | and carries it out. |
| 237 | - The sessions never mix, so neither model's prefix is disturbed by the other's |
| 238 | turns; both grow prepend-only and stay cache-friendly. This reconciles |
| 239 | "cache-first" with "two-model collaboration": switching models *inside one |
| 240 | shared conversation* would break the prefix and tank cache hits, so we don't. |
| 241 | |
| 242 | ### 3.6 Context management (compaction) |
| 243 | |
| 244 | Long tasks eventually fill the model's context window. Reasonix manages this with |
| 245 | **low-frequency compaction** that respects the cache-first design: |
| 246 | |
| 247 | - Each provider declares its `context_window` (tokens). Context maintenance is |
| 248 | tiered: below `agent.tool_result_snip_ratio` (default `0.6`) the session is |
| 249 | left untouched apart from the soft notice; at the snip ratio, stale tool |
| 250 | results before the recent tail are archived and shortened with deterministic |
| 251 | head/tail markers; at `agent.compact_ratio` (default `0.8`) stale tool results |
| 252 | are archived and pruned to short placeholders before any summary call; only if |
| 253 | pruning still leaves the prompt above the threshold does summary compaction |
| 254 | run. At `agent.compact_force_ratio` (default `0.9`), the existing forced fold |
| 255 | may proceed even when the fold economics would normally skip it. |
| 256 | - Users can inspect or change the 65–85% automatic threshold with |
| 257 | `reasonix config compact-ratio [--local] [VALUE]`. The default is 80%; the |
| 258 | project-local value overrides the shared user config used by desktop and new |
| 259 | CLI sessions. |
| 260 | - A positive `model_overrides.<model>.context_window` replaces the provider-wide |
| 261 | value after model resolution. Missing or zero model overrides inherit the |
| 262 | provider value; provider-level `context_window = 0` disables compaction. |
| 263 | - `max_output_tokens` is a separate total-output budget, not a conversion from |
| 264 | the client reasoning byte guard. Zero selects the provider's safe default, |
| 265 | positive values set an explicit cap, and negative values omit optional wire |
| 266 | limits. `model_overrides.<model>.max_output_tokens` can specialize mixed |
| 267 | gateways; Anthropic still supplies a mandatory `max_tokens` fallback. |
| 268 | - Tool-result snip/prune never removes messages, so assistant `tool_calls` and |
| 269 | tool results stay paired. `KeepErrors` preserves error/blocked tool outputs, |
| 270 | and the recent tail is not rewritten. Snipped results can later be upgraded to |
| 271 | pruned placeholders; already-pruned results are left alone. |
| 272 | - When summary compaction runs, it folds only the assistant/tool work. Every |
| 273 | **user turn** small enough to be a brief and every **prior digest** is kept |
| 274 | verbatim; the foldable remainder is summarized — using the executor's own |
| 275 | provider, no tools — in place. The boundary is aligned backward off any tool |
| 276 | result so the recent tail never begins with an orphan tool message whose |
| 277 | `tool_calls` were summarized away. |
| 278 | - The dropped originals are archived under the user config dir |
| 279 | (`reasonix/archive/<timestamp>.jsonl`; see §5 for its per-OS location), one |
| 280 | message per line, so the full history stays traceable. |
| 281 | - The read-only `history` tool gives the agent on-demand BM25 retrieval over |
| 282 | saved session JSONL files. `scope="project"` searches the current controller's |
| 283 | session directory; `scope="global"` also searches the user-global session |
| 284 | directory and compacted-history archives. `operation="around"` can then read a |
| 285 | bounded transcript window around a returned hit. Search keeps the best hit and |
| 286 | trims trailing common-word-only noise with a relative score floor; a 0-result |
| 287 | response tells the agent how to retry with rarer terms or widen scope. |
| 288 | - The read-only `memory` tool gives the agent on-demand search/list/read access |
| 289 | to saved auto-memory files. It complements the writer tools: `memory` checks |
| 290 | what already exists, `remember` saves or updates a fact, and `forget` removes |
| 291 | a stale one from the active index while archiving the file for traceability. |
| 292 | Archived memory files are visible in local management surfaces (`/memory`, |
| 293 | TUI, desktop panel) but are excluded from active-memory retrieval. Memory |
| 294 | search uses the same relative BM25 floor and guides the agent to fall back to |
| 295 | history when exact original wording or tool output matters. |
| 296 | - Before each real user turn, bounded BM25 recall selects relevant active facts |
| 297 | from the raw user message and appends them as a low-authority user-turn suffix. |
| 298 | Generic turns are suppressed, project facts override equivalent global |
| 299 | fallbacks, stale facts are down-ranked, and recall is bounded by result/character |
| 300 | budgets. This never mutates the stable system prompt or tool schemas. |
| 301 | - The owning controller may auto-allow only a bounded, non-sensitive, |
| 302 | create-only project/reference `remember`, including in a top-level headless |
| 303 | run. Global facts, preferences, feedback, |
| 304 | updates, duplicates, sensitive/oversized content, and every `forget` require a |
| 305 | fresh human approval even under Auto or YOLO. Guardian/safety review cannot |
| 306 | answer these prompts on the user's behalf. Sub-agents and headless surfaces |
| 307 | without the owning scoped controller fail closed. The approval request includes a compact preview, while |
| 308 | external notification hooks only receive the tool name. |
| 309 | - Facts carry immutable IDs, monotonic revisions, timestamps, type, and scope. |
| 310 | Updates snapshot the previous revision; restore and archive recovery create a |
| 311 | higher revision and reject path escapes, symlinks, collisions, and overwrites. |
| 312 | User-initiated memory edits in the local UI are already explicit user actions. |
| 313 | See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the |
| 314 | detailed implementation contract. |
| 315 | |
| 316 | **What survives a fold.** A fact the user states in a normal-sized turn is kept |
| 317 | verbatim and is never summarized away — at any point in the session, across any |
| 318 | number of compactions. A digest, once written, is likewise kept verbatim rather |
| 319 | than re-summarized, so facts it captured are not lost to drift. The one |
| 320 | **best-effort** boundary: a fact buried inside a single oversized message (a |
| 321 | large paste, over the per-turn pin budget) folds with the rest, so its survival |
| 322 | depends on the summarizer catching it while compressing bulk. There is no |
| 323 | reliable way to auto-detect an arbitrary fact in bulk, so durable facts belong in |
| 324 | their own turn rather than buried in a large paste; the raw oversized content is |
| 325 | still archived and recoverable either way. |
| 326 | |
| 327 | This is the **only** point where the prompt prefix changes — a deliberate, rare |
| 328 | "cache-reset point". Between compactions the session grows prepend-only and |
| 329 | stays cache-friendly, so cache hit rate (the key observability signal) stays |
| 330 | high. `context_window = 0` disables compaction for an instance. |
| 331 | |
| 332 | ### 3.7 Permissions (`internal/permission`) — per-call gating |
| 333 | |
| 334 | A coding agent runs shell commands and edits files autonomously. The permission |
| 335 | layer decides, **per tool call**, whether to allow it, deny it, or ask the user |
| 336 | first. It is independent of the model and of the CLI — the agent consults a |
| 337 | `Gate` interface at execute time; the gate is built from a static `Policy` plus |
| 338 | an optional interactive `Approver`. |
| 339 | |
| 340 | ```go |
| 341 | type Decision int // permission package |
| 342 | const (Allow Decision = iota; Ask; Deny) |
| 343 | |
| 344 | // Policy evaluates static rules against a tool call. Pure, no I/O. |
| 345 | type Policy struct { Mode Decision; Allow, Ask, Deny []Rule } |
| 346 | func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Decision |
| 347 | ``` |
| 348 | |
| 349 | - **Rule syntax.** A rule is `Tool` (matches any call in that tool family) or |
| 350 | `Tool(specifier)` (matches when the call's *subject* matches the specifier). |
| 351 | Bash and file mutation approvals use Claude Code-style families such as |
| 352 | `Bash(npm run build)`, `Bash(npm run test:*)`, and `Edit(docs/**)`. Built-in |
| 353 | file mutations include writes, edits, notebook edits, symbol/range deletes, |
| 354 | and `move_file` renames/moves. Legacy lowercase tool IDs still load for |
| 355 | compatibility. `Bash=<literal>` is the exact-command form: metacharacters in |
| 356 | the literal are ordinary characters and only the identical complete command |
| 357 | matches. The |
| 358 | `:*` suffix marks a Bash command-prefix approval; generated prefix rules also |
| 359 | reject later commands that introduce shell operators, so `Bash(go test:*)` |
| 360 | does not cover `go test ./... && rm -rf tmp`. |
| 361 | Legacy `Bash(go test *)` prefix rules still load, but new rules are saved as |
| 362 | `Bash(go test:*)`. The subject is extracted generically from the call's JSON |
| 363 | args by a small set of |
| 364 | known keys — `command` (bash), `path` / `file_path` (file tools), `pattern` |
| 365 | (grep/glob) — so tools need not change. A rule whose subject the args don't |
| 366 | expose only matches in its bare `Tool` form. |
| 367 | - **Dynamic Bash.** Parameter/arithmetic expansions, assignments, heredocs, unproved |
| 368 | redirects, and shell globs cannot reuse bare Bash, prefix, or glob allows; |
| 369 | remembered approvals are exact `Bash=<literal>` rules. They still follow the |
| 370 | normal posture fallback, so Auto and an approved-plan window may execute them |
| 371 | without prompting. Nested or indirect execution is stricter: command and |
| 372 | process substitution, a dynamic command name, parse failures, `eval`, |
| 373 | `source`, shell `-c`, PowerShell/cmd command strings, and runtime inline-code |
| 374 | flags require a human in interactive Ask/Auto. Guardian, allowing hooks, and |
| 375 | the approved-plan window cannot answer that decision; only an identical exact |
| 376 | grant or YOLO can bypass it by default. The advanced |
| 377 | `[permissions] allow_dynamic_bash = true` opt-in lets an Allow fallback, |
| 378 | including Auto, cover this class; explicit `ask` and `deny` rules retain |
| 379 | precedence. |
| 380 | - **Precedence.** `deny` > `ask` > `allow` > fallback. Fallback is `Allow` for |
| 381 | read-only tools and `Mode` (default `Ask`) for writers. `deny` always wins, so |
| 382 | a broad `allow = ["Bash"]` can still be carved by `deny = ["Bash(rm -rf*)"]`; |
| 383 | conversely `ask` overrides a broad `allow` to force a prompt on a risky subset. |
| 384 | - **Resolving `Ask`.** The interactive front-end (the chat TUI) prompts the user |
| 385 | — allow once / allow this approval scope for the session / always allow this |
| 386 | approval scope / deny — via an `Approver`. For Bash, the default scope is the |
| 387 | concrete command subject, and the user may choose a conservative command-prefix |
| 388 | scope when available (for example `Bash(go test:*)`) so similar invocations in |
| 389 | the same session or saved config do not prompt again. For file-mutation tools, |
| 390 | a session grant covers editing for the rest of the session while a persisted |
| 391 | grant is path-scoped when a path is available, stored as `Edit(<path>)` so all |
| 392 | built-in file-mutating tools share it. A |
| 393 | non-interactive run |
| 394 | (`reasonix run`, a sub-agent, anything with no TTY / no approver) cannot prompt. |
| 395 | Its explicit posture therefore resolves without blocking: Ask/manual fails |
| 396 | closed, Auto allows only ordinary writer fallback, and YOLO may bypass ordinary |
| 397 | Ask decisions. Nested or indirect Bash remains stricter: headless |
| 398 | Ask/Auto/DontAsk reject it unless an identical literal grant exists; YOLO or |
| 399 | `allow_dynamic_bash = true` with an Allow fallback may opt out. A `Deny` is a |
| 400 | hard block in *every* mode: the tool never executes and the model receives a |
| 401 | "blocked" result it can adapt to (the same shape as a plan-mode refusal). |
| 402 | - **MCP authorization.** Installing an MCP server authorizes all of its tools; |
| 403 | there is no second server, raw-tool, writer, or destructive approval policy. |
| 404 | Project configuration is trusted the same way and requires no separate launch |
| 405 | confirmation. Explicit global deny rules still win. `readOnlyHint` and |
| 406 | `destructiveHint` remain internal |
| 407 | facts for scheduling, Plan/read-only restrictions, and cached-to-live safety |
| 408 | reclassification. Strict read-only sub-agent registries expose only |
| 409 | authorized tools with `readOnlyHint: true` and no `destructiveHint`. The |
| 410 | two-model Planner uses the fixed `use_capability` proxy (never direct |
| 411 | `mcp__*` schemas) for authorized, non-destructive MCP without requiring |
| 412 | `readOnlyHint`; destructive tools are left for the Executor. In Balanced |
| 413 | two-model sessions the Executor owns an isolated frontend for the same proxy, |
| 414 | so Planner-discovered capability IDs remain executable after handoff. Schema-only |
| 415 | changes refresh the next-session cache without adding an execution approval |
| 416 | or retry. Immediately before dispatch, the proxy re-checks the current |
| 417 | controller's enablement, authorization, and complete runtime connection |
| 418 | identity; a same-name client on a shared Host is never sufficient authority. |
| 419 | - **Relationship to plan mode.** Plan mode (§3.4) is a plan-first collaboration |
| 420 | workflow, not an all-tools read-only mode. Before Permissions/Sandbox, the |
| 421 | host enforces explicit phase opt-outs (`complete_step` is read-only but |
| 422 | belongs to the post-approval execution phase, so it self-reports plan-unsafe |
| 423 | and is refused). The dedicated two-model Planner may call authorized, |
| 424 | non-destructive MCP even when `readOnlyHint` is absent; it hard-blocks |
| 425 | destructive targets and readers from unauthorized servers for the entire |
| 426 | planning phase. A single-model Plan without the dedicated Planner continues |
| 427 | to block MCP writer/destructive targets while Plan is active. |
| 428 | Ordinary built-in and Bash calls then use the same Ask/Auto/YOLO, explicit |
| 429 | `ask`/`deny`, and Sandbox path as Standard mode. A third-party MCP |
| 430 | `readOnlyHint` affects dispatch classification and strict-child eligibility, |
| 431 | but not the dedicated Planner's non-destructive trust path. Once the server is |
| 432 | installed or declared in project configuration, all non-destructive |
| 433 | capabilities enter the dedicated Planner proxy; only hinted readers enter |
| 434 | strict read-only sub-agent execution. `plan_mode_read_only_commands` is |
| 435 | retained for config/session round trips and does not grant or revoke calls in |
| 436 | the main Plan workflow. `read_only_task` and `read_only_skill` remain strict |
| 437 | read-only capabilities with their own tool registry and safe foreground Bash; |
| 438 | writer-capable `task` and skill execution remain permission-gated instead of |
| 439 | Plan-blocked, and their child turns inherit the Plan workflow marker and |
| 440 | explicit phase opt-outs. |
| 441 | - **User decisions are separate from tool approvals.** Runtime tool approval has |
| 442 | three user-facing postures: `ask` ("需要批准"), `auto` ("自动批准"), and |
| 443 | `yolo` ("Yolo批准"). `auto` lets the permission policy auto-approve the writer |
| 444 | fallback while preserving explicit ask/deny rules; `yolo` skips ordinary tool |
| 445 | permission prompts for approval-gated tools such as writers and Bash. Explicit |
| 446 | deny rules and forced fresh reviews still apply. Nested or indirect Bash |
| 447 | commands require a human in interactive Ask/Auto even during the approved-plan |
| 448 | window; ordinary expansions, assignments, redirects, and globs continue under |
| 449 | Auto fallback but cannot inherit reusable Bash rules. YOLO is the sole mode |
| 450 | bypass for the human-required class, while an identical exact literal remains |
| 451 | an ordinary explicit authorization. |
| 452 | Neither posture answers `ask` questions or approves `exit_plan_mode` plans. |
| 453 | Plan Mode is entered only through an explicit user choice and remains |
| 454 | independent of the active tool-approval posture. After a user approves a |
| 455 | plan, the controller opens a short `approvedPlanAutoApproveTools` execution |
| 456 | window so the model can perform the approved writes without re-prompting; that |
| 457 | transient window still does not auto-approve future plans. In headless `ask` |
| 458 | execution, any fallback answer is labelled as a model assumption, not as a |
| 459 | user decision. |
| 460 | |
| 461 | - **Collaboration mode is separate from tool approval.** The desktop composer |
| 462 | presents collaboration as `normal` ("正常模式"), `plan` ("计划模式"), and |
| 463 | `goal` ("目标模式"). `/goal <objective>` starts an autonomous, session-scoped |
| 464 | active goal: the controller prepends goal context to user turns outside the |
| 465 | cache-stable system prompt and keeps issuing continuation turns until the |
| 466 | model reports completion, repeats the same blocked state three times, the user |
| 467 | stops it, or the safety continuation limit is reached. Blocked-state matching |
| 468 | is normalized for casing, whitespace, and punctuation so minor wording drift |
| 469 | does not reset the audit; restarting a goal begins a fresh blocked audit. A |
| 470 | goal is treated as a task contract: if the objective includes Context, |
| 471 | Request, Output format, Constraints, or Pause policy sections, those sections |
| 472 | define the autonomous work boundary. When they are absent, the model infers a |
| 473 | lightweight contract from the conversation and workspace. The injected goal |
| 474 | block tells the model to pause only for irreversible or externally visible |
| 475 | operations, scope changes, or information only the user can provide; ordinary |
| 476 | uncertainty should be handled with sensible defaults and reported as an |
| 477 | assumption. Completion requires the concrete request, output format, |
| 478 | constraints, and relevant verification expectations to be satisfied or |
| 479 | explicitly reported as unverified. |
| 480 | Goals that look like long-horizon research, debugging, optimization, or |
| 481 | implementation work automatically add an AutoResearch protocol to the same |
| 482 | transient active-goal user block. AutoResearch is a Goal strategy, not a |
| 483 | standalone global skill: it writes project-local state under |
| 484 | `.reasonix/autoresearch/YYYYMMDD-HHMMSS-slug/` and keeps dynamic run state out |
| 485 | of `REASONIX.md`, `AGENTS.md`, project memory, tool schemas, and the |
| 486 | cache-stable system prompt. `/goal --research <objective>` forces that |
| 487 | strategy; `/goal --simple <objective>` forces lightweight Goal. Outside goal |
| 488 | mode, ordinary prompts never change collaboration mode or create durable |
| 489 | AutoResearch state; the user must choose Goal or use `/goal` explicitly. |
| 490 | `/goal clear` removes the active goal. Switching into plan/normal mode clears |
| 491 | the active goal in the desktop UI so the collaboration mode remains one of |
| 492 | the three choices, while the underlying tool approval posture is preserved. |
| 493 | |
| 494 | | Tool approval posture | Tool approvals | Plan approval | `ask` questions | |
| 495 | | --- | --- | --- | --- | |
| 496 | | Need approval / `ask` | Follow permission policy (`Ask` prompts interactively) | Waits for user | Waits for user | |
| 497 | | Auto approve / `auto` | Writer fallback auto-allowed; explicit ask/deny rules still apply | Waits for user | Waits for user | |
| 498 | | YOLO approval / `yolo` | Ordinary prompts auto-allowed; deny rules and fresh reviews remain | Waits for user | Waits for user | |
| 499 | | Approved-plan execution window | Approved plan's writer fallback is auto-allowed; explicit `ask` / `deny` rules remain | Future plans still wait | Waits for user | |
| 500 | |
| 501 | Out of the box (`mode = "ask"`, no rules), interactive `reasonix` prompts before |
| 502 | each writer/bash call and `reasonix run` fails closed on those calls because it |
| 503 | has no approver. Use `reasonix run --auto ...` / `-y` to allow ordinary writer |
| 504 | fallback in unattended automation; `--permission-mode auto` is equivalent. |
| 505 | Explicit `ask` rules still fail closed under Auto, and `deny` rules harden every |
| 506 | posture. |
| 507 | |
| 508 | ### 3.8 Slash commands (`internal/command`) |
| 509 | |
| 510 | The chat TUI accepts `/command` input. Three kinds share one dispatch: |
| 511 | |
| 512 | - **Built-in actions** (`/compact`, `/new`, `/clear`, `/effort`, `/mcp`, `/help`) manipulate session |
| 513 | state locally and never reach the model. `/new` starts a new session while |
| 514 | saving the previous transcript for resume/history. `/clear` requires |
| 515 | confirmation, then discards the current context without saving it; it does not |
| 516 | delete project memory. |
| 517 | - **Custom commands** are Markdown files under `.reasonix/commands/` (project) and |
| 518 | the user config dir, e.g. `~/.reasonix/commands/` on macOS/Linux; the project dir overrides the user dir on a |
| 519 | name clash. A file `review.md` becomes `/review`; a subdirectory namespaces it |
| 520 | (`git/commit.md` → `/git:commit`). Invoking one renders its body and sends the |
| 521 | result as the next user turn. |
| 522 | - **MCP prompts** (§3.3) appear as `/mcp__<server>__<prompt>`. |
| 523 | |
| 524 | ```markdown |
| 525 | --- |
| 526 | description: Review the staged diff |
| 527 | argument-hint: [focus-area] |
| 528 | --- |
| 529 | Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line. |
| 530 | ``` |
| 531 | |
| 532 | - Frontmatter is an optional `---`-fenced block of simple `key: value` lines; |
| 533 | `description` and `argument-hint` are recognised (no YAML dependency — Reasonix |
| 534 | stays lean). The remainder is the body template. |
| 535 | - Substitution in the body: `$ARGUMENTS` (all args, space-joined), `$1`…`$N` |
| 536 | (positional, empty when absent), `$$` (a literal `$`). Arguments are the |
| 537 | space-separated tokens after the command. |
| 538 | - Loading is pure (`command.Load(dirs...)`) and tested; a malformed file is |
| 539 | skipped, not fatal. Custom and MCP-prompt commands both resolve to text and |
| 540 | reuse the same "start a turn" path as a typed message. |
| 541 | |
| 542 | #### CLI modal/composer ownership |
| 543 | |
| 544 | The Bubble Tea chat TUI has one bottom composer. A slash-command overlay must |
| 545 | declare whether it owns keyboard input: |
| 546 | |
| 547 | - **Modal overlays** own navigation/confirm/cancel keys and must hide the |
| 548 | composer while open. Examples: `/mcp`, `/resume`, `/rewind`, approval prompts, |
| 549 | and non-typing `ask` choice cards. |
| 550 | - **Input-owned overlays** are attached to the textarea and must keep the |
| 551 | composer visible. Examples: slash/@ autocomplete and `ask` free-text mode. |
| 552 | |
| 553 | New CLI overlays must update `chat_tui.hideComposer()` and add/extend layout |
| 554 | tests so `bottomRows()` accounts for either `panel + status` or |
| 555 | `panel + composer + status`. This prevents inactive chat input boxes from being |
| 556 | rendered under modal panels. |
| 557 | |
| 558 | ### 3.9 Chat references (`@`) |
| 559 | |
| 560 | A chat message may embed `@` references; before the turn is sent, each is |
| 561 | resolved and prepended to the message as a tagged block the model can read. |
| 562 | |
| 563 | - `@<server>:<uri>` where `<server>` is a connected MCP server → an MCP |
| 564 | resource (`resources/read`), wrapped `<resource ref="…">…</resource>`. |
| 565 | - `@<path>` otherwise → a **local file or directory**, but only when the path |
| 566 | actually exists on disk. This existence gate is the disambiguator: an ordinary |
| 567 | `@mention` or an email address resolves to no file and stays literal text. A |
| 568 | file is wrapped `<file path="…">…</file>` (size-capped, binary files noted not |
| 569 | dumped); a directory becomes a recursive listing (depth-first, skipping common |
| 570 | noise like `.git` and `node_modules`). |
| 571 | - Resolution is asynchronous (off the TUI event loop); a fetch failure surfaces |
| 572 | as a notice but doesn't block the turn. Reads are user-initiated and read-only |
| 573 | — they do **not** pass the permission gate (§3.7). |
| 574 | - Typing `/` or `@` opens an autocomplete menu above the input. The `@` menu |
| 575 | navigates **one directory level at a time** (`os.ReadDir`, never a recursive |
| 576 | walk — bounded for huge directories): a directory entry descends, a file |
| 577 | completes, and MCP resources appear alongside top-level entries. The |
| 578 | bottom-region menu changes height only on these discrete actions, never per |
| 579 | streamed token, so scrollback stays clean (§ rendering). |
| 580 | |
| 581 | ### 3.10 Subagent profiles and explicit CLI execution |
| 582 | |
| 583 | A subagent profile is a Skill with `runAs: subagent` and, for profiles managed |
| 584 | by the desktop or CLI editors, `invocation: manual`. Profiles reuse the existing |
| 585 | project/global Skill files; they do not introduce another state format or |
| 586 | database. Manual invocation excludes a profile from the pinned Skill index so |
| 587 | the model cannot discover it implicitly, while explicit `/<name> <task>` |
| 588 | invocation remains available. |
| 589 | |
| 590 | Interactive slash invocation and `Controller.RunSubagentProfile` both execute |
| 591 | the profile with the Boot-wired Skill runners. Each run gets an isolated child |
| 592 | session and returns only its final answer to the caller. The headless contract is |
| 593 | explicit: |
| 594 | |
| 595 | - `reasonix subagent try <name> ... <task>` uses the read-only Skill runner; |
| 596 | - `reasonix subagent run <name> ... <task>` uses the normal permission and |
| 597 | sandbox path; and |
| 598 | - ordinary `Controller.Run` / `reasonix run` remains unchanged and does not |
| 599 | reinterpret slash-prefixed input as a subagent command. |
| 600 | |
| 601 | Desktop and CLI profile mutations share |
| 602 | `skill.ValidateEditableSubagentProfile`. Only simple manual project/global |
| 603 | profiles can be rewritten or deleted. Custom-scope Skills, unmanaged |
| 604 | frontmatter, and Skill directories containing `references/` or `scripts/` are |
| 605 | refused so an editor cannot silently flatten or discard rich Skill content. |
| 606 | Built-in profiles support configuration overrides but have no writable file. |
| 607 | |
| 608 | Effective model and effort precedence is: per-profile |
| 609 | `agent.subagent_models` / `agent.subagent_efforts`, this call's `model` / |
| 610 | `effort` on `task`/`fleet`, profile frontmatter, `agent.subagent_model` / |
| 611 | `agent.subagent_effort`, then executor/default model configuration. |
| 612 | |
| 613 | `task` accepts optional `profile` and `write_paths`. `fleet` dispatches 2–64 |
| 614 | profile-aware tasks under a session scheduler |
| 615 | (`agent.max_subagent_concurrency`, default 6; `agent.max_parallel_writers`, |
| 616 | default 3). Profile names are resolved at runtime from the Skill store and |
| 617 | must never enter tool schemas or the parent system prompt. Custom and named |
| 618 | built-in profile bodies are the full child system prompt (no implicit |
| 619 | concise default). `parallel_tasks` remains the compatible read-only batch |
| 620 | API on the same scheduler. In a persisted parent session, parallel/fleet |
| 621 | children save independent transcripts; the aggregate carries bounded previews |
| 622 | and stable refs, and `read_subagent_result` pages a referenced final answer by |
| 623 | UTF-8 byte offset under the current conversation-lineage/workspace boundary. |
| 624 | Headless runs remain ephemeral and return fair bounded previews without refs. |
| 625 | See [Subagent profiles](./SUBAGENT_PROFILES.md) |
| 626 | for the user-facing command and file-format contract. |
| 627 | |
| 628 | ## 4. Data Types (`internal/provider`) |
| 629 | |
| 630 | ```go |
| 631 | type Role string |
| 632 | const (RoleSystem Role = "system"; RoleUser Role = "user" |
| 633 | RoleAssistant Role = "assistant"; RoleTool Role = "tool") |
| 634 | |
| 635 | type Message struct { |
| 636 | Role Role `json:"role"` |
| 637 | Content string `json:"content,omitempty"` |
| 638 | ToolCalls []ToolCall `json:"tool_calls,omitempty"` |
| 639 | ToolCallID string `json:"tool_call_id,omitempty"` |
| 640 | Name string `json:"name,omitempty"` |
| 641 | } |
| 642 | |
| 643 | type ToolCall struct { ID, Name, Arguments string } // Arguments: raw JSON |
| 644 | type ToolSchema struct { Name, Description string; Parameters json.RawMessage } |
| 645 | type Request struct { Messages []Message; Tools []ToolSchema; Temperature float64; MaxTokens int } |
| 646 | |
| 647 | type ChunkType int |
| 648 | const (ChunkText ChunkType = iota; ChunkToolCall; ChunkDone; ChunkError) |
| 649 | |
| 650 | type Chunk struct { |
| 651 | Type ChunkType |
| 652 | Text string // ChunkText |
| 653 | ToolCall *ToolCall // ChunkToolCall |
| 654 | Err error // ChunkError |
| 655 | } |
| 656 | ``` |
| 657 | |
| 658 | ## 5. Configuration (TOML) |
| 659 | |
| 660 | Resolution order: **flag > project `./reasonix.toml` > the user config file |
| 661 | > built-in defaults**. Starting with **Reasonix v1.8.1**, the user config lives |
| 662 | at `~/.reasonix/config.toml` on macOS/Linux and |
| 663 | `%AppData%\reasonix\config.toml` on Windows. See |
| 664 | [Configuration paths](./CONFIG_PATHS.md) for migration and related data paths. |
| 665 | Fields marked user/global only are not overridden by project `reasonix.toml`. |
| 666 | Provider entries name secrets with `api_key_env`; saved key values live in |
| 667 | Reasonix's global `<Reasonix home>/.env`, shared by CLI and desktop. Project |
| 668 | `.env`, home `.env`, inherited shell environment variables, legacy credentials, |
| 669 | and the OS keyring are not provider-key runtime fallbacks. Project `.env` still |
| 670 | feeds workspace-scoped, non-provider `${VAR}` expansion for MCP/plugin settings |
| 671 | without importing provider keys or Reasonix control variables. |
| 672 | |
| 673 | ```toml |
| 674 | default_model = "deepseek" # provider name (→ its default model) or "provider/model" |
| 675 | # language = "zh" # ui language tag; empty = auto-detect from $LANG / $REASONIX_LANG |
| 676 | |
| 677 | [ui] |
| 678 | # shortcut_layout = "desktop" # classic|desktop; compatibility setting |
| 679 | # cursor_shape = "bar" # CLI/TUI textarea cursor: underline|block|bar |
| 680 | show_turn_usage = false # hide per-request token/cost receipts in the TUI; default true |
| 681 | |
| 682 | [agent] |
| 683 | system_prompt = "You are Reasonix, a coding agent..." # or system_prompt_file = "..." |
| 684 | temperature = 0.0 |
| 685 | reasoning_language = "auto" # visible reasoning text: auto|zh|en |
| 686 | # plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash uses Permissions |
| 687 | # planner_model = "deepseek-pro" # optional: two-model collaboration (low-frequency planner) |
| 688 | # subagent_model = "deepseek-pro" # optional default for runAs=subagent skills |
| 689 | # subagent_effort = "high" # optional default reasoning effort for subagents |
| 690 | # subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } |
| 691 | # subagent_efforts = { review = "max", security_review = "high" } |
| 692 | |
| 693 | # A vendor endpoint exposing several models under one base_url/key. |
| 694 | [[providers]] |
| 695 | name = "deepseek" |
| 696 | kind = "openai" |
| 697 | base_url = "https://api.deepseek.com" |
| 698 | # chat_url = "https://proxy.example.com/v1/chat/completions" # optional full chat request URL |
| 699 | # models_url = "https://proxy.example.com/v1/models" # optional model discovery URL |
| 700 | models = ["deepseek-v4-flash", "deepseek-v4-pro"] |
| 701 | default = "deepseek-v4-flash" # optional; defaults to models[0] |
| 702 | api_key_env = "DEEPSEEK_API_KEY" |
| 703 | context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables) |
| 704 | max_output_tokens = 32768 # total visible + reasoning + tool-call output; 0 = provider default |
| 705 | # model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } } |
| 706 | |
| 707 | # A single-model entry still works for custom OpenAI-compatible endpoints. |
| 708 | |
| 709 | [environment] |
| 710 | enabled = true # inject a stable startup summary of OS, shell, and common tool versions |
| 711 | |
| 712 | # Optional trusted executable paths shown to the model when PATH probing is not enough. |
| 713 | # Workspace-local paths are listed but not auto-executed during startup probing. |
| 714 | # [environment.tools] |
| 715 | # go = "/opt/homebrew/bin/go" |
| 716 | |
| 717 | [tools] |
| 718 | enabled = [] # omit/empty = all built-ins |
| 719 | bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap |
| 720 | mcp_startup_timeout_seconds = 30 # background initialize + tools/list safety cap |
| 721 | mcp_call_timeout_seconds = 300 # default MCP call safety cap; plugin/tool overrides may raise it |
| 722 | |
| 723 | [tools.shell] |
| 724 | prefer = "auto" # auto (default) | bash | powershell | pwsh — force the shell tool's interpreter |
| 725 | # path = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" # explicit executable for the chosen shell |
| 726 | |
| 727 | [skills] |
| 728 | # paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots |
| 729 | # excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders |
| 730 | # disabled_skills = ["review"] # hidden from prompt, slash invocation, and skill tools |
| 731 | |
| 732 | [permissions] |
| 733 | mode = "ask" # writer fallback when no rule matches: ask|allow|deny |
| 734 | deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode |
| 735 | allow = ["Bash(go test:*)", "Bash(git status:*)"] # never prompted |
| 736 | ask = [] # force a prompt even if otherwise allowed |
| 737 | |
| 738 | [sandbox] |
| 739 | # workspace_root = "" # file-writers confined here; empty = cwd |
| 740 | # allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit/move_file may modify |
| 741 | # forbid_read = ["${HOME}/.ssh"] # paths read/list/search tools and sandboxed bash may not inspect |
| 742 | |
| 743 | [serve] |
| 744 | auth_mode = "none" # none|token|password; use auth before binding beyond localhost |
| 745 | # token = "" # optional fixed token; empty token mode generates one at startup |
| 746 | # password_hash = "" # bcrypt hash generated with reasonix serve --hash-password --password '...' |
| 747 | # behind_proxy = false # trust X-Forwarded-* only behind a trusted reverse proxy |
| 748 | |
| 749 | [[plugins]] |
| 750 | name = "example" # type defaults to "stdio" |
| 751 | command = "reasonix-plugin-example" |
| 752 | args = [] |
| 753 | # env = { FOO = "bar" } |
| 754 | # startup_timeout_seconds = 60 # initialize + tools/list cap; 0 = global/default cap |
| 755 | # call_timeout_seconds = 600 # per-server MCP call timeout; 0 = global/default cap |
| 756 | # tool_timeout_seconds = { "generate_video" = 1800 } # raw MCP tool names |
| 757 | # [[plugins]] # a remote MCP server over Streamable HTTP |
| 758 | # name = "stripe" |
| 759 | # type = "http" # "stdio" (default) | "http" | "sse" |
| 760 | # url = "https://mcp.stripe.com" |
| 761 | # headers = { Authorization = "Bearer ${STRIPE_KEY}" } # ${VAR} / ${VAR:-default} expanded |
| 762 | ``` |
| 763 | |
| 764 | The native CLI updater always installs the latest strict `vX.Y.Z` official |
| 765 | release. Legacy channel configuration and arguments remain parseable during |
| 766 | 1.x, resolve to the official release, and are omitted on subsequent writes. |
| 767 | |
| 768 | The executor tracks an adaptive progress lease while a todo is active. A new |
| 769 | completion, unique successful read, command, or mutation renews the lease; |
| 770 | exact repeats do not. After 8 no-progress tool-call rounds the host appends a |
| 771 | one-shot reassessment nudge. After 16 it pauses and preserves work for a later |
| 772 | user turn. The serial contract is level-aware while preserving the |
| 773 | single-in_progress rule: in a two-level list the active level-1 sub-step is |
| 774 | the only `in_progress` item and its level-0 phase stays `pending`; sub-steps |
| 775 | complete in order, and the phase becomes `in_progress` — and signs off — only |
| 776 | after all of its sub-steps have completed. A level-1 item with no phase above |
| 777 | it is rejected. Retired `[agent].max_steps` and `planner_max_steps` keys remain |
| 778 | parseable for upgrade compatibility, but are ignored and removed by a one-time |
| 779 | migration. The CLI `--max-steps` flag and `[bot].max_steps` remain separate, |
| 780 | explicit controls for one-off and unattended execution. |
| 781 | |
| 782 | `reasonix setup` writes this default config so the CLI is usable out of the box. |
| 783 | |
| 784 | `[ui].cursor_shape` is normalized to `underline`, `block`, or `bar`; empty or |
| 785 | unknown values fall back to `bar`. It applies to the Bubble Tea CLI/TUI |
| 786 | textarea only, while desktop and browser inputs keep their platform-native |
| 787 | cursor behavior. |
| 788 | |
| 789 | `[serve]` controls the HTTP browser frontend used by `reasonix serve`. The |
| 790 | default `auth_mode = "none"` is intended for the loopback default |
| 791 | `127.0.0.1:8787`; deployments reachable from another machine must use `token` or |
| 792 | `password`. Password mode requires either a startup `--password` or a stored |
| 793 | bcrypt `password_hash`. `behind_proxy` must stay false unless the server is |
| 794 | behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto` |
| 795 | headers. |
| 796 | |
| 797 | MCP servers may also be declared in a project-root `.mcp.json` using Claude |
| 798 | Code's exact `mcpServers` schema (`command`/`args`/`env`, `type`/`url`/`headers`, |
| 799 | `${VAR}` expansion). It is read after the TOML files and merged into |
| 800 | `[[plugins]]`; on a name collision `reasonix.toml` wins (it is the more explicit, |
| 801 | Reasonix-specific source). This lets a server already configured for Claude work in |
| 802 | Reasonix unchanged. |
| 803 | |
| 804 | MCP startup has a separate lifecycle from an individual tool call. A caller |
| 805 | waits briefly for cold startup, while the shared launch/authorization/ |
| 806 | `initialize`/`tools/list` sequence may continue in the background up to |
| 807 | `mcp_startup_timeout_seconds` (default `30`). A per-server |
| 808 | `startup_timeout_seconds` overrides that cap. MCP call timeouts begin only after |
| 809 | the connection is ready. |
| 810 | |
| 811 | ```json |
| 812 | { "mcpServers": { |
| 813 | "stripe": { "type": "http", "url": "https://mcp.stripe.com", |
| 814 | "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } } |
| 815 | } } |
| 816 | ``` |
| 817 | |
| 818 | `[sandbox]` is the *enforcement* layer beneath permissions (which are *policy*). |
| 819 | Phase 0 confines the file-writing built-ins (`write_file`, `edit_file`, |
| 820 | `multi_edit`, `move_file`) to `workspace_root` (default cwd), the Reasonix user |
| 821 | config dir, plus `allow_write`: a write whose target — resolved to an absolute, |
| 822 | symlink-free path so a symlinked dir or `..` cannot tunnel out — falls outside |
| 823 | every root is refused, and the error is fed back to the model. Confinement is on |
| 824 | by default (root = cwd), so edits stay in the project while the agent can still |
| 825 | update its own global config. `forbid_read` lists files or directories the agent should |
| 826 | not read, list, or search; entries support `${VAR}` / `${VAR:-default}` expansion |
| 827 | and should be absolute, or use `${HOME}` for home-relative secrets such as |
| 828 | `${HOME}/.ssh`. `bash` is itself jailed by default when an OS sandbox is |
| 829 | available (`[sandbox] bash = "enforce"`: Seatbelt on macOS and bubblewrap on |
| 830 | Linux): each command is allowed to write only |
| 831 | the same roots plus platform-specific command temp/cache roots, denied reads |
| 832 | under `forbid_read`, and allowed to reach the network only when |
| 833 | `network = true`. |
| 834 | **Windows status:** Reasonix does not ship an OS-level Bash sandbox on Windows. |
| 835 | The effective mode is fixed to `off`; an older config containing |
| 836 | `bash = "enforce"` remains readable but resolves to `off`, `reasonix doctor` |
| 837 | reports the ignored value, and the desktop control is read-only. Bash therefore |
| 838 | runs unconfined on Windows. The in-process file tools continue to enforce |
| 839 | `workspace_root`, `allow_write`, and `forbid_read`. |
| 840 | When no OS sandbox is available, `bash = "enforce"` refuses bash execution |
| 841 | instead of running unconfined. Install the platform sandbox backend |
| 842 | (bubblewrap/`bwrap` on Linux, `sandbox-exec` on macOS) or set |
| 843 | `[sandbox] bash = "off"` to explicitly restore the pre-1.16 unconfined shell |
| 844 | behavior. The escape-prompt and broader OS support are Phase 1's remainder (§9). |
| 845 | |
| 846 | ## 6. Error Handling |
| 847 | |
| 848 | - Library code wraps with `fmt.Errorf("...: %w", err)` and returns; it never |
| 849 | prints or calls `os.Exit`. |
| 850 | - Only `cli` / `main` decide exit codes and user-facing messages. |
| 851 | - Tool execution errors are fed back to the model, not fatal. |
| 852 | - Network layer should apply bounded exponential backoff on 429 / 5xx |
| 853 | (interface reserved; implementation may follow). |
| 854 | |
| 855 | ## 7. Code Style |
| 856 | |
| 857 | - `gofmt` + `go vet` must be clean; package names lowercase; exported |
| 858 | identifiers documented; comments explain *why*, not *what*. |
| 859 | - No premature generalization. Prefer clear and direct. |
| 860 | |
| 861 | ## 8. Distribution |
| 862 | |
| 863 | - Build: `CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=$(VERSION)" -o reasonix ./cmd/reasonix` |
| 864 | - Cross matrix: `darwin|linux|windows` × `amd64|arm64`. |
| 865 | - Version injected via ldflags (`git describe --tags --always`). |
| 866 | - Install: prebuilt binary / `go install` / future `brew tap`. |
| 867 | |
| 868 | ## 9. Roadmap (not in current scope) |
| 869 | |
| 870 | - Sandbox Phase 1: an OS-level jail for `bash` so commands — not just the |
| 871 | file-writer built-ins (Phase 0) — are confined to the workspace. **Seatbelt on |
| 872 | macOS and bubblewrap on Linux ship, on by default when available** (see §5). |
| 873 | Remaining: the escape-prompt — detect sandbox-unavailable or sandbox-denied failures and |
| 874 | offer an explicit, permission-gated unconfined rerun (in `reasonix run`, the |
| 875 | command just fails and the model adapts), which completes the "allow inside the |
| 876 | box, prompt at its edge" model. With this in place, "always allow" rule |
| 877 | persistence becomes optional rather than load-bearing. |
| 878 | - MCP long tail (deferred deliberately — no consumer / no foundation yet): OAuth |
| 879 | 2.0 + `headersHelper` auth for remote servers; the remaining `.mcp.json` scopes |
| 880 | (local / user — project scope shipped, see §5); tool-search deferral; |
| 881 | `list_changed` live updates; channels / elicitation / roots; plugins that |
| 882 | provide *providers*, not just tools. |
| 883 | - An Anthropic-native provider `kind` (native prompt-cache control), proving the |
| 884 | registry generalises beyond one wire format. |
| 885 | - "Always allow" persistence writing learned rules back to project config; a |
| 886 | per-session permission override flag for `reasonix run`. |
| 887 |