wcode-protocol 0.5.0

Wire transport for wcode sessions: NDJSON frames, a socket server and client, a backend-agnostic handle
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# wcode

A minimal, pi-like coding agent for the terminal. It streams an LLM conversation
and gives it content-addressed tools — read (numbered lines), grep/find, bash,
edit/edits/replace, write, and ast-grep search/rewrite — and stays out of the way.
Built on a small kernel
([`wcode-harness`](crates/wcode-harness)) that speaks to any OpenAI-compatible
chat-completions endpoint.

## Install

Install with [Homebrew](https://brew.sh) — a tap by URL, no extra repo:

```
brew tap WeiNyn/wcode https://github.com/WeiNyn/wcode
brew install wcode
```

From crates.io (a Rust toolchain is required):

```
cargo install wcode-cli
```

Or grab a prebuilt binary — Linux (x86_64, aarch64) and macOS (Intel, Apple
Silicon) tarballs are attached to each
[release](https://github.com/WeiNyn/wcode/releases). Build the latest from source
with `cargo install --path crates/wcode-cli`.

More at **[weinyn.github.io/wcode](https://weinyn.github.io/wcode/)**.

## Configure

`~/.config/wcode/config.toml`:

```toml
model = "gpt-4.1"      # required
base_url = "https://api.openai.com/v1"  # optional; any OpenAI-compatible endpoint
api_key = "sk-..."     # optional
endpoint = "chat"      # optional; chat|responses, default chat
effort = "high"        # optional; free-style reasoning effort, omitted = not sent

# Per-model providers: when a model id is selected (mid-session `/model`, a
# worker's `spec.model`, or `--model`), this entry's endpoint/base_url/api_key
# re-point the provider. Absent fields inherit the globals above. Quote ids that
# contain dots or slashes.
[models."gpt-5.1"]
endpoint = "responses"
base_url = "https://api.openai.com/v1"
api_key = "sk-..."

[hooks]
rtk = "auto"           # optional; auto|true|false — route bash output through the
                       # rtk proxy (https://github.com/rtk-ai/rtk) to cut tokens
                       # (default auto = on only if the rtk binary is on PATH)

[tools]
grep = true            # optional; true|false — register the grep tool (off by default)
find = true            # optional; true|false — register the find tool (off by default)
parallel = true        # optional; true|false — run independent tool calls from one batch
                       # concurrently (default true; --sequential forces it off)

[compaction]
# Compact the conversation as it grows. An absent table = harness defaults.
budget = 100000            # optional; working token ceiling to compact near
                           # (default: the model's context window)
window = 200000            # optional; context-window override for unknown models
min_remaining = 16384      # compact once fewer than this many tokens remain
keep_recent_tokens = 20000 # recent verbatim context kept after compacting, in tokens
keep_recent_turns = 2      # complete turns always kept, whatever their size

[instructions]
# Instruction ("reference") files folded into the system prompt. An absent table
# = discover the candidate names from the working dir up to the repo root, plus
# a global file in the config dir, merged global-first. `file` overrides
# discovery with a single name/path; "off" disables.
file = "AGENTS.md"          # optional; load exactly this instead of discovery
names = ["AGENTS.md"]       # optional; candidate names per directory
                            # (default: AGENTS.override.md, AGENTS.md, CLAUDE.md)
global = true               # optional; also load one from ~/.config/wcode

[skills]
# Discover `SKILL.md` packages and fold their name + description into the system
# prompt; the body loads on demand when the agent `read`s the path. An absent
# table = discover from the standard roots.
enabled = true              # optional; discover skills at all. Default true
dirs = ["./team-skills"]    # optional; extra roots, scanned first
disabled = ["noisy-skill"] # optional; names to skip

[retry]
# Retry the connect/handshake (and the first streamed item) on transient
# failures — 429/5xx statuses and transport errors. An absent table = defaults.
max = 3                    # optional; retries after the first attempt (0 disables)
base_ms = 500              # optional; base backoff for the first retry
cap_ms = 8000              # optional; cap on a single backoff wait
ttft_ms = 60000            # optional; time-to-first-token timeout (0 disables)
idle_ms = 120000           # optional; inter-item idle timeout (0 disables)

[workspace]
# Whole-file compare-and-swap on mutations: wcode auto-attaches the digest from
# its last `read` to a mutation and refuses the write if the file changed since
# (a concurrent edit, a formatter), so a stale edit cannot silently clobber.
digest_cas = true          # optional; true|false — default ON

[theme]
# TUI theme, presentation-only. `name` selects a built-in preset —
# default | dark | light | gruvbox-dark | nord | solarized-dark | solarized-light,
# or `omarchy` for the live desktop theme (see below).
# `default` is named-16 (safe on any terminal); the others are truecolor, so they
# are a no-op on a non-truecolor terminal (a `light` preset also needs a light
# terminal). A role value is a named ANSI color (`cyan`, `light-yellow`,
# `dark-gray`, ...) or hex `#rrggbb`; absent roles inherit the palette. Roles:
# accent, dim, muted, border, user, body, error, success, warn, code, heading,
# heading_sub, link, tool_name, thinking, diff_add, diff_del. Hex is honored only
# under truecolor; NO_COLOR wins over everything. Select a preset live with the
# TUI's `/theme [name]` (bare `/theme` opens a picker) or start with `--theme <name>`;
# `--list-themes` prints the catalog. Explicit role keys survive a preset switch.
name = "gruvbox-dark"      # optional; a catalog preset (default: palette B)
# `name = "omarchy"` loads the current Omarchy desktop theme — the palette at
# ~/.local/state/omarchy/current/theme/colors.toml — and re-reads it on
# `/theme omarchy`, so an `omarchy theme set` shows up at the next switch. It is
# listed by `--list-themes` only where that file exists.
link = "light-cyan"        # optional; role = color (overrides the preset)
muted = "#5f5f5f"          # optional; hex is truecolor (opt-in)

[tui]
# TUI presentation (nothing here changes the agent's behavior). `width` is the
# transcript's initial measure (content width) in columns — the reading column
# centers when the band is wider than it; default 68, range 40..=200. An
# out-of-range value fails the load loudly. Adjust it live with `/width <cols>`
# or `Alt-[` / `Alt-]` (the runtime value is not persisted).
width = 96                 # optional; 40..=200 (default: 68)

# A preset team the orchestrator starts with (a non-empty [team] auto-enables
# agents — no --agents needed). One [[team]]
# entry per member; names are unique (a duplicate fails the load loudly). Keys:
#   name      (required) the member's address, unique
#   role      extra system-prompt text (`# Role`) appended to the worker blurb
#   model     model id override (default: the orchestrator's)
#   tools     tool allow-list (`message` is always kept; unknown names rejected)
#   read_only enforce read-only: refuse the mutating tools and mutating bash/bg
#   effort    reasoning-effort override (`-`/`none`/`off` clears; default: inherit)
#   base_url  provider base URL override (default: the orchestrator's)
#   api_key   provider API key override (default: the orchestrator's)
[[team]]
name      = "explorer"
role      = "recon only; never edit; cite file:line"
tools     = ["read", "grep", "find"]
read_only = true

[[team]]
name   = "reviewer"
role   = "verify diffs; PASS / NITS / FAIL"
effort = "high"
```

### `[workflow]` — a plan template (requires `--agents`)

A single `[workflow]` table seeds the task DAG at boot. Each `[[workflow.node]]`
becomes a node, instantiated in **topological order** (so a `depends_on` may name
a later-authored sibling):

```toml
[workflow]
max_attempts = 3            # optional; the rework cap (default 3)

[[workflow.node]]
id = "explore"              # required; a unique label
member = "explorer"         # EXACTLY ONE of member | script
title = "Explore: {{task}}" # optional label; {{task}} is the run's task

[[workflow.node]]
id = "implement"
member = "builder"
depends_on = ["explore"]    # sibling ids; resolved to Task ids at boot

[[workflow.node]]
id = "verify"
depends_on = ["implement"]
gate = true
script = "cargo test --workspace"   # a physical gate (exit code = verdict)
```

A node is a `member` (a `[team]` name) OR a `script` (`sh -c`, run under the bash
automation policy); `gate = true` makes its reject re-open its deps, and a gate
needs at least one `depends_on`. The graph is validated acyclic at load. The plan
shows in the TUI's `/tasks` listing as
`#id [state] title (owner) ← #deps ×attempts`.

A node may carry an optional `title` (its label, dispatched to the worker as
`#<id> <title>`; absent → the node `id`). The one placeholder recognized in a
title is `{{task}}` — substituted with the run's task; any other brace form is left
literal, and `script`s are not templated.

Passing `--task <text>` (or the `WCODE_TASK` env) turns a `[workflow]` run
**headless** (no TUI): wcode seeds the root with the task, drives the plan to a
terminal state, and exits — `0` once every node is `Done`, `1` if any node is
`Failed`, if the `--timeout <secs>` **wall-clock** cap elapses (`0`/absent = no
cap), or if the root's first turn errors (the cap starts after that turn). `--task` requires `[workflow]` and
`--agents`, and cannot be combined with `-p`; a `{{task}}` template without a task
is a boot error. `-p` is unaffected (it stays a single root turn), and a pure
`[team]` (no `[workflow]`) remains the interactive, per-task path.

```toml
[orchestrator]
# Root-only workflow guidance (requires --agents), folded into the system prompt
# as a `# Orchestrator workflow` section after the team roster.
guidelines = """
Step 1: message a member by name; Step 2: verify their result.
"""

[peers]
# Persistent phonebook entries for `message`'s `to` (requires --agents). A value
# containing `:` is an address alias (`agent:<id>`); otherwise it is a socket
# path, connected lazily (Unix only).
explorer = "/tmp/wcode/explorer.sock"
helper   = "agent:w7"
```

Environment variables beat the toml: `WCODE_BASE_URL`, `WCODE_API_KEY`, and
`OPENAI_API_KEY` as a key fallback, plus `WCODE_ENDPOINT`, `WCODE_EFFORT`,
`WCODE_RTK` and `WCODE_GREP`/`WCODE_FIND` (which override
`tools.grep`/`tools.find`), and `WCODE_COMPACT_BUDGET`, `WCODE_COMPACT_WINDOW`,
`WCODE_COMPACT_MIN_REMAINING`, `WCODE_COMPACT_KEEP_RECENT_TOKENS`,
`WCODE_COMPACT_KEEP_RECENT_TURNS`, and `WCODE_INSTRUCTIONS` (a file name/path, or
`off`), `WCODE_SKILLS` (extra skill roots, or `off`), `WCODE_TASK` (a `[workflow]`
run's task when `--task` is absent), and `WCODE_RETRY_MAX`,
`WCODE_RETRY_BASE_MS`, `WCODE_RETRY_CAP_MS`, `WCODE_RETRY_TTFT_MS`,
`WCODE_RETRY_IDLE_MS` (the `[retry]` table).

Provider precedence is `[models.<id>]` > flags/env/config: the toml globals,
`WCODE_*`, and `--base-url`/`--endpoint` together set the **default provider**;
a `[models.<id>]` entry for the selected model overrides the
`endpoint`/`base_url`/`api_key` fields it names (an unmapped model — or a field
the entry omits — uses the default).

`--config <path>` (or `WCODE_CONFIG`; flag beats env, relative to the cwd)
points at an **overlay** file deep-merged over the global config: tables merge
per key, so an overlay's `[tools] grep = true` adds that flag without clobbering
the rest of the global `[tools]`, while a scalar or array is replaced wholesale.
This keeps team/model overlays (per repo, per project) out of the provider
config. The explicit overlay layers **on top of** the auto-discovered project
overlays (see below); a missing or unparseable overlay is an error.

`--model` / `--base-url` / `--endpoint` / `--effort` / `--config` override a
successfully loaded config (and `--model` rescues a missing `model`), but
cannot rescue an unreadable or invalid `config.toml` — that still exits with an
error. `--effort -` (or `none`/`off`) clears back to send-nothing. When `--model`
selects an id, its `[models.<id>]` entry overrides the default provider for the
fields it names, and switching back to an unmapped model restores the default
provider (a model switch is reversible).

`--no-instructions` skips the instruction files; `--dump-system-prompt` prints
the composed system prompt (instructions included) and exits — no model needed.

With `--agents` and a `[team]`, wcode spawns each member at startup and the
root's system prompt gains a `# Your team` block listing them by name (and role);
members are addressed by name with `message`. The root also gets `member`:
`op = "status"` peeks a member's last completed work (no model call), `op = "ask"`
puts a tool-free question to the member's own model, and `op = "cancel"` stops its
in-flight run — all three work while the member is mid-run (a read comes from its
last turn boundary, a cancel takes effect without waiting for the run to end). A
non-empty `[team]` auto-enables agents mode — no `--agents` needed — and a served
`--owner` worker never sees the block. An
`[orchestrator] guidelines` string is appended as a `# Orchestrator workflow`
section after the team roster, so the workflow can reference the team.

Project config is auto-discovered from the working dir: `./.wcode/config.toml`
then `./.wcode/team.toml` (team wins where they overlap), folded over the global
config, with an explicit `--config`/`WCODE_CONFIG` overlay still layered on top.
Agent definitions are authored as markdown — YAML frontmatter (`name`, `model`,
`effort`, `tools`, `read_only`, `base_url`, `api_key`) plus a body that becomes
the member's `role`. They are discovered from two roots:

1. **project** — `./.wcode/agents/**/*.md`, folded **under** the TOML `[team]`
   (a TOML member of the same name wins);
2. **global** — `~/.config/wcode/agents/**/*.md`, the lowest precedence, so a
   team travels with you across repos.

A `[[team]]` entry may also point at a file outside either root, which is how a
preset references a role that lives elsewhere:

```toml
[[team]]
file = "roles/judge.md"     # cwd-relative (absolute allowed)
model = "claude-haiku-4-5"  # a sibling key overrides the file's
```

The file supplies the member; a sibling key overrides it. A `name` given both
inline and in the file must agree, and a missing or unparseable file is a hard
error (unlike a scanned `.md`, which is skipped with a warning).

`--no-project-config` (or `WCODE_PROJECT_CONFIG=off`) disables **project**
auto-discovery only — the `./.wcode/{config,team}.toml` overlays and the
`./.wcode/agents/` scan. The global `config.toml` and the global
`~/.config/wcode/agents/` scan are not project config and still load.

The repo ships `.wcode/team.toml` as exactly this — the `[orchestrator]` guidelines
and `[tools] grep/find = true`, with the members in `.wcode/agents/*.md` and the
provider/model left in the global config — so here `wcode` (or `wcode --agents`)
starts the full team with no flags.

Keyless local OpenAI-compatible endpoints (Ollama, llama.cpp, vLLM, ... over
`localhost` / `127.0.0.1` / `[::1]`) work without a key — wcode sends a
placeholder bearer token so `--base-url http://localhost:11434/v1` just works.

## Use

```
wcode                        # REPL
wcode -p "explain this repo" # one-shot: run, print reply, exit
wcode --agents --task "fix bug 123" --timeout 600   # headless [workflow] run
wcode --resume               # continue the latest session
wcode --no-session --model m --base-url http://localhost:11434/v1
wcode --list-models          # print GET {base_url}/models ids, exit
wcode --list-themes          # print the theme names (built-ins + `omarchy`), exit
wcode --theme nord           # select a preset or `omarchy` (or `[theme] name` in config)
wcode --no-instructions      # run without loading instruction files
wcode --dump-system-prompt   # print the composed system prompt, exit
wcode --detect-endpoint      # when base_url is unset, probe common local endpoints
wcode --dump-config          # print the resolved endpoint/base_url/model and key SOURCES, exit
wcode --no-skills            # run without discovering skills
wcode --sequential           # run each tool call one at a time
wcode --tui                  # force the full-screen TUI (default on a TTY)
wcode --no-tui               # force the line REPL (pipes/CI)
wcode serve --stdio          # serve the frame protocol over stdin/stdout (any platform; implies serve)
wcode --peer explorer=/tmp/wcode/explorer.sock  # register a remote peer for `message` (repeatable)
wcode serve --name w7 --owner agent:orchestrator  # serve this session as <owner>'s worker
```

The interactive path: with no `-p`, `wcode` starts a full-screen TUI when stdin
and stdout are a terminal, and the line REPL otherwise. `--tui`/`--no-tui` force
either; `-p`, `serve`, and piped input always take the non-TUI path. The TUI
renders the same `AgentEvent` stream, over a local session or a `--socket` one.

TUI keys: `Enter` submit · `Shift-Enter`/`Ctrl-J` newline · `Up`/`Down` recall
prompts · `PgUp`/`PgDn` (or the wheel) scroll · `Esc`/`Ctrl-C` cancel a run or
quit · `Ctrl-Y` copy the last reply (OSC-52) · `Ctrl-T` expand/collapse every
tool's output · `Ctrl-N`/`Shift-Tab` (or `Alt-1..9`) switch surface · `Ctrl-B`
toggle the sidebar · `Ctrl-A`/`E`/`W`/`U`/`K` readline-style input editing.
`Ctrl-G` enters **browse mode**, a `▌` cursor over the transcript (`j`/`k` next/
prev, `g`/`G` first/last, `Home`/`End` jump to the top/bottom block in view,
`{`/`}` previous/next block, `/` search the transcript with `n`/`N` repeating,
`Enter`/`Space` expand/collapse the selected block, `y` copy it, `?` help,
`Esc`/`q`/`Ctrl-G` leave). The mouse is captured: click a block to select it in
browse, click a sidebar row to focus that surface, drag across the transcript to
select and copy its text (left button only; a modal owns the mouse). `F1` (or
`/help`) opens the full keymap. The rounded input box carries the chrome in its
four corners (project · ⎇ branch · model · effort · the context gauge · the
mode/state), and a **working-team band** sits above the box — one dim row per
member while it runs. The input
box wraps and grows (capped at 8 rows, or half the screen, then scrolls to the
cursor); a paste over 100 chars or more than 3 lines collapses to a `❰ pasted … ❱`
chip; the full text is sent (outer whitespace trimmed). TUI `/`-commands: `/exit`, `/model <id>`, `/effort
[level]`, `/theme [name]` (bare `/theme` opens a picker), `/compact [text]`, `/usage`, `/copy`, `/team`,
`/surface`, `/btw <question>`, `/plan [on|off]`, `/verify`, `/tasks`, `/help` — the
subset that maps to a `Request` under the current session — plus `/changes` (list
the files this run changed, re-showing a diff), `/resume` (pick a session; the
CLI re-execs into it), `/new` (alias `/clear`; start a fresh session — a new
group for a team — by re-exec'ing with no `--resume`; refused over a socket), and
[--no-session]` (rebuild + re-exec into the current session, mirroring the REPL;
refused over a socket). The remaining session-lifecycle commands below stay in
the REPL.
`--agents` and a team, each member runs as its own **surface** (the root, plus one
per member): input and `/`-commands go to the **focused** surface — switch with
`/surface` (a picker), `Ctrl-N`/`Shift-Tab` (cycle), or `Alt-1..9` — and a
**left-docked** sidebar (30 columns wide, shown only when the terminal is ≥ 80
columns; `Ctrl-B` toggles it) with **Team / Todos / Changes** sections, the
focused row marked by a `*`. `/team`
prints the roster.

REPL commands (unknown `/...` lines go to the LLM as prompt text):

| command | effect |
|---|---|
| `/exit` | quit |
| `/new` | fresh conversation + session file |
| `/model <id>` | switch model mid-conversation (logged as a `ModelChange`); bare `/model` lists models |
| `/models [filter]` | list `GET {base_url}/models` ids, `*` marks current; optional case-insensitive substring filter |
| `/effort [level]` | show/set reasoning effort (free-style, logged as `EffortChange`); `/effort -` clears to send-nothing |
| `/resume [path]` | reopen a session and replay its history as context (default: latest); restores model + effort |
| `/sessions` | list sessions, `*` marks the current one |
| `/usage` | print aggregate token usage for the current conversation (input/output, plus cache read/write when reported) |
| `/btw <question>` | a tool-free side question answered from the current context (never committed); a dim `btw: …` prints while it is in flight — the TUI's status corner shows `⠹ btw…` until the reply |
| `/plan [on|off]` | toggle (bare) or set plan mode: the editing tools are disabled and mutating shell commands refused (the `PlanModeHooks` denylist), and a `# Plan mode` section is appended to the system prompt — explore and propose, then record the plan as a todo list and `/plan off` to execute |
| `/verify` | render the plan's progress: the last cached todo checklist, its done count, and whether anything is unfinished |
| `/compact [prompt]` | summarize older messages now, keeping the most recent; an optional `prompt` focuses the summary |
| `/skills` | list the discovered `SKILL.md` packages (name, description, file) |
| `/skill <name> [args]` | force-load a skill's body into a turn — for when the model doesn't pick it up from the prompt section on its own; extra `args` become the task |
| `/reload [--no-session]` | `cargo build --bin wcode` then re-exec into the same session (`--no-session` starts fresh); a failed build keeps the current binary running |

Effort fans out per endpoint: Chat sends `reasoning_effort`, Responses
sends `reasoning: { effort }`, unset sends nothing. Model listing only
returns id metadata — no capability flags — so effort support stays
user-managed.

Plan mode (`/plan [on|off]` in the REPL or the TUI) is a kernel `Hooks` policy
(`PlanModeHooks`) over a shared on/off handle: while on, the editing tools are
refused and mutating shell commands are blocked, and the `# Plan mode` section
(`PLAN_SECTION`) is appended to the system prompt. It is never persisted — a
`/new` or `/resume` starts with plan mode off.

Ctrl-C aborts the run in flight and stays in the REPL; when idle it exits.
Output: text streams to stdout, thinking and tool output are dimmed
(`⚙ name ✓ first-line-of-output`), errors to stderr.

## Tools

| tool | behavior |
|---|---|
| `webfetch` | GET a URL over http(s); HTML reduced to readable text. `format` = `text`/`markdown`/`html` (default `markdown`); `timeout` seconds (default 30, max 120). Body capped at 5 MiB, output at 40k chars. Read-only, always registered |
| `read` | file contents as `<line number>	<content>`. The number is for **locating**, never an address. A line longer than 300 chars is truncated with `…(+N)` — such a line is not usable as an `old_string` (use `grep`). `offset`/`limit` page; without `limit` the page is capped |
| `grep` | regex search; results are `path:lineno:content`, so a hit feeds straight into `edit` as an `old_string` and into `read` as an `offset` (match lines marked `  <--`). **Respects `.gitignore`** (plus `.git`/`target`/`node_modules` as a built-in floor) and skips binaries; `no_ignore:true` searches ignored files too. Glob include filters, context, case-insensitive. **Registered only when `[tools] grep = true` (off by default — `bash` can search)** |
| `find` | glob-based file/dir listing, one path per line. **Respects `.gitignore`**; `no_ignore:true` lists ignored entries too. **Registered only when `[tools] find = true` (off by default — `bash` can list files)** |
| `bash` | `sh -c` in the working dir; stdout, labeled `[stderr]`, exit code; 30s default timeout, Ctrl-C kills. An always-on catastrophic-risk gate (`BashRiskHooks`) refuses device writes, `mkfs`/`wipefs`/`shred` on a device, recursive `rm`/`chmod`/`chown` of `/`/`~`/`$HOME` (incl. the `/*` fold), and the fork bomb — returned as a `blocked:` tool error, no config knob |
| `bg` | manage tasks started with `bash { background: true }`. `list` (all tasks), `status <id>`, `output <id> [tail_lines]` (tail + the spill file path), `wait <id> [timeout_secs]` (block until it finishes; default 60s, max 300s), `kill <id>`. A finished task **pushes** `[message from bg]` back to the session — a turn if idle, next-turn context if running. Always registered |
| `ast_search` | AST-structural search via `ast-grep` (`$UPPERCASE` wildcards); **registered only when an `ast-grep`/`sg` binary is on PATH** |
| `ast_edit` | AST-structural rewrite of **one file** via `ast-grep` (`pattern`/`rewrite` with `$UPPERCASE` wildcards); `commit:false` dry-runs (diff only), default commits atomically and echoes the diff. **Registered only when an `ast-grep`/`sg` binary is on PATH** |
| `edit` | replace an exact `old_string` with `new_string`. Matching is **byte-exact** (whitespace counts) and must be unique unless `replace_all` — a miss is `E_NO_MATCH`, an ambiguous match `E_AMBIGUOUS_MATCH`, nothing written either way. Because the match *is* the check there is no separate staleness guard. `new_string` is verbatim; empty deletes. Whole-file rewrite = `write` |
| `edits` | apply a batch of the same literal-text ops (`{path, edits: [{old_string, new_string, replace_all?}]}`) to **one file** in a single call. Ops run **in order** against the accumulating content; the batch is atomic — if any op fails, nothing is written |
| `write` | create/overwrite; parents created automatically. Content is written byte-for-byte — indentation preserved, never reformatted |
| `todo` | maintain a session-local todo list — call with `todos` to replace the whole list (each item `content` + `status` pending/in_progress/completed), or with no args to read it back. Always registered, every session (workers included) |
| `session_search` | search prior session transcripts: `scope:all` (default) scans every session in the store, grouped by session, excluding the current one unless `include_current`; `scope:current` reads this session (including turns a compaction replaced with a summary) and also takes `turns{start,end}` and `stats`. Terms are ANDed, case-insensitive; thinking/tool text is skipped unless `include_tools`. Read-only, always registered |
| `spawn` | spawn a worker agent for a task — it runs its own session and reports its result back. Returns the worker's address (use it with `message`). Optional `model`/`role`/`tools`/`base_url`/`api_key`/`read_only`/`effort`; `to` defines the worker on a served peer (`--peer`/`[peers]` name or `agent:<id>`) instead of in-process. Root-only |
| `message` | send a message to another agent session: `to` is the recipient's address (omit → your orchestrator); `mode` is `wake` (default), `notify`, `interrupt`, or `ask` |
| `peers` | list the peers you can message, as `name -> address [state]` |
| `task` | plan and track the team's tasks: `op` is `create` (optional `deps`/`gate`), `assign`, `depends`, `complete`, `reject`, `reset`, or `list`. Root-only |

`edit`/`edits`/`write` share a mutation lock and write via a temp file +
rename, so concurrent file mutation can't interleave or truncate.

When the model issues several tool calls in one message, the independent ones run
**concurrently** — `read`/`grep`/`find`/`ast_search`/`webfetch` are read-only and opt in;
mutating tools and `bash` are barriers, so no read can race a write inside a
batch. Results are still appended in call order. `--sequential` (or
`[tools] parallel = false`) restores strictly one-at-a-time execution.

## Design: editing by literal text

Line numbers are positional — insert one line and every number below silently
means something else, the classic way agents corrupt files. So the editing tools
do not address lines by position at all: `edit` replaces an exact `old_string`
with `new_string`, and `edits` batches those ops. This is still
**content-addressed** — the address is the literal text rather than a hash of it
— so the drift-proof property is unchanged (D007).

- `read` prints `<line number>\t<content>`. The number is for *locating* — it
  feeds `offset` and a `file:line` citation — never an address.
- `edit` never re-types the target: it sends the old text and the new text, and
  the match is **byte-exact**, so whitespace counts and indentation is
  meaningful. A miss is `E_NO_MATCH`; a non-unique match is `E_AMBIGUOUS_MATCH`
  (add surrounding lines, or set `replace_all`). Nothing is written either way.
- **The match *is* the staleness check.** If the text changed since the read, it
  simply no longer matches — which is why there is no separate whole-file digest
  guard (the hash-anchor scheme needed one to cover the range *between* two
  anchors; text matching covers the whole span by construction).
- `edits` applies its ops **in order** against the accumulating content, so a
  later op can match what an earlier one wrote. The batch is atomic: any failing
  op refuses the whole call and nothing is written.
- Fully stateless: no anchor store, no session memory, so external edits (bash,
  formatters, other tools) are always picked up.
- `edit`/`edits`/`write` share a mutation lock and write via a temp file +
  rename, so concurrent mutation cannot interleave or truncate.

Structural search is a separate axis: `ast_search`/`ast_edit` shell out to an
`ast-grep` binary (the legacy `sg` alias is the fallback; the same auto-detect
pattern as the rtk hook).

## Skills

A skill is a directory with a `SKILL.md` — YAML frontmatter (`name`,
`description`) plus a body:

```markdown
---
name: pdf-tools
description: Extract text and tables from PDFs. Use for PDF documents.
---

# PDF tools
Run `scripts/extract.sh <file>`.
```

Only `name` and `description` enter the system prompt (as an `# Available
skills` section); the body loads on demand — the agent `read`s the SKILL.md
when a task matches. That is progressive disclosure: many skills cost many
one-line entries, not many bodies. A skill may ship `scripts/`, `references/`,
`assets/`; the body refers to them by relative path.

Roots, highest priority first (first `name` wins):

1. `[skills] dirs` / `WCODE_SKILLS` explicit roots,
2. the working dir and its ancestors up to the repo root — `.wcode/skills/`,
   then the shared dir `.agents/skills/` (falling back to `.claude/skills/`
   where `.agents` is absent),
3. global — `~/.local/share/wcode/skills/`, then the same shared pair.

Malformed skills (bad name, missing description, invalid YAML) warn on stderr
and are skipped. `--no-skills` disables discovery.

## Sessions

JSONL files in `~/.local/share/wcode/sessions`, named `<millis>_<id>.jsonl`
(`~/.local/share` is used even on macOS, matching the config convention).
One entry per message plus `model_change` / `effort_change` markers; torn
final lines are tolerated. `/resume` replays the history as the conversation
context and restores the last model + effort (`--model` / `--effort` flags
win when passed alongside `--resume`).

On a clean interactive exit (`/exit`, or EOF; the TUI's `/exit`) wcode prints the
exact command to bring the session back, e.g.
`resume: wcode --resume <sessions>/<id>/root.jsonl --model <id> --agents` for a
team — copy-paste it to relaunch. A remote (`--socket`) session prints nothing.

With a team, each root session lives in its own **group directory**
`<millis>_<id>/` holding `root.jsonl`, a `manifest.json` (the team topology),
and one `members/<name>.jsonl` per worker — so a team survives a crash and
`--resume` rebuilds the whole team (the root's history plus each member's).
Legacy flat `<millis>_<id>.jsonl` files still list and resume as bare roots.

## Philosophy

pi-like, deliberately small. One kernel — event loop, tool dispatch, hooks,
JSONL sessions — and a thin CLI on top. The kernel crate (`wcode-harness`) is
about 10k lines and the whole workspace about 52k, a fraction of a
batteries-included agent. Absent by
design: MCP, subagents, permission prompts, approval flows, config files for
behavior. That logic belongs in code, via `Hooks`: block or rewrite tool calls
(`before_tool_call`), transform context per turn, force stops. Build the
variant you want instead of configuring one.

### Extending

- **Tools** — implement `TypedTool` (typed args + schema via schemars), wrap
  with `erased()`, add to `default_tools()`.
- **Policies** — implement `Hooks` (`before_tool_call` returning a reason
  blocks execution and feeds the reason back to the LLM as an error).
- **Providers** — implement `StreamFn`: context + system prompt + tool defs
  in, stream of `LlmStreamEvent` out. `rig_stream_fn()` is the built-in
  OpenAI-compatible adapter.

## License

[MIT](LICENSE) © 2026 Wei