Expand the documentation with measured figures and operational detail

Most of this replaces "roughly 550 characters per tool" with the actual
per-tool measurements, and fills in the parts a reader hits after the happy
path: what a specific error means, what a setting costs, what is not covered.

Measured rather than estimated:
- Per-tool byte cost, all fourteen, and the per-set totals. 7,673 B for the
  full set, averaging 548.
- Builtin skill bodies at 5,284 B against a 681 B catalogue, which is the
  argument for loading bodies on demand.
- Full system prompt 3,571 chars, core-only 2,045.

New sections:
- tools: which sets to keep and why, the jail function itself, an output-cap
  table, and the real error strings for edit_file and multi_edit.
- configuration: env var per provider preset, cost-estimate limits, what each
  --no-* flag isolates, and three settings that do more than they look like.
- agents: step caps per variant, which variant to reach for, and the fact that
  reasoning is charged as output and discarded first by compaction.
- headless: exit code 0 means "the turn completed", not "the answer was yes" —
  with the jq pattern for gating on content. Timeouts, concurrent -c runs
  fighting over one session, CI recipes for --no-skills.
- mcp: parallel connect, startup cost, a debugging ladder, and that toolSets
  does not gate MCP tools.
- registry: publishing, local testing over http://localhost, and a
  troubleshooting section keyed on the actual validator messages.
- memory: what compaction discards in what order, /compact versus automatic
  pruning, and that -c matches on cwd.
- skills: the frontmatter reader's limits, and how to verify a skill loaded.

Corrections found while cross-checking against the source:
- The guard table was missing --force-with-lease and > /dev/sd…
- The done event's token fields are optional, so the jq example filters on one
  rather than assuming it.

Two honest limits now written down: the guard matches command strings, so a
base64-decoded or script-wrapped command is not caught; and a registry index is
trusted for its contents, not its authorship.

Verified: all internal links and heading anchors resolve, every docs/ page is
reachable from the README, 538 tests pass, typecheck clean.
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-03 09:26:37 +07:00
parent 84c60f2022
commit 9b978fdbe1
10 changed files with 583 additions and 72 deletions
+180 -31
View File
@@ -27,19 +27,42 @@ y allow once | a always allow edit_file | n deny
`a` whitelists that tool for the rest of the session. `n` tells the model it was denied and
to ask what to do instead. `--yolo` skips all prompts.
The approval is enforced by the SDK, not by the tools. A denied call **provably never
executes**: the SDK never reaches the tool's `execute`, so a tool cannot forget to honour a
denial or opt out of the check. See [architecture](architecture.md#why-approval-goes-through-the-sdk).
MCP tools are gated as a group because they are third-party code with unknown side effects —
`mcp__fs__read_file` sounds harmless and might not be. See [MCP](mcp.md).
**The guard runs before all of this.** It is not an approval — it is a refusal, and `--yolo`
does not reach it. See [plugins](plugins.md).
## Tool sets
Each tool costs roughly 550 characters of JSON schema on every request, and selection
accuracy drops as the list grows. Sets let you switch off what a project does not need:
Each tool costs its name, its description, and its JSON schema on **every request**. Measured
across the fourteen built-ins:
| Set | Tools |
|---|---|
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` |
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` |
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` |
| Tool | Bytes | Tool | Bytes |
|---|---|---|---|
| `read_many_files` | 972 | `git_blame` | 499 |
| `multi_edit` | 934 | `git_log` | 484 |
| `edit_file` | 618 | `git_diff` | 473 |
| `grep` | 595 | `bash` | 466 |
| `list_dir` | 594 | `git_show` | 432 |
| `read_file` | 526 | `git_status` | 292 |
| `glob` | 499 | `write_file` | 289 |
7,673 bytes for all fourteen, averaging 548. Roughly 1,900 tokens per request before your
prompt or the conversation. Selection accuracy also falls as the list grows: a model choosing
between six tools picks better than one choosing between twenty.
Sets let you switch off what a project does not need:
| Set | Tools | Cost |
|---|---|---|
| `core` | `read_file` `write_file` `edit_file` `glob` `grep` `bash` | ~2,993 B |
| `edit-plus` | `multi_edit` `list_dir` `read_many_files` | ~2,500 B |
| `git` | `git_status` `git_diff` `git_log` `git_show` `git_blame` | ~2,180 B |
```json
{ "toolSets": ["edit-plus"] }
@@ -50,7 +73,32 @@ agent is not an agent. A disabled set reaches neither the wire nor the system pr
a prompt that names an absent tool teaches the model to attempt calls that cannot succeed.
Session, plugin, and MCP tools are not part of this budget and are never gated here.
`/tools` shows which set each live tool came from.
An unrecognised set name is dropped silently. The header line at startup shows which sets
actually loaded, so a typo reads as "that set is off" rather than as an error — worth checking
if a tool you expected is missing.
`/tools` shows which set each live tool came from:
```
tools
20 offered this turn of 22 registered
- `bash` core
- `git_diff` git
- `list_dir` edit-plus
- `remember`
```
A tool with no set is a session, plugin, or MCP tool.
### Which sets to keep
Both extra sets earn their place in most projects, but not all:
- **No git in the repo?** `git` is 2,180 bytes the model can never use. Switch it off.
- **A model that handles many tools badly?** `{ "toolSets": [] }` trims to six, which is the
smallest set that still lets the agent work.
- **Reading a lot, editing rarely?** Keep `edit-plus` for `list_dir` and `read_many_files`
alone; they pay for themselves in round trips saved.
## File tools
@@ -107,7 +155,14 @@ replaceAll replace every occurrence instead of requiring exactly one
`oldString` must match byte-for-byte and appear exactly once unless `replaceAll` is set.
An ambiguous match is an error naming the count, which pushes the model to add surrounding
context rather than guessing which occurrence it meant.
context rather than guessing which occurrence it meant:
```
oldString appears 3 times in src/users.ts. Add surrounding context or set replaceAll.
```
That error is deliberately specific. `edit failed` would leave the model to retry blind; the
count tells it what to do next.
### `multi_edit`
@@ -121,7 +176,14 @@ the previous one, so edits may build on each other.
Atomic: every edit is validated and applied in memory first, so a failure on the third edit
leaves the file exactly as it was rather than half-changed. The same uniqueness rule as
`edit_file` applies per edit, and the error names which edit failed.
`edit_file` applies per edit, and the error names which edit failed:
```
edit 2: oldString not found in src/users.ts. No edits were applied.
```
The last sentence matters. Without it a model reading the error has to guess whether edit 1
landed, and its next move — retry the whole batch, or only what failed — depends on the answer.
### `list_dir`
@@ -131,9 +193,19 @@ depth levels to descend, 1-6, default 2
includeIgnored also show files git ignores
```
Tree view honouring `.gitignore`. Directories end with `/`, files show their size. Past the
depth limit the containing directory is still listed, so the shape of the tree stays visible
without its contents. Capped at 300 entries.
Tree view honouring `.gitignore`. Directories end with `/`, files show their size:
```
.
README.md 2B
src/
app.ts 2K
ui/
```
Past the depth limit the containing directory is still listed, so the shape of the tree stays
visible without its contents — `src/ui/` above appears at `depth: 2` even though its files do
not. Capped at 300 entries.
### `glob`
@@ -145,9 +217,12 @@ includeIgnored also return files git ignores
Walks the tree honouring `.gitignore` and `.shiroignore`, skipping `.git` and
`node_modules` unconditionally. Nested ignore files apply only within their own directory,
as git does. Returns posix paths relative to the workspace root. A symlinked directory is
classified as a directory and not descended into, since it can point anywhere including
back into the tree.
as git does. Returns posix paths relative to the workspace root.
A symlinked directory is classified as a directory and not descended into. Both halves matter:
`readdir` reports a junction as a non-directory, so without the extra `stat` a symlinked
directory leaked past `dir/` ignore rules and was yielded as a file with a nonsense size. Not
descending is separate — a link can point anywhere, including back into the tree.
### `grep`
@@ -162,6 +237,15 @@ Shells out to ripgrep when it is on PATH — roughly 15x faster on a real repo
back to a JavaScript walker otherwise. Output is `path:line: text` either way, so the model
sees one format regardless. Skips binaries. Caps at 200 hits.
Two details keep the two paths in agreement. ripgrep is passed `--no-require-git`, because it
otherwise ignores `.gitignore` outside a repository while the JavaScript fallback always honours
it. And an rg exit code above 1 means rg could not run the search at all, so the fallback takes
over; exit 1 is simply "no matches" and is reported as such.
Regex syntax differs between the two: ripgrep is Rust regex, the fallback is JavaScript. A
pattern using look-around works in the fallback and fails under rg. An invalid pattern is
reported as `Invalid regex: <reason>` rather than returning an empty result set.
### `bash`
```
@@ -194,12 +278,25 @@ running quits as usual.
All five are read-only and therefore approval-free. Each spawns `git` with a fixed argument
array rather than a shell string, so an argument like `--author="; rm -rf /"` can only ever
be a literal argument — which is what makes auto-approval safe.
be a literal argument — which is what makes auto-approval safe. A test asserts exactly that:
`git_log` with the path `; touch pwned.txt` creates no file.
Output is described rather than raw porcelain: `git_status` names the branch and says
`staged modified` or `untracked` per file instead of leaving the model to decode two columns
of flags. Outside a repository they fail with `<cwd> is not a git repository.` rather than
passing git's own error text through.
Output is described rather than raw porcelain. `git_status` names the branch and says
`staged modified` or `untracked` per file instead of leaving the model to decode porcelain's two
leading columns:
```
On main, 2 changed:
src/app.ts (staged modified, modified)
new.ts (untracked)
```
That file has a staged change *and* a later unstaged one, which the raw `MM` prefix conveys only
to a reader who knows the format.
Outside a repository they fail with `<cwd> is not a git repository.` rather than passing git's
own error text through. Other git failures do pass through, on purpose: `git_show no-such-ref`
reports what git said, because git's own message is the most useful thing available.
```
git_status branch, staged, modified, untracked
@@ -209,6 +306,13 @@ git_show ref path? one commit: message, author, diff
git_blame path startLine? endLine? who last changed each line
```
`git_log` defaults to 15 commits and caps at 40. `git_blame` without a range blames the whole
file; with `startLine` and no `endLine` it covers 40 lines from there.
Everything here is also reachable through `bash`. The reason the set exists anyway is the
approval boundary: `bash git diff` stops for a decision on every call, while `git_diff` cannot
mutate anything and so never needs one.
## Agent tools
### `task`
@@ -223,8 +327,17 @@ Spawns a read-only subagent with `read_file`, `glob`, and `grep` only. It return
report, so the parent pays for findings rather than the whole search transcript. It sees
none of the parent conversation, so its prompt has to stand alone.
Two properties follow from that tool set rather than from policy: it can never trigger an
approval prompt, because it has no gated tools; and the parent's context holds the conclusion
instead of the search. A subagent reading forty files to answer one question costs the parent
the answer, not the forty files.
`explore` finds and reports. `review` critiques code in severity order. Progress streams to
the subagent panel.
the subagent panel. Capped at 20 steps, and it shares the parent's model — an `explore` run
pays reasoning rates for what is really a search, which is [on the list](../TODO.md) to fix.
Not worth delegating a single grep: the subagent is a whole extra model loop, so it wins on a
search spanning many files and loses on anything you could answer in one call.
### `ask`
@@ -235,9 +348,11 @@ multiple allow more than one
```
Stops the turn and puts the question on screen. With options it is a picker; without, free
text. `esc` skips, which tells the model to decide and state its assumption.
text. `esc` skips, which returns "the user dismissed this; use your best judgement" — so a
dismissal is an instruction to decide, not a dead end.
Withheld entirely in headless mode — a question with no one to answer it would hang.
Withheld entirely in headless mode — a question with no one to answer it would hang. The system
prompt says so, and tells the model to decide and state its assumption instead.
### `todo_write`
@@ -249,6 +364,9 @@ Statuses: `pending`, `in_progress`, `done`, `blocked`. Send the whole list each
replaces the previous one. Warns when more than one task is `in_progress`, when nothing is
`in_progress` while work remains, or when a `blocked` task has no note.
The list lives in the system prompt, rebuilt every step, so it survives both pruning and
`/compact`. See [memory](memory.md).
### `remember`, `recall`, `forget`
Durable per-project notes. See [memory](memory.md).
@@ -264,15 +382,46 @@ Loads the body of a skill. See [skills](skills.md).
## Path safety
Every path a tool receives goes through a jail: resolved against the workspace root, then
checked that it did not escape. `../../etc/passwd` and absolute paths outside the root are
both refused before any filesystem call.
checked that it did not escape.
```ts
export function jail(p: string, root = process.cwd()): string {
const abs = isAbsolute(p) ? resolve(p) : resolve(root, p);
const rel = relative(resolve(root), abs);
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Path escapes workspace: ${p}`);
return abs;
}
```
`../../etc/passwd`, `a/../../secret`, and absolute paths outside the root are all refused
before any filesystem call. Resolving first and comparing after is what catches the middle
case: string-prefix checks on the raw input miss `a/../../secret` entirely.
The model's output is a trust boundary. It can emit any string, so the check happens on
every call rather than being assumed.
every call rather than being assumed. That includes `read_many_files`, where a bad path is
reported in its block like any other unreadable file.
`jail` guards the workspace, not the shell. `bash` runs whatever it is given, which is why
every call needs approval and why the guard plugin exists — see [plugins](plugins.md).
## Output caps
Any single tool result is truncated at 30,000 characters with a note saying how much was
cut. `grep` stops at 200 hits, `glob` at 200 paths, `list_dir` at 300 entries,
`read_many_files` at 20 files, `read_file` at 2000 lines by default. Without caps one `grep`
for `function` can end a session.
Any single tool result is truncated at 30,000 characters with a note saying how much was cut:
```
... [truncated 41,233 chars]
```
| Tool | Cap |
|---|---|
| any result | 30,000 characters |
| `grep` | 200 hits, each line cut at 300 chars |
| `glob` | 200 paths |
| `list_dir` | 300 entries |
| `read_many_files` | 20 files |
| `read_file` | 2,000 lines by default |
| `bash` | 120 s default timeout, 600 s max |
Without caps one `grep` for `function` can end a session. The caps are per call, so a model
that needs more can narrow and ask again — which is cheaper than one call that fills the
context and forces compaction.