Commit Diff


commit - /dev/null
commit + 6b55003318869d06a4f7b148d2bf0649d22053ac
blob - /dev/null
blob + b7432a22b56a53536c4f3e1a6774a14d76af77d4 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,26 @@
+# Build artifacts (objects + depfiles live in-tree next to sources)
+*.o
+*.d
+*.core
+
+# Per-subsystem build output dirs (regress uses obj/)
+obj/
+
+# Generated parser (yacc from common/parse.y)
+/src/parent/parse.c
+/src/parent/parse.h
+/regress/parse/parse.c
+/regress/parse/parse.h
+
+# Built worker binaries
+/src/parent/fugu
+/src/ui/fugu-ui
+/src/net/fugu-net
+/src/exec/fugu-exec
+/src/fetch/fugu-fetch
+
+# Regress test binaries
+regress/*/*_test
+
+# Release tarball staging (make dist)
+/fugu-*.tar.gz
blob - /dev/null
blob + 1c2ac7eccd5ba9586a9fc21a7cc462c1c3e0b78e (mode 644)
--- /dev/null
+++ .scratch/api-hardening/README.md
@@ -0,0 +1,179 @@
+# API hardening sweep: kill the length/NUL bug class at the contract level
+
+Status: done (sweep landed 2026-06-26; deferred items below)
+Origin: audit/fix session 2026-06-26 — `git log --grep audit` and the workflow output at `~/.claude/.../tasks/w3ojf5irm.output` for the history.
+
+## Outcome (2026-06-26)
+
+All six sub-tasks landed. Regress total grew from 660 to 687 checks across 21 suites, 0 failures.
+
+- **1 (buf contract):** `buf_puts` → `buf_puts_cstr` (81 call sites in 19 files); added `buf_terminate` + `buf_cstr` (assert-on-default-build); migrated every `buf_putc(x, '\0'); x.len--;` site (28+ sites across src + regress); new `regress/buf/` suite with 23 checks covering terminate/cstr/embedded-NUL round-trip.
+- **2 (obj_getstr_n promote):** added as public `obj_getstr_n` in `src/common/json.{c,h}`; the static copy in `fetch_run.c` removed; fetch_run.c now uses the common one. The wider migration of `obj_getstr` callers in `openai.c` / `anthropic.c` / `exec_run.c` / `skills.c` was AUDITED but most sites are dispatch-only (strcmp on enum-like strings) or feed `json_kv_string` (itself strlen-based — a separate migration). See "Deferred" below.
+- **3 (json_tok_strdup doc):** header comment in `json.h` steers wire/storage callers to `_n`.
+- **4 (tlsconn audit):** `tls_error()` is called only inside `tlsconn_dup_error`; the file-level contract is now documented inline. No new tls_error leaks.
+- **5 (ACL tests):** four host-match boundary checks added to `regress/fetch/fetch_test.c` — exact-pattern rejects "evillocalhost" vs "localhost"; dot-suffix accepts "localhost" vs ".localhost"; dot-suffix rejects "evillocalhost" vs ".localhost" (the label-anchor regression).
+- **6 (secret_load):** new `secret_load(have_inline, val, have_file, path, &out, &len)` in `parent.c`; `netprov_key` returns the true byte length; `delegate_net` and the Kagi path in `delegate_fetch` now `freezero(buf, keylen)` instead of `strlen` — so a file-loaded key with embedded NUL bytes is wiped completely. Old `resolve_secret` kept as a thin back-compat wrapper.
+
+## Deferred
+
+- Wider `obj_getstr` → `obj_getstr_n` migration in `exec_run.c` (paths/commands flowing into `open(2)`, `argv`), `openai.c` (tool name/description into `json_kv_string`), and `anthropic.c` (error message). Most sites need both `_n` AND a length-aware sink (`json_kv_string_n` etc.) — a separate sub-sweep.
+- Migration of remaining `obj_getstr` callers whose sinks are themselves strlen-based (`json_kv_string`, `buf_puts_cstr`). The producer fix has marginal value without the sink fix.
+- NUL-validation guards at the security boundary (exec_run.c paths/commands): explicit `if (strlen(s) != s_len) reject;` after `obj_getstr_n`. Not a behavior regression today (the strlen sink truncates uniformly), but the right defense.
+
+## Goal
+
+The audit and fix session closed a class of correctness/safety bugs caused by
+mixing length-aware producers with strlen-based consumers (the canonical case
+was `on_tool_call` in `net_api.c` reading past `buf->len` via strlen, splicing
+heap garbage into outbound JSON; siblings included `openai.c` accumulating
+streamed tool args via `buf_puts`, and `fetch_run.c` using `strlen` for HTTP
+body length on `json_tok_strdup` output that can carry embedded NULs).
+
+This issue tightens the **API contracts** so that future code physically
+cannot reintroduce the same shape. Six sub-tasks below; do them in order or
+land them piecewise — none blocks another beyond what's noted.
+
+## What's already done (do not redo)
+
+- `json_tok_strdup_n(d, tok, &lenp)` exists in `src/common/json.{c,h}` and is
+  used by the openai, anthropic, and fetch_run fixes.
+- An ad-hoc `obj_getstr_n` is **static** inside `src/fetch/fetch_run.c` — its
+  shape is the prototype for the promoted version (see task 2).
+- `acl_path_prefix_match(path, pref, preflen)` is a **static** helper in
+  `src/fetch/fetch_run.c` (around line 1054, see commit) — promote/extend it
+  in task 5.
+- `tlsconn_connect`'s `*errstr` already takes ownership (caller `free()`s) —
+  task 4 is about encapsulating libtls error access more cleanly, not the
+  ownership change itself.
+
+## Sub-tasks
+
+### 1. `struct buf` contract: make NUL-termination an explicit, asserted invariant
+
+The bug we hit was that `b->data[b->len]` can be uninitialized after
+`buf_puts`. The post-fix convention is "callers explicitly NUL-terminate
+when they later read `.data` as a C string." Bake that into the API.
+
+Concrete changes in `src/common/buf.{c,h}`:
+
+- Rename `buf_puts(b, s)` → `buf_puts_cstr(b, s)` so its strlen semantics are
+  loud at every call site. Mechanical sed-then-review.
+- Add `void buf_terminate(struct buf *b)` that does
+  `buf_putc(b, '\0'); b->len--;` (the pattern fugu now uses everywhere). Use
+  it at `on_tool_call`, `convo_add_user`, etc. for symmetry.
+- Add a debug-only helper:
+  `static inline const char *buf_cstr(const struct buf *b)` that
+  `assert(b->data[b->len] == '\0')` then returns `b->data`. Migrate the
+  `json_raw(&w, b.data)` callers to `json_raw(&w, buf_cstr(&b))`. Under
+  `FUGU_DEBUG=1` this would have caught the original overrun at first hit.
+
+Verification: `make FUGU_DEBUG=1 regress` still passes (existing tests
+already exercise the converted call sites); `make` (default) is unchanged.
+
+### 2. Promote `obj_getstr_n` to a shared primitive
+
+Currently a static helper in `fetch_run.c`. Many callers across `openai.c`,
+`anthropic.c`, `net_run.c`, `parent.c`, `confload.c`, etc. still use the
+strlen-based `obj_getstr` (or hand-rolled equivalents) on model/user input
+that can carry embedded NULs.
+
+- Add `char *obj_getstr_n(const struct json_doc *d, int obj, const char *key,
+  size_t *lenp)` to `src/common/json.{c,h}` (alongside `json_tok_strdup_n`).
+- Grep `obj_getstr(` and audit each call site:
+  - if the caller hands the string to a strlen-based writer that crosses the
+    network or storage boundary → migrate to `obj_getstr_n`
+  - if the caller only uses the string for equality / dispatch (`strcmp` on
+    a small enum) → leave it (NUL semantics are equivalent)
+- Specifically check: `src/common/anthropic.c` (audit flagged it as
+  "probably has the same pattern but wasn't fully read" — confirm or fix);
+  `src/exec/exec_run.c` (paths and shell commands — NULs here are
+  exploitable on the exec side, treat carefully).
+
+### 3. Deprecate `json_tok_strdup` once callers are migrated
+
+After task 2 completes, the only remaining strlen-based callers should be
+ones that legitimately don't care about embedded NULs. Document this:
+
+- Add a comment to `json_tok_strdup` in `json.h` saying "prefer
+  `json_tok_strdup_n` when the decoded bytes flow into a wire format or
+  storage; this strlen-truncating variant is for dispatch/equality only."
+- Optional: add `__attribute__((__deprecated__(...)))` if you want compile-
+  time pressure to migrate. Probably overkill given the small caller list.
+
+### 4. `tlsconn`: confine libtls error pointer to the module
+
+`tlsconn_connect` already returns an owned `*errstr`. But the underlying
+borrow-then-free pattern is intrinsic to libtls and could resurface in any
+new `tlsconn_*` function (e.g. a future `tlsconn_renegotiate`, or if someone
+exposes `tlsconn_get_state`). Encapsulate once:
+
+- Add `static char *tlsconn_dup_error(struct tls *ctx)` in `tlsconn.c` —
+  already added by the fix workflow; promote it to a small documented
+  helper inside the file (keep it static — it's an internal contract).
+- Audit `tlsconn.c`: any function that captures a `tls_error()` pointer for
+  return must go through `tlsconn_dup_error` BEFORE the next `tls_free` or
+  `tls_close`. The fix workflow handled `tlsconn_connect`'s three call sites
+  + the early `tls_client failed` literal; verify nothing else has crept in.
+
+### 5. ACL: route every path/host check through one helper
+
+`fetch_run.c:acl_path_prefix_match` exists. Sister checks (host match,
+deny-list, future allow-list extensions) should all go through similar
+named helpers so the segment-boundary rule, case-folding, and any future
+normalization live in ONE place:
+
+- Add `static int acl_host_match(const char *host, const char *pat)` if not
+  already factored — currently `acl_host_match` inlines the dot-suffix
+  logic. Trust but verify it's not vulnerable to the same off-by-one
+  pattern (audit said it isn't, but a unit test under
+  `regress/fetch/fetch_test.c` codifying the rule is worth having).
+- Consider normalizing patterns at config-load time: strip trailing slashes
+  on path patterns, lowercase host patterns. Push the normalization into
+  `confload.c` so runtime checks are simpler.
+
+### 6. `resolve_secret`: length-aware secret loading
+
+Current `resolve_secret` in `src/parent/parent.c` returns a `char *` and
+`explicit_bzero` happens at strlen boundary in callers. For keys with
+embedded NULs (rare but legitimate for raw-bytes credentials) the trailing
+bytes leak past `bzero`.
+
+- Add `int secret_load(const char *path, char **out, size_t *outlen)` that
+  returns (ptr, len) so callers can `freezero(buf, len)` correctly.
+- Use it in `delegate_net` / `delegate_fetch`. The M_SECRET payload already
+  carries a length over imsg, so net/fetch end is fine.
+- Keep `resolve_secret` as a thin wrapper around `secret_load` until all
+  callers migrate, then remove.
+
+## Constraints
+
+- **OpenBSD KNF** is the tiebreaker on style (the user has this as a
+  feedback memory: prefer base-system idioms when in doubt).
+- **No new dependencies.** Stay within the existing toolchain.
+- **Privsep contract is sacred.** No new path between workers; no new
+  imsg variant unless the existing M_TOOL_* / M_CONFIG / M_SECRET set
+  cannot model the change.
+- **Test-first where the cost is low.** Each task should land with a
+  regress test in the appropriate `regress/<subsystem>/` dir. The audit
+  fix workflow's tests are the template.
+
+## Verification
+
+- `make` clean (no warnings, default flags)
+- `make FUGU_DEBUG=1` clean (UBSan-trap + Fortify + -Wextra -Wformat=2)
+- `make -C regress regress` — all dirs pass, total check count ≥ 870
+- A focused regress test exists for each migration that introduces a new
+  invariant (e.g. task 1 should add a `regress/buf/` if not present and
+  test that `buf_terminate` + `buf_cstr` round-trips)
+- The audit summary in the session output (`w5m6qp6ti.output`) should
+  re-run with zero confirmed findings against the modified files
+
+## Out of scope
+
+- Adding ASan/MSan support. OpenBSD's clang ships no runtime; UBSan-trap is
+  what's portable. Don't try to graft an ASan port.
+- Refactoring providers (anthropic.c, openai.c) beyond the embedded-NUL
+  migration. The agent-loop/streaming design is sound.
+- The skills feature. Skills landed in two prior phases; the contract is
+  stable. Hands off unless a task above touches a function skills uses,
+  in which case re-run the skills regress (`regress/skills/`).
blob - /dev/null
blob + 4636cfdab272ae9b5379d2c75cbd02651695bfb9 (mode 644)
--- /dev/null
+++ .scratch/fetch-tls-refactor/README.md
@@ -0,0 +1,119 @@
+# fetch_run.c: route TLS through `tlsconn` instead of open-coding libtls
+
+Status: done (2026-06-26)
+Origin: /code-review ultra finding #12, 2026-06-26 session.
+
+## Outcome (2026-06-26)
+
+http_fetch now attaches TLS via `tlsconn_attach` instead of open-coding `tls_client` / `tls_configure` / `tls_connect_socket` / `tlsconn_do_handshake`. Real libtls error reasons now surface to the user (e.g. "TLS handshake failed: certificate verification failed: certificate has expired" instead of the historical canned literal "TLS handshake failed"). All four `tlsconn_*` hardening properties — owned `*errstr` strings, the `tlsconn_dup_error` chokepoint, the centralised handshake retry loop, and the lifecycle invariants under heap churn — now protect fetch the same way they protect net.
+
+Total regress 705 → 710 (0 failures), +5 from the new `tlsconn_attach` test.
+
+### New tlsconn primitive
+
+`tlsconn_attach(struct tlsconn *, struct tls_config *, int fd, const char *servername, char **errstr)` complements `tlsconn_connect`. fetch needs it because its `dial()` does the TCP connect under its own SSRF guard + wall-clock deadline; `tlsconn_connect` (which dials internally via libtls) would silently bypass both. Ownership of `fd` transfers to the `tlsconn` on success; on failure `c.fd` stays -1 and the caller still owns the fd.
+
+### fetch_run.c changes
+
+- `http_fetch` signature: `const char **errstr` → `char **errstr` (caller-owned heap string).
+- `dial` signature: same flip — `*errstr` is heap-allocated at every failure site.
+- All `*errstr = "literal"` writes migrated to `*errstr = xstrdup("literal")`.
+- TLS section reduced to: `if (tlsconn_attach(&c, fs->tlscfg, fd, host, &tlserr) == -1) { compose; free(tlserr); goto out; }`.
+- `out:` label uses `tlsconn_close(&c)` (idempotent; handles the never-attached case too).
+- Three callers (`do_web_search`, `do_web_fetch`, `do_http_request`) updated to `char *err = NULL` and `free(err)` after the buf_printf.
+
+### New regress
+
+`test_attach_caller_keeps_fd_on_failure` in `regress/tlsconn/`: caller dials a stub that immediately closes; `tlsconn_attach` returns -1 with heap-owned `*errstr` (heap-stable under churn); `c.fd` stays -1; caller's fd is still its responsibility to close. Codifies the asymmetric-ownership invariant between `tlsconn_connect` and `tlsconn_attach`.
+
+### Verified
+
+- `make` clean
+- `make FUGU_DEBUG=1` clean (UBSan-trap + Fortify + Wextra + Wformat=2)
+- `make regress` — 710 checks, 0 failures across all 22 suites
+- Adversarial workflow review: 3 lenses (correctness / memory-leaks / KNF), all `verdict: ok`. Cosmetic fixes from the review applied.
+
+### Live verification (deferred)
+
+The handoff's Verification section calls for a hit against an expired-cert host to see the new error text. Defer until the user reinstalls the port (`/usr/local/bin/fugu` is still on the previous build). Suggested live test once reinstalled:
+
+```
+/web_fetch https://expired.badssl.com/
+```
+
+Before: `web_fetch: expired.badssl.com: TLS handshake failed`
+After: `web_fetch: expired.badssl.com: TLS handshake failed: certificate verification failed: certificate has expired`
+
+## Goal
+
+`http_fetch` in `src/fetch/fetch_run.c` (around line 822) calls `tls_client`, `tls_configure`, `connect_socket`, `tls_handshake` directly, instead of going through the `tlsconn` module that net_api uses. Consequences:
+
+1. Every TLS error in the fetch worker surfaces a canned string literal (`"TLS handshake failed"`, etc.) instead of the real `tls_error(c.ctx)` reason. Users can't tell a cert verify failure from a hostname mismatch from a connect-time DNS error.
+2. Any future hardening of `tlsconn_connect` — SNI policy, cert pinning, OCSP, the recent owned-errstr ownership change — silently bypasses fetch.
+3. The use-after-free bug class the audit fixed in `tlsconn_connect` could re-emerge here: `tls_error()` returns a borrowed pointer; if fetch_run captures it before `tls_close()`/`tls_free()`, it has the same lifetime hazard.
+
+Route `http_fetch` through `tlsconn` so the contract is enforced in one place.
+
+## Why this matters
+
+Today the fetch worker handles:
+
+- Kagi search (`web_search`)
+- Web page fetch with HTML→text rendering (`web_fetch`)
+- General HTTP request to allowlisted hosts (`http_request`)
+
+All three share the `http_fetch` helper. All three see degraded error messages on TLS failure and don't benefit from any tlsconn hardening.
+
+This is pre-existing (not a regression from this session), but: the audit hardened `tlsconn_connect`'s `*errstr` ownership; with fetch open-coding TLS, that hardening only protects net, not fetch. Anyone touching fetch's TLS path has to remember to keep it in sync with tlsconn.
+
+## What's already done
+
+- `tlsconn_connect` returns an owned, heap-allocated `*errstr` (`char **`, caller frees) — landed in the audit-fix workflow.
+- `tlsconn.c` has `tlsconn_dup_error` (static helper) centralising the libtls error capture.
+- API hardening handoff documents the broader pattern (`.scratch/api-hardening/README.md` task 4).
+
+This handoff is the concrete refactor that picks up that work.
+
+## Approach
+
+Migrate `http_fetch` (and any sister helpers in fetch_run.c that call libtls directly) to:
+
+1. `tlsconn_client_config(cafile)` for the cert config (already shared)
+2. `tlsconn_connect(&c, cfg, host, port, &err)` for connect+handshake
+3. `tlsconn_write_all(&c, ...)` for the request
+4. `tlsconn_read(&c, ...)` in the response loop
+5. `tlsconn_close(&c)` on cleanup
+
+Match the shape `net_api.c:net_api_call` uses (lines 169-180). On `tlsconn_connect` returning -1, free the owned `err` string after emitting it.
+
+The CA bundle path lives in netstate as `cafile` and in fetchstate as a parallel field — make sure fetch's tls_config equivalent uses the same path semantics.
+
+## Concrete starting points
+
+- `src/fetch/fetch_run.c:822` — start of `http_fetch`, the function to migrate
+- `src/fetch/fetch_run.c` — search for `tls_client`, `tls_configure`, `tls_handshake`, `tls_close`, `tls_free` to find all sites
+- `src/common/tlsconn.{c,h}` — the API to use
+- `src/net/net_api.c:127-232` — `net_api_call` is the working example; mirror its idiom
+- `src/fetch/fetch.c` and `src/fetch/fetch_run.h` — fetchstate definition; check whether `cafile` and `tlscfg` are already plumbed (similar to net's `netstate.tlscfg`)
+
+## Constraints
+
+- OpenBSD KNF
+- Preserve the existing `FETCH_MAXBODY` bound on response size
+- Preserve pledge promises: fetch worker pledges `stdio inet dns`; tlsconn calls don't open files (the CA bundle is loaded before pledge tightens — confirm)
+- Don't introduce a new imsg variant; the existing M_TOOL_REQUEST/M_TOOL_RESULT/M_ERROR set is sufficient
+- The error message format that bubbles up to M_ERROR should now include the real libtls reason — update the `net_emit_error` calls accordingly so the user-facing diagnostic is informative
+
+## Verification
+
+- `make` and `make FUGU_DEBUG=1` clean
+- `make regress` — fetch tests pass (currently the harness doesn't inject TLS failures; see the `tls-regress-harness` handoff)
+- Manual test against a host with an expired or mismatched cert: confirm the M_ERROR text now contains a libtls-quality reason rather than the generic literal
+- After the `tls-regress-harness` work lands, add a fetch-side test that injects a TLS failure and asserts the M_ERROR carries the real reason
+- Live: run `/web_fetch https://expired.badssl.com` (or equivalent) and check the user sees a useful error
+
+## Out of scope
+
+- Replacing the HTTP layer in fetch with the one in `src/common/http.c` (net's). They serve different purposes (fetch streams body for HTML→text conversion); leave the HTTP framing alone, change only the TLS plumbing.
+- Refactoring the Kagi-specific code path. Kagi is just a host on top of `http_fetch`; the migration helps it transparently.
+- Adding HSTS/cert-pinning policy. That belongs in `tlsconn_client_config` and lives in the API hardening sweep.
blob - /dev/null
blob + 5008bfc0ddcb51cebdf6ed3e7d44a3aa54b88d41 (mode 644)
--- /dev/null
+++ .scratch/journal-turn-txn/README.md
@@ -0,0 +1,89 @@
+# Journal transactions: failed turns must not leave orphan records
+
+Status: ready-for-agent
+Origin: /code-review ultra finding #11, 2026-06-26 session.
+
+## Goal
+
+When a turn fails mid-flight (HTTP error from the provider, network drop, malformed stream), the in-memory convo is truncated back to a mark — but the JOURNAL already contains the records the turn wrote BEFORE submitting (skill marker, synth assistant tool_use, synth user tool_result, and any partial assistant message blocks). On resume those records replay, putting the convo in a state the user never actually experienced and the model never actually responded to.
+
+This is most visible with the Phase 2 skills synth pair (three records in flight), but the same shape applies to any future "write-then-submit" turn.
+
+Add transactional boundaries to the journal so resume can faithfully reproduce ONLY the turns that succeeded.
+
+## Why this matters
+
+Today after a failure mid-skill:
+
+- In-memory convo: rolled back to before the synth pair.
+- Journal: `[marker, assistant(tool_use), user(tool_result)]` — orphan trio.
+- Next turn the user types "Hello", journal appends a normal user message.
+- Resume replay: the orphan trio gets converted back to convo state, then the user("Hello") follows.
+- The next API request goes out with `[…, assistant(tool_use), user(tool_result), user("Hello")]` — two adjacent user messages, which Anthropic Messages strict-rejects, and OpenAI tolerates but treats as confused context.
+
+Skills magnify this because Phase 2 records three messages per invocation; pre-Phase-2 a failed turn left a single orphan user message that mostly didn't break alternation.
+
+## What's already done
+
+- The synth pair shape (skills_synth_invocation produces `user(typed) + assistant(tool_use) + user(tool_result)`) — landed in the review-fix workflow.
+- convo_replay now renders tool_result blocks — same workflow.
+
+The orphan-on-failure case was deferred from that workflow because it needs a journal protocol change, not a localised fix.
+
+## Approach
+
+Add a `{"fugu":"turn_failed"}` marker to the journal record vocabulary. On failure, emit the marker right after the partial turn's records. On resume, when the replay walker sees `turn_failed`, scan backwards through journal records until it hits the most recent transactional boundary (a skill marker, a `/clear` marker, a `/compact` marker, or the start of the journal) and DROP all records in between.
+
+This treats every skill marker and every regular user-message boundary as an implicit "begin transaction"; turn_failed is the abort signal.
+
+### Concrete shape
+
+In `src/ui/convo.c`:
+
+- Add `RK_TURN_FAILED` to `enum rec_kind`.
+- Add `convo_marker_turn_failed(struct buf *out)` to emit `{"fugu":"turn_failed"}`.
+- In `record_kind`, recognise the new marker.
+- In `resume_line`, when seeing `RK_TURN_FAILED`:
+  - Walk backwards through `rc->cv` removing the last K messages (free them) up to and including the marker that opened the failed transaction. The marker is the closest previous record matching `RK_SKILL` OR a "user typed" boundary. **Note:** since record_kind only sees one line at a time, you'll need a richer replay state — track the convo-position-at-last-boundary in `struct resume_ctx`, and on TURN_FAILED truncate to that saved position.
+- After the truncation, the next non-marker record reopens a new transaction.
+
+In `src/ui/ui.c` and `src/ui/ui_curses.c`:
+
+- After a failed turn (the `r == 1` branch where we already do `convo_truncate(cv, mark)`), also append a `turn_failed` marker to the journal.
+
+In `src/ui/convo.h` (the header for the new functions).
+
+### Wire format
+
+Use `{"fugu":"turn_failed"}` — symmetric with `/clear` and `/compact`. No payload needed since the marker semantically means "undo from the previous boundary."
+
+## What's been ruled out
+
+- **"Just don't journal until success."** The streamed assistant message records arrive INTERLEAVED with the turn; they must journal eagerly or you lose them on crash. Buffering the whole turn defeats the append-only-as-it-happens design.
+- **"Reorder so the response comes BEFORE the synth pair."** Can't — the response is generated from those messages.
+- **"Make the synth pair atomic in the imsg protocol."** Wrong layer; this is a journal/resume concern, not an imsg concern.
+
+## Concrete starting points
+
+- `src/ui/convo.c:241` — `enum rec_kind` definition
+- `src/ui/convo.c:84-107` — pattern for marker emitters (`convo_marker_clear`, `convo_marker_compact`)
+- `src/ui/convo.c:278-310` — `resume_line` switch where the new case lands
+- `src/ui/ui.c:373-381` — the `r == 1` failure branch where `convo_truncate(cv, mark)` runs today
+- `src/ui/ui_curses.c` — equivalent path inside the M_TURN_DONE / M_ERROR handlers
+
+## Constraints
+
+- OpenBSD KNF style
+- Backward compatibility: old journals (no turn_failed markers, possibly with orphans) must still replay without crashing — just produce the buggy convo they always produced. Don't auto-clean retroactively.
+- Resume's CP_INFO display: emit something like "previous failed turn discarded" so the user sees what happened.
+
+## Verification
+
+- A new regress test under `regress/resume/`: write a journal with marker + synth pair + turn_failed, then a fresh user message. Replay. Assert the convo contains only the fresh user message, and a CP_INFO was emitted.
+- Live: invoke `/smoke` against a deliberately broken provider (e.g. temporarily set an invalid API key), watch fugu error, then exit, then `fugu -c` to resume. The resumed convo should NOT contain the failed skill invocation.
+- `make regress` total count rises by the new tests; all dirs green.
+
+## Out of scope
+
+- A general transaction API for arbitrary multi-record sequences. This is a focused fix for the skill-synth + failed-turn case.
+- Crash recovery (process killed mid-write). The journal is append-only with O_APPEND; partial lines are handled by the existing parser.
blob - /dev/null
blob + 384391355dd860b0b922abe4da40ccc8b1c1cefc (mode 644)
--- /dev/null
+++ .scratch/tls-regress-harness/README.md
@@ -0,0 +1,194 @@
+# TLS regress harness: failure injection + use-after-free regression test
+
+Status: done (2026-06-26)
+Origin: audit session 2026-06-26 — the tlsconn use-after-free fix landed
+without a regress test because the existing harness only exercises the
+happy path. See `src/common/tlsconn.c:tlsconn_connect` (already fixed) and
+`regress/net/net_test.c` for the existing stub TLS server.
+
+## Outcome (2026-06-26)
+
+New `regress/tlsconn/` suite. 18 checks, all passing against the fixed code. Total regress 687 → 705 (0 failures across 22 suites).
+
+Tests landed:
+
+- **Mode 3 (connect-to-nothing):** `tlsconn_connect` against an unbound port. Asserts `*errstr` is non-NULL, non-empty, length stable across heap churn, byte-stable across heap churn, and that `c.fd == -1` / `c.ctx == NULL` post-failure.
+- **Mode 2 (TCP-accept-then-close):** fork()ed stub binds 127.0.0.1:0, accepts, immediately closes. tls_handshake fails inside libtls; same heap-churn assertions on the captured error string.
+- **close-on-uninitialised:** `tlsconn_close` on a memset-zeroed `struct tlsconn` is a clean no-op and idempotent.
+- **repeated-failures-distinct-pointers:** Two back-to-back `tlsconn_connect` failures produce two errstr allocations with distinct heap pointers but identical message bytes — rules out a future regression that returns a shared static buffer.
+
+Heap churn primitive: 32 rounds × 64KB allocations, each filled with `0xA5` then freed, forcing OpenBSD's default malloc.conf junk-fill (`0xDB`) to overwrite any region a stale `tls_error()` pointer might have referenced.
+
+## UAF regression validation (the proof)
+
+The handoff asked for a revert-the-fix experiment to prove the test catches the original bug. Done:
+
+- Replaced the three `tlsconn_dup_error(c->ctx)` calls in `tlsconn_connect`'s fail paths with the pre-fix borrowed-pointer assignment `*errstr = (char *)tls_error(c->ctx)`.
+- Rebuilt + ran `make regress` → process aborted with **SIGABRT** (from a CHECK macro tripping an internal libc consistency check on the corrupted heap region).
+- Restored the fix → 18 checks pass cleanly.
+
+The test cleanly distinguishes the fixed implementation from the buggy one. The exact failure mode (SIGABRT vs CHECK-FAIL vs hang) varies between runs because OpenBSD's heap-junk pattern depends on free/alloc order — but the test ALWAYS detects abnormal behavior with the UAF in place.
+
+## What's already done
+
+- The use-after-free in `tlsconn_connect` is fixed: `*errstr` is now
+  `xstrdup`-allocated and caller-owned. Signature in `tlsconn.h` is
+  `char **errstr` (not `const char **`); contract documented in the
+  header comment around line 39.
+- Callers in `src/net/net_api.c` (lines ~169 and ~273) `free(err)` after
+  `net_emit_error` consumes it.
+- A static `tlsconn_dup_error(struct tls *ctx)` helper in `tlsconn.c`
+  centralises the libtls error capture pattern. See API hardening issue
+  task 4 for completing that encapsulation.
+
+## Goal
+
+Add failure injection to the regress harness for libtls failure modes,
+then write a focused regression test for the use-after-free that was just
+fixed in `tlsconn_connect`. The test should fail loudly against the
+pre-fix code (read-after-free of the libtls error buffer) and pass on the
+post-fix code.
+
+The harness should be reusable: any future TLS robustness work — cert
+rotation, peer verify errors, partial handshakes — can lean on the same
+injection primitives.
+
+## Why this matters
+
+The use-after-free at `tlsconn.c:70` (pre-fix) was confirmed real by three
+adversarial verifiers but stayed latent because:
+
+- OpenBSD's default `malloc.conf` junk-fills freed memory, so the
+  `tls_error()` buffer USUALLY still looks valid after `tls_free()` returns
+- The existing regress only exercises happy-path TLS (`regress/net/net_test.c`
+  stub serves valid responses)
+- The error string ends up in an M_ERROR message displayed to the user, so
+  garbage would be obvious — but only in the rare connect-failure flow
+
+Codified test coverage prevents the next analogous mistake from sleeping
+in production.
+
+## What's already done (do not redo)
+
+- The use-after-free in `tlsconn_connect` is fixed: `*errstr` is now
+  `xstrdup`-allocated and caller-owned. Signature in `tlsconn.h` is
+  `char **errstr` (not `const char **`); contract documented in the
+  header comment around line 39.
+- Callers in `src/net/net_api.c` (lines ~169 and ~273) `free(err)` after
+  `net_emit_error` consumes it.
+- A static `tlsconn_dup_error(struct tls *ctx)` helper in `tlsconn.c`
+  centralises the libtls error capture pattern. See API hardening issue
+  task 4 for completing that encapsulation.
+
+## Approach
+
+The existing harness at `regress/net/net_test.c` runs:
+
+- `H` (this process) acting as parent+UI
+- `N` (forked) running `net_loop` — talks to S over libtls
+- `S` (forked) a stub libtls server with a self-signed cert and canned response
+
+To inject failures, `S` (or a sibling stub) needs modes:
+
+### Mode 1 — Reject at TLS handshake (cert mismatch)
+
+S serves a cert whose subject CN does not match the host string N tries to
+connect to. Or a cert signed by a CA that N hasn't been configured to
+trust. Either path should make `tls_handshake` fail; `tlsconn_connect`'s
+fail label runs, `*errstr` should be a valid heap string after return.
+
+### Mode 2 — Drop connection mid-handshake
+
+S accepts the TCP connection but closes before completing TLS. Surfaces as
+a `tls_error` from the libtls state machine. Same expectation on `*errstr`.
+
+### Mode 3 — Connect-to-nothing
+
+H configures N to talk to a port that nothing is listening on. `tls_connect`
+fails at the TCP layer. Different libtls error message, same lifecycle.
+
+## Concrete starting points
+
+- `regress/net/net_test.c` — the existing stub harness. Lines that fork the
+  stub server, generate the cert, drive H's expectations. Reuse the
+  cert-generation Makefile target.
+- `regress/net/Makefile` — has the openssl/cert step. Either factor it out
+  or duplicate for a new `regress/tlsconn/` if the failure-injection tests
+  don't need a real `net_loop`.
+- `src/common/tlsconn.c` — fixed code under test. Note the three `fail`
+  labels at lines ~70, ~74, ~78 and the early literal at line ~66.
+- `src/common/tlsconn.h:39-47` — the documented contract that the test
+  asserts against.
+- `src/net/net_api.c:140` (declares `char *err`), `:169-175` (fail path
+  with the `free(err)`), `:247`, `:273-278` — the two caller patterns.
+
+## Test outline
+
+Recommended location: a new `regress/tlsconn/` dir, mirroring
+`regress/json/` (which links the C source directly without forking a
+worker). Avoid net_test's full N+S+H choreography unless you specifically
+want to exercise the M_ERROR path.
+
+```c
+/* Pseudocode -- adapt to the harness idiom */
+
+static void
+test_tlsconn_connect_fails_with_owned_errstr(void)
+{
+    struct tls_config *cfg = tlsconn_client_config(NULL); /* default */
+    struct tlsconn c;
+    char *err = NULL;
+    int   rc;
+
+    /* Mode 3 is the cheapest -- no stub server, no cert. */
+    rc = tlsconn_connect(&c, cfg, "127.0.0.1", "1",  /* unbound port */ &err);
+
+    CHECK(rc == -1, "connect to unbound port fails");
+    CHECK(err != NULL, "errstr populated on failure");
+    CHECK(strlen(err) > 0, "errstr is non-empty");
+    /* The key assertion: poison the heap, then read err.  On the buggy
+     * code this would have been pointing into freed libtls memory and
+     * the bytes would no longer match.  On the fixed code we own the
+     * memory so it's stable. */
+    explicit_bzero_then_resurrect_test_heap();
+    CHECK(strlen(err) > 0, "errstr still valid after heap churn");
+
+    free(err);
+    tls_config_free(cfg);
+}
+```
+
+For Modes 1 and 2 add a small stub server inline (the cert-generation in
+`regress/net/Makefile` is reusable verbatim — split it into a regress-wide
+make include).
+
+## Constraints
+
+- **OpenBSD KNF** style.
+- **No real network.** Use loopback + ports the kernel picks (bind to
+  port 0, query the OS for the assigned port — the same pattern
+  net_test.c uses).
+- **Sanitizer-friendly.** The test must pass under `FUGU_DEBUG=1` (UBSan
+  trap). If you trigger a TLS error path that has a known UBSan signal
+  in libtls itself, document it and skip rather than mask.
+- **No new tlsconn API.** Test against the existing contract. If a new
+  helper is genuinely needed, file a follow-up for API hardening task 4.
+
+## Verification
+
+- `make -C regress/tlsconn regress` — the new test runs and passes
+- Revert the fix on a scratch branch (restore `*errstr = tls_error(c->ctx)`
+  in `tlsconn.c`), rebuild, re-run — the test should fail. Document the
+  observed failure mode in the commit message. (Don't ship the revert.)
+- `make -C regress regress` — total check count rises by the count this
+  test adds, all dirs still green
+- `make FUGU_DEBUG=1 regress` — the same, under UBSan trap
+
+## Out of scope
+
+- Verifying every libtls function fugu uses. Focus on the failure paths
+  that hit the documented contract (`tlsconn_connect`'s `*errstr`).
+- Mocking the entire libtls API. Use real libtls against a stub server;
+  that's how net_test.c works and it's the right scope.
+- Touching `src/net/net_api.c` callers. They've been updated; the test is
+  unit-level on tlsconn.c.
blob - /dev/null
blob + 5d698d507c141ae6341126006fb465c715709410 (mode 644)
--- /dev/null
+++ AGENTS.md
@@ -0,0 +1,15 @@
+# AGENTS.md
+
+## Agent skills
+
+### Issue tracker
+
+Local markdown under `.scratch/<feature>/`. No remote tracker; PRs are not a triage surface. See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Five canonical roles, default strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`), recorded as a `Status:` line in each issue file. See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context: one `CONTEXT.md` and one `docs/adr/` at the repo root (created lazily). See `docs/agents/domain.md`.
blob - /dev/null
blob + 1f1f3da5df55ec73c2be616a13d5fcfe56e42e40 (mode 644)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,23 @@
+Copyright (c) 2026 Isaac <isaac@itm.works>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+----------------------------------------------------------------------
+
+src/common/log.c and src/common/log.h are reused from OpenBSD base and
+are covered by the same ISC terms above, copyright (c) 2003, 2004
+Henning Brauer <henning@openbsd.org>.
+
+fugu is inspired by the behavior of "pi" (the @earendil-works/pi-coding-agent
+project, MIT, copyright (c) Mario Zechner). It is a clean-room reimplementation
+and shares no source with pi.
blob - /dev/null
blob + 7ac86ed774c4324a94572ea3a4703c4bdd0aa43b (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,35 @@
+# fugu — OpenBSD-native AI coding agent
+
+# Keep VERSION in step with V in the port's Makefile.
+VERSION=	0.1
+DISTNAME=	fugu-${VERSION}
+# Where `make dist` writes the release tarball the port installs from.
+DISTDIR?=	/usr/ports/distfiles
+
+SUBDIR=	src
+
+.include <bsd.subdir.mk>
+
+# Package the working tree into the source distfile the port consumes: a clean
+# ${DISTNAME}.tar.gz (top directory ${DISTNAME}/, no build artifacts) written to
+# ${DISTDIR}.  Afterwards, in the port: `make makesum` to refresh distinfo, then
+# `make reinstall`.  The model never installs itself; this only produces the
+# tarball that ports(7) builds and installs from.
+.PHONY: dist
+dist:
+	rm -rf ${DISTNAME}
+	mkdir ${DISTNAME}
+	pax -rw LICENSE Makefile README.md docs etc man regress run-fugu src \
+	    ${DISTNAME}
+	find ${DISTNAME} \( -name '*.o' -o -name '*.d' \) -exec rm -f {} +
+	find ${DISTNAME} -type d -name obj -exec rm -rf {} +
+	find ${DISTNAME}/regress -type f -name '*_test' -exec rm -f {} +
+	rm -f ${DISTNAME}/src/parent/parse.c ${DISTNAME}/src/parent/parse.h \
+	    ${DISTNAME}/regress/parse/parse.c ${DISTNAME}/regress/parse/parse.h
+	rm -f ${DISTNAME}/src/parent/fugu ${DISTNAME}/src/ui/fugu-ui \
+	    ${DISTNAME}/src/net/fugu-net ${DISTNAME}/src/exec/fugu-exec \
+	    ${DISTNAME}/src/fetch/fugu-fetch
+	mkdir -p ${DISTDIR}
+	tar -czf ${DISTDIR}/${DISTNAME}.tar.gz ${DISTNAME}
+	rm -rf ${DISTNAME}
+	@echo "wrote ${DISTDIR}/${DISTNAME}.tar.gz"
blob - /dev/null
blob + 40d2e5ddc746cab904cec4884e9462119c1e0671 (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,86 @@
+# fugu
+
+`fugu` is an OpenBSD-native AI coding agent — a small, privilege-separated
+coding assistant written in C, in OpenBSD style, sandboxed with `pledge(2)` and
+`unveil(2)`. It is a clean-room reimplementation (in spirit, not source) of the
+TypeScript agent *pi* by Mario Zechner.
+
+## Status
+
+Functional. The five privilege-separated processes, the curses and line-mode
+front ends, the streaming Anthropic agent loop, the file/shell/search and
+brokered web tools, and the append-only session journal with resume, `/clear`
+and `/compact` are all in place, with man pages and an OpenBSD port. The
+behavior is exercised by the `regress/` suite (including `make`-driven
+`pledge`/`unveil` sandbox checks, verifiable at the syscall level with
+`ktrace`/`kdump`).
+
+## Usage
+
+    fugu                 # start a session in the current project
+    fugu -l              # list saved sessions
+    fugu -c              # resume the most recent session
+    fugu -r <id>         # resume a session by id
+    fugu -n              # print the effective config (secrets redacted) and exit
+
+In a session, type a message and press Enter; `/clear` resets the active
+context, `/compact` replaces it with a model-written summary, and `/quit`
+exits. Configuration and the Anthropic key live in `~/.fugu/fugu.conf` (mode
+`0600`); see the `fugu(1)` and `fugu.conf(5)` manuals.
+
+## Platform
+
+**OpenBSD only.** fugu depends on `pledge(2)`, `unveil(2)`, `libtls`, `imsg(3)`,
+base `curses`, and the BSD `make` build system. It deliberately does **not**
+build on Linux or other systems — portability shims would undermine the security
+model.
+
+## Architecture
+
+Five privilege-separated processes, each a separate binary that the parent
+`fork`+`exec`s, communicating over `imsg(3)` socketpairs. The parent relays all
+worker-to-worker messages (no direct worker-to-worker channels):
+
+| binary | role | pledge |
+|--------|------|--------|
+| `fugu` | parent: coordinator + imsg relay; reads config, delegates secrets | `stdio proc` |
+| `fugu-ui` | curses/line TUI; owns the tty and the session journal | `stdio tty` |
+| `fugu-net` | libtls HTTPS to the Anthropic API; holds the API key | `stdio inet dns` |
+| `fugu-exec` | file tools + `ksh`; no network | `stdio rpath wpath cpath proc exec` |
+| `fugu-fetch` | libtls web search/fetch (Kagi); parses untrusted HTML in isolation | `stdio inet dns` |
+
+`fugu-exec`'s execpromises omit `inet`, so neither it nor the commands it runs
+can reach the network (opt in per project with `allow_subprocess_net`); its
+`unveil` confines writes to the working tree. `fugu-ui` opens its journal under
+`~/.fugu/sessions` while it briefly holds `rpath wpath cpath`, then narrows to
+`stdio tty`.
+
+Each role links only the libraries it needs (`libtls` only in net/fetch,
+`curses` only in ui), so the untrusted-input processes never map code they don't
+use. fugu runs entirely unprivileged (no root, not a daemon).
+
+## Build (on OpenBSD)
+
+    make obj        # optional
+    make
+    doas make install   # installs under ${PREFIX:-/usr/local}
+
+The OpenBSD port lives in the ports tree (not this repo), under
+`mystuff/productivity/fugu`; build a package with `make package` there.
+
+## Layout
+
+    src/common/   shared code: log, util, buf, json, imsgproto, http, sse, conf
+    src/parent/   fugu        -> ${PREFIX}/bin   (also installs the man pages)
+    src/ui/       fugu-ui     -> ${PREFIX}/libexec/fugu
+    src/net/      fugu-net    -> ${PREFIX}/libexec/fugu
+    src/exec/     fugu-exec   -> ${PREFIX}/libexec/fugu
+    src/fetch/    fugu-fetch  -> ${PREFIX}/libexec/fugu
+    man/          fugu.1, fugu.conf.5
+    etc/          fugu.conf.sample
+    regress/      unit, integration, and pledge/unveil sandbox tests
+
+## License
+
+ISC — see [LICENSE](LICENSE). Bundles the shared OpenBSD `log.c`/`log.h` (ISC,
+© Henning Brauer). Inspired by *pi* (MIT, © Mario Zechner); no shared source.
blob - /dev/null
blob + c5d32ac7e2d236fda6ec003c3df94fa4f0b540b7 (mode 644)
--- /dev/null
+++ docs/adr/0001-keep-kagi-as-search-adapter-seam.md
@@ -0,0 +1,69 @@
+# 1. Keep kagi as a single-adapter seam in anticipation of multi-provider search
+
+- Status: Accepted
+- Date: 2026-06-25
+
+## Context
+
+fugu has one search adapter today: `src/fetch/kagi.{c,h}`. Its interface is the
+pure request-path/response-shape half of the Kagi v1 search API —
+`kagi_search_body()` writes the JSON request body, `kagi_format_results()`
+renders a success envelope into the text the model sees, and `kagi_error_msg()`
+extracts the message from an error envelope. The HTTP transport, the
+Authorization header, and the secret token live in `fetch_run.c`; the adapter
+never touches a socket or a file.
+
+This is a one-implementation interface. A deletion test asks: if `kagi.{c,h}`
+were inlined into `do_web_search()` in `fetch_run.c`, what would get worse? The
+answer is that the JSON-shape logic (envelope discrimination, per-field caps,
+malformed-response handling) would concentrate in the same function that
+already owns transport, deadlines, the SSRF guard, and the Kagi secret. That
+function is already the longest in the fetch worker; folding the adapter in
+would deepen it further and entangle two concerns — wire protocol vs. response
+semantics — that today have a clean seam between them.
+
+The provider layer in `src/common/` is the precedent. `anthropic.{c,h}` and
+`openai.{c,h}` share `provider.h` and the same shape: a pure request builder
+plus a streaming decoder that fires a common `struct llm_cbs`. The net worker
+is identical for either provider. That shape was introduced before the second
+adapter existed; it paid off when openai landed as a drop-in alongside
+anthropic rather than a refactor of the net worker.
+
+The kagi seam exists for the same reason. We have confirmed there is a future
+with other search adapters — Perplexity, Exa, and similar — and the seam is
+the insertion point for them.
+
+## Decision
+
+Keep `src/fetch/kagi.{c,h}` as a search adapter. Its interface stays the pure
+request-path/response-shape pair: build a request body, render a success
+envelope, extract an error message. Transport, secrets, and ACL stay in
+`fetch_run.c`.
+
+Future search providers (Perplexity, Exa, etc.) plug in at the same seam — a
+sibling adapter module exposing the same shape, selected by configuration in
+the fetch worker. When the second adapter lands, the common shape should be
+lifted into a `search_provider.h` mirroring `provider.h`, the same way
+`provider.h` was introduced alongside the second LLM adapter.
+
+## Consequences
+
+- Cost: with only one implementation behind it today, this is a hypothetical
+  seam — leverage we have paid for but not yet collected. The adapter adds one
+  module, one header, and a small amount of indirection that a strict YAGNI
+  reading would call premature.
+- Benefit: a second search provider becomes a drop-in adapter rather than a
+  refactor of `fetch_run.c`. The transport, the SSRF guard, the deadline
+  machinery, and the secret-handling in `do_web_search()` do not have to be
+  re-opened. The pattern is the same one that made openai a drop-in alongside
+  anthropic.
+- Locality: response-shape logic stays out of `fetch_run.c`, which keeps the
+  fetch worker focused on transport, sandboxing, and ACLs. The adapter is pure
+  (no sockets, no files), so it is trivially testable and trivially auditable
+  for a privilege-separated worker.
+- Guidance for reviewers: do not re-suggest collapsing this seam on the basis
+  that there is only one implementation behind it. The seam is intentional,
+  matches the established provider-adapter precedent, and is the documented
+  insertion point for additional search providers. If a second adapter has
+  still not landed after a long period, revisit this ADR rather than silently
+  inlining.
blob - /dev/null
blob + 457df00d424b7a11e6d3e0e00893ff35298c4583 (mode 644)
--- /dev/null
+++ docs/adr/0002-keep-html2text-as-common-module.md
@@ -0,0 +1,53 @@
+# 2. Keep html2text as a common module, anticipating multi-worker consumption
+
+Date: 2026-06-25
+
+## Status
+
+Accepted
+
+## Context
+
+`html2text` (`src/common/html2text.{c,h}`) is a single-pass, allocation-light HTML stripper: ~343 LOC of carefully tested implementation hidden behind a one-function interface:
+
+```c
+void html2text(const char *html, size_t len, struct buf *out);
+```
+
+The implementation handles hostile web input in strictly linear time, defers whitespace to collapse runs and trailing space, skips `script`/`style`/comment content, decodes the common character references, breaks at block-level elements, and caps the produced text at `HTML2TEXT_MAXOUT` (256 KiB).
+
+Today the only consumer is `fugu-fetch` (`src/fetch/fetch_run.c:383`), which calls it when the fetched `Content-Type` is HTML or unknown. `fugu-fetch` and `fugu-net` both run with exactly `stdio inet dns`; `html2text` itself needs no syscalls beyond `stdio`, so its sandbox cost is zero in any worker that already has `stdio`. Tests live in `regress/html2text/` and exercise both exact-output and substring contracts.
+
+The owner has stated that html2text will probably be used in other processes. A plausible second consumer already lurks: `net_run.c`'s tool description advertises the fetch tool as returning "readable text (HTML converted to plain text)" — the same reduction will be useful anywhere a worker handles an HTML payload it did not request (model-server error pages, provider HTML responses to non-streaming endpoints, future tool outputs that proxy web content).
+
+Without an ADR, a future reviewer applying YAGNI/locality heuristics is likely to suggest folding the 343 LOC implementation back into `fetch_run.c` on the grounds that it has one caller. This ADR exists to head that off.
+
+## Decision
+
+Keep `html2text.{c,h}` in `src/common/` as a shared deep module. The interface stays a single function with a fixed output cap; the implementation stays opaque to callers.
+
+Specifically:
+
+- Do not inline `html2text` into `fetch_run.c`, even though fetch is currently its sole consumer.
+- Do not split `html2text` across workers; there is one canonical implementation, and the `regress/html2text` suite is the authority on its behavior.
+- New consumers in other workers (`net`, `exec`, `ui`, `parent`) link the same translation unit via their `Makefile`'s `SRCS`, exactly as `fetch` does today.
+- The seam — the one-function header — is the contract. Callers may not depend on intermediate state, the `emit` struct, or the entity table.
+
+## Consequences
+
+**Positive**
+
+- Future workers that need HTML stripping inherit the safe-stripping invariants (linear time, output cap, no allocation amplification, defended against malformed input) for free, without re-deriving them.
+- The shape mirrors `mdrender`, the other common deep module with currently one consumer (`ui`) — establishing a consistent pattern for "small interface, deep implementation, shared across workers."
+- `regress/html2text` tests cover all current and future consumers; no per-worker duplication of test fixtures.
+- Code review is anchored: this ADR exists specifically so reviewers do not re-propose collapsing `html2text` into `fetch_run.c` on locality grounds.
+
+**Negative / accepted costs**
+
+- The shared seam is currently hypothetical. Until a second consumer lands, the module pays a small organizational cost (a header in `common/`, an entry in `regress/`) for benefit not yet realized.
+- A change to the `html2text` contract (e.g. raising `HTML2TEXT_MAXOUT` or changing whitespace policy) will eventually need coordination across multiple workers rather than one file.
+
+**Revisit if**
+
+- 18 months pass with `fetch` still the only consumer, or
+- a second consumer's needs diverge enough (different output format, different cap policy) that a shared interface becomes a forcing function rather than a convenience.
blob - /dev/null
blob + b548c538d606a65bde9bcd4ca3a458ec7c52cd9b (mode 644)
--- /dev/null
+++ docs/agents/domain.md
@@ -0,0 +1,51 @@
+# Domain Docs
+
+How the engineering skills should consume this repo's domain documentation when exploring the codebase.
+
+## Before exploring, read these
+
+- **`CONTEXT.md`** at the repo root, or
+- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
+- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
+
+If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
+
+## File structure
+
+Single-context repo (most repos):
+
+```
+/
+├── CONTEXT.md
+├── docs/adr/
+│   ├── 0001-event-sourced-orders.md
+│   └── 0002-postgres-for-write-model.md
+└── src/
+```
+
+Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
+
+```
+/
+├── CONTEXT-MAP.md
+├── docs/adr/                          ← system-wide decisions
+└── src/
+    ├── ordering/
+    │   ├── CONTEXT.md
+    │   └── docs/adr/                  ← context-specific decisions
+    └── billing/
+        ├── CONTEXT.md
+        └── docs/adr/
+```
+
+## Use the glossary's vocabulary
+
+When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
+
+If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
+
+## Flag ADR conflicts
+
+If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
+
+> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
blob - /dev/null
blob + a2f08fb077b319ddaffe5ca74b93a4930cdc5248 (mode 644)
--- /dev/null
+++ docs/agents/issue-tracker.md
@@ -0,0 +1,19 @@
+# Issue tracker: Local Markdown
+
+Issues and PRDs for this repo live as markdown files in `.scratch/`.
+
+## Conventions
+
+- One feature per directory: `.scratch/<feature-slug>/`
+- The PRD is `.scratch/<feature-slug>/PRD.md`
+- Implementation issues are `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`
+- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
+- Comments and conversation history append to the bottom of the file under a `## Comments` heading
+
+## When a skill says "publish to the issue tracker"
+
+Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
+
+## When a skill says "fetch the relevant ticket"
+
+Read the file at the referenced path. The user will normally pass the path or the issue number directly.
blob - /dev/null
blob + b716855d485f3865f9dfde2a82141721f065b2e7 (mode 644)
--- /dev/null
+++ docs/agents/triage-labels.md
@@ -0,0 +1,15 @@
+# Triage Labels
+
+The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
+
+| Label in mattpocock/skills | Label in our tracker | Meaning                                  |
+| -------------------------- | -------------------- | ---------------------------------------- |
+| `needs-triage`             | `needs-triage`       | Maintainer needs to evaluate this issue  |
+| `needs-info`               | `needs-info`         | Waiting on reporter for more information |
+| `ready-for-agent`          | `ready-for-agent`    | Fully specified, ready for an AFK agent  |
+| `ready-for-human`          | `ready-for-human`    | Requires human implementation            |
+| `wontfix`                  | `wontfix`            | Will not be actioned                     |
+
+When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
+
+Edit the right-hand column to match whatever vocabulary you actually use.
blob - /dev/null
blob + f46a47bb428394fbd2b3c4f08c7236e68d53647a (mode 644)
--- /dev/null
+++ docs/base-idiom-audit-findings.md
@@ -0,0 +1,576 @@
+# fugu ↔ OpenBSD base: idiom audit findings
+
+**Date:** 2026-06-21 · **Scope:** read-only audit (no code changed) · **Host:** OpenBSD 7.9-current, amd64 (`server`), base at `/usr/src`.
+
+This is a complete, function-by-function accounting of where fugu's hand-rolled
+utilities overlap something OpenBSD **base** already does well — a "seam." Each of
+the **233** functions/groups across **21 source units** is classified exactly once:
+
+- **ADOPT** — base exposes a *linkable* idiomatic equivalent (libc / libutil / libevent / libtls / a base header) that is a drop-in or near-drop-in.
+- **REFERENCE** — base solves the same problem but only *inside a base program* (not linkable, e.g. `ftp(1)`'s HTTP client). Align idioms/edge-cases; don't link.
+- **KEEP** — no base equivalent (or only a ports lib, out of charter). One-phrase reason recorded.
+
+**Method / confidence.** Every ADOPT/REFERENCE claim was produced by a per-file
+audit agent and then independently re-checked by an adversarial verifier against
+the live `/usr/src` tree, headers, and man pages — 42 agents total. **16 citation
+corrections** were applied (all kept the verdict; only the file/line was wrong);
+they appear inline as **[verified correction: …]** in the Notes column. No verdict
+was overturned and no missed seams were found. The `imsg` API was confirmed to be
+the **post-rewrite `imsgbuf_*` / `imsg_get_ibuf`** surface — **fugu is already on
+it, no migration debt.**
+
+---
+
+## TL;DR
+
+| Bucket | Count | Meaning |
+|---|---|---|
+| **ADOPT — actionable** | **1** | Genuine new library adoption (`scandir`). |
+| **ADOPT — already idiomatic** | 16 | Already correctly on the base API; listed to prevent regression. |
+| **REFERENCE** | 67 | Align to a base program's idiom; a handful are real work, most confirm-only. |
+| **KEEP** | 149 | No base option; justified bespoke code. |
+
+The headline is honest: fugu is **already strongly base-idiomatic**. Only **one**
+clean new library adoption exists; the real value is in a few **REFERENCE/SYNC**
+alignments (security-relevant secrecy check, lexer modernization, logging drift).
+
+---
+
+## 1. Adopt these (clear wins)
+
+### Actionable (currently NOT on base — change recommended)
+
+- **`parent/parent.c` `resolve_last` / `list_sessions` → `scandir(3)` + comparator.**
+  Replace the hand-rolled `opendir`/`while(readdir)`/`closedir` trio with one
+  `scandir()` call using a `select()` filter matching the `.jsonl` suffix. **Keep**
+  the mtime `qsort`/`sessrow_cmp` — `scandir`'s `alphasort` sorts by *name*, and
+  fugu orders by *mtime*. Cite `/usr/src/usr.bin/diff/diffdir.c:82`, man `scandir(3)`
+  (decl `/usr/include/dirent.h:91`). **Effort M.** The single clean new adoption in the tree.
+
+### Already idiomatic (confirmed on the base API — do not regress)
+
+These are already calling the right linkable base symbol; recorded for completeness:
+
+- `conf.c` list lifecycle → `sys/queue.h` TAILQ macros.
+- `tlsconn.c` (all 7 fns) → libtls `tls_*` (`-ltls`).
+- `net_run.c` `net_apply_config`/`cfg_str` → `strtonum(3)`; `net_state_clear` → `freezero(3)` + `tls_config_free`.
+- `exec.c` `main` → `getopt(3)`/`signal(3)`/`fstat(2)`; `tools_file.c` `tool_edit` → `memmem(3)`.
+- `ui_curses.c` `cw`→`wcwidth(3)`, `utf8_to_wcs`→`mbrtowc(3)`, `submit`→`wcrtomb(3)`, `do_resize`→`resizeterm(3)`.
+- `ui.c`/`journal.c` line input → `getline(3)`; `exec_run.c` sandbox → two-arg `pledge`/`unveil` `execpromises`; `imsgproto.c` → current `imsgbuf_*`.
+
+---
+
+## 2. Reference / Sync (align to base source — the real alignment work)
+
+**Priority 1 — `conf.c` `conf_check_secrecy` → byte-sync to base `check_file_secrecy()`, and add it to `parse.y` `pushfile`.** *(Security-relevant.)*
+fugu stores API keys (`anthropic_key_file`, `kagi_token_file`) yet `pushfile`
+performs **no** secrecy check. Base copies `check_file_secrecy()` verbatim into ~30
+daemons (`/usr/src/usr.sbin/httpd/parse.y:1795`, called from `pushfile` at `:1834`;
+`bgpd/parse.y:4007`). Two deltas to resolve deliberately: **(a)** base takes an
+**open fd + `fstat()`** (closes the TOCTOU window) — fugu re-`stat()`s by path;
+adopt the fd/`fstat` shape. **(b)** fugu's policy is *stricter* (exact owner, true
+0600) than base (owner-or-root, allows group/world *read*) — **keep fugu's stricter
+policy**, but make the code read like the base idiom reviewers recognize. It is a
+program-internal static (not in `lib/` — confirmed), so this is REFERENCE (copy the
+~18 lines), never ADOPT. **Effort S–M.**
+
+**Priority 2 — `common/parse.y` lexer → sync to current `usr.sbin/httpd/parse.y`.**
+fugu's lexer is forked from an *older* pf/httpd template and has drifted on three
+coupled points:
+1. No `igetc()` indirection layer (`httpd/parse.y:1540`) — fugu inlines raw getc/unget in `lgetc`.
+2. Fixed `pushback_buffer[128]` (returns EOF on overflow) vs httpd's **growable per-file `ungetbuf`** moved into `struct file`, doubled via `reallocarray` (`:1605-1616`) — removes the 128-char ceiling.
+3. Old `parsebuf`/`parseindex` macro expansion vs httpd's **`START_EXPAND`/`DONE_EXPAND` sentinels + `expanding` flag** pushed back through `igetc` (`:1656,1679-1684`) — enables nested macros and deletes the separate parsebuf path.
+
+**Correction to the brief's lead:** there is **no `expand_macros` function** in current
+base `parse.y` (verified: 0 hits in any `usr.sbin`/`sbin` parse.y) — the real
+mechanism is the sentinel/flag scheme above. This is the highest-effort item
+(removing `parsebuf` touches `lgetc`+`lungetc`+`findeol`+`yylex` together) and
+couples with Priority 1 (threading the `secret` flag through `pushfile`/`conf_parse_file`).
+**Effort M–L;** low correctness risk — modernization plus the secrecy win.
+
+**Priority 3 — `common/log.c` → re-baseline onto current base `log.c`.**
+fugu's file is a verbatim copy of `bgpd/log.c` rev 1.64. Current `ntpd/log.c` rev
+1.19 has evolved: a `LOG_TO_STDERR|LOG_TO_SYSLOG` **dest bitmask** (dual-sink instead
+of strictly either/or), multi-level `-v` verbosity, and dropped the `"fatal in "`
+prefix. Decide whether to adopt the ntpd lineage. The `log_warn`/`log_warnx`/`fatal`/
+`fatalx` leaves map to **linkable** libc `warn(3)`/`warnx(3)`/`err(3)`/`errx(3)`
+(in `lib/libc/gen/{warn,warnx,err,errx}.c`) — but **keep the wrappers**: the libc
+variants are stderr-only and cannot route to syslog or honor `log_procname`.
+**Effort S** (cherry-pick drift) to **M** (full re-baseline).
+
+**Priority 4 — low-effort idiom alignment / confirm-only (no behavior change):**
+
+- `util.c` x-allocators → ssh `xmalloc.c`: add the `SIZE_MAX/nmemb < size` guard in `xcalloc` to match canonical; consider `xrecallocarray`/`freezero` wrappers for secret buffers (charter). `xasprintf` is **not** present — add the ssh idiom if formatted-alloc is needed.
+- `http.c` → ftp `fetch.c`: bracket IPv6-literal hosts + drop default `:80/:443` from the Host header (`fetch.c:740-772`); trim trailing OWS (SP/HT) on header lines (`:880-882`). Chunked-decode + `strtonum` already match.
+- `url.c` → ftp `fetch.c`: keep fugu's **stricter** allowlist/anti-injection (better than base's blocklist); align only IPv6/port edge cases (`fetch.c:497-514`).
+- `confload.c` → ssh `groupaccess.c:47` (`ga_init`) for the group-name loop; `getgrouplist(3)` already correct. Decide grow-and-retry vs fixed `NGROUPS_MAX`.
+- `imsgproto.c` / `net_run` imsg paths → already current `imsgbuf_*`; mirror `relayd/proc.c:593 proc_dispatch` EPIPE/channel-death handling.
+- `tools_file.c` `write_atomic` → acme-client `fileproc.c:59` — **fugu adds `fsync(2)`, keep it**; `write_all` → ssh `atomicio.c:50` / vmd `atomicio.c:56`, align EAGAIN/0-byte handling if the fd ever goes non-blocking.
+- `tools_shell.c` `run_capture` → ssh `misc.c:2786 subprocess()` — fugu adds timeout + output cap + SIGKILL (keep); align EINTR. **Do not** adopt `popen` (no stderr/timeout).
+- `fetch_run.c` `dial`/`http_fetch` → ftp `fetch.c:599 timed_connect` / `:646` TLS loop — already aligned; keep the per-`ai` `addr_blocked` SSRF guard ftp lacks.
+- `ui_curses.c` → mandoc `term_ascii.c:93` (locale), ssh `utf8.c`, less `line.c` — already idiomatic; cross-check the `ascii_rep`/`fold_wcs` table against mandoc's; handle `wcwidth==0` combining chars in `render` as less does.
+- `json.c` `json_emit_escaped`/`json_tok_strdup` → `vis(3)`/`unvis(3)` are adjacent but **not** drop-ins (different escape grammar) — keep fugu's JSON-correct tables.
+
+---
+
+## 3. Evaluate (tradeoff calls — you decide; recommendation given)
+
+**`buf.c` → `ibuf` (libutil) / `open_memstream(3)` — RECOMMEND KEEP.**
+Neither is a clean drop-in. `open_memstream` (string role; base user
+`radiusd_standard.c:345`) loses live O(1) `.data`/`.len` introspection (updates only
+on `fflush`) and fatal-OOM. `ibuf` (wire-byte role; `imsg-buffer.c`) has **no
+formatted-append** — yet `buf_printf` is the dominant use across json/http/url — and
+is shaped for framing, not NUL-terminated strings. `buf` is ~115 lines,
+dependency-free, fatal-on-OOM, with a hard-cap growth guard the base options lack.
+Adopting either is **effort L for negative payoff**; align idioms, keep `buf`.
+
+**`parent.c` `relay_loop` poll(2) → libevent — RECOMMEND KEEP poll() for now.**
+Every base privsep daemon uses libevent (`dhcpleased/frontend.c:167-190`), and
+libevent **is** base (charter-allowed). But fugu is deliberately **foreground,
+per-invocation, non-daemon**, fixed 4-channel, no timers/signals beyond SIGWINCH/
+SIGPIPE — a flat `poll()` loop is simpler and callback-free. The one real gap:
+write-side backpressure (`imsgbuf_queuelen` → arm `EV_WRITE`); fugu only `POLLIN`s
+and relies on `ip_flush` blocking — addressable in the poll model too. Adopt libevent
+only if timers/backpressure/more channels arrive. **This is the single largest
+stylistic divergence from base — and a justified one.**
+
+**`parent.c` `run`/`spawn` → relayd/httpd `proc.c` privsep framework — RECOMMEND REFERENCE, don't link.**
+`relayd/proc.c:180 proc_init` / `:70 proc_exec` / `:351 proc_kill` are the canonical
+privsep helpers, but pull in libevent + the full `struct privsep`/instance-array
+model fugu intentionally avoids. fugu's flat 4-worker spawn (with
+`closefrom(IMSG_CHILD_FD+1)` + `/dev/null` redirection) is arguably *tighter* than
+`proc_exec`. Mirror the ordering/teardown discipline; keep fugu's implementation.
+
+---
+
+## 4. Keep (justified — no base option)
+
+- **No base library exists:** `json.c` (no base JSON emitter/parser), `sse.c` (no base SSE/EventSource), `html2text.c` (no base HTML parser — lynx/w3m are ports), `anthropic.c` (Anthropic Messages protocol), `kagi.c` (Kagi API), `convo.c` (Messages-API conversation model), `journal.c` (append-only jsonl).
+- **Security cores with no base analog:** `fetch_run.c` `addr_blocked` (SSRF private-range classifier — base has none), `imsgproto.c` reassembly/oldest-first eviction (base imsg has no chunking), `conf.c` merge engine (sshd-Match-style, program-internal), path-traversal guards (`session_exists`, `valid_id`).
+- **App glue over fugu's own buf/json/imsg layers:** the bulk of `net_run.c`, `exec_run.c`, `fetch_run.c`, `ui.c` — application control flow with no library to adopt.
+
+---
+
+## 4b. OpenBSD base-library linkage check (follow-up)
+
+A library-first pass (complementing the file-by-file audit above): what each
+binary actually links, whether it is all base, and whether any *unused* base
+library would replace hand-rolled code. Verified on `server` via `objdump -p`
+(direct `DT_NEEDED`), `ldd`, and `/usr/lib`.
+
+**Linkage is correct, minimal, and 100% base.** Direct `NEEDED` per binary:
+
+| Binary | Direct NEEDED (base libs) |
+|---|---|
+| `fugu` (parent) | libc, libutil |
+| `fugu-ui` | libc, libcurses, libutil |
+| `fugu-net` | libc, libtls, libutil |
+| `fugu-exec` | libc, libutil |
+| `fugu-fetch` | libc, libtls, libutil |
+
+`libutil` (imsg) is shared by all; `libtls` only where TLS is spoken; `libcurses`
+only in the UI. `libssl`/`libcrypto` appear in `ldd` but are **transitive** via
+`libtls` (not direct `NEEDED`), so the port's `WANTLIB = c curses tls util` is
+exactly right. **No `/usr/local` (ports) dependency anywhere** — charter satisfied.
+
+**No new library to adopt.** Every base library fugu does *not* link was checked
+and is either not-a-fit or a feature-add, not a replacement for hand-rolled code:
+
+| Base lib (present) | Verdict | Reason |
+|---|---|---|
+| `libedit` (`editline(3)`) | KEEP hand-rolled | **Correction: libedit IS in base** (the audit's `handle_input` row wrongly said otherwise). Still not a fit: `el_wgets` owns a blocking read loop and can't interleave with the curses `nodelay`+imsg `poll(2)` loop that edits input *while* streaming deltas render. Line-mode (`getline`) is non-interactive, so editing buys nothing there either. |
+| `libevent` | EVALUATE → KEEP | The poll→libevent design question (see §3); base, deliberately unused for a foreground non-daemon. |
+| `libpanel`/`libform`/`libmenu` | KEEP | Curses extensions for overlapping windows/forms/menus; fugu's TUI is one full-screen layout — no fit. |
+| `libz` | KEEP (note) | gzip/deflate. fugu sends no `Accept-Encoding` and never receives compressed bodies, so nothing to decode today. Available **if** HTTP gzip is ever wanted (feature, not a replacement). |
+| `libm` / `libexpat` / `libreadline` / `libcrypto` (direct) | KEEP | No floating point; JSON not XML (jsmn); libedit is preferred over GPL readline and still no fit; no direct crypto (TLS is libtls's job). |
+
+Net: linkage needs no change; the only artifact is the libedit fact-fix to the
+`handle_input` row below.
+
+---
+
+## 5. Complete per-function accounting
+
+Legend: **Eff** = adoption effort (S/M/L). `path:line` are real sites confirmed on
+`server`. **[verified correction: …]** marks a citation the adversarial verifier
+fixed. Every function/group across all 21 units appears exactly once below.
+
+### `src/common/log.c`
+
+_Verbatim hand-copy of /usr/src/usr.sbin/bgpd/log.c rev 1.64 (only the $OpenBSD$ CVS tag line removed); the canonical daemon-logging idiom is not a linkable lib, so the whole unit is KEEP/SYNC-to-base, with the warn/fatal leaf group REFERENCE-aligned to libc err(3)/warn(3)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| log_init | Sets debug/verbose state, calls log_procinit(__progname), openlog() when not in debug, tzset(). | `/usr/src/usr.sbin/bgpd/log.c:34` | **KEEP** | S | Hand-copied base code; sync seam, not a libc adopt. | fugu line 32 == bgpd line 34 (offset 2 from the dropped $OpenBSD$ tag). NOTE DRIFT vs newer ntpd log.c rev 1.19: log_init(int n_dest,int n_verbose,int facility) uses a LOG_TO_STDERR\|LOG_TO_SYSLOG bitmask instead of a boolean debug. fugu is on the older bgpd lineage. |
+| log_procinit | Stashes the process name used in fatal() messages. | `/usr/src/usr.sbin/bgpd/log.c:49` | **KEEP** | S | Identical to bgpd; sync seam. | ntpd rev 1.19 makes log_procname a non-static extern; cosmetic only, no functional difference. |
+| log_setverbose | Setter for the verbose level (toggles log_debug output). | `/usr/src/usr.sbin/bgpd/log.c:56` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No drift from either bgpd or ntpd. |
+| log_getverbose | Getter for the verbose level. | `/usr/src/usr.sbin/bgpd/log.c:62` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No drift from either bgpd or ntpd. |
+| logit | Varargs front-end: collects ap and forwards to vlog at a given priority. | `/usr/src/usr.sbin/bgpd/log.c:68` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No drift; pure va_start/vlog/va_end shim. |
+| vlog | Core sink: in debug mode vfprintf to stderr (asprintf adds newline, OOM fallback); else vsyslog. Preserves errno. | `/usr/src/usr.sbin/bgpd/log.c:78` | **KEEP** | M | Sync seam; the dual-sink routing logic has no linkable base equivalent. | DRIFT vs ntpd log.c rev 1.19 vlog: ntpd va_copy's ap and can emit to BOTH stderr and syslog (dest bitmask); fugu's bgpd-era version is strictly either/or. Adopt the ntpd variant if dual-sink logging is ever desired. |
+| log_warn | errno-appending warning: asprintf "%s: %s"(emsg,strerror(errno)) then vlog(LOG_ERR); OOM fallback; restores errno. | `/usr/src/lib/libc/gen/warn.c` warn(3) | **REFERENCE** | M | warn(3)/vwarnc(3) IS linkable but writes only to stderr via __progname; cannot route to syslog or honor log verbosity. | **[verified correction: warn() is in warn.c, not err.c]** err.c lives under lib/libc (linkable, confirmed err.h decls /usr/include/err.h:55-65). Already mirrors the strerror-append + errno-preservation idiom; keep the wrapper purely for the syslog sink. Cannot drop-in replace. |
+| log_warnx | Warning without errno suffix; vlog(LOG_ERR). | `/usr/src/lib/libc/gen/warnx.c` warnx(3) | **REFERENCE** | S | warnx(3) is the linkable libc analog but stderr-only; keep wrapper for the syslog sink. | **[verified correction: warnx() is in warnx.c, not err.c]** Decl confirmed at /usr/include/err.h:63. Align idiom only; the LOG_ERR/syslog routing is the reason to keep the wrapper. |
+| log_info | vlog(LOG_INFO) informational message. | `/usr/src/usr.sbin/bgpd/log.c:138` | **KEEP** | S | Byte-identical to bgpd; sync seam. | No libc equivalent at LOG_INFO severity (err/warn only cover error/warning); no drift from canonical. |
+| log_debug | vlog(LOG_DEBUG) gated on verbose flag. | `/usr/src/usr.sbin/bgpd/log.c:148` | **KEEP** | S | Sync seam. | Minor DRIFT vs ntpd rev 1.19 which gates on verbose>1 (multi-level verbosity); fugu/bgpd gate on verbose!=0. Only matters if multi-level -v is wanted. |
+| vfatalc | Static helper: formats 'fatal in <proc>: <msg>: <strerror(code)>' (or without code) and logit(LOG_CRIT). | `/usr/src/usr.sbin/bgpd/log.c:160` err(3) | **KEEP** | M | Sync seam; the LOG_CRIT/syslog formatter has no linkable base equivalent. | DRIFT vs ntpd rev 1.19: ntpd drops the 'fatal in ' prefix, logging just '<proc>: ...'. fugu retains bgpd's 'fatal in ' wording. static, so REFERENCE-only mapping to verrc(3); kept as part of the synced unit. |
+| fatal | Varargs entry: vfatalc(errno,...) then exit(1). __dead. | `/usr/src/lib/libc/gen/err.c` err(3) | **REFERENCE** | S | err(3)/verrc(3) is the linkable 'print strerror + exit' primitive (__dead, errno-append) but exits via stderr only and can't hit syslog/log_procname. | **[verified correction: err() lives in err.c (decl err.h:43)]** err.h:43 declares err as __dead, matching fugu's __dead fatal in log.h. Align idiom; keep wrapper for the syslog sink. exit(1) vs err()'s configurable eval is the only semantic gap. |
+| fatalx | Varargs entry: vfatalc(0,...) then exit(1) (no errno). __dead. | `/usr/src/lib/libc/gen/errx.c:34` errx(3) | **REFERENCE** | S | errx(3) is the linkable libc analog (print + exit, no strerror) but stderr-only. | **[verified correction: errx() __dead void at errx.c:34]** Decl confirmed at /usr/include/err.h:51 (__dead). Align idiom; keep wrapper for syslog routing. |
+
+### `src/common/util.c`
+
+_All 4 functions are the ssh xmalloc.c "fatal-on-NULL" wrapper idiom — REFERENCE (xmalloc.c is program-internal, not a linkable lib); the underlying malloc/calloc/reallocarray/strdup are libc and already called directly inside. Wins are alignment to ssh's edge-case handling, not adoption of a library._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| xmalloc | malloc wrapper: rejects size==0, calls fatal() on NULL, never returns NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:27` malloc(3) | **REFERENCE** | S | Wrapper itself is not linkable from base (xmalloc.c lives in usr.bin/ssh/, not lib/); libc malloc already called inside. Align idiom to ssh: ssh's fatal message reports the byte count ('out of memory (allocating %zu bytes)'). fugu's body matches ssh closely otherwise. | Confirmed xmalloc.c exists only at /usr/src/usr.bin/ssh/xmalloc.c (find under /usr/src/lib returned nothing) => REFERENCE not ADOPT. malloc declared in <stdlib.h>, man 3 malloc. |
+| xcalloc | calloc wrapper: rejects nmemb/size==0, fatal() on NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:40` malloc(3) | **REFERENCE** | S | Not linkable; calloc already called inside. Alignment gap: ssh adds an explicit 'SIZE_MAX / nmemb < size' overflow check (xmalloc.c:47-48) before calloc; fugu omits it. calloc itself already detects overflow, so this is belt-and-suspenders, but mirror it to match canonical idiom. | calloc resolves to MALLOC(3), declared <stdlib.h>. Consider whether the redundant SIZE_MAX check is worth carrying given calloc already guards overflow. |
+| xreallocarray | reallocarray wrapper: rejects nmemb/size==0, fatal() on NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:56` malloc(3) | **REFERENCE** | S | Not linkable; reallocarray (the overflow-safe libc primitive, declared /usr/include/stdlib.h:263) already called inside. Idiom divergence: ssh does NOT reject zero size in xreallocarray (only in xmalloc/xcalloc); fugu adds a zero-size fatalx which is stricter/fine. Note base also offers recallocarray(3)/freezero(3) and ssh ships xrecallocarray (xmalloc.c:68) — adopt that wrapper if fugu ever needs zeroing reallocation for secrets per charter. | reallocarray at /usr/include/stdlib.h:263. recallocarray/freezero present in MALLOC(3) — relevant to 'secrets never leaked' charter; flag as a future addition, not a change to this func. |
+| xstrdup | strdup wrapper: fatal() on NULL, never returns NULL. | `/usr/src/usr.bin/ssh/xmalloc.c:80` strdup(3) | **REFERENCE** | S | Not linkable; strdup (declared /usr/include/string.h:106) already called inside. fugu's strdup-based body is cleaner than ssh's strlen+xmalloc+memcpy version and equally correct — no change needed; keep the REFERENCE link only for the fatal-on-NULL contract. | strdup at /usr/include/string.h:106, man 3 strdup. Lead mentioned xasprintf but it is NOT present in this file (ssh provides xasprintf/xvasprintf at xmalloc.c:91-112 over libc vasprintf(3) — adopt the same idiom if/when fugu needs formatted alloc). |
+
+### `src/common/buf.c`
+
+_Tiny dependency-free string/byte buffer; base has two real equivalents (open_memstream(3) for the dominant string-building role, ibuf/libutil for wire bytes) but neither is a clean drop-in — open_memstream loses O(1) mid-build .data/.len introspection and fatal-OOM, ibuf has no printf — so the abstraction is a justified KEEP with REFERENCE alignment to open_memstream idioms._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| buf_printf | Formatted append via two-pass vsnprintf (size then write); trailing NUL written but not counted in len. | `/usr/src/usr.sbin/radiusd/radiusd_standard.c:345` open_memstream(3) | **REFERENCE** | M | open_memstream wraps a FILE* so vfprintf does the formatting natively and growth is automatic, but it is program-internal-style usage (a libc FILE), errors are silent (NULL/ferror) vs fugu's fatalx, and *psize only updates on fflush — so adopting it removes fugu's two-pass overflow-checked path but loses fatal-on-OOM. Mirror radiusd's open_memstream/fprintf/fclose idiom for string assembly; do not claim ibuf here — ibuf has no printf. | This is the dominant use of buf across json.c/http.c/url.c. ibuf (imsg.h) offers NO formatted-append primitive, so it is not an equivalent for buf_printf. |
+| buf_append | Raw byte append with grow; no-op on len==0. | `/usr/src/lib/libutil/imsg-buffer.c:137` ibuf_add(3) | **REFERENCE** | M | ibuf_add(struct ibuf*, const void*, size_t) in linkable libutil is a near-signature match and ibuf_dynamic(len,max) gives the growable/bounded variant fugu's sse.c maxtotal check wants. But ibuf returns int error codes (fugu treats OOM as fatal via xreallocarray) and is designed for wire framing, not NUL-terminated strings; aligning is worthwhile only if the buffer is reframed as bytes. Cite imsg.h:73 decl, imsg-buffer.c impl. | **[verified correction: ibuf_add def is :137 (verified)]** struct ibuf + full API declared /usr/include/imsg.h:34-115; ibuf_data/ibuf_size give .data/.len equivalents but only after-the-fact, not the live field access fugu uses. |
+| buf_reserve | Grow capacity to hold need more bytes; doubles from BUF_MIN(64) with explicit (size_t)-1/2 overflow guard -> fatalx. | `/usr/src/lib/libutil/imsg-buffer.c:76` ibuf_reserve(3) | **KEEP** | S | base ibuf_reserve returns a pointer to reserved space (write-cursor semantics), not fugu's capacity-only ensure; the doubling allocator itself is private to imsg-buffer.c. No linkable drop-in for this exact contract. | The overflow guard at buf.c:47 is the edge case to keep; imsg-buffer.c bounds growth via ibuf max arg instead — worth mirroring the bound idiom if ever reframed. |
+| buf_init | Zero-initialize the struct (NULL/0/0). | ibuf_dynamic(3) | **KEEP** | S | base buffers are heap-allocated by ibuf_dynamic returning struct ibuf*; fugu uses caller-owned stack structs with init — no base equivalent for in-place init of a caller struct. | Trivial; only exists because fugu embeds struct buf by value (json writer, sse parser) rather than owning a pointer. |
+| buf_putc | Append one byte (cast int->unsigned char). | putc(3) | **KEEP** | S | trivial one-line wrapper over buf_append; if open_memstream were adopted this becomes putc(c, fp). Not independently worth a base dependency. | Heavy use in json.c UTF-8 encoding (buf.c via json.c:336-348). Maps cleanly to putc only if the whole buffer becomes a FILE*. |
+| buf_puts | Append a C string via strlen+append. | fputs(3) | **KEEP** | S | trivial wrapper; equivalent to fputs(s, fp) under open_memstream. No standalone linkable equivalent for the struct-buf form. | Folds into the open_memstream REFERENCE decision; not an independent ADOPT. |
+| buf_reset | Logically truncate to empty (len=0) keeping capacity for reuse. | `/usr/src/lib/libutil/imsg-buffer.c:76` ibuf_truncate(3) | **KEEP** | S | ibuf_truncate exists (imsg.h:98) but operates on the wire-buffer write-position model, not fugu's capacity-retaining reset of a by-value struct; semantics differ enough that it is not a drop-in. | open_memstream has no cheap reset-keeping-capacity (would need rewind+ftruncate on the stream); this favors keeping fugu buf where reuse matters. |
+| buf_free | free data and re-zero the struct. | ibuf_free(3) | **KEEP** | S | ibuf_free frees a heap struct ibuf*; open_memstream requires fclose then free(*pbuf). fugu frees only the data of a caller-owned struct — no equivalent for that ownership shape. | Re-zeroing after free (buf.c:112-114) is a use-after-free hardening idiom worth keeping regardless of any base alignment. |
+
+### `src/common/json.c`
+
+_Base ships no linkable JSON parse/emit C library (confirmed: no libjson/libjansson, nothing in /usr/src/lib references jsmn/json_parse), so the emitter and jsmn wrapper are all KEEP; two honest adjacencies exist (vis(3) for escaping, wctomb/citrus for UTF-8) but neither is a clean drop-in for JSON semantics._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| json_writer_init | Initialize a JSON emitter writer over a struct buf (depth/type/count state machine). | — | **KEEP** | S | no base JSON emitter; writer state is fugu-specific over fugu's struct buf | State-machine init; nothing in base to align to. |
+| json_pre / json_push / json_pop (emitter separator/nesting core) | Place commas, enforce key-before-value, push/pop object/array containers with depth/type checks. | — | **KEEP** | S | no base JSON serializer; this comma/nesting logic is the heart of a JSON emitter base does not provide | Tightly-cohesive static helpers; aborts via fugu-local fatalx (log.h:41, an err.h-style wrapper). No linkable equivalent. |
+| json_object_start / json_object_end / json_array_start / json_array_end | Public container open/close wrappers around json_push/json_pop. | — | **KEEP** | S | no base JSON emitter API | Thin public faces over the static core; grouped as the container API. |
+| json_emit_escaped | Write a JSON string literal: escape " \ \b \f \n \r \t, \u%04x for <0x20, UTF-8 passthrough. | vis(3) | **REFERENCE** | M | vis encodes C-style/\xNN, not JSON \uXXXX; escape set and UTF-8 passthrough differ, so it cannot be substituted | vis.h is base and linkable (vis(3) confirmed), but JSON escaping rules diverge from VIS_* encodings; mirror only the idiom (single-pass switch, control-char fallthrough), keep fugu's own JSON-correct table. |
+| json_key | Emit an object key (escaped) plus ':', with key-after-key / key-outside-object guards. | — | **KEEP** | S | no base JSON emitter | Reuses json_emit_escaped; same KEEP rationale as the emitter core. |
+| json_string / json_string_n | Emit a JSON string value from a C string or explicit length. | — | **KEEP** | S | no base JSON emitter | Wrap json_pre + json_emit_escaped. |
+| json_int | Emit a JSON integer via buf_printf("%lld"). | — | **KEEP** | S | no base JSON emitter; plain snprintf-style formatting needs no base lib | Number formatting is trivial; no base symbol to adopt. |
+| json_bool / json_null / json_raw | Emit literal true/false/null, or pre-formatted raw JSON, after a json_pre separator. | — | **KEEP** | S | no base JSON emitter | json_raw trusts caller-formatted text; no base analog. |
+| json_kv_string / json_kv_int / json_kv_bool | Convenience: emit key + typed value in one call. | — | **KEEP** | S | no base JSON emitter | Pure composition of json_key + value emitters; grouped as the kv convenience set. |
+| json_parse | Run jsmn over input, growing the token array on JSMN_ERROR_NOMEM (doubling, INT_MAX/2 overflow guard), via fugu's xreallocarray. | — | **KEEP** | S | no base JSON parser; jsmn.h is the charter-permitted vendored tokenizer | Confirmed nothing under /usr/src/lib references jsmn/json_parse; growth loop is idiomatic; uses fugu-local xreallocarray (util.h:25). |
+| json_doc_free | Free token array and clear the json_doc. | — | **KEEP** | S | frees jsmn token array; no base type involved | Trivial owner-side free. |
+| json_tok_type / json_tok_size / json_tok_streq | Accessors translating jsmn token fields into fugu's enum, plus a token==string compare against d->src. | — | **KEEP** | S | thin accessors over vendored jsmntok_t; no base equivalent | json_tok_streq uses memcmp(3) (base) internally, but the function itself is a jsmn-specific accessor — grouped as the token accessors. |
+| json_skip / json_obj_get | Recursively skip a token subtree; look up a value index by key in a jsmn OBJECT. | — | **KEEP** | S | jsmn flat-token traversal; no base JSON DOM/query exists | Core of the jsmn-index navigation; nothing in base to align to. |
+| utf8_encode | Encode a Unicode code point to 1-4 UTF-8 bytes into a struct buf (used when decoding \uXXXX escapes). | `/usr/src/lib/libc/citrus/citrus_utf8.c:258` wctomb(3) | **REFERENCE** | M | wctomb only emits UTF-8 when the process locale's ctype is UTF-8 (MB_CUR_MAX); a pledged privsep daemon may not guarantee that, so a deterministic hand-rolled encoder is safer | _citrus_utf8_ctype_wcrtomb at citrus_utf8.c:258 is the base encoder behind wctomb(3) (declared stdlib.h); mirror its bit layout but keep fugu's locale-independent version. Not a clean ADOPT due to locale dependence. |
+| hex4 | Parse exactly 4 hex digits into an unsigned int for \uXXXX decoding. | — | **KEEP** | S | fixed-4-digit in-place hex scan; strtoul/strtonum don't fit (no terminator, would over-read) | Deliberately bespoke; base integer parsers can't bound to 4 chars without a copy. |
+| json_tok_strdup | Allocate an unescaped copy of a JSON string token: handle \" \\ \/ \b\f\n\r\t and \uXXXX incl. surrogate pairs, building into a struct buf. | unvis(3) | **REFERENCE** | M | strunvis decodes vis(3)/C-style escapes, not JSON's \uXXXX surrogate-pair rules; substituting it would mis-handle Unicode escapes | unvis(3)/strunvis is base and linkable but speaks a different escape grammar; mirror the streaming-decode idiom only. Calls fugu's utf8_encode/hex4 for the JSON-specific parts. |
+
+### `src/common/imsgproto.c`
+
+_fugu uses the CURRENT rewritten imsg API (imsgbuf_*, imsg_get_*, imsg_composev, imsg_forward) idiomatically; the thin plumbing wrappers are REFERENCE (already aligned to base, nothing more to adopt), and the chunking/reassembly layer is correctly KEEP because base imsg has no chunking._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| ip_init | Zero the imsgproto state and initialize the imsgbuf over fd via imsgbuf_init(); returns -1 on failure. | `/usr/src/usr.sbin/bgpd/bgpd.c:278` imsg_init(3) | **REFERENCE** | S | Wraps current imsgbuf_init(&ibuf, fd)==-1 idiom 1:1 (matches bgpd.c:278; symbol at imsg.h:141). Already idiomatic; cannot 'adopt' since the value-add is the surrounding chunking struct. No change needed — confirms fugu is on the NEW API, not the removed imsg_init. | Confirmed fugu uses the rewritten API: imsgbuf_init present at /usr/include/imsg.h:141; old imsg_init is gone from the header. Linkable from libutil (/usr/src/lib/libutil/imsg.c). |
+| ip_clear | Release all reassembly slots, then imsgbuf_clear() the channel. | imsg_init(3) | **REFERENCE** | S | imsgbuf_clear at imsg.h:147 is used verbatim; the reasm_release loop around it is fugu-specific teardown with no base analog. Correct as-is. | Half of this function (the reasm loop) is KEEP-shaped; the imsg half is already the current idiom. Net: aligned, no action. |
+| ip_fd | Accessor returning the underlying channel fd (ip->ibuf.fd). | imsg_init(3) | **REFERENCE** | S | Reads the public imsgbuf.fd field (declared in struct imsgbuf, imsg.h). Base exposes no fd-getter function, so a one-line accessor over the public field is the idiomatic approach (bgpd/iked do the same inline). Keep. | Not an ADOPT: there is no imsgbuf_fd() symbol in base; the field is public and read directly elsewhere in /usr/src. |
+| ip_compose | Frame a logical message into one or more imsg chunks (each prefixed with struct fugu_chdr) and queue them via imsg_composev; zero-length sent as one empty chunk. | `/usr/src/usr.sbin/httpd/proc.c:724` imsg_init(3) | **REFERENCE** | M | Per-chunk send uses imsg_composev(&ibuf, type, id, pid, fd, iov, iovcnt) exactly as httpd/proc.c:724 (signature at imsg.h:165, man-confirmed). The chunking/splitting loop has NO base equivalent (MAX_IMSGSIZE=16384 cap, base imsg never fragments). The imsg call is aligned; the framing is intrinsically fugu's. | imsg_composev confirmed in man imsg_init(3) with matching signature. The fugu_chdr framing over it is the KEEP part; the call idiom itself is already correct. |
+| ip_flush | Block until all queued frames are written (wraps imsgbuf_flush). | imsg_init(3) | **REFERENCE** | S | Pure 1:1 pass-through to imsgbuf_flush (imsg.h:146). Idiomatic; trivial wrapper for a uniform ip_* surface. No change. | Wrapper exists only to keep callers off the raw imsgbuf; base symbol is current and correct. |
+| ip_write | Single non-blocking write pass (wraps imsgbuf_write). | imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsgbuf_write (imsg.h:145). Current API, idiomatic. No change. | Part of the event-loop write path; mirrors how bgpd drives imsgbuf_write. |
+| ip_read | Read pending bytes into the buffer (wraps imsgbuf_read); 1 ok / 0 EOF / -1 error. | `/usr/src/usr.sbin/bgpd/bgpd.c:1280` imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsgbuf_read (imsg.h:144); fugu's 1/0/-1 contract matches the base return convention used at bgpd.c:1280. Already idiomatic. | Confirms current API; the removed imsg_read/imsg_read_nb is not used. |
+| ip_get | Pull the next fully reassembled logical message, draining buffered chunks via imsgbuf_get and folding each through handle_chunk. | imsg_init(3) | **REFERENCE** | M | The drain loop uses imsgbuf_get (imsg.h:149, returns 1/0/-1) and imsg_free (imsg.h:172) per the documented contract. The multi-frame reassembly accumulation it wraps has NO base analog. imsg calls are aligned; reassembly is fugu's. | imsgbuf_get is the current accessor (old imsg_get returns ssize_t and still exists at imsg.h, but fugu correctly uses the newer int-returning imsgbuf_get). Loop structure mirrors base imsgbuf_get drain loops. |
+| ip_msg_free | Free the reassembled message buffer and zero the struct fugu_msg. | — | **KEEP** | S | Frees fugu_msg.data (fugu-owned heap), not a base struct imsg; imsg_free is for struct imsg and does not apply here. | Distinct from imsg_free(&imsg) which fugu calls separately in ip_get. No base equivalent for the reassembled-message struct. |
+| ip_frame_get | Pull one raw frame for the relay without reassembly (wraps imsgbuf_get); 1/0/-1. | imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsgbuf_get (imsg.h:149). Idiomatic raw-frame pull used by the parent relay path; current API. No change. | Same accessor as ip_get's loop but exposed unwrapped for the relay that forwards by id without touching payload. |
+| ip_forward | Relay a received frame to another channel unaltered via imsg_forward (the parent's routing primitive, by imsg_get_id). | `/usr/src/sbin/dhcpleased/dhcpleased.c:716` imsg_init(3) | **REFERENCE** | S | 1:1 pass-through to imsg_forward(&dst.ibuf, imsg) exactly as dhcpleased.c:716 and iked/control.c:345 (symbol at imsg.h:162). This is precisely the base relay idiom — fugu is correctly using imsg_forward rather than re-composing. No change. | Strong confirmation fugu mirrors base privsep relay design: forward-by-id without reassembly, the same pattern dhcpleased/iked/bgpd use. The current imsg_forward is the right tool. |
+| reasm_find | Linear-scan the IP_MAXASM reassembly slots for the in-progress partial matching (src, msgid). | — | **KEEP** | S | no base option: base imsg has no chunking/reassembly layer, so no lookup primitive exists to adopt. | Intrinsic to fugu's logical-message-over-imsg framing. Fixed 8-slot table; no base data structure applies. |
+| reasm_alloc | Acquire a reassembly slot, evicting the oldest (by seq) partial when all IP_MAXASM slots are busy to prevent channel wedging. | — | **KEEP** | M | no base option: oldest-first eviction over a bounded reassembly table is fugu's anti-DoS policy; base imsg never reassembles, so nothing to adopt. | Security-relevant bounded-slot eviction. Charter-aligned (defends against a hostile peer holding partials open). Pure fugu logic. |
+| reasm_reserve | Grow a partial's buffer toward (but never past) its advertised total, doubling capacity, using xreallocarray. | — | **KEEP** | S | no base option: the grow-to-delivered-bytes (not advertised-total) anti-DoS policy is fugu-specific; the underlying reallocarray is already base via the project's xreallocarray wrapper. | The only base API in play here is reallocarray(3), already adopted transitively through util.h's xreallocarray. The capping policy itself has no base analog. |
+| reasm_release | Free a partial's buffer and zero the slot. | — | **KEEP** | S | no base option: frees fugu's reassembly slot; trivial free()+memset with no base equivalent. | Cleanup helper for the reassembly table. free(3) is the only base call and needs no wrapper. |
+| handle_chunk | Fold one received imsg chunk into reassembly state: parse fugu_chdr via imsg_get_buf, validate bounds/ordering, fast-path single-chunk messages, and complete or accumulate; 1/0/-1. | imsg_init(3) | **REFERENCE** | L | Frame-field access uses the current accessors imsg_get_type (imsg.h:161), imsg_get_id (158), imsg_get_len (159), imsg_get_buf (155, man-confirmed return 0/-1) — all idiomatic and correct. The reassembly state machine, bounds-checking, and ordering enforcement around them have NO base equivalent. Accessors aligned; reassembly is KEEP-shaped. | fugu correctly uses imsg_get_buf (the copy-out accessor, returns 0/-1 per man imsg_init(3)) rather than poking imsg.data directly — the recommended current idiom. The validation/reassembly logic is the security core and is intrinsically fugu's. Largest function; bulk of it is KEEP, but the imsg surface is exemplary REFERENCE. |
+
+### `src/common/http.c`
+
+_A self-contained, hostile-server-hardened incremental HTTP/1.1 client; no linkable base HTTP parser exists, so the protocol logic stays KEEP, but every idiom should be aligned to ftp(1)'s url_get/save_chunked (REFERENCE), and the one true libc win (strtonum) is already adopted._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| http_build_request | Serialise an HTTP/1.1 request (request-line, Host, Connection: close, caller headers, optional Content-Length, body) into a buf. | `/usr/src/usr.bin/ftp/fetch.c:706` | **REFERENCE** | S | fetch.c builds the exact same idiom — "GET %s HTTP/1.1\r\nConnection: close\r\nHost: %s\r\n" via ftp_printf to a FILE* (fetch.c:706-787). It is program-internal, not linkable. Mirror two edge cases fugu omits: (1) IPv6 literal hosts must be bracketed and the scope/%-suffix stripped before the Host header (fetch.c:740-748); (2) omit a default :80/:443 port from Host since some servers choke (fetch.c:766-772). fugu takes host verbatim, so the caller must pre-format these. | Charter-clean (buf_printf only). Not an ADOPT: no linkable request-builder in base; align idioms only. |
+| http_resp_init / http_resp_clear / http_resp_done | Lifecycle for the incremental response parser: zero-init state (status=HTTP_STATUS, content_length=-1, default maxline, init line/ctype bufs), free the bufs, and report completion. | — | **KEEP** | S | Plain struct lifecycle around fugu's own buf type; no base library object models an incremental HTTP response. ftp(1) keeps this state as locals in url_get, not a reusable object. | Grouped: three trivial cohesive accessors/lifecycle helpers over struct http_resp. Nothing to adopt. |
+| http_resp_push | Core incremental state machine: feed arbitrary response bytes, split lines (capped at maxline), de-frame body by Content-Length / chunked / read-until-close, and hand decoded body bytes to a callback. | `/usr/src/usr.bin/ftp/fetch.c:1154` | **REFERENCE** | M | Base has NO linkable incremental HTTP body de-framer. ftp(1) solves the same problem but over a buffered FILE* with getline()/fread() (blocking, program-internal): save_chunked (fetch.c:1154-1219) and the Content-Length read path. fugu's push-model parser is actually MORE robust than the reference for its threat model (no FILE* buffering, works on partial network reads, every line maxline-capped). Worth mirroring from save_chunked: it strips the optional chunk extension with header[strcspn(header,"; \t\r\n")]='\0' (fetch.c:1167) and verifies the per-chunk trailing CRLF byte-exactly (fetch.c:1196-1207) — fugu already does both. Note fugu correctly ignores Content-Length when chunked is set, matching fetch.c:993-994. | This is the heart of the unit. KEEP-the-code, REFERENCE-the-idioms. No base linkable equivalent (would need a ports HTTP lib = out of charter). |
+| http_resp_eof | Signal connection close: completes an EOF-framed body (HTTP_BODY_EOF -> HTTP_DONE), else treats a mid-message close as truncation (-1). | — | **KEEP** | S | Trivial terminal-state transition specific to fugu's push API; base ftp(1) detects this implicitly via getline()/fread() returning EOF, not as a separable function. | No symbol to adopt. |
+| http_line | Dispatch one complete (CRLF-stripped) line to the right handler based on parser state: status, header, end-of-headers, chunk-size, or trailer. | `/usr/src/usr.bin/ftp/fetch.c:868` | **REFERENCE** | S | fetch.c's header-reading for(;;) loop (fetch.c:868-936) is the analogous line dispatcher but inlined into url_get and FILE*-driven; not linkable. fugu's split into a state-switch is cleaner for a push parser. Align the trailing-whitespace strip: fetch.c trims trailing \r\n AND spaces/tabs (fetch.c:880-882), whereas fugu only strips a single trailing \r — consider trimming trailing SP/HT too for robustness against sloppy servers. | Internal dispatcher; REFERENCE for the whitespace-trim edge case only. |
+| http_status | Parse the status line: require "HTTP/" prefix, skip the version token and spaces, read exactly 3 status digits into r->status. | `/usr/src/usr.bin/ftp/fetch.c:806` strtonum(3) | **REFERENCE** | S | fetch.c parses status via cp=strchr(buf,' '); cp++; strtonum(cp,200,503,&errstr) (fetch.c:806-812) — simpler, and notably it reuses the libc strtonum win for the numeric conversion. But fetch.c does NOT validate the "HTTP/" prefix and clamps the accepted code range to 200..503; fugu's explicit prefix check + 3-digit scan is stricter and better for a hostile peer. Keep fugu's logic; the REFERENCE is the strtonum idiom (already used elsewhere in fugu). | Hand-rolled digit scan is justified by the prefix-validation requirement; strtonum on the substring is an option but loses the 'exactly 3 digits' guarantee. |
+| http_header | Parse one header line: split on ':', trim OWS around the value, and record the three headers fugu cares about (Transfer-Encoding: chunked, Content-Length via strtonum, Content-Type). | `/usr/src/usr.bin/ftp/fetch.c:912` strtonum(3) | **REFERENCE** | S | fugu already mirrors fetch.c almost exactly: strncasecmp on the header name then strtonum(val,0,LLONG_MAX,&errstr) for Content-Length (fetch.c:912-918) and strcasecmp for Transfer-Encoding chunked (fetch.c:975-978). The strtonum usage IS the adopted libc win. Difference: fetch.c skips leading OWS with strspn(cp," \t") but doesn't trim trailing OWS; fugu trims both — keep fugu's. One alignment note: fetch.c compares the whole "chunked" value with strcasecmp (exact), fugu uses a ci_contains substring match which is more lenient and matches multi-value TE lists like "gzip, chunked". | Already well-aligned to base idioms; strtonum already adopted. No new linkable symbol beyond strtonum. |
+| http_endhdrs | On the blank line ending headers, pick the body-framing mode: no-body statuses (1xx/204/304), then chunked > Content-Length > read-until-close, per RFC 7230. | `/usr/src/usr.bin/ftp/fetch.c:993` | **REFERENCE** | S | fetch.c encodes the same 'chunked wins over Content-Length' rule (sets filesize=-1 when chunked, fetch.c:993-994) but does NOT special-case 204/304/1xx bodyless responses — fugu's handling here is more correct than the reference. Program-internal regardless. Keep fugu's; cite fetch.c only for the precedence rule. | fugu is stricter than base here; pure REFERENCE for the one shared rule. |
+| http_chunksize | Parse a hex chunk-size line with overflow checking, stop at ';'/non-hex (extension), require >=1 digit, route size 0 -> trailer and size>0 -> chunk-data. | `/usr/src/usr.bin/ftp/fetch.c:1169` | **REFERENCE** | M | Direct analogue of save_chunked's chunksize = strtoul(header,&end,16) with the header[strcspn(header,"; \t\r\n")]='\0' extension strip (fetch.c:1167-1172). fetch.c relies on strtoul + errno + an explicit chunksize>INT_MAX cap; fugu hand-rolls the hex parse with an inline (size > (LLONG_MAX-d)/16) overflow guard and a LLONG_MAX ceiling. fugu could ADOPT strtoul(3) to shrink the loop, but only after NUL-terminating the line (it has the line buf, so feasible) — fetch.c's INT_MAX clamp is a useful sanity cap fugu may want to mirror to bound a single chunk. Net: REFERENCE; a strtoul refactor is optional and not a clean drop-in because fugu parses a non-NUL-terminated (ptr,len) slice. | strtoul is linkable libc, but the (ptr,len) interface and the desire for an explicit ceiling make the hand-rolled scan reasonable; treat strtoul as an optional simplification, not a mandated ADOPT. |
+| ci_contains | Case-insensitive substring search over a length-bounded, non-NUL-terminated haystack (used to spot "chunked" inside a Transfer-Encoding value list). | `/usr/src/lib/libc/string/strcasestr.c:1` strcasestr(3) | **KEEP** | S | strcasestr(3) is real linkable libc (declared /usr/include/string.h:133, man strcasestr(3)) and does exactly this — BUT it requires a NUL-terminated haystack, while ci_contains operates on a (ptr,len) slice of the un-terminated line buffer. Adopting it would force a copy-and-NUL-terminate of the header value first, which is strictly more work and more allocation than the current strncasecmp sliding-window. So no clean drop-in; keep the helper. (memmem(3) is the case-sensitive length-bounded cousin but offers no case-folding.) | Verified strcasestr lives under lib/libc (linkable, not program-static), so the symbol is legit — it just doesn't fit fugu's non-terminated-buffer contract. This is the honest 'looks adoptable but isn't a drop-in' case. |
+
+### `src/common/sse.c`
+
+_Incremental push-parser for the SSE (text/event-stream) wire format the Anthropic Messages API streams over; base has zero SSE support and no streaming line-splitter that fits a cross-chunk push model, so the whole unit is KEEP._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| sse_init / sse_clear | Lifecycle: zero the parser, init the three growable buffers (line/event/data), set the callback and the maxline/maxtotal hostile-stream caps; sse_clear frees buffers and re-zeros. | — | **KEEP** | S | no base SSE/EventSource type to init or tear down; buffer mgmt is delegated to buf.* (its own unit) | Trivial struct setup over fugu's own struct sse_parser + struct buf. Nothing in base to align to. Grouped as the tight init/teardown pair: both just memset + buf_init/buf_free over the same fields. |
+| sse_push | Feed arbitrary-sized byte chunks; split on CR/LF/CRLF (swallowing the LF of a CRLF via after_cr state), accumulate partial lines across calls into p->line, enforce per-line maxline cap, and hand complete lines to sse_line. | getline(3)/getdelim(3) and strsep(3) exist but neither fits a stateful cross-chunk push parser | **KEEP** | M | no base option: getline/getdelim are pull-model over FILE* (need a seekable stream, not libtls chunks across a privsep boundary); strsep splits a complete in-memory string, not a partial byte stream with CRLF-straddling state | Confirmed no base SSE: ssh server apropos sse and grep -rln 'event-stream\|server-sent\|EventSource' /usr/src both empty. The CR/LF/CRLF + maxline-bounded incremental line assembly is exactly the part base does NOT provide as a library; it is the reason this file exists. Buffer growth itself goes through buf_putc/buf_reset (buf.c unit) where any open_memstream(3)/ibuf overlap would be evaluated, not here. |
+| sse_line | Parse one complete line: blank line dispatches the event; ':' comment ignored; split field name at first colon with the one-optional-leading-space rule; handle event:/data: (data lines joined by '\n'), enforce maxtotal cap; accept-and-ignore id/retry/unknown. | — | **KEEP** | M | no base option: this is the EventSource spec field grammar (W3C), not implemented anywhere in OpenBSD base | Field-name/value split could nominally use memchr(3) for the colon scan instead of the hand-rolled for-loop, but that is a micro-cleanup inside buf.* territory, not an idiomatic base equivalent for the function. The spec semantics (leading-space strip, data '\n' join, maxtotal guard) have no base counterpart to adopt or reference. |
+| sse_dispatch | Finalize an accumulated event: strip the trailing '\n' from data per spec, default event type to "message", NUL-terminate event and data buffers (uncounted), invoke the user callback, then reset event/data state for the next event. | — | **KEEP** | S | no base option: EventSource dispatch/default-event-type semantics are not in base | Pure SSE-spec dispatch logic plus a callback invocation; nothing linkable in base mirrors it. The NUL-terminate-uncounted idiom is a buf.* convention (documented in buf.h). |
+
+### `src/common/conf.c`
+
+_Config model is app-specific KEEP throughout; the one base-overlap is conf_check_secrecy, which mirrors base's verbatim-copied check_file_secrecy (program-internal, REFERENCE) but diverges in signature (path/stat vs fd/fstat -> TOCTOU) and policy._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| conf_check_secrecy | StrictModes guard: a secrets/key file must be owned by uid and mode 0600 before fugu reads it; warns and returns -1 otherwise. | `/usr/src/usr.sbin/smtpd/parse.y:check_file_secrecy (canonical full version; identical body in httpd/parse.y, iked/parse.y; bgpd/parse.y:4007 is the stripped 7.9-current variant)` | **REFERENCE** | S | Base check_file_secrecy is a per-program static in parse.y (not in lib/ or /usr/include), so it is not linkable; align idioms only. Deltas to mirror: (1) base takes an OPEN fd and uses fstat() to close the TOCTOU window -- fugu takes a path and re-stat()s by name; if fugu later open()s the same path it can be swapped. Prefer open()+fstat(fileno). (2) base permits owner==root OR getuid(); fugu requires st_uid==uid exactly -- stricter and arguably correct for a privsep secret, but note the divergence for reviewers. (3) base rejects S_IWGRP\|S_IXGRP\|S_IRWXO (allows group/world READ); fugu rejects S_IRWXG\|S_IRWXO i.e. demands true 0600 -- again stricter, appropriate for secrets. fugu's stricter policy is defensible; the actionable win is matching the fd/fstat shape so the code reads like the base idiom reviewers know. | Confirmed no linkable libutil/libc secrecy helper: grep of /usr/src/lib and /usr/include for check_file_secrecy/secrecy/StrictModes returned nothing. So this can never become ADOPT -- REFERENCE is the ceiling. fugu's redacting/warning via log_warn/log_warnx already matches base style. |
+| conf_init / conf_match_new / conf_clear (TAILQ lifecycle group) | Initialize the conf (zero global, TAILQ_INIT matches), allocate+append a match block, and tear down all match blocks + global freeing strings. | `/usr/include/sys/queue.h:439 (TAILQ_FOREACH), :465 (TAILQ_INIT); INSERT_TAIL/REMOVE/FIRST in same header` queue(3) | **ADOPT** | S | Already adopted -- these helpers use base sys/queue.h TAILQ macros idiomatically (TAILQ_INIT, INSERT_TAIL, FOREACH, FIRST, REMOVE). No change needed; recorded as ADOPT because the list machinery IS the base equivalent and fugu uses it correctly. xcalloc/xstrdup wrappers are fugu/ssh-style (ssh xmalloc.c is program-internal, not a base lib), out of scope here. | 83 TAILQ references in queue.h confirm base provenance. Grouped because all three are thin lifecycle wrappers around the same TAILQ head and share no other logic. |
+| setstr / conf_set_string / conf_set_flag / conf_settings_clear | Field setters: free-then-xstrdup a string field, OR a CS_* presence bit into the mask, set a scalar's bit, and free all string fields + zero the struct. | — | **KEEP** | S | App-specific bitmask/presence model over fugu's own conf_settings struct; no base API expresses 'set bit N and dup string'. free()/strdup primitives are base but the orchestration is bespoke. | conf_settings_clear must stay in sync with the struct's string members in conf.h:51-62 (model, search_provider, anthropic_key, kagi_token, *_file) -- maintenance hazard if a field is added, but not a base-equivalence question. |
+| settings_merge / match_applies / conf_resolve / conf_overlay (merge engine group) | Layered config resolution: copy masked fields from src into dst, test whether a match block applies to a user/group set, collapse global+matches with first-match-wins per key (locked mask), then overlay the user's personal settings. | — | **KEEP** | M | No base option -- the bitmask merge / first-match-wins-per-key semantics are fugu-specific policy over fugu's own structs; sshd's Match resolution (servconf.c) is program-internal and not a linkable analogue worth REFERENCE since fugu's model differs (per-key locking vs sshd's per-line). | Grouped as one cohesive engine: settings_merge is the primitive, match_applies the predicate, conf_resolve/conf_overlay the public drivers. strcmp on group names is plain libc. If anything, conf.h's comment already correctly frames it as 'sshd Match-style' -- documentation alignment only, not adoption. |
+| conf_settings_print | Render effective settings for `fugu -n`, printing each set field and redacting secret values as "***" while still showing *_file paths. | — | **KEEP** | S | App-specific serializer over fugu's struct; plain fprintf(3). Secret redaction policy (print *** for key/token, show file paths) is a fugu charter requirement with no base equivalent. | Good charter compliance: secrets never emitted verbatim (conf.c:222-226), consistent with 'secrets never in journal'. fprintf is base libc but the field-by-field logic is bespoke. |
+
+### `src/common/confload.c`
+
+_Single function conf_load: config-load orchestration whose libc primitives (getpwuid/getgrouplist/getgrgid) are already idiomatic base calls, but whose group-name resolution and overall load flow should be aligned to OpenSSH groupaccess.c / auth.c idioms — REFERENCE, not a single linkable lib._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| conf_load (group resolution: getgrouplist + getgrgid + xstrdup loop, lines 62-79) | Resolve the invoking user's supplementary groups to group *names* so the config 'Match group' scoping can compare against them. | `/usr/src/usr.bin/ssh/groupaccess.c:47` getgrouplist(3) | **REFERENCE** | S | getgrouplist(3) itself is confirmed linkable libc (/usr/include/unistd.h:499; impl /usr/src/lib/libc/gen/getgrouplist.c) and fugu's call is correct/idiomatic. But the surrounding 'getgrouplist -> loop getgrgid -> xstrdup(gr->gr_name)' helper is exactly OpenSSH ga_init (groupaccess.c:47-67); ga_match (groupaccess.c:73) is the matching half. There is no linkable libutil wrapper for the name-resolution loop, so this is REFERENCE: mirror ga_init's structure. Note divergence: OpenSSH uses a fixed NGROUPS_MAX+1 array; fugu does a grow-and-retry on the documented getgrouplist==-1 'list too small' return — fugu's is arguably more correct but deviates from the base idiom; align deliberately one way or the other. | Verified: man 3 getgrouplist (NAME/SYNOPSIS/RETURN confirm int getgrouplist(const char*,gid_t,gid_t*,int*), -1 == too small). Header /usr/include/unistd.h:499. Base callers: /usr/src/usr.bin/ssh/groupaccess.c, /usr/src/usr.sbin/authpf/authpf.c, /usr/src/sbin/mountd/mountd.c. groupaccess.c:63-65 is the byte-for-byte analogue of confload.c:73-78. getgrgid(3) likewise libc. |
+| conf_load (overall load flow: getpwuid/getuid, HOME fallback, optional /etc/fugu.conf via access(), StrictModes secrecy + overlay of ~/.fugu/fugu.conf) | Build the effective config for the invoking user: identify user, locate home, parse optional system defaults, then StrictModes-check and overlay the per-user secrets file. | `/usr/src/usr.bin/ssh/auth.c:352` getpwuid(3) | **REFERENCE** | M | The primitives are all already idiomatic base libc (getpwuid(getuid()) is the canonical pattern, seen in id.c, ftp/main.c, cu.c, etc.; HOME-from-getenv-then-pw->pw_dir fallback and the truncation-checked snprintf("%s/%s") path build are the OpenSSH auth.c idiom at auth.c:342/352). No single linkable library bundles 'load+overlay+StrictModes' — that is per-daemon glue. So KEEP-the-glue / REFERENCE-the-idioms: align the home-dir fallback and path construction to auth.c, and (handled in conf.c's conf_check_secrecy) mirror OpenSSH's secure-path/StrictModes ownership+mode check. resource handling (xreallocarray/xstrdup/free of gids+gnames, conf_clear on every exit) is correct; the rc/goto-done cleanup is sound. | Confirmed idioms: getpwuid(getuid()) base callers include /usr/src/usr.bin/id/id.c, /usr/src/usr.bin/ftp/main.c, /usr/src/usr.bin/cu/cu.c. pw->pw_dir as home fallback: /usr/src/usr.bin/ssh/auth.c:342, auth-rhosts.c:211. snprintf(ret,sizeof(ret),"%s/%s",pw->pw_dir,file) truncation idiom: /usr/src/usr.bin/ssh/auth.c:352. The StrictModes ownership/permission enforcement itself lives in conf_check_secrecy (conf.c, out of scope for this file) and is the OpenSSH secure_filename/auth_secure_path analogue — flag for conf.c's audit. Everything here stays in fugu (KEEP as code) while aligning idioms (REFERENCE). |
+
+### `src/common/parse.y`
+
+_Standard OpenBSD parse.y descendant; libc primitives (strtonum/bsearch/vasprintf) already idiomatic, but the lexer is forked from an OLDER httpd template — it has drifted behind current httpd/parse.y on three points: igetc indirection layer, growable ungetbuf, and check_file_secrecy in pushfile. These are REFERENCE alignments (httpd is a program, not a linkable lib), not ADOPT._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| yyerror | Format a parse error with file:lineno prefix and route it to the logger; bumps file->errors. | `/usr/src/lib/libc/stdio/vasprintf.c` vasprintf(3) (under printf(3)) | **REFERENCE** | S | Already uses vasprintf correctly and matches the httpd yyerror shape. Only nit vs template: fugu logs via log_warnx; httpd uses the same pattern. No real change needed; keep aligned to httpd's yyerror. | vasprintf is a true libc symbol (lib/libc/stdio). The yyerror skeleton itself lives only inside base PROGRAMS (httpd/parse.y), so it is a REFERENCE idiom, not a linkable API. fugu's version is faithful. |
+| kw_cmp | strcmp comparator for bsearch over the keyword table. | `/usr/src/usr.sbin/httpd/parse.y:1500` bsearch(3) | **KEEP** | S | Trivial 1-line comparator; identical to every base parse.y. Nothing to adopt — it IS the idiom. | bsearch is libc but the comparator is caller-supplied by definition; cannot be 'adopted' from base. Matches httpd/ftpd/relayd verbatim. Leave as-is. |
+| lookup | Binary-search the sorted keyword[] table to map an identifier to a token (or STRING). | `/usr/src/usr.sbin/httpd/parse.y:1505` bsearch(3) | **KEEP** | S | Already the canonical sorted-table + bsearch idiom; keyword list is fugu-specific so no base table to reuse. | bsearch confirmed in libc (man 3 bsearch). The 'must stay sorted' table is the standard parse.y pattern; fugu follows it correctly. No drift. |
+| lgetc | Lexer input: returns next char honoring quote mode, line-continuation backslash-newline, macro parsebuf, and pushback buffer; pops included files at EOF. | `/usr/src/usr.sbin/httpd/parse.y:1561` | **REFERENCE** | M | DRIFT: fugu's lgetc still embeds the old parsebuf/parseindex + global pushback logic inline. Current httpd factored the raw getc/unget into a separate igetc() (parse.y:1540) and added an eof_reached 'fake EOL' so the last unterminated line counts correctly. Aligning means splitting lgetc into lgetc+igetc and adding eof_reached. | Confirmed: httpd lgetc now calls igetc() and has the eof_reached fake-newline branch (parse.y:1586-1601) that fugu lacks. Pure REFERENCE: lives in a base PROGRAM, not linkable. Low correctness risk but real edge-case (unterminated final line) drift. |
+| lungetc | Push one char back onto the input; backstops the macro parsebuf, else uses a fixed 128-byte pushback_buffer. | `/usr/src/usr.sbin/httpd/parse.y:1605` reallocarray(3) | **REFERENCE** | M | DRIFT: fugu uses a fixed global char pushback_buffer[128] (MAXPUSHBACK) that returns EOF on overflow. Current httpd moved the unget buffer INTO struct file as a growable ungetbuf/ungetpos/ungetsize, doubled via reallocarray (parse.y:1610-1616). This is what makes the START_EXPAND/DONE_EXPAND macro scheme possible and removes the 128-char ceiling. | reallocarray is libc (man 3 reallocarray). The growable-ungetbuf design is the httpd template's; align by moving pushback into struct file. This change is the prerequisite for the modern macro-expansion fix below. |
+| findeol | Error recovery: consume input up to next newline/EOF and return ERROR so the grammar resyncs. | `/usr/src/usr.sbin/httpd/parse.y:1621` | **REFERENCE** | S | DRIFT (minor): fugu's findeol clears parsebuf and hand-drains the global pushback_buffer. Current httpd findeol is simpler — just loops on lgetc(0) — because pushback now lives in struct file and igetc handles it. Once lungetc/igetc are aligned, fugu's findeol collapses to httpd's 9-line form. | Confirmed httpd findeol body (parse.y:1621-1636) has no parsebuf/pushback poking. REFERENCE only (program-internal). Trivial once the unget refactor lands. |
+| yylex | Tokenizer: skips ws/comments, expands $macros, scans quoted strings, numbers (strtonum), and bare identifiers; returns tokens. | `/usr/src/usr.sbin/httpd/parse.y:1656` strtonum(3) | **REFERENCE** | L | DRIFT: fugu expands macros the OLD way (set parsebuf=val; parseindex=0; goto top). Current httpd pushes the macro value back through lungetc bracketed by START_EXPAND(1)/DONE_EXPAND(2) sentinels and guards re-expansion with `expanding` and `!expanding` (parse.y:1656,1679-1684; igetc 1550-1553; defines 1534-1537). This kills the separate parsebuf code path entirely and allows nested/recursive macro use. strtonum usage is already correct and idiomatic. | **[verified correction: NO expand_macros fn exists in httpd parse.y; real mechanism is START_EXPAND/DONE_EXPAND sentinels + `expanding` flag read by igetc (parse.y:1540,1550-1553)]** There is NO function named expand_macros in current httpd/parse.y (grep found it only in vendored perl) — the lead's wording is imprecise; the real mechanism is the inline START_EXPAND/DONE_EXPAND + `expanding` flag interpreted by igetc. strtonum confirmed libc (man 3 strtonum). REFERENCE: template lives in a base program. This is the highest-effort alignment because removing parsebuf touches lgetc, lungetc, findeol, and yylex together. |
+| pushfile | Open an include/config file, allocate a struct file, push it on the include stack. | `/usr/src/usr.sbin/httpd/parse.y:1815` fstat(2) | **REFERENCE** | M | DRIFT + CHARTER-relevant: fugu's pushfile has NO secrecy check and links files via a bare nfile->prev pointer (and oddly never inserts onto a list — the caller assigns file=nfile). Current httpd takes a `secret` arg and, when set, runs check_file_secrecy(fileno, name) (parse.y:1833-1839) rejecting group/world-readable or non-owner files, and tracks files on a TAILQ. For fugu, whose charter says secrets never leak, the anthropic_key_file / kagi_token_file settings argue for adopting check_file_secrecy on the config (and key) files. | check_file_secrecy is a per-program helper (httpd/parse.y:1795), NOT a libc symbol — REFERENCE, copy the ~18 lines. Underlying fstat(2) and the S_IWGRP/S_IXGRP/S_IRWXO mode test are base. High value given fugu stores API keys; this is the most security-relevant drift in the file. |
+| popfile | Close current file, propagate its error count to the parent, pop the include stack. | `/usr/src/usr.sbin/httpd/parse.y:1855` | **REFERENCE** | S | DRIFT (minor): fugu walks file->prev; httpd uses TAILQ_PREV over a `files` TAILQ and frees the per-file ungetbuf. If the lungetc/ungetbuf and TAILQ alignments are adopted, popfile must also free ungetbuf and TAILQ_REMOVE. Otherwise functionally equivalent. | **[verified correction: popfile is :1855; :1844 is inside pushfile]** REFERENCE: program-internal. Couples to the pushfile/lungetc refactors; on its own fugu's prev-pointer version is correct. |
+| symset | Define/replace a macro symbol (name=val), honoring persistent (-D cmdline) entries. | `/usr/src/usr.sbin/httpd/parse.y` | **KEEP** | S | Matches the base template; uses fugu's xstrdup/xcalloc wrappers. No base library API for a symbol table — it is the standard per-parser TAILQ idiom. | Identical in spirit to httpd symset. No linkable base equivalent (a macro symbol table is parser-local). fugu also lacks the cmdopts/symset_cmdline path but that is feature scope, not drift. Keep. |
+| symget | Look up a macro symbol's value by name. | `/usr/src/usr.sbin/httpd/parse.y` | **KEEP** | S | Standard TAILQ_FOREACH lookup; no base library symbol-table API to adopt. | Matches base template verbatim. Keep. |
+| conf_parse_file | Public entry point: set conf/cur, pushfile(filename), run yyparse, collect errors, drain the macro table so a re-parse starts clean. | `/usr/src/usr.sbin/httpd/parse.y:1880` | **REFERENCE** | S | fugu's driver matches the base parse_config shape (pushfile/topfile/yyparse/popfile/return errors?-1:0). Two alignments: (1) once pushfile gains a `secret` arg, pass the right flag here; (2) httpd's parse_config additionally walks symset cmdline opts and warns on unused symbols — optional. The macro-drain-on-exit is fugu's own (good hygiene for repeated parses). | REFERENCE: every base parser has this driver but it is program-internal, not linkable. fugu's is correct and idiomatic; the only meaningful follow-on is threading the check_file_secrecy `secret` flag through. |
+
+### `src/common/url.c`
+
+_Both functions are sound, hardened reimplementations with no linkable base equivalent; base only solves the same problems inside programs (ftp/httpd) — REFERENCE for idioms, but fugu's allowlist/anti-injection posture is stricter than base and worth keeping._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| url_parse | Parse an absolute http(s) URL into {https, host, port, path} with bounded fixed buffers; default port from scheme; drop fragment; reject userinfo-smuggling, non-host chars in host, and space/control bytes in path (anti request/Host smuggling). | `/usr/src/usr.bin/ftp/fetch.c:306` strtonum(3), strlcpy(3) | **REFERENCE** | M | No base library parses URLs (the only man -k url hits are curl, a ports lib). url_get() at fetch.c:306 is static/program-internal: scheme split fetch.c:351-373, userinfo+path split fetch.c:377-405, IPv6-bracket + port split fetch.c:497-514. Mirror its [IPv6]/port handling, but note divergences: fetch.c uses strrchr for ']' and last ':' (portnum, fetch.c:508-509) and defers host validity to getaddrinfo; fugu bounds the authority with strcspn/memchr and additionally enforces a host-char allowlist (url.c:101-106) and rejects control/space in path (url.c:108-113) — hardening base does NOT do. Keep fugu's stricter posture; align only edge-case handling. Underlying primitives strtonum/strlcpy/strlcat/strcspn/memchr are all base and already used idiomatically. | strtonum(3) confirmed in <stdlib.h>; strlcpy/strlcat(3) in <string.h>; strcspn declared at /usr/include/string.h:78. Port range check via strtonum(u->port,1,65535,&errstr) is the canonical base idiom. fugu's leftmost-bounded parse is arguably safer than fetch.c's strrchr against crafted authorities. Keep the function; treat fetch.c as a robustness checklist, not a source to link. |
+| url_encode | RFC 3986 percent-encode: emit alnum and unreserved (-_.~) verbatim, %XX-encode everything else (uppercase hex), writing into a struct buf. | `/usr/src/usr.sbin/httpd/httpd.c:682` | **REFERENCE** | S | No linkable percent-encoder in libc/libutil. Two base analogs, both program-internal: httpd.c:682 url_encode (same static char hex[]="0123456789ABCDEF" table, but a BLOCKLIST — only encodes space #%?\"&< plus <=0x1f/>=0x7f — and calloc's a buffer) and fetch.c:141 url_encode (to_encode() predicate, malloc, lowercase %02x). fugu's ALLOWLIST (keep only RFC3986 unreserved) is stricter and more correct for query/form-data than httpd's blocklist, and it streams into buf instead of allocating. Adopt nothing; cite httpd.c:682 as the table/idiom reference and keep fugu's allowlist. | isalnum from <ctype.h> (already included, url.c:19) is base/standard. fugu's uppercase-hex + unreserved-keep matches RFC 3986 sec 2.3 exactly; httpd's blocklist would leave e.g. '+' and ';' unencoded, which is wrong for form bodies. Keeping fugu's implementation is the right call; the base programs validate the hex-table idiom but should not be adopted. |
+
+### `src/common/html2text.c`
+
+_A deliberately-minimal HTML-to-text reducer; OpenBSD base has no HTML parser or entity decoder (lynx/w3m are ports), so the tag/entity/whitespace machinery is all KEEP; only the UTF-8 codepoint encoder has a base analog (wctomb(3) / less), and only the output sink overlaps open_memstream(3)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| html2text | Main single-pass linear scanner: strips tags, skips comments/declarations/PIs, scans tag names honoring quoted attrs, dispatches script/style raw-skip, break/paragraph tags, entities, and whitespace collapse. | — | **KEEP** | L | no base HTML parser; lynx/w3m are ports (/usr/ports/www/lynx, /usr/ports/www/w3m), nothing in /usr/src/usr.bin, no html header in /usr/include | Charter-correct: hand-rolled, strictly linear over hostile input, output capped at HTML2TEXT_MAXOUT. Base offers zero linkable HTML tokenizer, so this stays bespoke. |
+| emit_codepoint | Encodes a Unicode codepoint to UTF-8 by hand (rejecting controls, surrogates, out-of-range), 1-4 byte forms. | `/usr/src/usr.bin/less/cvt.c:97` wctomb(3) | **REFERENCE** | M | wctomb(3) is locale-dependent (LC_CTYPE) and only emits UTF-8 under a UTF-8 locale; the hand encoder is locale-agnostic and pledge-safe, so adopting wctomb would be a regression for this hostile-input path. Mirror less/cvt.c only for the encode idiom, keep the manual path. | wctomb declared in <stdlib.h> (confirmed man 3 wctomb). Canonical base call site is less/cvt.c:97 `dst += wctomb(dst, ch)`. Keep fugu's validation of surrogates/range which wctomb does not guarantee across locales. |
+| emit_named | Resolves a named HTML character reference (amp/lt/gt/nbsp/copy/mdash/...) from a small static table to its replacement text, else echoes &name; literally. | — | **KEEP** | M | no base HTML-entity decoder; mandoc chars.c maps roff special-char escapes (\(co), not HTML entity names | Table-driven and intentionally tiny. strncasecmp inside it is base libc (strings.h:75) but the entity table and fallback logic are fugu-specific. |
+| decode_entity | Parses an &...; reference starting at '&': numeric (&#...; / &#x...;) with digit/overflow clamping, or alphanumeric named refs, dispatching to emit_codepoint/emit_named; falls back to literal '&'. | — | **KEEP** | M | no base charref parser | Uses isxdigit/isdigit/tolower/isalnum (ctype.h:68-79, base libc) but the &#/&#x/&name; grammar and clamping are HTML-specific glue with no base equivalent. |
+| skip_raw | Skips raw CDATA-like content (script/style body) to the matching delimited close tag, refusing bare-prefix false matches like </scriptx> so hidden text cannot leak. | — | **KEEP** | M | no base HTML tokenizer; raw-text-element handling is HTML-spec-specific | Security-relevant (prevents script text leakage). strncasecmp/isspace are base libc; the close-tag delimiter logic is bespoke and correct to keep. |
+| is_break_tag / is_paragraph_tag / tag_is | Tight group: tag_is does a length+case-insensitive name compare; is_break_tag and is_paragraph_tag test membership in static block-level tag sets to decide line vs paragraph breaks. | strncasecmp(3) | **KEEP** | S | the case-insensitive compare is already base (strncasecmp, strings.h:75); the tag-name sets and break/paragraph classification are HTML semantics with no base source | tag_is already wraps base strncasecmp idiomatically (declared /usr/include/strings.h:75). The membership tables (p/br/div/li/... vs paragraph tags) are pure HTML policy; keep as-is. |
+| emit_byte / emit_space / emit_break | Tight group: deferred-whitespace state machine. emit_space/emit_break record pending whitespace intent; emit_byte flushes the strongest pending whitespace (break beats space, capped at 2 newlines) then appends one byte to the output buf, honoring HTML2TEXT_MAXOUT. | `/usr/src/lib/libc/stdio/open_memstream.c:1` open_memstream(3) | **KEEP** | M | output accumulation overlaps open_memstream(3)/fugu's own struct buf, but the whitespace-deferral state machine is the actual value here and has no base equivalent | Lead's noted overlap: open_memstream(3) (stdio.h:256, lib/libc/stdio/open_memstream.c) is the base growable-output sink, but fugu already uses its own struct buf (xreallocarray-backed, OOM-fatal) with a hard MAXOUT cap that open_memstream does not give. The space/break collapse logic is bespoke and worth keeping; do not swap the sink for open_memstream (loses the cap and the fatal-OOM guarantee). |
+
+### `src/common/tlsconn.c`
+
+_Thin blocking libtls client shim; every function is a near-1:1 wrapper over base libtls (linkable, -ltls) used idiomatically per the man pages and ftp(1) fetch.c. All ADOPT (already on base); minor alignment nits only._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| tlsconn_client_config | Build a verifying client tls_config; optionally load a CA bundle file into memory via tls_load_file + tls_config_set_ca_mem so the connect path needs no rpath (pledge "stdio inet dns"). | `/usr/src/lib/libtls/tls_util.c:151` tls_load_file(3) | **ADOPT** | S | Already correct base usage. One alignment nit: the man page pairs tls_load_file() with tls_unload_file() (zeroes the buffer) rather than plain free(ca) at lines 46/50. CA data is not secret so impact is cosmetic, but tls_unload_file() is the documented idiomatic counterpart and is a linkable base symbol (declared in /usr/include/tls.h, impl tls_util.c:223). Optional: also consider tls_config_set_protocols() if you want to pin TLSv1.2+1.3 explicitly rather than relying on the libtls default. | **[verified correction: tls_load_file def is :151 (tls_config_* live in tls_config.c)]** tls_load_file/tls_config_set_ca_mem/tls_config_new all confirmed in base man page (man -k) and declared in /usr/include/tls.h (grep -c tls_load_file = 1). Comment at line 44 correctly notes set_ca_mem copies the data, justifying the free of the caller's copy. |
+| tlsconn_connect | Create a tls_client, configure it, tls_connect(host,port) (libtls does its own socket+DNS), then drive the handshake to completion; libtls owns and closes the fd. | `man tls_connect(3)` | **ADOPT** | S | Already correct base usage; the tls_configure->tls_connect*->handshake-loop->tls_error sequence mirrors fetch.c:650-665 exactly. No change needed. | **[verified correction: ftp/fetch.c never calls tls_connect() (uses tls_connect_socket); cite the man page]** tls_connect confirmed in man -k output (tls_connect(3) groups tls_connect_socket etc.). Error reporting via tls_error(ctx) matches fetch.c idiom. Fail path frees ctx and nulls it correctly. |
+| tlsconn_connect_socket | Same as tlsconn_connect but over a caller-supplied connected fd (used when the worker does its own connect under tighter pledge); records fd ownership in c->fd, clears it on failure since caller still owns the passed fd. | `/usr/src/usr.bin/ftp/fetch.c:655` tls_connect_socket(3) | **ADOPT** | S | Already correct base usage; near-identical to fetch.c:655 (tls_connect_socket(tls, fd, sslhost) then the tls_handshake WANT_POLL loop). fd-ownership handling on the fail path (c->fd = -1 so caller keeps the fd) is a correct extension over fetch.c. No change needed. | tls_connect_socket confirmed in man -k and tls_connect_socket(3) man page exists. fetch.c uses servername (sslhost) the same way fugu passes servername. |
+| tlsconn_read | Blocking read wrapper: call tls_read and retry immediately on TLS_WANT_POLLIN/POLLOUT so callers see ordinary blocking-read semantics. | `/usr/src/usr.bin/ftp/fetch.c:1779` tls_read(3) | **ADOPT** | S | Already correct base usage; the do{}while(WANT_POLLIN\|\|WANT_POLLOUT) loop is the exact documented blocking-fd pattern (tls_read(3) RETURN VALUES: "the same function call should be repeated immediately") and is byte-for-byte fetch.c's stdio_tls_read_wrapper (fetch.c:1778-1782). No change needed. | **[verified correction: off-by-one: tls_read call is :1779]** Confirmed against tls_read(3) man page text and fetch.c:1767/1780. Returns -1 on error / size on success straight through, matching man page. |
+| tlsconn_write | Blocking write wrapper: call tls_write and retry immediately on TLS_WANT_POLLIN/POLLOUT. | `/usr/src/usr.bin/ftp/fetch.c:1766` tls_write(3) | **ADOPT** | S | Already correct base usage; matches the tls_write(3) man-page blocking example (continue on WANT_POLL*) and fetch.c's stdio_tls_write_wrapper (fetch.c:1759-1769) line-for-line. No change needed. | **[verified correction: off-by-one: tls_write call is :1766]** Confirmed against tls_write(3) EXAMPLES section and fetch.c:1767. |
+| tlsconn_write_all | Loop tlsconn_write over a buffer until all len bytes are written, since tls_write may return a short count. | — | **KEEP** | S | No base TLS write-all primitive; this is a trivial short-write drain loop over tls_write. The pattern (while len>0: ret=tls_write; buf+=ret; len-=ret) is exactly the tls_write(3) man-page blocking example, so it is idiomatic, just not a library symbol. | Correct: tls_write can short-write so the loop is necessary. Minor robustness note (not base-related): on n==0 it would spin, but tls_write does not return 0 per the man page (size on success, -1 on error, or WANT_POLL* handled inside tlsconn_write), so this cannot loop forever in practice. |
+| tlsconn_close | Tear down: tls_close (TLS-layer shutdown) then tls_free the context, and close the owned fd if any. | `/usr/src/lib/libtls/tls.c:921` tls_close(3) | **ADOPT** | S | Already correct base usage. Aligns with tls_close(3): "Only the TLS layer will be shut down and the caller is responsible for closing the file descriptors, unless the connection was established using tls_connect()." fugu correctly close()s c->fd only for the connect_socket case (fd>=0) and leaves the tls_connect() case (fd==-1) for libtls to close. Minor: tls_close can return TLS_WANT_POLL* / -1, which fugu ignores; acceptable on teardown but could be drained like the other loops for symmetry. | **[verified correction: tls_close def (path_line was empty)]** tls_close(3) is part of the tls_read(3) man page group (verified). fd ownership logic is consistent with the connect/connect_socket fd bookkeeping. |
+| do_handshake | Static helper: drive tls_handshake to completion, retrying on TLS_WANT_POLLIN/POLLOUT (blocking semantics). | `/usr/src/usr.bin/ftp/fetch.c:660` tls_handshake(3) | **ADOPT** | S | Already correct base usage; the do{}while(WANT_POLL*) loop is identical to fetch.c:658-661. Explicit handshake before first read/write is optional per tls_handshake(3) (read/write auto-handshake) but is a clean way to surface handshake errors early. No change needed. | **[verified correction: off-by-one: tls_handshake call is :660]** Confirmed against tls_handshake(3) (in the tls_read(3) group) and fetch.c:658-661. Returns the raw 0/-1 which callers map to errstr via tls_error. |
+
+### `src/common/anthropic.c`
+
+_App-specific Anthropic Messages API request serializer + SSE event interpreter; built entirely on fugu's own buf/json layers. No linkable base equivalent exists (no base JSON/SSE library). The one base seam, strtonum in obj_getint, is already idiomatically adopted. Everything else is KEEP._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| anthropic_build_request | Serialise a streaming Messages request body (model, max_tokens, stream, system, messages array, tools) into a struct buf via the json_writer emitter. | — | **KEEP** | S | Anthropic wire-format construction; no base JSON emitter exists (no json header in /usr/include). | Pure glue over fugu's json_writer. The buf-vs-open_memstream question belongs to buf.c/json.c, not here; this function never touches raw stdio. Charter-clean. |
+| anthropic_build_request_raw | Same as build_request but splices pre-formatted messages_json verbatim as the messages value (the net worker's conversation projection). | — | **KEEP** | S | Anthropic-specific body shape; no base equivalent. | Near-duplicate of build_request differing only in messages source; could be internally factored but that is a local refactor, not a base seam. |
+| anthropic_stream_init / anthropic_stream_clear | Lifecycle for struct anthropic_stream: zero the struct, copy callbacks, init/free the three accumulator bufs (tool_id, tool_name, tool_json), set cur_index=-1. | — | **KEEP** | S | Trivial struct init/teardown over fugu buf; nothing in base to adopt. | memset(0)+buf_init/buf_free idiom. Standard C; no libutil/libevent helper applies. |
+| anthropic_stream_event | Top-level SSE-event dispatcher: parse the event's JSON data, validate it is an object, then route by event name (content_block_delta/start/stop, message_start/delta/stop, error; ping/unknown ignored). | — | **KEEP** | M | No base SSE/event-stream parser exists (grep of /usr/src for event-stream/content_block_delta: zero hits). | Strcmp dispatch table over Anthropic's event vocabulary. Entirely protocol-specific; base offers no linkable equivalent and no program-internal one to mirror. |
+| ev_message_start | Handle message_start: read message.usage.{input,output}_tokens and fire on_usage callback. | — | **KEEP** | S | Anthropic usage-block extraction; app-specific. | Navigates fugu json_doc via json_obj_get + obj_getint helper. No base seam. |
+| ev_block_start | Handle content_block_start: record block index, detect tool_use blocks, capture tool id/name into accumulator bufs. | — | **KEEP** | S | Anthropic content-block state machine; app-specific. | obj_getstr -> buf_puts -> free pattern over fugu json. Correctly frees each strdup. No base equivalent. |
+| ev_block_delta | Handle content_block_delta: route text_delta to on_text callback and input_json_delta into the tool_json accumulator (thinking_delta ignored in v1). | — | **KEEP** | S | Anthropic delta-type routing; app-specific. | Streaming fragment dispatch; no base equivalent. json_tok_strdup borrowed from fugu json layer. |
+| ev_block_stop | Handle content_block_stop: if the closing block was a tool_use, emit the assembled tool call (id, name, accumulated JSON, defaulting to {} when empty) via on_tool_call, then reset accumulators. | — | **KEEP** | S | Anthropic tool-call finalisation; app-specific. | Uses bufcstr borrow-trick to NUL-terminate accumulators. No base seam. |
+| ev_message_delta | Handle message_delta: update output_tokens (fire on_usage) and surface delta.stop_reason via on_stop. | — | **KEEP** | S | Anthropic stop/usage extraction; app-specific. | json_tok_type guard before strdup is correct. No base equivalent. |
+| ev_error | Handle error events: extract error.type/message, mark the stream fatally errored, fire on_error with safe fallbacks. | — | **KEEP** | S | Anthropic error-envelope handling; app-specific. | Frees both strdup'd strings; NULL-safe fallbacks. No base seam. |
+| obj_getstr | Helper: duplicate an object member (string or primitive) as a free()-able C string, or NULL if the key is absent. | — | **KEEP** | S | Thin wrapper over fugu json_obj_get/json_tok_strdup; no base JSON accessor exists. | Belongs to the json layer's call-convenience, not a base-replaceable unit. |
+| obj_getint | Helper: read an object member as long long, parsing the token text with strtonum() and falling back to a default on absence or parse error. | `/usr/src/usr.bin/calendar/io.c` strtonum(3) | **KEEP** | S | Already adopts the correct base API; no further base alignment available. | Verified strtonum is base: declared /usr/include/stdlib.h:305, man strtonum(3), idiomatic across /usr/src/usr.bin (aucat/audioctl/cal/calendar). The function already uses strtonum(LLONG_MIN,LLONG_MAX,&errstr) with the canonical errstr-checked pattern, so this is best-practice already, not a pending ADOPT. Listed KEEP because the only base seam is already taken. |
+| bufcstr | Helper: borrow a struct buf's bytes as a NUL-terminated C string by appending '\0' then uncounting it (len--), so the data pointer is a valid cstr without growing len. | — | **KEEP** | S | buf-internal idiom; depends entirely on fugu's struct buf, not a base type. | If buf.c were ever reimplemented on open_memstream(3) (stdio.h, confirmed in base), this borrow-trick would become a simple fflush+pointer read — but that is a buf.c-layer decision, out of scope for this file. Here it stays KEEP. |
+
+### `src/parent/parent.c`
+
+_Privsep coordinator is already well-aligned to base idioms (imsgbuf_*, freezero, closefrom). One clean ADOPT (scandir for the 3 hand-rolled dir scans); the spawn/relay core is a strong REFERENCE to relayd/proc.c; the poll loop vs libevent is a deliberate-design REFERENCE, not a must-adopt given fugu's foreground/non-daemon charter._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| usage | Print usage string to stderr and exit(1). | — | **KEEP** | S | trivial per-program boilerplate; no library equivalent | Standard OpenBSD usage() pattern already (extern __progname, fprintf, exit). Nothing to adopt. |
+| main | getopt parse, conf_load, dispatch -n/-l/-r/-c, then run() the privsep coordinator. | getopt(3) | **KEEP** | S | already uses base getopt(3) idiomatically | Option handling is textbook base getopt. argc/optind cleanup correct. No change. |
+| create_session_dir | Best-effort mkdir of ~/.fugu and ~/.fugu/sessions (0700). | — | **KEEP** | S | plain mkdir(2); no base mkdir -p library helper exists | Base has no libc 'mkpath'/mkdir-p; mkpath(3) is libutil-adjacent but not present here. Two literal mkdir calls are fine and idiomatic. |
+| sessions_path | Compose "$HOME/.fugu/sessions" with overflow check. | — | **KEEP** | S | snprintf-with-truncation-check is the base idiom already | Correct (>= size) truncation guard. No win available. |
+| session_exists | Validate id has no '/'/'.'/'..' then stat ~/.fugu/sessions/<id>.jsonl as a regular file. | — | **KEEP** | S | path-traversal guard + stat is application policy, not a library seam | The strchr('/')/dot checks are a deliberate security guard; no base helper replaces it. Keep. |
+| resolve_last / list_sessions (directory scan group) | Enumerate ~/.fugu/sessions/*.jsonl: resolve_last picks newest by mtime; list_sessions builds rows and prints. Both hand-roll opendir+readdir(+qsort in list). | `/usr/src/usr.bin/diff/diffdir.c:82` scandir(3) | **ADOPT** | M | scandir mallocs the whole namelist up front and you supply a select() filter + comparator; for resolve_last you still walk+stat each entry so the win is mostly the readdir/closedir boilerplate, not the mtime logic. The qsort in list_sessions stays (mtime sort, not name sort, so alphasort doesn't replace sessrow_cmp). | scandir confirmed declared /usr/include/dirent.h:91 and man scandir(3); real base caller /usr/src/usr.bin/diff/diffdir.c:82 (scandir(path1,&dirp1,selectfile,alphasort)). ADOPT collapses the opendir/while(readdir)/closedir trio into one call with a select() that matches the ".jsonl" suffix. Note: the mtime-newest sort is fugu-specific, so keep sessrow_cmp + qsort; scandir replaces the iteration, not the ordering. |
+| session_preview | Parse a stored JSON message, extract "content" string, fold control chars to space, collapse whitespace into a one-line preview. | — | **KEEP** | S | JSON + control-char sanitization is app logic over vendored jsmn; no base option | vis(3)/strnvis exist in base for visual encoding but the goal here is space-folding for a fixed-width column, not vis escaping. Keep. |
+| scan_session | Count non-empty lines (messages) in a .jsonl file and preview the first via getline. | getline(3) | **KEEP** | S | already uses base getline(3); newline trim is inherent, no one-call variant | getline confirmed (GETDELIM(3)). The manual \n/\r trim loop is the standard idiom; base offers no trimming reader. Keep. |
+| sessrow_cmp | qsort comparator ordering sessions newest-mtime-first. | qsort(3) | **KEEP** | S | mtime comparator can't be alphasort(3); it's domain ordering | alphasort sorts by name; this sorts by mtime descending, so it must stay hand-written. Correct (avoids subtraction overflow by branching). Keep. |
+| run | Spawn all 4 workers with policy argv, delegate config+secrets, pledge "stdio proc", run relay, shutdown. | `/usr/src/usr.sbin/relayd/proc.c:180` | **REFERENCE** | L | relayd's proc_init/proc_setup is a full privsep framework (struct privsep, instance arrays, IMSG_CTL_PROCFD fd-passing). Adopting it wholesale is heavy and libevent-coupled; fugu's flat 4-worker model is simpler and intentionally foreground. Mirror the ordering/teardown discipline, don't link. | proc_init at /usr/src/usr.sbin/relayd/proc.c:180 is the architectural analogue (setup pipes -> exec children -> connect). REFERENCE only: it's program-internal (usr.sbin), not a linkable lib, and pulls in libevent + the privsep struct model that fugu deliberately avoids. |
+| spawn | socketpair + fork; in child dup2 the pair onto fd 3, redirect non-ui stdio to /dev/null, closefrom, execv; in parent ip_init the channel. | `/usr/src/usr.sbin/relayd/proc.c:70` | **REFERENCE** | M | proc_exec at proc.c:70 is the canonical base pattern: dup2 the pipe onto a fixed PROC_PARENT_SOCK_FILENO then execvp. fugu mirrors it (fd 3 = IMSG_CHILD_FD). It's not linkable, so REFERENCE: align edge-case handling (fd==target fcntl(F_SETFD,0) vs dup2) but keep fugu's own closefrom(3)/dev-null hardening, which is already strong. | closefrom(2) confirmed /usr/include/unistd.h:491 and already used. fugu's spawn is arguably tighter than proc_exec (closefrom(IMSG_CHILD_FD+1) drops every stray fd; proc_exec relies on F_SETFD discipline). Mirror, don't replace. |
+| delegate_net / delegate_exec / delegate_fetch (config+secret delegation group) | Build a JSON config object per worker and ip_compose+ip_flush M_CONFIG, then resolve and send M_SECRET, freezero-ing the secret copy. | imsg_compose(3) | **KEEP** | S | the imsg send path is already base (imsgproto wraps imsg_compose/imsgbuf_flush); JSON-config building is app logic | Secret hygiene already correct: freezero(key,strlen) confirmed in malloc(3); secrets travel over imsg payload, never argv/env (charter-compliant). No base helper builds the JSON config. Keep; this is the secrets-never-in-argv design working as intended. |
+| resolve_secret | Return an inline secret (xstrdup) or read+trim the first line of a key file via getline. | getline(3) | **KEEP** | S | getline + trim is the base idiom; no secret-file reader in base | Reading a credential file's first line is app policy. getline already used. Caller freezero's the result. Keep. |
+| worker_by_ep | Map an endpoint id to its active worker slot (linear scan of 4). | — | **KEEP** | S | 4-element routing table lookup; no library seam | Trivial routing helper over fugu's own WDESC table. Nothing in base applies. |
+| relay_loop | poll() over all active worker fds; on POLLIN drain frames, on POLLHUP/ERR exit. The coordinator's central event loop. | `/usr/src/sbin/dhcpleased/frontend.c:167` event(3) | **REFERENCE** | L | libevent IS base (/usr/lib/libevent.so, event(3)) and dhcpleased/frontend.c:167-190 shows the canonical event_init()+per-channel event_set/event_add+event_dispatch() design. But for a fixed set of 4 channels with no timers/signals beyond SIGPIPE, a flat poll() loop is simpler, has no callback indirection, and keeps the coordinator foreground/non-daemon (fugu's charter). RECOMMEND evaluating libevent only if timers/backpressure/more channels arrive; do NOT adopt now. | Honest tradeoff: this is the big design question. event_dispatch confirmed in event(3) and libevent.a/.so present in base. dhcpleased frontend(): event_init() (frontend.c:167), event_set/event_add per imsg channel (185-187), event_dispatch() (190). The win would be write-side backpressure (imsgbuf_queuelen -> EV_WRITE, see relayd proc.c imsg_event_add) which fugu's poll loop currently doesn't arm (it only POLLINs; relies on ip_flush blocking). That's the one real gap worth noting — but addressable in the poll model too. REFERENCE, recommend-not-adopt. |
+| relay_drain | imsgbuf_read one worker, loop ip_frame_get pulling raw frames, route each by imsg_get_id to the destination worker via ip_forward+ip_flush, never reassembling payloads. | `/usr/src/usr.sbin/relayd/proc.c:593` imsg_forward(3) | **REFERENCE** | M | fugu already uses the exact modern base primitives (imsgbuf_read, imsg_get, imsg_get_id, imsg_forward, imsg_free — all confirmed in /usr/include/imsg.h via the imsgproto wrapper). proc_dispatch (proc.c:593) is the structural twin: imsgbuf_read -> for(;;) imsg_get -> handle/forward -> imsg_free, then imsg_event_add. Can't link it (program-internal + libevent-bound), so REFERENCE: mirror its EPIPE/n==0 channel-death handling. | imsg_forward confirmed /usr/include/imsg.h:162, imsg_get_id at :158, imsgbuf_read at :144 — fugu is already on the post-rewrite imsgbuf_* API (the rewrite the lead warned about), so no migration debt. The relay's forward-by-id-without-reassembly is precisely what imsg_forward(3) is for and what relayd/proc_forward_imsg does. Strongest alignment point: copy proc_dispatch's death/EPIPE branch semantics. |
+| shutdown_workers | Close each worker channel (EOF -> child exits), clear imsg state, then waitpid each pid. | `/usr/src/usr.sbin/relayd/proc.c:351` waitpid(2) | **REFERENCE** | S | proc_kill (proc.c:351) does the analogous close-pipes-then-reap; fugu's two-pass (close all, then wait all) is correct and arguably cleaner. No linkable helper; align the reaping discipline only. | waitpid(2) base. fugu's ordering (EOF every child first so they all exit concurrently, then reap) avoids serial-shutdown stalls. Keep the implementation; REFERENCE proc_kill purely for the close->SIGsomething->wait convention if a forced-kill timeout is ever wanted (fugu currently relies on EOF, which is fine for cooperative workers). |
+
+### `src/net/net.c, src/net/net_run.c`
+
+_net.c is a textbook base-idiomatic worker main (getopt/log/pledge/fstat-S_ISSOCK) — fully aligned, nothing to ADOPT beyond what it already uses; net_run.c is app-specific agent-loop/imsg-dispatch/blocking-HTTPS logic that is mostly KEEP, with the imsg plumbing and synchronous TLS HTTP client being REFERENCE alignments to base imsgbuf_* and ftp(1)'s url_get/tls_read blocking client._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| main (net.c) | Worker entry: getopt(d/v), log_init/log_setverbose, ignore SIGPIPE, fstat the inherited fd and require S_ISSOCK, ip_init the imsg channel, run net_loop. | `/usr/src/usr.sbin/httpd/httpd.c (getopt main idiom); /usr/include/sys/stat.h:140 (S_ISSOCK)` getopt(3) | **REFERENCE** | S | Already mirrors base worker-main conventions verbatim (getopt loop, SIG_IGN on SIGPIPE per ssh mux.c:1991, log_init/fatal from the copied base log.c, S_ISSOCK guard); nothing to change, keep aligned. SIG_IGN-on-SIGPIPE matches base; could use ssh_signal-style wrapper but that is ports/ssh-internal, not warranted. | All primitives (getopt, signal, fstat, S_ISSOCK, fprintf, __progname) are pure libc/base. The only non-libc calls are fugu's own log_*/ip_init wrappers, which themselves wrap base log.c and base imsg. This main is the idiomatic OpenBSD privsep-worker pattern; no ADOPT gap. |
+| net_loop | The imsg event loop: emit M_READY, then ip_get/ip_read-drain dispatch on M_SECRET/M_CONFIG/M_SUBMIT/M_SUMMARIZE/M_SHUTDOWN; stores the API key via xmalloc/freezero; narrows pledge to 'stdio rpath inet dns' on entry. | `/usr/src/lib/libutil/imsg.c; /usr/src/lib/libutil/imsg_init.3` imsg_init(3) | **REFERENCE** | M | The read-drain-then-dispatch shape is the canonical imsg consumer loop; fugu reimplements it behind ip_get/ip_read which wrap the current imsgbuf_* API (imsgbuf_init/imsgbuf_read/imsg_get) — confirmed current via imsg_init(3) listing imsgbuf_init/imsgbuf_read/imsgbuf_flush. Align idioms to base; the dispatch switch itself is app-specific (KEEP-flavoured). | Verified base now exposes imsgbuf_init/imsgbuf_read/imsgbuf_flush/imsg_get (the post-rewrite API) in imsg_init(3) and /usr/include/imsg.h. The actual imsgbuf_* adoption lives in fugu's ip_* wrapper (imsgproto.c, not in these files); these files only consume that wrapper. pledge/freezero are pure base. Loop body (secret handling, turn/summarize routing) is application logic = KEEP, but the plumbing is a base-aligned REFERENCE. |
+| net_run_turn | The agent loop: repeatedly call the Messages API; while the model requests tools, build the assistant message, run tools, append tool_results, and re-call, up to NET_MAX_ITERS (50). | — | **KEEP** | L | Anthropic agent/tool-use orchestration; no base analogue. | Pure application control flow over fugu's own buf/json/imsg helpers. The synchronous structure (block on each API call, block on each tool round) is the deliberate design; base has nothing comparable. Only the inner net_api_call has a base REFERENCE. |
+| net_summarize | /compact: append a closing user turn, run one tool-less Messages call with the summary system prompt (deltas hushed), emit collected text as M_SUMMARY. | — | **KEEP** | M | Anthropic-specific summarisation call; no base equivalent. | Application logic; uses fugu json_writer + net_api_call. No base seam. |
+| net_api_call | One streamed Messages call: build JSON body + HTTP request, tlsconn_connect, write_all the request, then loop tlsconn_read into a 4KB buffer feeding http_resp_push/eof, capture non-200 error body, classify status. | `/usr/src/usr.bin/ftp/fetch.c:306 (url_get); /usr/src/usr.bin/ftp/fetch.c:1773 (stdio_tls_read_wrapper)` tls_connect(3) | **REFERENCE** | M | libtls itself is base (-ltls, tls.h) and IS used via fugu's tlsconn wrapper; but the request-build / status-parse / blocking-read-loop is solved only inside ftp(1) (usr.bin), not a linkable library, so REFERENCE. Mirror ftp(1)'s blocking idiom: retry tls_read on TLS_WANT_POLLIN/POLLOUT (fetch.c:1773-1782) rather than treating a short/again read as fatal, and its status-line/header parsing in url_get. | Confirmed ftp(1) does exactly fugu's pattern: tls_connect_socket then a do/while tls_read loop that retries on TLS_WANT_POLLIN/TLS_WANT_POLLOUT (stdio_tls_read_wrapper, fetch.c:1773), wrapped through funopen at fetch.c:666; url_get (fetch.c:306) builds the request and parses the response status. fugu's tlsconn_read presumably already handles TLS_WANT; the alignment value is in the edge-case/status-parsing handling. No linkable HTTP client in base = not ADOPT. |
+| conv_append | Append one JSON message object to a messages array that ends in ']' (drop ']' , add ',' + object + ']'). | — | **KEEP** | S | Trivial JSON-array splice on fugu's buf; no base API. | Manipulates fugu buf directly. No base JSON facility (jsmn is parse-only and vendored). |
+| build_assistant_msg / build_tool_results (assistant+tool_result JSON builders) | Serialize the collected turn into Anthropic message JSON: an assistant message (text + tool_use blocks) and a user message of tool_result blocks (the latter runs each tool and embeds its content/is_error). | — | **KEEP** | M | Anthropic message-shape serialization via fugu json_writer; no base JSON writer. | Cohesive pair that builds the two conversation-turn JSON objects. Pure application logic; base has no JSON-emitter library (jsmn is read-only). |
+| send_tool_and_wait | Compose+flush an M_TOOL_REQUEST to the tool worker, then block ip_get/ip_read until the matching M_TOOL_RESULT (or M_SHUTDOWN), parse content/is_error, ignoring other messages mid-turn. | `/usr/src/lib/libutil/imsg.c` imsg_init(3) | **REFERENCE** | M | The compose/flush then read-until-reply pattern is the base imsg RPC idiom (imsg_compose + imsgbuf_flush + imsgbuf_read/imsg_get). fugu wraps it as ip_compose/ip_flush/ip_get/ip_read; align to current imsgbuf_* naming and MAX_IMSGSIZE bounds. The synchronous-block-mid-turn policy is app-specific. | Uses fugu ip_* wrappers (which sit on base imsgbuf_*). Verified base API names current in imsg_init(3). Not ADOPT because the wrapper and the blocking-RPC policy are fugu's; the underlying transport is the base imsg adoption already in the ip_* layer. |
+| tool_dst | Route a tool name to its worker endpoint: web_search/web_fetch -> EP_FETCH, else EP_EXEC. | — | **KEEP** | S | Two strcmp dispatch; pure fugu routing policy. | Trivial; no base seam. |
+| turncollect_free | Free a turncollect: buf_free text, free each toolcall id/name/input, free arrays and stop_reason. | — | **KEEP** | S | Struct destructor over fugu allocations; no base API. | Standard free helper. free(3) is libc but there is no base 'destructor' to adopt. |
+| net_emit / net_emit_error | Emit an imsg (ip_compose+ip_flush, fatalx on failure); net_emit_error formats 'what: detail' into a buf and emits M_ERROR to the UI. | `/usr/src/lib/libutil/imsg.c` imsg_init(3) | **REFERENCE** | S | Thin convenience over the base imsg send path (compose+flush). Keep, but ensure the wrapped calls track current imsgbuf_flush semantics. net_emit_error formatting is app-specific. | Wraps fugu ip_* over base imsgbuf_*. fatalx on send failure is the base daemon convention (log.c). |
+| net_apply_config / cfg_str | Parse a JSON config object (json_parse) and update model/max_tokens/system/host/port/cafile; max_tokens via strtonum(1,1000000); cfg_str fetches a JSON string field. | strtonum(3) | **ADOPT** | S | Already correctly uses strtonum(3) (base, <stdlib.h>) with min/max + errstr for max_tokens — the idiomatic OpenBSD safe-integer parse; no change needed, this is the win already taken. The JSON field extraction (json_obj_get/json_tok_strdup) is fugu/jsmn and stays KEEP. | strtonum(3) confirmed in base (man 3 strtonum, declared in <stdlib.h>). This is a genuine base-API adoption fugu already does correctly. The surrounding JSON config plumbing has no base equivalent (jsmn vendored). |
+| net_ensure_ready | One-time readiness: load the TLS client config from cafile (tlsconn_client_config), then narrow pledge to 'stdio inet dns' and set ready. | tls_connect(3) | **REFERENCE** | S | The privilege-narrowing-after-setup (open CA bundle under rpath, then drop rpath) is the canonical pledge staging idiom. CA load uses libtls (base) behind tlsconn_client_config. Keep; align CA-load error handling to libtls conventions. | pledge(2) and libtls are base. The staged pledge narrowing matches base daemons. tlsconn_client_config is fugu's wrapper over base tls_config_*; the adoption already happened in the tlsconn layer (not these files). |
+| net_state_clear | Teardown: freezero the key, free model/system/host/port/cafile, tls_config_free, zero the struct. | freezero(3) | **ADOPT** | S | Correctly uses freezero(3) (base, malloc(3) family) to scrub the secret key and tls_config_free(3) (base libtls) — both idiomatic; no change needed. Honors the charter's 'secrets never linger' intent. | freezero(3) confirmed in MALLOC(3) listing; tls_config_free is base libtls. Both are real base symbols already adopted. The rest is plain free(3). |
+| net_body | HTTP body sink trampoline: on status 200 feed bytes to the SSE parser, else accumulate into the error buf. | — | **KEEP** | S | Callback trampoline between fugu http_resp and sse parser; no base API. | Pure fugu pipeline glue. |
+| sse_to_anthropic | SSE-event trampoline: forward (event,data,len) to anthropic_stream_event. | — | **KEEP** | S | One-line forwarder between fugu sse and anthropic parsers; no base seam. | Server-Sent-Events is not a base facility; vendored/own parser. KEEP. |
+| anthropic stream callbacks (on_text/on_tool_call/on_usage/on_stop/on_err/on_done) | Six libcb callbacks that relay streamed events to the UI (M_DELTA/M_TOOL_CALL/M_USAGE/M_ERROR) and collect them into the active turncollect; on_done is a no-op. | — | **KEEP** | M | Anthropic streaming event handlers; no base analogue. | Cohesive callback set wired into NET_CBS. They emit imsg via net_emit (base-imsg REFERENCE already covered there) but the event semantics are entirely Anthropic-specific. KEEP. |
+
+### `src/exec/exec.c, src/exec/exec_run.c`
+
+_Thin worker entry + cwd-confined sandbox; the unveil/pledge/execpromises sandbox is an idiomatic base privsep pattern (REFERENCE), getopt/signal are libc (ADOPT, already used), the rest is app-specific imsg/tool/json plumbing (KEEP)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| main | fugu-exec worker entry: getopt("dvN"), log_init/setverbose, ignore SIGPIPE, fstat the imsg fd 3 to refuse direct invocation, then ip_init + exec_sandbox + exec_loop. | getopt(3) | **ADOPT** | S | Already on the base idiom; nothing to change. | Pure base-idiom entry: getopt(3) option loop, signal(SIGPIPE,SIG_IGN) (cf /usr/src/usr.bin/ftp/ftp.c), fstat(2)+S_ISSOCK guard, __progname libc extern. The only non-base calls (log_init, ip_init) are fugu-internal helpers and stay KEEP by composition, but main() itself is textbook libc. Verified signal/SIGPIPE idiom present in base (ftp.c, dig). |
+| exec_sandbox | Builds the cwd-confined no-network sandbox: unveil(".","rwc"), hide ~/.fugu, unveil base tool/lib dirs rx, optional resolver/trust store for opt-in subprocess net, unveil(NULL,NULL) lock, then two-arg pledge(promises, execpromises) where execpromises is the ceiling inherited across execve(2) for ksh/grep/find/ls children — inet-excluding unless allow_subprocess_net. | `/usr/src/libexec/ld.so/ldd/ldd.c:186` pledge(2), unveil(2) | **REFERENCE** | S | No base library wrapper to link — pledge/unveil are raw syscalls; this is align-the-idiom, not adopt-a-symbol. | Confirmed already idiomatic. The exec->execpromises pattern (tighten a child's pledge ceiling before/around execve) is exactly ldd's pledge("stdio rpath exec","stdio rpath") at /usr/src/libexec/ld.so/ldd/ldd.c:186, verified by reading lines 185-186. Two-arg pledge(promises,execpromises) confirmed in pledge(2) synopsis (man line 10) and behavior at man lines 218/286. unveil-then-lock(NULL,NULL) before the final pledge matches unveil(2)'s 'strongly recommended to lock' guidance. Lead's 'sandbox idiom is REFERENCE' confirmed correct; structure is sound. |
+| unveil_or_skip | Helper: unveil(path,perms) tolerating ENOENT so optional dirs (/sbin, /usr/local/lib, resolver files) need not exist. | `/usr/src/sbin/sysctl/sysctl.c:270` unveil(2) | **REFERENCE** | S | No linkable helper in base — every program open-codes the same 2-line guard. | Byte-for-byte the base idiom: /usr/src/sbin/sysctl/sysctl.c:270 does `if (unveil(_PATH_DEVDB,"r")==-1 && errno!=ENOENT)` (also lines 272, 275), verified by grep. fugu's wrapper is the correct factoring of that pattern; nothing to adopt, just confirms idiomatic alignment. |
+| exec_loop | imsg receive loop: ip_get/ip_read drain, dispatch M_CONFIG->apply_config, M_TOOL_REQUEST->handle_tool, M_SHUTDOWN->exit; frees each msg. | — | **KEEP** | S | app-specific dispatch over fugu's imsgproto wrapper and fugu_msg taxonomy. | Underlying transport already rides base imsg via fugu's imsgproto, but the loop control flow and message types (M_CONFIG/M_TOOL_REQUEST/M_SHUTDOWN) are fugu's. No base equivalent for the dispatch itself. |
+| apply_config | Parse M_CONFIG JSON object and read allow_write bool into execstate. | — | **KEEP** | S | app-specific config schema via fugu's vendored jsmn wrapper; no base JSON in charter scope. | Trivial schema-specific parse over json_doc; out of scope for base adoption (jsmn is the only allowed vendored dep). |
+| handle_tool | Dispatch M_TOOL_REQUEST by tool name (read/write/edit/shell/grep/find/ls) into tool_* implementations, marshal missing-arg errors, then emit_result. | — | **KEEP** | M | core app tool registry; no base equivalent. | Lead confirms tool dispatch is app-specific KEEP. The strcmp-chain could be a dispatch table but that's a style nit, not a base-adoption opportunity. Arg extraction via obj_getstr, error strings via buf. |
+| emit_result | Build M_TOOL_RESULT JSON {id,content,is_error} via fugu's json_writer and send it with ip_compose/ip_flush to EP_NET. | — | **KEEP** | S | app-specific result framing over fugu's JSON writer and imsg wrapper. | No base option: JSON output is fugu's own writer; imsg compose/flush is fugu's imsgproto over base imsgbuf_*. |
+| obj_getstr | Convenience accessor: fetch a JSON string value by key from an object token, strdup'd, else NULL. | — | **KEEP** | S | thin accessor over fugu's vendored jsmn json_doc; no base JSON library in scope. | Pure jsmn-wrapper helper (json_obj_get + json_tok_type + json_tok_strdup); nothing in base to align to. |
+
+### `src/exec/tools_file.c, src/exec/tools_shell.c`
+
+_Both files already lean on base libc primitives (mkstemp, memmem, closefrom) correctly; the composite helpers (atomic-write, file-slurp, fork+exec+poll capture) have no LINKABLE base equivalent — their closest base analogues are program-internal (acme-client/sed atomic-write, ssh subprocess capture), so they are REFERENCE/KEEP, not ADOPT._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| tool_read | Public entry: slurp a file into out, formatting an errno message on failure. | — | **KEEP** | S | no base option: app-specific buf+errno-message glue, no linkable libc equivalent for 'read whole file into growable buf' | Just dispatches to static slurp() and renders strerror into the struct buf. Nothing for base to replace; the real reuse opportunity lives in slurp(). |
+| tool_write | Public entry: allow_write policy gate, then durable atomic write of content to path. | — | **KEEP** | S | no base option: the allow_write authorization gate and message are fugu policy; the durable-write mechanics are in write_atomic() | Charter-relevant guard (allow_write no). Base has no equivalent for the policy decision; mechanics evaluated under write_atomic. |
+| tool_edit | Read file, locate a unique old_string, splice in new_string, atomic-write the result. | `/usr/include/string.h:122` memmem(3) | **ADOPT** | S | Already adopted: the load-bearing search uses base memmem(3) (libc), declared at string.h:122, confirmed binary-safe via big_len; the surrounding splice is app logic. | memmem(3) is genuine base libc (man 3 memmem; string.h:122). fugu already calls it correctly for both find and the second-occurrence uniqueness probe (m+1, content.len-off-1). The splice/orchestration itself stays fugu's; no further base API applies. Marked ADOPT because the one library-replaceable seam (substring search) is already on the base API and should stay there rather than hand-rolling. |
+| write_all | Write a full buffer to an fd, looping on partial writes and retrying EINTR. | `/usr/src/usr.bin/ssh/atomicio.c:50` | **REFERENCE** | S | ssh's atomicio()/atomiciov() is the canonical full-write loop but lives in ssh's tree, not libc/libutil; grep of /usr/include shows no atomicio declaration, so it is not linkable. | Verified: 'grep atomicio /usr/include' returns nothing — atomicio is per-program in usr.bin/ssh, so KEEP-by-link but mirror its EINTR/EAGAIN handling. fugu's loop already matches the EINTR-retry + partial-write accounting; the only gap vs atomicio is it does not treat a 0-byte write as an error. Low value to change; cite ssh/atomicio.c as the idiom of record. |
+| slurp | Read an entire file into a growable buf with EISDIR/EFBIG guards and a 1 MiB cap. | `/usr/src/usr.bin/ssh/sshbuf-io.c` | **KEEP** | S | no base option: base has no libc/libutil 'load entire file into buffer' call; grep of /usr/include finds no read_file/load_file, and sshbuf_load_file is ssh-internal | Confirmed no linkable equivalent (no read_file/load_file in /usr/include; sshbuf_load_file only under usr.bin/ssh). The S_ISDIR->EISDIR pre-check and TOOLS_MAXFILE cap are fugu-specific safety. open/fstat/read-loop is the right base primitive set already. Keep as-is. |
+| write_atomic | Durable atomic file replace: mkstemp sibling temp, write, preserve mode via fchmod, fsync, rename; unlink temp on any failure. | `/usr/src/usr.sbin/acme-client/fileproc.c:59` mkstemp(3) | **REFERENCE** | M | The base atomic-write pattern (mkstemp -> fchmod -> write -> close -> rename, unlink-on-fail) exists only inside programs (acme-client serialise(), sed, cron crontab.c), not as a linkable libc helper; fugu already uses the base mkstemp(3) primitive correctly. | Canonical base site: acme-client/fileproc.c serialise() at fileproc.c:59 (mkstemp), fchmod fileproc.c:63, rename fileproc.c:77, unlink-on-fail fileproc.c:87 — but NO fsync. sed/main.c:405 preserves mode via 'fchmod(fd, sb.st_mode & ALLPERMS)', matching fugu's st_mode & 0777 preservation; cron/crontab.c:398/525 same shape. fugu IMPROVES on all of them by adding fsync(2) before rename for crash durability — keep that. Action: align edge cases to acme-client (it is the idiom of record) but do not regress fsync. mkstemp(3) itself is confirmed base libc (stdlib.h). |
+| run_capture | Fork a child with stdin=/dev/null and stdout/stderr to a pipe, closefrom() to drop the imsg fd, execv, poll a CLOCK_MONOTONIC deadline, cap captured output, SIGKILL on timeout/overflow, reap with EINTR-safe waitpid. | `/usr/src/usr.bin/ssh/misc.c:2786` closefrom(2) | **REFERENCE** | L | ssh/misc.c subprocess() is a near-identical pipe+fork+dup2(/dev/null)+closefrom(STDERR_FILENO+1)+execv capture, but it is an ssh-internal helper (not libutil) and stops at returning the pipe fd — it has no timeout or output cap, both of which fugu adds. popen(3) is unusable (no stderr capture, no timeout). So overall KEEP-by-link, REFERENCE-by-idiom. | Strong analogue verified: ssh/misc.c:2786 pipe(p); 2796 fork(); 2823 dup2(devnull,STDIN); 2834 dup2(fd,STDOUT); 2838 closefrom(STDERR_FILENO+1); 2863 execve/execv; parent waitpid EINTR loop ~2881 — mirrors fugu line-for-line including the closefrom(STDERR_FILENO+1) to shed inherited fds (fugu's comment: drop imsg fd). closefrom(2) is genuine base (unistd.h:491). The CLOCK_MONOTONIC poll-deadline is the ssh monotime()/ptimeout idiom (misc.c:1735, 341). Differences worth keeping in fugu: wall-clock timeout via monotonic deadline, TOOLS_MAXOUTPUT cap with SIGKILL backpressure, truncation/timeout annotations. Align error/EINTR handling to ssh; do not adopt popen. |
+| tool_shell / tool_grep / tool_find / tool_ls | Thin public wrappers that build a fixed argv (ksh -c, grep -RnI, find, ls -la) and delegate to run_capture. | — | **KEEP** | S | no base option: these are fixed-argv constructors selecting base programs (/bin/ksh, /usr/bin/grep, /usr/bin/find, /bin/ls); there is no linkable library form, and re-implementing grep/find/ls in-process would violate simplicity and the sandbox model | Tight cohesive group of 4 trivial argv-builders over run_capture. Correct design: shell out to base tools and inherit the worker's unveil/pledge sandbox rather than re-implement. The (char*)(uintptr_t) casts launder const into the execv char*const[] contract — harmless. Nothing for base to provide here; the only shared mechanism (capture) is covered by run_capture above. |
+
+### `src/fetch/fetch.c, src/fetch/fetch_run.c, src/fetch/kagi.c`
+
+_Fetch worker: app-specific imsg/tool plumbing (KEEP) over base libtls (tls(3) already ADOPTED via direct calls); the connect+SSRF dial loop and TLS handshake loop mirror ftp(1)'s program-internal fetch.c (REFERENCE); getaddrinfo(3) usage is correct libc; addr_blocked SSRF guard and Kagi formatting have no base equivalent (KEEP)._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| main | fugu-fetch worker entry: getopt, log_init, SIGPIPE ignore, fstat-asserts imsg fd 3 is a socket, ip_init, runs fetch_loop. | getopt(3) | **KEEP** | S | App-specific worker bootstrap; already built from base primitives (getopt/signal/fstat) with nothing to swap. | The fd-3-is-a-socket guard via fstat+S_ISSOCK is idiomatic OpenBSD; no helper to adopt. |
+| fetch_loop | Main imsg event loop: pledge('stdio rpath inet dns'), send M_READY, dispatch M_SECRET/M_CONFIG/M_TOOL_REQUEST/M_SHUTDOWN, freezero the Kagi token. | pledge(2) | **KEEP** | S | App-specific brokered-tool loop over fugu's imsg layer; pledge is already used correctly. | Secret handling (freezero on token, never argv/env) matches charter. Nothing linkable to adopt. |
+| apply_config | Parse M_CONFIG JSON, override cafile/kagi_host/kagi_port/allow_private. | — | **KEEP** | S | App config over fugu's json wrapper; no base JSON library by charter. | Pure application glue. |
+| ensure_ready | Lazily build TLS client config from cafile via tlsconn_client_config, then narrow pledge to 'stdio inet dns'. | tls_load_file(3) | **KEEP** | S | Already sits on base libtls via fugu's tlsconn wrapper (out of this file's scope); the privilege-narrowing pledge step is app-specific. | Confirmed tls_config_set_ca_file is base (TLS_LOAD_FILE(3)). The wrapper itself lives in tlsconn.c, not audited here; this function is just the call+pledge orchestration => KEEP. |
+| handle_tool | Parse M_TOOL_REQUEST JSON (id/name/input), dispatch web_search vs web_fetch, emit M_TOOL_RESULT. | — | **KEEP** | S | App-specific tool router over fugu json/imsg; no base analog. | Application dispatch logic. |
+| do_web_search | Build Kagi search path+auth header, call http_fetch (guard off for trusted Kagi host), format results; freezero the auth header holding the token. | — | **KEEP** | S | Kagi-API-specific request orchestration; no base equivalent. | Good: token-bearing auth value is freezero'd after use, consistent with charter. |
+| do_web_fetch | Parse URL, enforce https-only, http_fetch with SSRF guard (unless allow_private), reject redirects, branch on content-type to html2text/text/binary with caps. | — | **KEEP** | M | App-specific fetch policy (no-redirect, content-type handling, size caps); no base helper. | strcasestr/strncasecmp are libc and used correctly. The policy (no redirect follow to avoid re-guarding) is deliberate and not something base offers. |
+| ms_left | Compute milliseconds remaining to a CLOCK_MONOTONIC deadline, clamped to [0, FETCH_TIMEOUT*1000]. | `/usr/src/usr.bin/ftp/util.c:1081` clock_gettime(2) | **KEEP** | S | Tiny deadline-math helper; clock_gettime already base, no library function computes a clamped ms remainder. | Could optionally use timespecsub(3) from <sys/time.h> for the subtraction, but the manual arithmetic is fine and idiomatic; not worth a finding upgrade. |
+| io_wait | poll() a single fd for events until the deadline; 1 ready / 0 timeout / -1 error. | poll(2) | **KEEP** | S | Thin single-fd poll wrapper around base poll(2); nothing to adopt. | libevent is base and allowed, but a blocking deadline-bounded worker doing one request at a time correctly uses poll directly; event-loop migration would be overkill here. |
+| addr_blocked | SSRF guard: classify a resolved sockaddr as loopback/RFC1918/link-local/CGNAT/multicast/ULA/v4-mapped and refuse non-public addresses. | — | **KEEP** | M | No base option: confirmed no private-range/SSRF helper in /usr/src/lib/libc (grep empty); base ftp(1) has no such guard at all. | Uses base IN6_IS_ADDR_LOOPBACK/LINKLOCAL/MULTICAST/UNSPECIFIED/V4MAPPED macros correctly. The IPv4 octet checks and fc00::/7 ULA + CGNAT (100.64/10) handling are hand-rolled because base provides no equivalent. This is the security core and must stay in fugu. |
+| dial | getaddrinfo(host,port) then iterate results: SSRF-guard each resolved addr, non-blocking connect with EINPROGRESS+io_wait deadline, return connected fd. | `/usr/src/usr.bin/ftp/fetch.c:599` getaddrinfo(3) | **REFERENCE** | M | getaddrinfo itself is correct libc usage (cf. /usr/src/usr.bin/ftp/fetch.c:531); the connect-with-timeout loop should align to ftp's timed_connect, which is a program-internal helper (defined /usr/src/usr.bin/ftp/util.c:1081, declared in ftp's extern.h) — not linkable, so REFERENCE not ADOPT. | ftp/fetch.c:599 calls timed_connect(fd,res->ai_addr,res->ai_addrlen,connect_timeout) while iterating getaddrinfo res — same shape as dial(). ftp uses its own non-blocking-connect helper; mirror its edge-case handling (per-ai fallthrough, SO_ERROR check). fugu adds the addr_blocked guard per-ai which ftp lacks; keep that. getaddrinfo(3) confirmed declared netdb.h:316, implemented lib/libc/asr/getaddrinfo.c. |
+| http_fetch | One HTTPS GET under a wall-clock deadline: dial, build Host/UA/Accept headers + extras, tls_client/tls_configure/tls_connect_socket, deadline-bounded handshake/write/read loops, push into http_resp, cap body. | `/usr/src/usr.bin/ftp/fetch.c:646` tls_connect_socket(3) | **REFERENCE** | M | The libtls calls ARE the base API and are already adopted (drop-in); what to mirror is the TLS_WANT_POLLIN/POLLOUT handshake/read loop idiom from ftp(1)'s program-internal fetch.c. The HTTP request build, deadline plumbing, and body cap are fugu-specific. | ftp/fetch.c:646-666 does the identical tls_client->tls_configure->tls_connect_socket->tls_handshake-loop sequence; fugu correctly extends it with TLS_WANT_POLLIN/OUT + io_wait for non-blocking deadline enforcement (ftp uses funopen/blocking stdio instead). Confirmed tls_connect_socket in TLS_CONNECT(3), tls_config_set_ca_file in TLS_LOAD_FILE(3). No drop-in to swap — already on base libtls; align idioms/error-string handling to ftp's sockerror(). |
+| fetch_body | http_resp body sink: append decoded bytes into struct buf up to a cap, set truncated flag. | — | **KEEP** | S | Callback over fugu's buf module enforcing the download cap; no base analog. | Application size-capping callback. |
+| emit_result | Serialize {id,content,is_error} via fugu json_writer and ip_compose/ip_flush an M_TOOL_RESULT. | — | **KEEP** | S | App-specific result serialization over fugu's json/imsg layers. | Pure glue. |
+| obj_getstr | Helper: fetch a string-typed value for a key from a fugu json object, strdup'd. | — | **KEEP** | S | Thin accessor over fugu json; no base JSON API by charter. | Duplicated logic with kagi.c:field() but local to this file; trivial. |
+| state_clear | Teardown: freezero the Kagi token, free cafile/host/port, tls_config_free, zero the struct. | freezero(3) | **KEEP** | S | App-specific cleanup already built from base freezero + libtls free; nothing to adopt. | Correct secret hygiene: freezero on the token rather than free. |
+| kagi_search_path | Build the Kagi /api/v0/search query path with url_encode'd query and limit=10. | — | **KEEP** | S | Kagi-endpoint-specific URL builder; no base equivalent. | url_encode is a fugu module (url.h), out of scope here. |
+| kagi_format_results | Parse Kagi JSON response: handle error envelope, walk data[] array, extract title/url/snippet of search-type results, field-cap and format up to KAGI_MAX_RESULTS. | — | **KEEP** | M | Kagi-API-specific response formatting; no base analog. | Per-field caps (KAGI_TITLE/URL/SNIPPET_MAX) defend the model against a hostile result flooding context — app policy, must stay. |
+| field | Helper: fetch a string-typed value for a key from a fugu json object, strdup'd. | — | **KEEP** | S | Thin json accessor; identical to fetch_run.c:obj_getstr but file-local static. | Could be de-duplicated with obj_getstr into a shared json helper, but that is a fugu-internal refactor, not a base-adoption finding. |
+
+### `src/ui/ui_curses.c`
+
+_Wide-char curses TUI on a poll(2) loop: the curses/libc primitives (get_wch, mvaddnwstr, wcwidth, mbrtowc, wcrtomb, setlocale, nl_langinfo, resizeterm) are all base-linkable and already used idiomatically — verdict REFERENCE/ADOPT to align with ssh/utf8.c and mandoc/term_ascii.c; the transcript/block model, line editor, imsg pump and slash-command handling are app-specific KEEP._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| ui_curses_run | Top-level TUI: locale/UTF-8 setup, unveil/pledge narrowing, initscr+raw/noecho/keypad/nodelay, SIGWINCH sigaction, and the poll(2) loop multiplexing stdin (get_wch) and the imsg fd. | `/usr/src/usr.bin/mandoc/term_ascii.c:93` curs_initscr(3) | **REFERENCE** | M | curses init/raw-mode setup is base-linkable and already idiomatic; the locale forcing mirrors mandoc term_ascii.c:93-94 setlocale(LC_CTYPE,"")->"en_US.UTF-8". The poll(2)+curses+imsg event multiplexing is fugu-specific (no base curses program drives a non-blocking get_wch poll loop), so align idioms only, do not adopt a driver. | setlocale(LC_CTYPE,"") then nl_langinfo(CODESET)!="UTF-8" fallback to en_US.UTF-8/C.UTF-8 is exactly mandoc's UTF8_LOCALE idiom (term_ascii.c:37,93-94) and ssh utf8.c's CODESET test (utf8.c:50-60). nodelay+get_wch drain loop is correct for non-blocking wide input. Consider libevent (base) instead of hand-rolled poll, but charter-neutral; keep poll. |
+| on_winch | SIGWINCH handler: sets a volatile sig_atomic_t flag so the poll loop can resize. | sigaction(2) | **KEEP** | S | trivial async-signal-safe flag setter; no base library equivalent to adopt | Idiomatic: only touches a volatile sig_atomic_t. sa_flags 0 (no SA_RESTART) is deliberate so poll(2) returns EINTR — matches base practice. |
+| block_new / blocks_free / replay_block | Transcript model: grow the block array, free it, and convert a replayed convo part into a typed block. | — | **KEEP** | S | application-specific transcript/block storage; no base equivalent | Uses project xreallocarray/buf helpers (reallocarray(3) is the base primitive underneath, already covered elsewhere). Pure app logic. |
+| prefix_of / attr_of | Map a block kind to its display prefix string and curses attribute (A_BOLD/A_DIM/etc). | curs_attr(3) | **KEEP** | S | app styling table; the A_* constants are base but the mapping policy is fugu-specific | A_BOLD/A_DIM are base curses attrs (curs_attr(3)); only the kind->attr policy is app-specific. Nothing to adopt. |
+| cw | Display width of a wide char, clamping control/unknown (wcwidth==-1) to 1 column. | `/usr/src/usr.bin/ssh/utf8.c:146` wcwidth(3) | **ADOPT** | S | already a thin wrapper over the base wcwidth(3); keep wrapper but align the -1 fallback to base practice | wcwidth(3) is base libc (WCWIDTH(3), declared <wchar.h>). ssh/utf8.c:146 and less/line.c:256 handle wcwidth==-1 explicitly; fugu's clamp-to-1 is a reasonable display choice. Already adopting the base API — this is a correct wrapper, not a reimplementation. |
+| ascii_rep / fold_wcs | Lossy display-only transliteration of common Unicode punctuation to ASCII (smart quotes, dashes, ellipsis, arrows, symbols), with worst-case 4x expansion. | `/usr/src/usr.bin/mandoc/term_ascii.c` | **REFERENCE** | M | mandoc solves the same Unicode->ASCII fold problem but inside term_ascii.c (not a linkable API); mirror its table-driven approach, cannot link it | mandoc -Tascii does exactly this lossy fold but as program-internal code in usr.bin/mandoc/term_ascii.c — REFERENCE, not ADOPT. fugu's hand-maintained switch is fine; consider cross-checking its substitution table against mandoc's for coverage. No base library exports a transliteration function. |
+| utf8_to_wcs | Decode a UTF-8 byte run into a freshly allocated wchar_t array using mbrtowc, substituting U+FFFD on invalid/incomplete sequences and resetting mbstate. | `/usr/src/usr.bin/sort/bwstring.c` mbrtowc(3) | **ADOPT** | S | already built on the base mbrtowc(3); error recovery should mirror base (reset state, advance 1 byte) which it does | **[verified correction: real mbrtowc caller (ssh utf8.c uses mbtowc, not mbrtowc)]** mbrtowc(3) is base libc (MBRTOWC(3), <wchar.h>). ssh/utf8.c:130-131 uses mbtowc + (void)mbtowc(NULL,...) to reset state on error; less/line.c:686 uses mbrtowc with mbstate. fugu's (size_t)-1/-2 handling + memset(&st) reset matches the idiom. Correct use of the base API. |
+| render | Wrap every block to t->cols display columns (word-wrap on spaces, break too-wide glyphs) and, when drawing, emit visual lines with mvaddnwstr + attron/attroff; returns total visual line count. | `man curs_addwstr(3)` | **REFERENCE** | M | mvaddnwstr/attron are base-linkable and used correctly; the column-accurate word-wrap (lastsp tracking, too-wide-glyph break) is fugu-specific — align wrap edge cases to less/mandoc | **[verified correction: no base program demos mvaddnwstr; cite the man page]** mvaddnwstr is base wide curses (curs_addwstr(3), ncurses.h). less/line.c does the analogous wcwidth-driven column accounting for wrap (line.c:256,388,394). The wrapping algorithm itself is app layout (KEEP-ish) but the curses calls are base — net REFERENCE: keep the algorithm, ensure wcwidth==0 combining chars are handled as less does. |
+| draw | Full-screen repaint: erase, render scrollback window, draw reverse-video status bar, draw the horizontally-scrolled input line tracking the cursor by display columns, position cursor, refresh. | curs_addstr(3) | **KEEP** | M | uses base curses primitives but the status-bar/input-line layout and horizontal-scroll-to-cursor are entirely app UI | All curses calls (erase/mvaddch/mvaddnstr/mvaddnwstr/move/clrtoeol/refresh/attron A_REVERSE) are base and used idiomatically, but the composed screen layout is fugu-specific rendering — nothing to adopt beyond the primitives already linked. |
+| do_resize | On SIGWINCH: TIOCGWINSZ ioctl, resizeterm to the new size, re-read getmaxyx, force a full clear. | resizeterm(3) | **ADOPT** | S | resizeterm(3) and getmaxyx are base-linkable and already used; TIOCGWINSZ ioctl is the standard base resize idiom | resizeterm(3) is a base libcurses call (confirmed man resizeterm(3), <curses.h>). The TIOCGWINSZ->resizeterm->getmaxyx->clearok sequence is the canonical curses resize handler. Already adopting the base API correctly. |
+| drain_imsg | Read available imsg bytes then dispatch every decoded fugu_msg via handle_msg until the buffer drains. | imsg_init(3) | **KEEP** | S | thin pump over the project's ip_* imsg wrapper; the imsg layer is audited in imsgproto, not here | Underlying transport is base imsg (rewritten to imsgbuf_* in -current) but accessed through fugu's ip_read/ip_get abstraction — the adopt decision belongs to the imsgproto unit, not this UI file. Here it is pure dispatch glue. |
+| handle_msg | Dispatch streamed protocol messages (M_DELTA/M_MESSAGE/M_TOOL_CALL/M_USAGE/M_TURN_DONE/M_SUMMARY/M_ERROR) into transcript blocks, convo state, journal appends, and status updates; parses tool-call/usage JSON with jsmn. | — | **KEEP** | M | application protocol state machine; JSON via vendored jsmn (allowed); no base equivalent | Pure fugu protocol semantics over convo/journal/json helpers. JSON parsing is the vendored jsmn permitted by charter. Nothing in base to align to. |
+| handle_input | Translate a wint_t key (KEY_* function keys vs control/plain chars) into scrolling, cursor motion, line edits (backspace/delete/home/end/kill), quit, and submit. | curs_get_wch(3); libedit/editline(3) | **KEEP** | M | consumes base curses KEY_* codes but implements a bespoke single-line emacs-ish editor | **[verified correction: libedit IS in base (/usr/lib/libedit.so, man editline(3)) — the earlier "no editline in base" claim was wrong.]** Still KEEP, but for the right reason: libedit's `el_wgets` runs its own blocking read loop and cannot interleave with the curses `nodelay` get_wch + imsg `poll(2)` loop, which must edit the input line *while* streaming response deltas render. KEY_* come from get_wch with KEY_CODE_YES; the editor is correctly hand-rolled. |
+| in_insert | Insert one wchar_t at the cursor, growing the wide input buffer (doubling) via xreallocarray and memmove-ing the tail. | reallocarray(3) | **KEEP** | S | app gap-free insert into a wchar_t buffer; only the allocator primitive is base and already wrapped | reallocarray(3) is base and already used via xreallocarray; the editor buffer management is app-specific. Nothing further to adopt. |
+| submit | Encode the wide input line back to UTF-8 with wcrtomb, intercept slash-commands via ui_classify, else append a user block, update convo+journal, build the request and ip_compose/ip_flush an M_SUBMIT, set busy. | `/usr/src/lib/libc/citrus/citrus_utf8.c` wcrtomb(3) | **ADOPT** | S | the wcrtomb wide->multibyte encode is base libc and used correctly; the surrounding submit/convo/imsg flow is app logic (KEEP) but the encode primitive is a clean ADOPT | **[verified correction: wcrtomb impl (ssh utf8.c never calls wcrtomb)]** wcrtomb(3) is base libc (<wchar.h>); fugu's MB_LEN_MAX buffer + mbstate_t + skip-on-(size_t)-1 loop is the standard inverse of the mbrtowc decode. No base program calls wcrtomb (only gnu/ subtrees), so cite the symmetric mbrtowc reference; the API itself is the win. Rest of submit (convo_build/ip_compose) is app KEEP. |
+| do_command | Execute intercepted slash commands: /quit, /clear (journal marker + convo_clear), /compact (build convo, ip_compose M_SUMMARIZE, set compacting/busy). | — | **KEEP** | S | application command dispatch; no base equivalent | Pure fugu UX/protocol logic (clear/compact context management). Nothing in base to adopt or mirror. |
+
+### `src/ui/ui.c, src/ui/journal.c, src/ui/convo.c`
+
+_Mostly KEEP app-specific glue; two genuine base alignments: getline(3) is already correctly adopted for line input, and the hand-rolled write_all partial-write loop should align to base's atomicio (program-internal, REFERENCE) — plus open_memstream(3) is the base string-builder idiom behind fugu's local buf/json_writer._
+
+| File:function | Purpose | Base equivalent (path:line / man) | Verdict | Eff | Risk / Tradeoff | Notes |
+|---|---|---|---|---|---|---|
+| line_replay | Format one replayed transcript part (user/tool/assistant/info) to stdout when resuming a session in line mode. | — | **KEEP** | S | app-specific transcript-formatting callback; no base equivalent | Plain printf with a fugu convo_part switch. Nothing to adopt. |
+| ui_classify | Map a typed line to a slash-command enum (/clear, /compact, /quit, /exit). | — | **KEEP** | S | trivial strcmp command dispatch; no base option | A handful of strcmp(3) comparisons. Already idiomatic; nothing linkable to replace it. |
+| line_collect | Pump the imsg channel to stdout for one reply turn (deltas, message persistence, tool calls, done/error). | — | **KEEP** | S | drives fugu imsgproto/convo/journal; no base option | Loop over ip_get/ip_read is fugu's own imsgproto wrapper (over base imsgbuf_*). The protocol pump itself is app logic. |
+| line_compact | Wait for an M_SUMMARY reply to a /compact request, copying the summary into a buf. | — | **KEEP** | S | fugu imsgproto summary handshake; no base option | Same imsg pump pattern as line_collect, specialized for the summarize round-trip. |
+| ui_line_run | The readline-free line-mode REPL: pledge/unveil narrowing, getline(3) read+chomp loop, command dispatch, submit/compact round-trips, journal append, failed-turn rollback. | `/usr/src/usr.bin/ftp/fetch.c:796` getdelim(3)/getline(3) | **KEEP** | S | orchestration glue that already uses the base getline(3) idiom correctly | This is the file's central line-input seam and it ALREADY adopts the base idiom: getline(&line,&lcap,stdin) with a manual \n/\r chomp and free(line) at exit — identical to base callers like ftp/fetch.c:796 and grep/file.c. Confirmed: declared /usr/include/stdio.h:159, man getdelim(3). No change needed; do NOT regress to fgets+fixed buffer. The surrounding submit/compact/journal flow is app logic (KEEP). |
+| main | fugu-ui entry: log_init, ignore SIGPIPE, getopt(-a/-R), verify imsg fd 3 is a socket via fstat, isatty TTY detection, choose curses vs line front-end, teardown. | getopt(3) | **KEEP** | S | setup/dispatch glue already using base getopt/isatty/fstat idiomatically | Already uses the canonical base APIs (getopt while-switch, isatty+TERM probe, fstat+S_ISSOCK guard, signal(SIGPIPE,SIG_IGN)). Nothing to adopt or realign. |
+| sessions_dir | Compose "$HOME/.fugu/sessions" into a caller buffer; fail if HOME unset or it overflows. | snprintf(3) | **KEEP** | S | getenv+snprintf path build; truncation-checked, already idiomatic | Correct snprintf >= bufsz truncation check. The base alternative for HOME expansion would be nothing more linkable; this is the right pattern. KEEP. |
+| valid_id | Reject session ids that aren't a safe basename (no '/', not '.'/'..') before interpolating into a path. | — | **KEEP** | S | no base linkable path-component validator (base does this ad hoc per program) | Searched ssh/sftp-server.c and friends; base performs the same ../slash rejection inline per program (no shared API). fugu's strchr('/')+strcmp('.'/'..') is the standard hand-rolled check. KEEP. |
+| write_all | Loop write(2) until all n bytes are written, retrying on EINTR; -1 on real error. | `/usr/src/usr.sbin/vmd/atomicio.c:56` | **REFERENCE** | S | base's atomicio() is the canonical full-write loop but is a per-program vendored file, not a linkable base symbol | Confirmed atomicio is NOT in /usr/include and NOT in libutil (util.h) — it is copied into ssh, nc, cvs, vmd, vmd, ldattach, sendbug independently. So we cannot link it (charter: base+jsmn only). fugu's write_all is correct but should mirror atomicio's edge cases at /usr/src/usr.sbin/vmd/atomicio.c:56-71: it handles EAGAIN (poll) and treats write returning 0 as EPIPE; write_all currently only handles EINTR. For a blocking journal fd that's fine, but align the 0-return/EAGAIN handling if the fd ever becomes non-blocking. REFERENCE: copy the idiom, don't link. |
+| journal_unveil | unveil(2) the sessions dir rwc before the unveil lock so the journal fd can be created. | unveil(2) | **KEEP** | S | directly uses base unveil(2); nothing to adopt | Already a base syscall used idiomatically (best-effort: missing dir simply disables journaling). KEEP. |
+| journal_open | Open/create the append-only jsonl session file: derive a sortable epoch-pid id (or validate a resume id), build the path, open O_WRONLY\|O_APPEND\|O_CREAT 0600. | `/usr/src/usr.bin/ftp/fetch.c` open(2) | **KEEP** | S | already uses the base O_APPEND\|O_CREAT open(2) idiom; id scheme is app-specific | O_APPEND open with 0600 perms matches base append-log callers (ftp/fetch.c, cvs/history.c use O_APPEND). Time-based id via time(NULL)+getpid avoids any timezone files (good for pledge). No linkable base helper for the id scheme. KEEP. |
+| journal_foreach | Open a session jsonl by id and invoke a callback per non-empty, chomped line. | `/usr/src/usr.bin/ftp/fetch.c:874` getdelim(3)/getline(3) | **KEEP** | S | already uses the base fopen+getline(3) read-loop idiom correctly | This is the read-side line seam and it ALREADY adopts base: fopen("r") + while(getline(&line,&cap,fp)!=-1) with \n/\r chomp and free(line)+fclose — the exact pattern in ftp/fetch.c:874 and grep/file.c. Confirmed in /usr/include/stdio.h:159. No change; the surrounding callback dispatch is app logic. |
+| journal_append | Append one json record plus a newline to the journal fd; disable journaling on first write error. | — | **KEEP** | S | thin wrapper over write_all (covered separately); no base option | Calls write_all twice (record + '\n'). The full-write idiom is the REFERENCE flagged on write_all; this wrapper itself is app policy (drop-on-error). KEEP. |
+| journal_close | Close the journal fd if open and mark it -1. | close(2) | **KEEP** | S | trivial close(2) wrapper; no base option | Nothing to adopt. |
+| convo_add_role | Append a {"role":..,"content":..} message built from plain text via fugu's json_writer into a buf, then xstrdup it into the message array. | `/usr/src/sbin/iked/ikev2.c:6508` open_memstream(3) | **KEEP** | M | replacing the project-wide buf/json_writer abstraction with open_memstream would not be a net win and crosses out of this file | FYI seam: open_memstream(3) IS base (declared /usr/include/stdio.h:256; real users iked/ikev2.c:6508, radiusd/radiusd_standard.c:345) and is the canonical base way to build a string by fprintf-ing into a FILE*. But fugu deliberately uses a vendored buf + json_writer shared project-wide; swapping in open_memstream here only (not the buf abstraction in buf.c) would be inconsistent. KEEP this function; consider open_memstream only if/when the buf abstraction itself is reviewed. |
+| convo_add_user | Append a user-role message (thin wrapper over convo_add_role). | — | **KEEP** | S | one-line app wrapper; no base option |  |
+| convo_set_summary | Replace the active context with a /compact summary stored as a user/assistant pair (so role alternation the Messages API requires stays valid). | — | **KEEP** | S | app-specific Messages-API context model; no base option | Pure conversation-model policy. |
+| convo_marker_clear | Build the journal marker {"fugu":"clear"} recording a context reset. | — | **KEEP** | S | app journal-marker schema via json_writer; no base option | Same json_writer/buf seam as convo_add_role; open_memstream note applies project-wide, not here. |
+| convo_marker_compact | Build the journal marker {"fugu":"compact","summary":..} carrying the summary. | — | **KEEP** | S | app journal-marker schema; no base option |  |
+| convo_add_raw | Append a raw JSON message (copied, NUL-terminated) to the message array. | — | **KEEP** | S | xmalloc+memcpy+reallocarray growth of an app array; no base option | Uses fugu's x-allocators (over base reallocarray(3)); the array model is app logic. KEEP. |
+| convo_truncate | Free messages from index n upward, shrinking the active context. | — | **KEEP** | S | app array teardown; no base option |  |
+| convo_free | Free all messages and the message array. | — | **KEEP** | S | app array teardown; no base option |  |
+| convo_build | Serialize the message array into a JSON array projection for each M_SUBMIT. | — | **KEEP** | S | concatenation into fugu's buf; no base option | Joins pre-built JSON message strings with commas inside [ ]. Pure app projection over buf. |
+| replay_tool_use | Format one tool_use block (name + input) and hand it to the front-end like a live M_TOOL_CALL during replay. | — | **KEEP** | S | app replay formatting over jsmn json helpers; no base option | Uses fugu json_obj_get/json_tok_strdup (jsmn-backed). App logic. |
+| convo_replay | Parse one stored message and emit display parts (string content, or array blocks: assistant text + tool_use; skip tool_result). | — | **KEEP** | M | app-specific Messages-API content-block walk over vendored jsmn; no base option | Charter-allowed jsmn is the JSON parser; the block-shape interpretation is fugu domain logic. KEEP. |
+| record_kind | Classify a journal line as message / clear-marker / compact-marker (extracting summary) / skip. | — | **KEEP** | S | app journal record discriminator over jsmn; no base option |  |
+| resume_line | Apply one journal line during resume: keep+draw a message, reset context on /clear, or restore the summary on /compact — reconstructing the active context. | — | **KEEP** | M | app resume/replay state machine; no base option | Core of session resume semantics. App logic. |
+| convo_resume | Replay a session journal by id back into a fresh convo (and via callback into the front-end transcript). | — | **KEEP** | S | thin driver over journal_foreach; no base option | Wires resume_ctx into journal_foreach (whose getline read-loop is the base idiom already in use). KEEP. |
blob - /dev/null
blob + 42bfacdc31a806121abd926fd0ee48884dc6edfc (mode 644)
--- /dev/null
+++ docs/base-idiom-audit.md
@@ -0,0 +1,175 @@
+# fugu ↔ OpenBSD base: idiom audit brief
+
+**For:** a fresh agent/session with no prior context.
+**Type:** read-only audit producing a written report. **Do not refactor in this pass.**
+
+## Goal
+
+fugu is an OpenBSD-native, privilege-separated AI coding agent in C (5 separate
+binaries: `fugu` + `fugu-{ui,net,exec,fetch}`), sandboxed per-process with
+`pledge(2)`/`unveil(2)`, base + a vendored `jsmn.h` only. While building it we
+hand-rolled a number of utilities (a growable buffer, a JSON emitter, an
+HTTP/1.1 client, an SSE parser, config-file secrecy checks, `poll(2)` event
+loops, directory listing/sorting, etc.).
+
+**The task:** go function by function through fugu's source and decide, for each,
+whether OpenBSD **base** already offers an idiomatic equivalent we should be
+using or aligning to — a "seam" where our custom code overlaps something base
+already does well. Produce a prioritized report. Implementation comes later, as a
+separate pass, after the user reviews the findings.
+
+## Environment & access
+
+- **Host:** `ssh server` → OpenBSD 7.9-current, amd64. Non-interactive `ssh
+  server '<cmd>'` works. **Never** `pkill -f fugu` on the host (it shares the ssh
+  session — use `pkill -x fugu-net` etc. if ever needed).
+- **Base source:** `/usr/src` on the host (NOT `/var/src`). Ports: `/usr/ports`.
+  Headers also in `/usr/include`; manuals via `man`/`mandoc` on the host.
+- **fugu source:** edit locally at `/home/isaac/org/tech/coding/fugu`, then
+  `rsync -a --exclude='*.o' <local>/ server:fugu/` and build on the host
+  (`ssh server 'cd fugu && make'`). The host copy is `~/fugu` (`/home/isaac/fugu`).
+  This audit is read-only, so you mostly just read the local tree and grep
+  `/usr/src` on the host.
+- **Already validated:** the whole thing builds clean and passes 20 regress
+  suites; an OpenBSD port exists under `ports/fugu/`. None of that should change.
+
+## What counts as a "seam" — classify every function
+
+For each fugu function (or cohesive group), assign exactly one verdict:
+
+- **ADOPT** — base exposes a *linkable* idiomatic equivalent (a `libc`,
+  `libutil`, `libevent`, or `libtls` API, or a base header) that is a drop-in or
+  near-drop-in. These are the wins. Examples to confirm: `scandir(3)`,
+  `check_file_secrecy()`, `open_memstream(3)`, `recallocarray(3)`.
+- **REFERENCE** — base *solves the same problem* but only inside a base program
+  (not a library we can link, e.g. `ftp(1)`'s HTTP client). We can't reuse the
+  code, but we should align our approach/idioms/edge-case handling to it. Cite
+  the base file and what to mirror.
+- **KEEP** — no base equivalent exists; the custom code is justified. Record the
+  one-line reason (so the report is a complete accounting, not just a TODO list).
+
+A seam that would require a **non-base dependency** (a ports library) is out of
+scope by fugu's charter — note it as KEEP with the reason "no base option."
+`libevent` **is** base and therefore allowed, but see the tradeoff note below.
+
+## Method (how to check base — verify, don't assert)
+
+For every claim "base has X," prove it on the host:
+1. **Man page:** `ssh server 'man 3 scandir'` (or section 1/2/5) — confirms it's a
+   supported public API, shows the signature.
+2. **Header:** `grep -rn 'scandir' /usr/include` — confirms it's declared in base.
+3. **A real user in base:** `grep -rln 'scandir(' /usr/src` — shows the idiomatic
+   call site to copy. Cite `path:line`.
+Never claim an API from memory; OpenBSD prunes/renames (e.g. the imsg API was
+rewritten to `imsgbuf_*`). If unsure whether something is a true library symbol
+vs. a per-program static, check whether it's in a `lib/` dir or a program dir.
+
+## Source inventory (the work-list)
+
+29 source files under `fugu/src/` (ignore the generated `parent/parse.c`; audit
+`common/parse.y`). Per-file purpose and the **lead** to investigate first:
+
+| fugu file | purpose | first lead in /usr/src | prior verdict |
+|---|---|---|---|
+| `common/log.c` | warn/warnx/fatal/log_* | `usr.sbin/bgpd/log.c`, `ntpd/log.c` (fugu's is derived from this; check for drift) | KEEP/SYNC |
+| `common/util.c` | `xmalloc`/`xcalloc`/`xreallocarray`/`xstrdup`/`xasprintf` | `usr.bin/ssh/xmalloc.c` (canonical); libc `reallocarray`/`recallocarray`/`freezero` | REFERENCE |
+| `common/buf.c` | growable byte buffer (`buf_init/append/printf/puts/putc/reserve/free`) | `lib/libutil/imsg-buffer.c` (`ibuf_*`); `open_memstream(3)` (user: `usr.sbin/radiusd/radiusd_standard.c`) | EVALUATE |
+| `common/json.c` | JSON emitter + jsmn wrapper | none (no base JSON) | KEEP |
+| `common/imsgproto.c` | chunked framing over libutil imsg | `lib/libutil/imsg.c` + `imsg-buffer.c` (`imsgbuf_*`, `imsg_compose`, `imsg_get_ibuf`); base has no chunking | REFERENCE/EVALUATE |
+| `common/http.c` | HTTP/1.1 request build + chunked decode | `usr.bin/ftp/fetch.c` (base HTTP/HTTPS client) | REFERENCE |
+| `common/sse.c` | server-sent-events parser | none | KEEP |
+| `common/conf.c` | config model + `conf_check_secrecy` + merge/print | `check_file_secrecy()` in `usr.sbin/bgpd/parse.y`, `dvmrpd/parse.y`, `eigrpd/parse.y` | ADOPT |
+| `common/confload.c` | `conf_load` (getpwuid/getgrouplist + StrictModes + overlay) | base daemons' config load; `getgrouplist(3)` | REFERENCE |
+| `common/parse.y` | yacc grammar + lexer (`lgetc/lungetc/findeol/pushfile/popfile/symset/symget`) | `usr.sbin/httpd/parse.y` (the canonical template — diff against it) | SYNC (high value) |
+| `common/url.c` | URL parse + RFC3986 encode + anti-injection | `usr.bin/ftp/fetch.c` (`url_get`/parsing) | REFERENCE |
+| `common/html2text.c` | HTML→text | none | KEEP |
+| `common/tlsconn.c` | libtls client config/connect/read/write + poll deadline | `usr.bin/ftp/fetch.c` libtls usage; `man tls_connect_socket`/`tls_config_set_protocols` | REFERENCE |
+| `parent/parent.c` | privsep coordinator: spawn (fork+exec+socketpair), imsg relay (poll), secret delegation, session dir list/resolve (opendir+qsort), conf load | (a) `usr.sbin/httpd/proc.c` + `relayd/proc.c` (privsep process helper); (b) `sbin/dhcpleased/*.c` (libevent + imsg event loop); (c) `scandir(3)` (user: `usr.bin/diff/diffdir.c`) | EVALUATE (high value) |
+| `net/net.c` | net worker main (tiny) | — | KEEP |
+| `net/net_run.c` | Anthropic agent loop + imsg + http calls | request/response is synchronous — compare to base's blocking-client style | REFERENCE |
+| `ui/ui.c` | line-mode front end + command dispatch | — | KEEP |
+| `ui/ui_curses.c` | wide-char curses TUI | `lib/libcurses`; base curses users (`usr.bin/top`, `systat`) for wide-char/`get_wch` idioms | REFERENCE |
+| `ui/journal.c` | append-only jsonl session log | none | KEEP |
+| `ui/convo.c` | conversation model | none (app-specific) | KEEP |
+| `exec/exec.c` | exec worker main + getopt + pledge | pledge/unveil idioms (already idiomatic) | KEEP |
+| `exec/exec_run.c` | sandbox (unveil+execpromises) + tool dispatch | base privsep `unveil`/`pledge` + execpromises usage | KEEP/REFERENCE |
+| `exec/tools_file.c` | read/write/edit (atomic temp+rename) | base atomic-write idiom (`mkstemp`+`rename`); `freezero` | REFERENCE |
+| `exec/tools_shell.c` | `run_capture` (pipe+fork+exec+poll timeout) | base fork+exec+pipe capture (no `popen` — needs stderr+timeout) | KEEP/REFERENCE |
+| `fetch/fetch.c` | fetch worker main | — | KEEP |
+| `fetch/fetch_run.c` | SSRF guard (getaddrinfo + addr_blocked) + libtls fetch | `getaddrinfo(3)` idioms; no base SSRF helper | KEEP/REFERENCE |
+| `fetch/kagi.c` | Kagi API request/format | none | KEEP |
+
+## High-value seams (confirmed; investigate these first)
+
+These four are the most likely real wins — all confirmed present in base:
+
+1. **`conf_check_secrecy` → base `check_file_secrecy()`** (`bgpd/parse.y` et al.).
+   The base function (StrictModes-style 0600/owner check) is copied verbatim across
+   daemons. fugu should match it byte-for-byte so reviewers recognize it.
+2. **`parent` directory listing → `scandir(3)` + comparator** (`diff/diffdir.c`).
+   `resolve_last()`/`list_sessions()` hand-roll opendir+readdir+`qsort`; `scandir`
+   is the one-call idiom with a sort comparator.
+3. **`common/parse.y` lexer → diff against `usr.sbin/httpd/parse.y`.** Our lexer is
+   the classic pf/httpd template; base has since evolved it (e.g. `igetc`,
+   `expand_macros`, growable token buffer, `check_file_secrecy`). Sync to current.
+4. **`buf.c` → `ibuf`/`open_memstream`.** Decide deliberately: `ibuf` (libutil) for
+   wire/byte buffers, `open_memstream(3)` for string building. Tradeoff: `buf` is
+   tiny and dependency-free; quantify what adopting either actually buys.
+
+## The big design question (flag, don't pre-decide)
+
+**`poll(2)` loops → `libevent` + the base imsg-privsep pattern.** Every base
+privsep daemon (`dhcpleased`, `slaacd`, `unwind`, `httpd`, `relayd`) is built on
+`libevent` (`event_init`/`event_add`/`event_dispatch`) plus a shared
+`imsg_event_add`/`dispatch_imsg`/`proc.c` idiom. fugu hand-rolls `poll()` in the
+parent relay and each worker. This is the single largest stylistic divergence
+from base — **but** fugu is deliberately foreground, per-invocation, and *not* a
+daemon, and its flows are largely synchronous request/response. Treat this as an
+architectural recommendation with an honest tradeoff analysis (idiomatic-ness and
+`proc.c` reuse vs. added machinery for a non-daemon), **not** an assumed adoption.
+Likewise evaluate whether `parent.c`'s spawn/relay should be rebuilt on
+`httpd/proc.c`'s privsep helper.
+
+## Output: the deliverable
+
+Write findings to `fugu/docs/base-idiom-audit-findings.md`:
+
+- **Top of file:** a prioritized "Adopt these" list (the clear ADOPT wins), then
+  "Evaluate" (tradeoff calls), then "Keep (justified)" — each one line.
+- **Body:** one row per fugu function (or tight group):
+  `File:function | Purpose | Base equivalent (path:line + man page) | Verdict | Effort (S/M/L) | Risk/Tradeoff | Notes`.
+- Every ADOPT/REFERENCE row must cite a real `path:line` in `/usr/src` (or a man
+  page) you actually looked at. KEEP rows state the reason in one phrase.
+- It must be a *complete* accounting: every function appears exactly once.
+
+## Suggested execution (this is a parallelizable, read-heavy audit)
+
+Run it as a multi-agent **Workflow** (the user has opted into orchestration for
+this; confirm "ultracode"/explicit opt-in is still in effect before launching):
+
+- **Phase 1 — audit (fan out):** one agent per source file or tight group (~14
+  units; e.g. group `net/*`, `exec/*`, `fetch/*`, `ui/*`, and each `common/*`
+  individually since common holds the densest seams). Each agent: reads the fugu
+  file locally, enumerates its functions, then for each greps `/usr/src` +
+  checks the man page/header on `server`, and returns **structured** findings
+  (use a JSON schema: array of `{func, purpose, base_equiv, path_line, verdict,
+  effort, tradeoff}`). Give every agent this brief's "Method" and "classify"
+  rules and the relevant row(s) from the inventory as its starting lead.
+- **Phase 2 — synthesize (single agent):** merge all findings, de-dup
+  cross-cutting ones (e.g. `check_file_secrecy` touches conf.c + parse.y), sort
+  into the Adopt/Evaluate/Keep buckets, and write the findings file. Then a
+  **completeness check**: assert every inventory file is covered and every
+  ADOPT/REFERENCE claim carries a real citation; re-audit anything missing.
+
+## Guardrails / non-goals
+
+- **Read-only.** Produce the report; change no code, Makefiles, or the port.
+- **Honor fugu's charter:** OpenBSD-only, base + vendored `jsmn` only, per-process
+  `pledge`/`unveil`, secrets never in argv/env/journal. Reject any seam needing a
+  ports dependency.
+- **Verify every base claim** against the actual tree/man page with a citation;
+  the imsg API in particular was rewritten — confirm current symbols.
+- **Separate library reuse (ADOPT) from style alignment (REFERENCE).** Don't
+  recommend linking a base *program's* internal code.
+- For design-level seams (libevent, `proc.c`), present the tradeoff and a
+  recommendation; the user decides.
blob - /dev/null
blob + 93124eb9cb6a5e0b43ff1618811cc1b0577b980a (mode 644)
--- /dev/null
+++ etc/fugu.conf.sample
@@ -0,0 +1,69 @@
+# fugu.conf.sample - configuration for fugu(1).
+#
+# Installed as /etc/fugu.conf.sample.  Copy to /etc/fugu.conf to use.  fugu reads
+# ALL of its configuration -- including the secrets (api_key, anthropic_key,
+# kagi_token) -- from this single file.  It is installed set-group-id _fugu, so
+# the parent can read a root-owned /etc/fugu.conf that the model's tools cannot:
+#
+#     install -o root -g _fugu -m 0640 /etc/fugu.conf.sample /etc/fugu.conf
+#
+# It MUST be mode 0640 (or stricter), owned by root, group _fugu; fugu refuses to
+# start if it is world-accessible or group-writable.  See fugu.conf(5).
+
+# Default model and per-turn generation budget.
+#model			"claude-sonnet-4-6"
+#max_tokens		4096
+
+# API wire protocol: "anthropic" (default) or "openai".  The "openai" provider
+# speaks chat/completions, which most other vendors emulate; point api_host at
+# the vendor, override api_path if it does not serve at /v1, and set a matching
+# model and api_key.
+#provider		"openai"
+#api_host		"openrouter.ai"
+#api_path		"/api/v1/chat/completions"	# OpenRouter; default is /v1/chat/completions
+#model			"anthropic/claude-sonnet-4.6"
+
+# Several providers at once.  The flat keys above define the default provider;
+# each "provider" block adds another, switchable at runtime with /model (which
+# lists every provider's models, tagged by name).  See fugu.conf(5).
+#provider "gpt" {
+#	type		"openai"
+#	api_key_file	"/home/me/.fugu/openai.key"
+#	model		"gpt-5"
+#}
+
+# System prompt sent on every turn.  Unset = fugu's concise built-in default
+# (identity, terse-tone rules, scope/style defaults, tool guidance); set this
+# to replace it.  A string setting, so keep it on one logical line; end a line
+# with \ to continue.
+#system			"You are a terse assistant for an OpenBSD C codebase."
+
+# Advisory token budget for the conversation sent to the model.
+#context_limit		200000
+
+# Let the file tools modify the working tree (default yes).
+#allow_write		yes
+
+# Let commands run by the shell tool use the network (default no).  When no,
+# the exec process and its children are pledged without "inet", so commands
+# such as ftp(1), nc(1) and "git fetch" fail by design.
+#allow_subprocess_net	no
+
+# Offer the brokered web search/fetch tools to the model.
+#web_search		yes
+
+# Let the model make arbitrary HTTPS requests (the http_request tool) to a
+# restricted set of hosts.  Only offered when http_allow is set; a request must
+# match an http_allow pattern and no http_block pattern.  Patterns are
+# host[/pathprefix]; a leading dot matches subdomains.  fugu adds no credentials
+# of its own -- the model supplies its own headers.  See fugu.conf(5).
+#http_allow		"api.github.com .githubusercontent.com"
+#http_block		"api.github.com/admin"
+
+# Render the interface as ASCII instead of UTF-8 (default no).
+#ascii			no
+
+# Per-user / per-group scoping, first match wins (see fugu.conf(5)).
+#match group "wheel" {
+#	allow_subprocess_net	yes
+#}
blob - /dev/null
blob + b4ad8ceb3a3eb594b00d0ab490ecc51d3b4cfc82 (mode 644)
--- /dev/null
+++ man/fugu.1
@@ -0,0 +1,316 @@
+.\" Copyright (c) 2026 Isaac <isaac@itm.works>
+.\"
+.\" Permission to use, copy, modify, and distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd June 21, 2026
+.Dt FUGU 1
+.Os
+.Sh NAME
+.Nm fugu
+.Nd OpenBSD-native AI coding agent
+.Sh SYNOPSIS
+.Nm
+.Op Fl cdlnv
+.Op Fl r Ar id
+.Sh DESCRIPTION
+.Nm
+is an interactive AI coding agent that pairs the Anthropic Messages API with a
+set of local tools \(en reading, writing and editing files, running
+.Xr ksh 1
+commands, searching the tree, and brokered web search and fetch \(en so that a
+language model can work inside a project under the user's direction.
+.Pp
+.Nm
+is built as five privilege-separated programs that the parent
+.Xr fork 2 Ns / Ns Xr execve 2 Ns s
+at startup and that speak to one another only through the parent over
+.Xr imsg 3 :
+.Pp
+.Bl -tag -width fugu-fetchXX -compact
+.It Nm
+parent: reads configuration, delegates secrets, and relays every
+worker-to-worker message.
+Pledge
+.Qq stdio proc .
+.It Nm fugu-ui
+the terminal front end (a
+.Xr curses 3
+transcript on a tty, or a line reader otherwise).
+.It Nm fugu-net
+the only process holding the model API key (or keys, when several providers are
+configured); it speaks TLS to the configured model endpoints and nothing else.
+.It Nm fugu-exec
+runs the file and shell tools with
+.Em no
+network access; subprocesses are confined to the project directory.
+.It Nm fugu-fetch
+performs web search and fetch and parses untrusted HTML in an address space
+that holds neither the API key nor the ability to
+.Xr execve 2 .
+.El
+.Pp
+Each process restricts itself with
+.Xr pledge 2
+and
+.Xr unveil 2
+to the least authority it needs, so a flaw in one (a prompt injection that
+subverts tool execution, a memory bug in HTML parsing) cannot reach the
+network, the credential, or files outside the working tree.
+.Nm
+holds no
+.Xr setuid 2
+privilege and runs no daemon, one invocation per session.
+Its only privilege is set-group-id
+.Sy _fugu ,
+used by the parent solely to read the root-owned
+.Pa /etc/fugu.conf ;
+the parent sheds that group as soon as the secrets are delegated, and every
+worker sheds it at startup, so neither the workers nor the shell tools they run
+can reach
+.Pa /etc/fugu.conf
+or any other
+.Sy _fugu Ns -readable
+file.
+.Pp
+On startup
+.Nm
+reads its configuration (see
+.Xr fugu.conf 5 ) ,
+opens a session journal under
+.Pa ~/.fugu/sessions ,
+and presents a prompt.
+Type a message and press
+.Ic Enter
+to send it; the assistant's reply streams back, and any tool calls it makes are
+shown as they run.
+.Pp
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl c
+Resume the most recently modified session, continuing its journal.
+.It Fl d
+Keep logging to standard error for the whole run, including while the session is
+active.
+Startup errors \(en such as a configuration syntax error \(en always go to
+standard error; once the interface takes over the terminal, logging normally
+switches to
+.Xr syslog 3
+so it does not paint over the display.
+.Fl d
+suppresses that switch, which is useful for debugging at the cost of a garbled
+screen.
+.It Fl l
+List the saved sessions \(en id, last-modified time, message count and a
+preview of the first message \(en newest first, then exit.
+.It Fl n
+Parse the configuration, resolve the effective settings for the invoking user,
+print them with secrets redacted, and exit without starting a session.
+.It Fl r Ar id
+Resume the session with the given
+.Ar id
+(as shown by
+.Fl l ) ,
+continuing its journal.
+.It Fl v
+Be more verbose; repeat for more detail.
+.El
+.Sh COMMANDS
+While a session is running, a line beginning with a slash is interpreted as a
+command rather than sent to the model:
+.Bl -tag -width "/compactXX"
+.It Ic /clear
+Reset the active context to empty so the next message starts a fresh
+conversation.
+The journal is preserved on disk and the prior transcript stays on screen as
+scrollback; a marker records the reset.
+.It Ic /compact
+Replace the active context with a model-written summary of the conversation so
+far (one extra API call), reducing the tokens carried into later turns.
+The full journal is preserved; a marker records the summary.
+.It Ic /model
+Open a fuzzy-searchable menu of the models offered by every configured provider
+and switch to the one chosen for the rest of the session.
+The list is fetched live from each provider's model endpoint; when more than one
+provider is configured each entry is tagged with its provider name, and choosing
+a model also makes its provider the active one
+.Pq see Sx PROVIDER BLOCKS in Xr fugu.conf 5 .
+In the menu, type to filter,
+.Ic Up Ns / Ns Ic Down
+(or
+.Ic ^P Ns / Ns Ic ^N )
+to move the selection,
+.Ic Enter
+to choose, and
+.Ic Esc
+to cancel.
+.It Ic /quit , Ic /exit
+End the session.
+.El
+.Pp
+A slash name that is not one of the built-ins above is looked up in
+.Pa ~/.fugu/skills/
+.Pq see Sx FILES :
+on a hit, the skill's body is submitted as a user message, with any text typed
+after the name appended as a second paragraph.
+A marker records the invocation in the journal so a resumed session reproduces
+it.
+.Pp
+.Ic Tab
+.Pq or Ic Shift-Tab No to go backward
+on a partial slash command cycles through the commands that match what has been
+typed, built-ins first and then loaded skills alphabetically.
+.Sh KEYS
+The
+.Xr curses 3
+front end edits the input line with modal,
+.Xr vi 1 Ns -like
+keys.
+It starts in insert mode, where typed characters are inserted at the cursor;
+.Ic Esc
+switches to normal mode for navigation and editing commands.
+.Pp
+These keys work in either mode:
+.Bl -tag -width "PgUp / PgDnXX"
+.It Ic Enter
+Send the current line.
+.It Ic Tab , Ic Shift-Tab
+On a line beginning with a slash, cycle forward
+.Pq Ic Tab
+or backward
+.Pq Ic Shift-Tab
+through the slash commands that match what has been typed.
+.It Ic PgUp / PgDn
+Scroll the transcript a page at a time; the newest output is shown again after
+.Ic PgDn .
+.It Ic Up / Down
+Recall the previous or next prompt sent this session.
+.It Ic Ctrl-L
+Repaint the screen.
+.It Ic Ctrl-C
+Clear the input line, or quit when it is already empty.
+.It Ic Ctrl-D
+Quit when the input line is empty.
+.El
+.Pp
+In normal mode:
+.Bl -tag -width "i a A I o OXX"
+.It Ic h l 0 $ ^ w b e
+Move by character, to the start or end of the line, to the first non-blank, or
+by word.
+.It Ic i a A I o O
+Enter insert mode \(en before or after the cursor, or at the start or end of
+the line.
+.It Ic x D dd
+Delete the character under the cursor, to end of line, or the whole line;
+.Ic d
+also takes a motion
+.Pq Ic dw , de , db , d$ , d0 .
+.It Ic j / k
+Recall the next or previous prompt sent this session.
+.It Ic J / K
+Scroll the transcript down or up one line.
+.El
+.Sh ENVIRONMENT
+.Bl -tag -width FUGU_CONF
+.It Ev HOME
+Location of the session directory
+.Pa ~/.fugu/sessions .
+.It Ev FUGU_CONF
+Alternate path to the configuration file, for development and the test suite.
+Honoured only when
+.Nm
+is
+.Em not
+installed set-group-id
+.Pq Xr issetugid 2 ;
+an installed
+.Nm
+ignores it and always reads
+.Pa /etc/fugu.conf ,
+so it cannot be steered onto another
+.Sy _fugu Ns -readable
+file.
+.El
+.Sh FILES
+.Bl -tag -width "~/.fugu/sessions/XX"
+.It Pa /etc/fugu.conf
+Configuration and secrets; mode
+.Ar 0640 ,
+owner
+.Sy root ,
+group
+.Sy _fugu
+(see
+.Xr fugu.conf 5 ) .
+.It Pa ~/.fugu/sessions/
+Append-only session journals, one
+.Pa <id>.jsonl
+per session.
+.It Pa ~/.fugu/skills/
+Slash-invoked prompt templates.
+Each
+.Pa <name>/SKILL.md
+is a markdown file with an optional
+.Sq ---
+fenced header carrying
+.Sq name
+and
+.Sq description
+lines; the body below the closing fence is what the model sees when the user
+types
+.Sy / Ns Ar name .
+Loaded once at startup; create or edit files between sessions.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh SEE ALSO
+.Xr ksh 1 ,
+.Xr pledge 2 ,
+.Xr unveil 2 ,
+.Xr imsg 3 ,
+.Xr fugu.conf 5
+.Sh STANDARDS
+.Nm
+speaks the Anthropic Messages API with streaming server-sent events.
+.Sh HISTORY
+.Nm
+is a clean-room reimplementation, in behavior rather than source, of the
+TypeScript agent
+.Em pi
+by Mario Zechner.
+.Sh AUTHORS
+.An Isaac Aq Mt isaac@itm.works .
+.Sh CAVEATS
+.Nm
+runs tools and shell commands chosen by a language model.
+Although
+.Xr pledge 2
+and
+.Xr unveil 2
+confine those tools to the working tree and deny them the network, the model
+can still modify, delete, or read any file beneath the directory in which
+.Nm
+is started.
+Run it from the project you intend it to change, and review what it does.
+In particular, when started from within your home directory the tools can read
+.Pa ~/.fugu/sessions ,
+so the model may see other sessions' transcripts; the configuration secrets,
+however, are not reachable
+.Pq they live in the Sy _fugu Ns -only Pa /etc/fugu.conf .
+.Pp
+By default subprocesses started by the shell tool have no network access;
+set
+.Ic allow_subprocess_net
+in
+.Xr fugu.conf 5
+to permit it.
blob - /dev/null
blob + eddfbc12ede46945df2f202c02619486e18a086d (mode 644)
--- /dev/null
+++ man/fugu.conf.5
@@ -0,0 +1,434 @@
+.\" Copyright (c) 2026 Isaac <isaac@itm.works>
+.\"
+.\" Permission to use, copy, modify, and distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd June 21, 2026
+.Dt FUGU.CONF 5
+.Os
+.Sh NAME
+.Nm fugu.conf
+.Nd configuration file for fugu
+.Sh DESCRIPTION
+.Xr fugu 1
+reads all of its configuration, including the secrets, from a single file:
+.Pp
+.Bl -tag -width "/etc/fugu.confXX" -compact
+.It Pa /etc/fugu.conf
+Owned by
+.Sy root ,
+group
+.Sy _fugu ,
+mode
+.Ar 0640 .
+.El
+.Pp
+Because
+.Xr fugu 1
+is installed set-group-id
+.Sy _fugu ,
+the parent can read this root-owned file while the model's file and shell tools,
+which run as the invoking user without that group, cannot
+.Pq see Sx FILES and the Sx CAVEATS in Xr fugu 1 .
+Secrets
+.Po
+.Ic api_key ,
+.Ic anthropic_key ,
+.Ic kagi_token
+.Pc
+therefore live here too, administered centrally rather than in each user's home.
+.Pp
+Effective settings are resolved low to high precedence: compiled defaults, the
+global section, then the first matching
+.Ic match
+block.
+The merged result for the invoking user can be printed with
+.Ic fugu -n ,
+which redacts secret values.
+.Pp
+The file holds secrets, so the parent refuses to start if it is
+world-accessible or group-writable, or owned by anyone but
+.Sy root
+or the invoking user.
+For development and the test suite, a binary that is
+.Em not
+set-group-id honours the
+.Ev FUGU_CONF
+environment variable as the path to read instead
+.Pq see Xr fugu 1 ;
+an installed, set-group-id
+.Xr fugu 1
+ignores the environment so it cannot be steered onto another
+.Sy _fugu Ns -readable
+file.
+.Sh SYNTAX
+The grammar follows the
+.Xr pf.conf 5 Ns / Ns Xr httpd.conf 5
+family.
+Comments begin with
+.Sq # .
+Argument strings are enclosed in double quotes.
+A boolean is the word
+.Ic yes
+or
+.Ic no .
+Macros may be defined and later expanded with a leading
+.Sq $ :
+.Bd -literal -offset indent
+key = "value"
+anthropic_key $key
+.Ed
+.Pp
+Other files may be pulled in with
+.Ic include Ar path .
+.Sh SETTINGS
+The following keywords may appear in the global section or inside a
+.Ic match
+block:
+.Bl -tag -width Ds
+.It Ic model Ar string
+The model id to use, as named by the configured
+.Ic provider .
+Defaults to
+.Qq claude-sonnet-4-6 ,
+the default Anthropic model.
+.It Ic provider Ar string
+The API wire protocol to speak, one of
+.Qq anthropic
+.Pq the default
+or
+.Qq openai .
+The
+.Qq openai
+provider speaks the OpenAI
+.Pa /v1/chat/completions
+API that most other vendors and local servers emulate
+.Pq OpenRouter , Groq , Together , DeepSeek , and Ollama , llama.cpp , vLLM ,
+authenticating with
+.Ic api_key
+as a
+.Ar Bearer
+token.
+Set
+.Ic api_host
+to the vendor's host
+.Pq and usually a
+.Ic model
+and
+.Ic api_key
+to match .
+.It Ic system Ar string
+System prompt sent to the model on every turn, used to set the assistant's
+role, conventions, or constraints.
+Unset by default, in which case fugu sends a concise built-in default prompt;
+set this to replace that default entirely.
+The value is a single quoted string
+.Pq as with any string setting, an embedded literal newline is not preserved ,
+so write a multi-sentence prompt as one line.
+.It Ic max_tokens Ar number
+Maximum number of tokens the model may generate per turn.
+.It Ic context_limit Ar number
+Advisory token budget for the conversation projection sent to the model.
+.It Ic allow_write Ar yes Ns | Ns Ar no
+Whether the file tools may modify the working tree.
+Default
+.Ic yes .
+.It Ic allow_subprocess_net Ar yes Ns | Ns Ar no
+Whether commands run by the shell tool may use the network.
+Default
+.Ic no :
+the exec process and its children are pledged without
+.Ar inet ,
+so network-using commands
+.Po Xr ftp 1 , Xr nc 1 , git fetch Pc
+fail by design until this is enabled.
+.It Ic web_search Ar yes Ns | Ns Ar no
+Whether the brokered
+.Ic web_search
+and
+.Ic web_fetch
+tools are offered to the model.
+Default
+.Ic yes ;
+set
+.Ic no
+to withhold both tools, so the model cannot reach the network at all.
+.It Ic http_allow Ar string
+A whitespace-separated list of host patterns the model may reach with the
+general
+.Ic http_request
+tool, which makes an arbitrary-method HTTPS request with model-supplied headers
+and body.
+The tool is offered to the model
+.Sy only
+when this list is set; with no
+.Ic http_allow
+it does not exist.
+A request is permitted when its host matches at least one
+.Ic http_allow
+pattern and no
+.Ic http_block
+pattern.
+Each pattern is
+.Ar host Ns Op / Ns Ar pathprefix :
+.Bl -bullet -compact -offset indent
+.It
+.Qq example.com
+matches that host exactly;
+.It
+.Qq .example.com
+matches
+.Qq example.com
+and any subdomain of it;
+.It
+an optional
+.Pa / Ns Ar pathprefix
+further requires the request path to begin with it
+.Pq Qq api.example.com/v1 .
+.El
+Host matching is case-insensitive.
+fugu injects no credentials of its own, so the model can only reach hosts the
+operator already lists, using whatever headers it supplies; the
+.Ic web_fetch
+SSRF guard
+.Pq private, loopback and link-local addresses refused
+also applies.
+.It Ic http_block Ar string
+A whitespace-separated list of patterns, in the same form as
+.Ic http_allow ,
+that are denied even when an
+.Ic http_allow
+pattern would otherwise permit them
+.Pq a block wins .
+.It Ic search_provider Ar string
+Name of the web search provider.
+.It Ic ascii Ar yes Ns | Ns Ar no
+Render the terminal interface transliterated to ASCII instead of UTF-8.
+Default
+.Ic no .
+.It Ic api_host Ar string
+Hostname of the model API endpoint, overriding the provider's built-in default
+.Po
+.Qq api.anthropic.com
+or
+.Qq api.openai.com
+.Pc .
+Required for third-party
+.Qq openai
+endpoints
+.Pq e.g. Qq openrouter.ai .
+.It Ic api_port Ar string
+TCP port of the model API endpoint.
+Defaults to
+.Qq 443 .
+fugu connects over TLS only, so the endpoint must speak HTTPS.
+.It Ic api_path Ar string
+Request path of the chat/completions endpoint, overriding the provider's
+default
+.Po
+.Qq /v1/messages
+or
+.Qq /v1/chat/completions
+.Pc .
+Needed for OpenAI-compatible vendors that do not serve at
+.Qq /v1 ,
+for example
+.Qq /api/v1/chat/completions
+.Pq OpenRouter
+or
+.Qq /openai/v1/chat/completions
+.Pq Groq .
+.It Ic api_key Ar string
+The model API key, used for any provider.
+.Sy Secret ;
+kept in the root-owned
+.Pa /etc/fugu.conf .
+For the
+.Qq anthropic
+provider the legacy
+.Ic anthropic_key
+is an accepted synonym;
+.Ic api_key
+takes precedence when both are set.
+.It Ic api_key_file Ar string
+Path to a file whose first line is the model API key, used instead of
+.Ic api_key .
+.It Ic anthropic_key Ar string
+The Anthropic API key; a legacy synonym for
+.Ic api_key
+kept for compatibility.
+.Sy Secret ;
+kept in the root-owned
+.Pa /etc/fugu.conf .
+.It Ic anthropic_key_file Ar string
+Path to a file whose first line is the Anthropic API key, used instead of
+.Ic anthropic_key .
+.It Ic kagi_token Ar string
+The Kagi API token used by the web tools.
+.Sy Secret ;
+kept in the root-owned
+.Pa /etc/fugu.conf .
+.It Ic kagi_token_file Ar string
+Path to a file whose first line is the Kagi token, used instead of
+.Ic kagi_token .
+.El
+.Sh MATCH BLOCKS
+.Pa /etc/fugu.conf
+may scope settings to particular users or groups with first-match-wins
+.Ic match
+blocks, in the style of
+.Xr ssh_config 5 Ns 's
+.Ic Match :
+.Bd -literal -offset indent
+match user "alice" {
+	model "claude-opus-4-8"
+	allow_subprocess_net yes
+}
+
+match group "students" {
+	allow_write no
+}
+.Ed
+.Pp
+The first block whose
+.Ic user
+or
+.Ic group
+matches the invoking user supplies the scoped settings; later blocks are not
+consulted.
+Secrets may not appear in a
+.Ic match
+block, as they may not appear anywhere in
+.Pa /etc/fugu.conf .
+.Sh PROVIDER BLOCKS
+The flat
+.Ic provider , model , api_key , api_host , api_port
+and
+.Ic api_path
+settings above configure a single, default provider.
+To make more than one provider available at once \(en a direct Anthropic key
+.Em and
+an OpenAI-compatible endpoint, say \(en give each additional one a named
+.Ic provider
+block:
+.Bd -literal -offset indent
+provider "name" {
+	type		"anthropic" | "openai"
+	model		string
+	api_key		string
+	api_key_file	string
+	api_host	string
+	api_port	string
+	api_path	string
+}
+.Ed
+.Pp
+.Ic type
+selects the wire protocol
+.Pq it is the in-block spelling of the flat Ic provider No keyword ;
+the remaining keys mean exactly what they do at the top level, but scoped to
+this provider.
+The flat top-level keys still define an implicit
+.Qq default
+provider, so existing single-provider configurations are unchanged; each
+.Ic provider
+block adds one more.
+At startup the default provider (or, if none is configured, the first block) is
+active; the
+.Ic /model
+command
+.Pq see Xr fugu 1
+lists the models of every configured provider, tagged by name, and switching to
+one also switches the active provider for the rest of the session.
+A block's
+.Ic api_key
+is a secret like any other and lives in the same root-owned
+.Pa /etc/fugu.conf ;
+.Ic api_key_file
+may point at a separate file the
+.Sy _fugu
+group can read instead.
+.Sh FILES
+.Bl -tag -width "/etc/fugu.conf.sampleXX" -compact
+.It Pa /etc/fugu.conf
+The configuration and secrets; mode
+.Ar 0640 ,
+owner
+.Sy root ,
+group
+.Sy _fugu .
+.It Pa /etc/fugu.conf.sample
+Annotated example installed with the package.
+.El
+.Sh EXAMPLES
+A minimal
+.Pa /etc/fugu.conf
+.Pq Sy root : Ns Sy _fugu No mode Ar 0640 :
+.Bd -literal -offset indent
+anthropic_key "sk-ant-..."
+model "claude-sonnet-4-6"
+system "You are a terse assistant for an OpenBSD C codebase.  \e
+Follow KNF, prefer base over ports, and explain risky changes."
+.Ed
+.Pp
+Reading the key from a separate file, and enabling the web tools:
+.Bd -literal -offset indent
+anthropic_key_file "/etc/fugu/anthropic.key"
+kagi_token_file "/etc/fugu/kagi.token"
+web_search yes
+.Ed
+.Pp
+Using an OpenAI-compatible provider
+.Pq here OpenRouter , whose endpoint is not at Qq /v1 :
+.Bd -literal -offset indent
+provider "openai"
+api_host "openrouter.ai"
+api_path "/api/v1/chat/completions"
+model "anthropic/claude-sonnet-4.6"
+api_key "sk-or-..."
+.Ed
+.Pp
+Groq's free tier
+.Pq endpoint at Qq /openai/v1 :
+.Bd -literal -offset indent
+provider "openai"
+api_host "api.groq.com"
+api_path "/openai/v1/chat/completions"
+model "llama-3.3-70b-versatile"
+api_key "gsk-..."
+.Ed
+.Pp
+Two providers at once: a direct Anthropic key as the default, plus a named
+OpenAI provider.
+.Ic /model
+then lists and switches between both.
+.Bd -literal -offset indent
+anthropic_key "sk-ant-..."
+model "claude-sonnet-4-6"
+
+provider "gpt" {
+	type "openai"
+	api_key_file "/etc/fugu/openai.key"
+	model "gpt-5"
+}
+.Ed
+.Sh SEE ALSO
+.Xr fugu 1
+.Sh CAVEATS
+Because
+.Pa /etc/fugu.conf
+holds secrets, a file that is world-accessible, group-writable, or owned by
+neither
+.Sy root
+nor the invoking user is a fatal error rather than a warning:
+.Xr fugu 1
+refuses to start until it is corrected.
blob - /dev/null
blob + 5afa5d62906884cb805efc418be4b2e7d2d8094f (mode 644)
--- /dev/null
+++ regress/anthropic/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the Anthropic provider layer (src/common/anthropic.c).
+
+PROG=		anthropic_test
+SRCS=		anthropic_test.c anthropic.c sse.c json.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-anthropic_test
+
+run-anthropic_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + d0a75de6bd444fcf0a156298a3b1a10b2c20737d (mode 644)
--- /dev/null
+++ regress/anthropic/anthropic_test.c
@@ -0,0 +1,428 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "anthropic.h"
+#include "buf.h"
+#include "sse.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* ---- callback recorder ---- */
+
+static struct buf	rec_text;
+static char		*rec_tool_id, *rec_tool_name, *rec_tool_input;
+static int		 rec_tools;
+static long long	 rec_in, rec_out;
+static int		 rec_usage;
+static char		*rec_stop;
+static int		 rec_done;
+static char		*rec_err_type, *rec_err_msg;
+static int		 rec_errors;
+
+static void
+rec_reset(void)
+{
+	buf_reset(&rec_text);
+	free(rec_tool_id);   rec_tool_id = NULL;
+	free(rec_tool_name); rec_tool_name = NULL;
+	free(rec_tool_input); rec_tool_input = NULL;
+	rec_tools = 0;
+	rec_in = rec_out = 0;
+	rec_usage = 0;
+	free(rec_stop);      rec_stop = NULL;
+	rec_done = 0;
+	free(rec_err_type);  rec_err_type = NULL;
+	free(rec_err_msg);   rec_err_msg = NULL;
+	rec_errors = 0;
+}
+
+static void
+cb_text(const char *t, size_t n, void *a)
+{
+	(void)a;
+	buf_append(&rec_text, t, n);
+}
+
+static void
+cb_tool(const char *id, const char *name, const char *input, size_t n, void *a)
+{
+	(void)a;
+	free(rec_tool_id);   rec_tool_id = strdup(id);
+	free(rec_tool_name); rec_tool_name = strdup(name);
+	free(rec_tool_input);
+	rec_tool_input = malloc(n + 1);
+	memcpy(rec_tool_input, input, n);
+	rec_tool_input[n] = '\0';
+	rec_tools++;
+}
+
+static void
+cb_usage(long long in, long long out, void *a)
+{
+	(void)a;
+	rec_in = in;
+	rec_out = out;
+	rec_usage++;
+}
+
+static void
+cb_stop(const char *sr, void *a)
+{
+	(void)a;
+	free(rec_stop);
+	rec_stop = strdup(sr);
+}
+
+static void
+cb_error(const char *type, const char *msg, void *a)
+{
+	(void)a;
+	free(rec_err_type); rec_err_type = strdup(type);
+	free(rec_err_msg);  rec_err_msg = strdup(msg);
+	rec_errors++;
+}
+
+static void
+cb_done(void *a)
+{
+	(void)a;
+	rec_done++;
+}
+
+static const struct llm_cbs RECORDER = {
+	cb_text, cb_tool, cb_usage, cb_stop, cb_error, cb_done
+};
+
+static void
+ev(struct anthropic_stream *s, const char *event, const char *data)
+{
+	anthropic_stream_event(s, event, data, strlen(data));
+}
+
+/* ---- request builder ---- */
+
+static void
+test_build_basic(void)
+{
+	struct buf		out;
+	struct anth_msg		msgs[] = {
+		{ "user", "hello" },
+		{ "assistant", "hi there" },
+	};
+	const char		*expect =
+	    "{\"model\":\"claude-sonnet-4-6\",\"max_tokens\":1024,"
+	    "\"stream\":true,\"messages\":[{\"role\":\"user\","
+	    "\"content\":\"hello\"},{\"role\":\"assistant\","
+	    "\"content\":\"hi there\"}]}";
+
+	buf_init(&out);
+	anthropic_build_request(&out, "claude-sonnet-4-6", 1024, NULL,
+	    msgs, 2, NULL);
+	buf_terminate(&out);
+	CHECK(strcmp(out.data, expect) == 0, "build basic:\n%s", out.data);
+	buf_free(&out);
+}
+
+static void
+test_build_system_tools(void)
+{
+	struct buf		out;
+	struct anth_msg		msgs[] = { { "user", "hi" } };
+	const char		*expect =
+	    "{\"model\":\"m\",\"max_tokens\":8,\"stream\":true,"
+	    "\"system\":\"be brief\",\"messages\":[{\"role\":\"user\","
+	    "\"content\":\"hi\"}],\"tools\":[{\"name\":\"read\"}]}";
+
+	buf_init(&out);
+	anthropic_build_request(&out, "m", 8, "be brief", msgs, 1,
+	    "[{\"name\":\"read\"}]");
+	buf_terminate(&out);
+	CHECK(strcmp(out.data, expect) == 0, "build sys+tools:\n%s", out.data);
+	buf_free(&out);
+}
+
+static void
+test_build_escaping(void)
+{
+	struct buf		out;
+	struct anth_msg		msgs[] = { { "user", "quote \" and \\ and\nnewline" } };
+	const char		*expect =
+	    "{\"model\":\"m\",\"max_tokens\":1,\"stream\":true,"
+	    "\"messages\":[{\"role\":\"user\","
+	    "\"content\":\"quote \\\" and \\\\ and\\nnewline\"}]}";
+
+	buf_init(&out);
+	anthropic_build_request(&out, "m", 1, NULL, msgs, 1, NULL);
+	buf_terminate(&out);
+	CHECK(strcmp(out.data, expect) == 0, "build escaping:\n%s", out.data);
+	buf_free(&out);
+}
+
+/* ---- streaming event interpreter ---- */
+
+static void
+test_text_stream(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "content_block_start",
+	    "{\"type\":\"content_block_start\",\"index\":0,"
+	    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}");
+	ev(&s, "content_block_delta",
+	    "{\"type\":\"content_block_delta\",\"index\":0,"
+	    "\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}");
+	ev(&s, "content_block_delta",
+	    "{\"type\":\"content_block_delta\",\"index\":0,"
+	    "\"delta\":{\"type\":\"text_delta\",\"text\":\", world\"}}");
+	ev(&s, "content_block_stop",
+	    "{\"type\":\"content_block_stop\",\"index\":0}");
+	buf_terminate(&rec_text);
+	CHECK(strcmp(rec_text.data, "Hello, world") == 0, "text stream: '%s'",
+	    rec_text.data);
+	CHECK(rec_tools == 0, "text stream: no tool call");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_tool_call(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "content_block_start",
+	    "{\"type\":\"content_block_start\",\"index\":1,"
+	    "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\","
+	    "\"name\":\"get_weather\",\"input\":{}}}");
+	ev(&s, "content_block_delta",
+	    "{\"type\":\"content_block_delta\",\"index\":1,"
+	    "\"delta\":{\"type\":\"input_json_delta\","
+	    "\"partial_json\":\"{\\\"x\\\"\"}}");
+	ev(&s, "content_block_delta",
+	    "{\"type\":\"content_block_delta\",\"index\":1,"
+	    "\"delta\":{\"type\":\"input_json_delta\","
+	    "\"partial_json\":\":42}\"}}");
+	ev(&s, "content_block_stop",
+	    "{\"type\":\"content_block_stop\",\"index\":1}");
+	CHECK(rec_tools == 1, "tool: one call (%d)", rec_tools);
+	CHECK(rec_tool_id && strcmp(rec_tool_id, "toolu_1") == 0, "tool: id");
+	CHECK(rec_tool_name && strcmp(rec_tool_name, "get_weather") == 0,
+	    "tool: name");
+	CHECK(rec_tool_input && strcmp(rec_tool_input, "{\"x\":42}") == 0,
+	    "tool: input '%s'", rec_tool_input ? rec_tool_input : "(null)");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_tool_no_args(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "content_block_start",
+	    "{\"type\":\"content_block_start\",\"index\":0,"
+	    "\"content_block\":{\"type\":\"tool_use\",\"id\":\"t2\","
+	    "\"name\":\"now\",\"input\":{}}}");
+	ev(&s, "content_block_stop",
+	    "{\"type\":\"content_block_stop\",\"index\":0}");
+	CHECK(rec_tools == 1 && rec_tool_input &&
+	    strcmp(rec_tool_input, "{}") == 0, "tool no-args input '%s'",
+	    rec_tool_input ? rec_tool_input : "(null)");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_usage_stop_done(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "message_start",
+	    "{\"type\":\"message_start\",\"message\":{\"id\":\"m\","
+	    "\"role\":\"assistant\",\"usage\":{\"input_tokens\":10,"
+	    "\"output_tokens\":1}}}");
+	CHECK(rec_usage == 1 && rec_in == 10 && rec_out == 1,
+	    "usage start: in=%lld out=%lld", rec_in, rec_out);
+	ev(&s, "message_delta",
+	    "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+	    "\"end_turn\",\"stop_sequence\":null},"
+	    "\"usage\":{\"output_tokens\":25}}");
+	CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "stop reason");
+	CHECK(rec_in == 10 && rec_out == 25, "usage delta: in=%lld out=%lld",
+	    rec_in, rec_out);
+	ev(&s, "message_stop", "{\"type\":\"message_stop\"}");
+	CHECK(rec_done == 1, "done");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_null_stop_reason(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "message_delta",
+	    "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":null}}");
+	CHECK(rec_stop == NULL, "null stop_reason not reported");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_error_event(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "error",
+	    "{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\","
+	    "\"message\":\"slow down\"}}");
+	CHECK(rec_errors == 1 && rec_err_type &&
+	    strcmp(rec_err_type, "overloaded_error") == 0, "error type");
+	CHECK(rec_err_msg && strcmp(rec_err_msg, "slow down") == 0, "error msg");
+	/* after a fatal error, further events are ignored */
+	ev(&s, "content_block_delta",
+	    "{\"type\":\"content_block_delta\",\"index\":0,"
+	    "\"delta\":{\"type\":\"text_delta\",\"text\":\"ignored\"}}");
+	CHECK(rec_text.len == 0, "post-error events ignored");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_ping_and_badjson(void)
+{
+	struct anthropic_stream	s;
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "ping", "{\"type\":\"ping\"}");
+	CHECK(rec_errors == 0 && rec_done == 0 && rec_text.len == 0,
+	    "ping ignored");
+
+	ev(&s, "content_block_delta", "{");	/* malformed JSON */
+	CHECK(rec_errors == 1 && rec_err_type &&
+	    strcmp(rec_err_type, "parse_error") == 0, "bad json -> parse_error");
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+/* ---- integration: SSE wire -> sse -> anthropic ---- */
+
+static void
+sse_trampoline(const char *event, const char *data, size_t datalen, void *arg)
+{
+	anthropic_stream_event(arg, event, data, datalen);
+}
+
+static void
+test_integration(void)
+{
+	struct anthropic_stream	s;
+	struct sse_parser	p;
+	const char		*wire =
+	    "event: message_start\n"
+	    "data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+	    "{\"input_tokens\":7,\"output_tokens\":1}}}\n"
+	    "\n"
+	    "event: content_block_start\n"
+	    "data: {\"type\":\"content_block_start\",\"index\":0,"
+	    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+	    "\n"
+	    "event: content_block_delta\n"
+	    "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+	    "{\"type\":\"text_delta\",\"text\":\"It is \"}}\n"
+	    "\n"
+	    "event: content_block_delta\n"
+	    "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+	    "{\"type\":\"text_delta\",\"text\":\"sunny.\"}}\n"
+	    "\n"
+	    "event: content_block_stop\n"
+	    "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+	    "\n"
+	    "event: message_delta\n"
+	    "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+	    "\"end_turn\"},\"usage\":{\"output_tokens\":9}}\n"
+	    "\n"
+	    "event: message_stop\n"
+	    "data: {\"type\":\"message_stop\"}\n"
+	    "\n";
+	size_t			i, len = strlen(wire);
+
+	anthropic_stream_init(&s, &RECORDER, NULL);
+	sse_init(&p, sse_trampoline, &s);
+
+	/* feed the wire in 7-byte network-sized pieces */
+	for (i = 0; i < len; i += 7) {
+		size_t n = (len - i < 7) ? len - i : 7;
+		if (sse_push(&p, wire + i, n) != 0)
+			failures++;
+	}
+
+	buf_terminate(&rec_text);
+	CHECK(strcmp(rec_text.data, "It is sunny.") == 0,
+	    "integration text: '%s'", rec_text.data);
+	CHECK(rec_in == 7 && rec_out == 9, "integration usage in=%lld out=%lld",
+	    rec_in, rec_out);
+	CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "integration stop");
+	CHECK(rec_done == 1, "integration done");
+
+	sse_clear(&p);
+	anthropic_stream_clear(&s);
+	rec_reset();
+}
+
+int
+main(void)
+{
+	buf_init(&rec_text);
+
+	test_build_basic();
+	test_build_system_tools();
+	test_build_escaping();
+	test_text_stream();
+	test_tool_call();
+	test_tool_no_args();
+	test_usage_stop_done();
+	test_null_stop_reason();
+	test_error_event();
+	test_ping_and_badjson();
+	test_integration();
+
+	buf_free(&rec_text);
+	printf("anthropic_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 171364dcfe0b3bf4079a31790fb59c57bf44d39a (mode 644)
--- /dev/null
+++ regress/buf/Makefile
@@ -0,0 +1,22 @@
+# Unit tests for the growable byte-buffer module (src/common/buf.c).
+# Covers the post-audit-hardening contract: buf_terminate writes a NUL but does
+# not count it in .len, and buf_cstr's debug-assert catches the missing-NUL bug
+# at the read site.
+
+PROG=		buf_test
+SRCS=		buf_test.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-buf_test
+
+run-buf_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 84c4a5a1b90eafca48034526d47f0ecd922b739d (mode 644)
--- /dev/null
+++ regress/buf/buf_test.c
@@ -0,0 +1,236 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Unit tests for the buf module, focused on the post-hardening contract:
+ *
+ *   - buf_terminate(b) writes a '\0' at b->data[b->len] but leaves .len
+ *     pointing at the last logical byte (so strlen(b->data) == b->len).
+ *   - buf_cstr(b) reads .data as a C string and asserts the trailing NUL is
+ *     there; empty buf returns "".
+ *   - buf_append + buf_terminate round-trips bytes including embedded NULs
+ *     (the NUL appears IN the buffer length-prefixed; the trailing NUL is
+ *     the C-string terminator, not part of the payload).
+ *
+ * The original bug shape -- buf_puts (now buf_puts_cstr) writes via strlen,
+ * leaving b->data[b->len] uninitialized -- is documented with a test that
+ * shows the byte at .len IS unset after just buf_puts_cstr, then becomes
+ * '\0' after buf_terminate.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static void
+test_terminate_basic(void)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_append(&b, "hello", 5);
+	buf_terminate(&b);
+	CHECK(b.len == 5, "buf_terminate keeps len at logical bytes (got %zu)",
+	    b.len);
+	CHECK(b.data[b.len] == '\0',
+	    "buf_terminate writes a NUL at b.data[b.len]");
+	CHECK(strlen(b.data) == 5,
+	    "strlen(b.data) matches b.len (got %zu)", strlen(b.data));
+	CHECK(strcmp(b.data, "hello") == 0, "payload intact after terminate");
+	buf_free(&b);
+}
+
+static void
+test_terminate_empty(void)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_terminate(&b);
+	CHECK(b.len == 0, "empty + terminate stays at len 0 (got %zu)", b.len);
+	CHECK(b.data != NULL && b.data[0] == '\0',
+	    "empty + terminate writes NUL at position 0");
+	buf_free(&b);
+}
+
+static void
+test_buf_cstr_empty(void)
+{
+	struct buf	b;
+	const char	*s;
+
+	buf_init(&b);
+	/* No allocation -- data is still NULL. */
+	s = buf_cstr(&b);
+	CHECK(s != NULL && *s == '\0',
+	    "buf_cstr on uninitialised buf returns \"\"");
+	buf_free(&b);
+}
+
+static void
+test_buf_cstr_after_terminate(void)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_puts_cstr(&b, "abcd");
+	buf_terminate(&b);
+	CHECK(strcmp(buf_cstr(&b), "abcd") == 0,
+	    "buf_cstr returns the payload as C string");
+	buf_free(&b);
+}
+
+/*
+ * The original bug: buf_puts_cstr (was buf_puts) writes payload bytes via
+ * strlen but does NOT write a trailing NUL.  After only that call,
+ * b->data[b->len] is uninitialised heap.  buf_terminate fixes it; buf_cstr
+ * is the read-side contract that catches a missing fix.
+ */
+static void
+test_puts_cstr_does_not_terminate(void)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_putc(&b, 'X');		/* scribble sentinels at positions 0..2 */
+	buf_putc(&b, 'X');
+	buf_putc(&b, 'X');
+	b.len = 0;			/* reset, but the bytes stay in .data */
+
+	buf_puts_cstr(&b, "ab");
+	CHECK(b.len == 2, "buf_puts_cstr sets len to strlen");
+	CHECK(b.data[0] == 'a' && b.data[1] == 'b',
+	    "payload bytes written");
+	CHECK(b.data[b.len] == 'X',
+	    "b.data[b.len] is the prior sentinel, NOT '\\0' "
+	    "(this is the bug class buf_terminate + buf_cstr exists to catch)");
+
+	buf_terminate(&b);
+	CHECK(b.data[b.len] == '\0',
+	    "buf_terminate writes '\\0' at b.data[b.len]");
+	CHECK(strcmp(buf_cstr(&b), "ab") == 0,
+	    "buf_cstr round-trips after buf_terminate");
+	buf_free(&b);
+}
+
+/*
+ * Embedded NULs survive buf_append + buf_terminate: the payload is
+ * length-counted in .len, the trailing terminator is just for safe C-string
+ * read-back of the AROUND data (it's at b.data[b.len], past the payload).
+ */
+static void
+test_embedded_nul_roundtrip(void)
+{
+	struct buf	b;
+	const char	*payload = "ab\0cd";	/* length 5 incl. NUL */
+	size_t		i;
+	int		bytes_match = 1;
+
+	buf_init(&b);
+	buf_append(&b, payload, 5);
+	buf_terminate(&b);
+	CHECK(b.len == 5,
+	    "buf_append + buf_terminate preserves length-5 payload "
+	    "(got %zu)", b.len);
+	for (i = 0; i < 5; i++)
+		if (b.data[i] != payload[i])
+			bytes_match = 0;
+	CHECK(bytes_match, "bytes (including embedded NUL) match");
+	CHECK(b.data[5] == '\0', "trailing NUL after the payload");
+	/*
+	 * strlen would return 2 here (it stops at the embedded NUL); that is
+	 * exactly why buf_cstr is documented as dispatch-only when the buf
+	 * may carry NULs, and length-aware readers should use .len directly.
+	 */
+	CHECK(strlen(b.data) == 2, "strlen sees only the first NUL");
+	buf_free(&b);
+}
+
+/*
+ * buf_terminate is idempotent in the sense that calling it twice still
+ * leaves b.data[b.len] == '\0' and b.len unchanged -- two calls are
+ * surprising but should not corrupt state.  Catches a future regression
+ * that, say, makes buf_terminate forget to decrement .len.
+ */
+static void
+test_terminate_idempotent(void)
+{
+	struct buf	b;
+	size_t		len_before;
+
+	buf_init(&b);
+	buf_append(&b, "xy", 2);
+	buf_terminate(&b);
+	len_before = b.len;
+	buf_terminate(&b);
+	CHECK(b.len == len_before, "second buf_terminate keeps .len");
+	CHECK(b.data[b.len] == '\0', "trailing NUL still in place");
+	CHECK(strcmp(buf_cstr(&b), "xy") == 0,
+	    "buf_cstr still round-trips");
+	buf_free(&b);
+}
+
+/*
+ * buf_freezero should still work after buf_terminate, and the data buffer
+ * should be cleared to all zeros up to .cap before free.  We can only
+ * observe the pre-free state -- after free the pointer is dangling -- so
+ * verify by reading the bytes back via b.data (set to NULL by freezero).
+ */
+static void
+test_freezero_after_terminate(void)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_append(&b, "secret", 6);
+	buf_terminate(&b);
+	buf_freezero(&b);
+	CHECK(b.data == NULL, "buf_freezero NULLs the data pointer");
+	CHECK(b.len == 0, "buf_freezero zeros the length");
+	CHECK(b.cap == 0, "buf_freezero zeros the capacity");
+}
+
+int
+main(void)
+{
+	test_terminate_basic();
+	test_terminate_empty();
+	test_buf_cstr_empty();
+	test_buf_cstr_after_terminate();
+	test_puts_cstr_does_not_terminate();
+	test_embedded_nul_roundtrip();
+	test_terminate_idempotent();
+	test_freezero_after_terminate();
+
+	printf("buf_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 581967c540081b97e030687f396e67eab011aa41 (mode 644)
--- /dev/null
+++ regress/chat/Makefile
@@ -0,0 +1,34 @@
+# Tracer-bullet end-to-end test: run fugu(1) chatting with a libtls stub server.
+# Requires the worker binaries to be built (run `make` at the top level first).
+
+PROG=		chat_test
+SRCS=		chat_test.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-ltls -lutil
+DPADD+=		${LIBTLS} ${LIBUTIL}
+
+FUGU_BIN=	${.CURDIR}/../../src/parent/fugu
+FUGU_UI_BIN=	${.CURDIR}/../../src/ui/fugu-ui
+FUGU_NET_BIN=	${.CURDIR}/../../src/net/fugu-net
+FUGU_EXEC_BIN=	${.CURDIR}/../../src/exec/fugu-exec
+FUGU_FETCH_BIN=	${.CURDIR}/../../src/fetch/fugu-fetch
+
+REGRESS_TARGETS=	run-chat_test
+
+run-chat_test: ${PROG}
+	@td=`mktemp -d /tmp/fugu-chat-cert.XXXXXX`; \
+	    openssl req -x509 -newkey rsa:2048 -nodes \
+	        -keyout $$td/key.pem -out $$td/cert.pem \
+	        -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+	    FUGU_BIN=${FUGU_BIN} FUGU_UI_BIN=${FUGU_UI_BIN} \
+	        FUGU_NET_BIN=${FUGU_NET_BIN} FUGU_EXEC_BIN=${FUGU_EXEC_BIN} \
+	        FUGU_FETCH_BIN=${FUGU_FETCH_BIN} \
+	        ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+	    rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + e1ae12a6ec0886e822d476c1a5790cde4d563aeb (mode 644)
--- /dev/null
+++ regress/chat/chat_test.c
@@ -0,0 +1,326 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The tracer bullet, end to end: run the real `fugu` binary (which spawns
+ * fugu-ui and fugu-net), feed a line to its stdin, and read the streamed reply
+ * from its stdout.  net is pointed at a libtls stub server standing in for
+ * api.anthropic.com, so no credentials or outbound network are needed.
+ *
+ * Requires FUGU_BIN / FUGU_UI_BIN / FUGU_NET_BIN in the environment (set by the
+ * Makefile) and a cert/key pair in argv (generated by the Makefile).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+
+static int	checks;
+static int	failures;
+
+/* Remove the session journals the run leaves behind, then the directory. */
+static void
+rm_sessions(const char *home)
+{
+	char		 path[1100], f[1200];
+	DIR		*d;
+	struct dirent	*e;
+
+	snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+	if ((d = opendir(path)) != NULL) {
+		while ((e = readdir(d)) != NULL) {
+			if (e->d_name[0] == '.')
+				continue;
+			snprintf(f, sizeof(f), "%s/%s", path, e->d_name);
+			unlink(f);
+		}
+		closedir(d);
+	}
+	rmdir(path);
+}
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static const char *const SSE_BODY =
+    "event: content_block_start\n"
+    "data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+    "\n"
+    "event: content_block_delta\n"
+    "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"text_delta\",\"text\":\"pong\"}}\n"
+    "\n"
+    "event: content_block_stop\n"
+    "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+    "\n"
+    "event: message_delta\n"
+    "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n"
+    "\n"
+    "event: message_stop\n"
+    "data: {\"type\":\"message_stop\"}\n"
+    "\n";
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+	buf_printf(b, "%zx\r\n", n);
+	buf_append(b, data, n);
+	buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+	const unsigned char	*p = buf;
+	size_t			 off = 0;
+	ssize_t			 n;
+
+	while (off < len) {
+		n = tls_write(t, p + off, len - off);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+			continue;
+		if (n < 0)
+			return (-1);
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+	struct tls_config	*cfg;
+	struct tls		*srv, *cctx;
+	struct buf		 resp;
+	char			 req[2048];
+	ssize_t			 n;
+	int			 cfd;
+	size_t			 sl, half;
+
+	if ((cfd = accept(lfd, NULL, NULL)) == -1)
+		_exit(20);
+	if ((cfg = tls_config_new()) == NULL)
+		_exit(21);
+	if (tls_config_set_cert_file(cfg, cert) == -1 ||
+	    tls_config_set_key_file(cfg, key) == -1)
+		_exit(22);
+	if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+		_exit(23);
+	if (tls_accept_socket(srv, &cctx, cfd) == -1)
+		_exit(24);
+	do {
+		n = tls_handshake(cctx);
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+	if (n == -1)
+		_exit(25);
+	do {
+		n = tls_read(cctx, req, sizeof(req));
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+
+	buf_init(&resp);
+	buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+	buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+	buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+	sl = strlen(SSE_BODY);
+	half = sl / 2;
+	add_chunk(&resp, SSE_BODY, half);
+	add_chunk(&resp, SSE_BODY + half, sl - half);
+	buf_puts_cstr(&resp, "0\r\n\r\n");
+	(void)tls_write_all(cctx, resp.data, resp.len);
+	buf_free(&resp);
+	tls_close(cctx);
+	_exit(0);
+}
+
+static void
+write_config(const char *home)
+{
+	char	dir[1024], path[1024];
+	FILE	*fp;
+	int	fd;
+
+	snprintf(dir, sizeof(dir), "%s/.fugu", home);
+	if (mkdir(dir, 0700) == -1)
+		err(1, "mkdir %s", dir);
+	snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+	if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+		err(1, "open %s", path);
+	if (fchmod(fd, 0600) == -1)		/* defeat a permissive umask */
+		err(1, "fchmod");
+	if ((fp = fdopen(fd, "w")) == NULL)
+		err(1, "fdopen");
+	fputs("anthropic_key \"test-key\"\n", fp);
+	fputs("model \"claude-sonnet-4-6\"\n", fp);
+	fputs("max_tokens 64\n", fp);
+	fclose(fp);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "chat_test: TIMED OUT\n");
+	_exit(97);
+}
+
+int
+main(int argc, char *argv[])
+{
+	struct sockaddr_in	sa;
+	socklen_t		slen;
+	pid_t			sp, fp;
+	const char		*fugu_bin;
+	char			home[] = "/tmp/fugu-chat.XXXXXX";
+	char			out[8192], portstr[16];
+	size_t			outlen = 0;
+	ssize_t			k;
+	int			lfd, one = 1, port, status;
+	int			in_pipe[2], out_pipe[2];
+
+	if (argc != 3)
+		errx(1, "usage: %s cert.pem key.pem", argv[0]);
+	if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+		errx(1, "FUGU_BIN not set");
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(90);
+
+	if (mkdtemp(home) == NULL)
+		err(1, "mkdtemp");
+	write_config(home);
+
+	/* stub-server TCP listener on an ephemeral 127.0.0.1 port */
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 1) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	port = ntohs(sa.sin_port);
+	snprintf(portstr, sizeof(portstr), "%d", port);
+
+	if ((sp = fork()) == -1)
+		err(1, "fork");
+	if (sp == 0) {
+		stub_server(lfd, argv[1], argv[2]);
+		_exit(0);
+	}
+	close(lfd);
+
+	if (pipe(in_pipe) == -1 || pipe(out_pipe) == -1)
+		err(1, "pipe");
+
+	if ((fp = fork()) == -1)
+		err(1, "fork");
+	if (fp == 0) {
+		dup2(in_pipe[0], STDIN_FILENO);
+		dup2(out_pipe[1], STDOUT_FILENO);
+		close(in_pipe[0]);
+		close(in_pipe[1]);
+		close(out_pipe[0]);
+		close(out_pipe[1]);
+		setenv("HOME", home, 1);
+		{	/* not setgid in the harness, so FUGU_CONF is honoured */
+			char	confpath[1100];
+
+			snprintf(confpath, sizeof(confpath), "%s/.fugu/fugu.conf",
+			    home);
+			setenv("FUGU_CONF", confpath, 1);
+		}
+		setenv("FUGU_NET_HOST", "localhost", 1);
+		setenv("FUGU_NET_PORT", portstr, 1);
+		setenv("FUGU_NET_CAFILE", argv[1], 1);
+		execl(fugu_bin, fugu_bin, (char *)NULL);
+		_exit(127);
+	}
+	close(in_pipe[0]);
+	close(out_pipe[1]);
+
+	/* drive the conversation */
+	if (write(in_pipe[1], "ping\n", 5) != 5)
+		CHECK(0, "write to fugu stdin");
+
+	while (outlen < sizeof(out) - 1) {
+		k = read(out_pipe[0], out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "pong") != NULL)
+			break;
+	}
+	CHECK(strstr(out, "pong") != NULL, "assistant reply contains 'pong'");
+
+	close(in_pipe[1]);		/* EOF -> ui quits -> fugu shuts down */
+	close(out_pipe[0]);
+
+	if (waitpid(fp, &status, 0) == -1)
+		err(1, "waitpid fugu");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "fugu exit status %d", status);
+	if (waitpid(sp, &status, 0) == -1)
+		err(1, "waitpid stub");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "stub server exit %d", status);
+
+	/* best-effort cleanup of the temp HOME tree */
+	{
+		char	p[1100];
+
+		snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+		unlink(p);
+		rm_sessions(home);
+		snprintf(p, sizeof(p), "%s/.fugu", home);
+		rmdir(p);
+		rmdir(home);
+	}
+
+	printf("chat_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + fa935eb6a4497a82ce29e2f5545fc9c956f7c825 (mode 644)
--- /dev/null
+++ regress/compact/Makefile
@@ -0,0 +1,34 @@
+# End-to-end test of /compact against a libtls stub api.anthropic.com.
+# Requires the worker binaries to be built (run `make` at the top level first).
+
+PROG=		compact_test
+SRCS=		compact_test.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-ltls -lutil
+DPADD+=		${LIBTLS} ${LIBUTIL}
+
+FUGU_BIN=	${.CURDIR}/../../src/parent/fugu
+FUGU_UI_BIN=	${.CURDIR}/../../src/ui/fugu-ui
+FUGU_NET_BIN=	${.CURDIR}/../../src/net/fugu-net
+FUGU_EXEC_BIN=	${.CURDIR}/../../src/exec/fugu-exec
+FUGU_FETCH_BIN=	${.CURDIR}/../../src/fetch/fugu-fetch
+
+REGRESS_TARGETS=	run-compact_test
+
+run-compact_test: ${PROG}
+	@td=`mktemp -d /tmp/fugu-compact-cert.XXXXXX`; \
+	    openssl req -x509 -newkey rsa:2048 -nodes \
+	        -keyout $$td/key.pem -out $$td/cert.pem \
+	        -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+	    FUGU_BIN=${FUGU_BIN} FUGU_UI_BIN=${FUGU_UI_BIN} \
+	        FUGU_NET_BIN=${FUGU_NET_BIN} FUGU_EXEC_BIN=${FUGU_EXEC_BIN} \
+	        FUGU_FETCH_BIN=${FUGU_FETCH_BIN} \
+	        ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+	    rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 38bf6abb445f3e36e9c450325af2af19a4317b52 (mode 644)
--- /dev/null
+++ regress/compact/compact_test.c
@@ -0,0 +1,398 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * End-to-end test of /compact: run the real fugu (line mode) against a libtls
+ * stub standing in for api.anthropic.com.  The stub serves two connections -- a
+ * normal turn ("pong") and then the summarisation call ("Compacted summary.").
+ * We type "ping" then "/compact" and verify the summary is shown and that the
+ * journal records a compact marker carrying the summary text.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* Append a complete single-text-block SSE response with the given text. */
+static void
+make_sse(struct buf *b, const char *text)
+{
+	buf_puts_cstr(b, "event: message_start\n"
+	    "data: {\"type\":\"message_start\",\"message\":{\"id\":\"x\","
+	    "\"role\":\"assistant\",\"usage\":{\"input_tokens\":5,"
+	    "\"output_tokens\":0}}}\n\n");
+	buf_puts_cstr(b, "event: content_block_start\n"
+	    "data: {\"type\":\"content_block_start\",\"index\":0,"
+	    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n");
+	buf_printf(b, "event: content_block_delta\n"
+	    "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+	    "{\"type\":\"text_delta\",\"text\":\"%s\"}}\n\n", text);
+	buf_puts_cstr(b, "event: content_block_stop\n"
+	    "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n");
+	buf_puts_cstr(b, "event: message_delta\n"
+	    "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+	    "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n\n");
+	buf_puts_cstr(b, "event: message_stop\n"
+	    "data: {\"type\":\"message_stop\"}\n\n");
+}
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+	buf_printf(b, "%zx\r\n", n);
+	buf_append(b, data, n);
+	buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+	const unsigned char	*p = buf;
+	size_t			 off = 0;
+	ssize_t			 n;
+
+	while (off < len) {
+		n = tls_write(t, p + off, len - off);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+			continue;
+		if (n < 0)
+			return (-1);
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+/* Accept one TLS connection, read the request, and stream `text` as SSE. */
+static void
+serve_one(int lfd, struct tls *srv, const char *text)
+{
+	struct tls	*cctx;
+	struct buf	 body, resp;
+	char		 req[4096];
+	ssize_t		 n;
+	int		 cfd;
+	size_t		 sl, half;
+
+	if ((cfd = accept(lfd, NULL, NULL)) == -1)
+		_exit(30);
+	if (tls_accept_socket(srv, &cctx, cfd) == -1)
+		_exit(31);
+	do {
+		n = tls_handshake(cctx);
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+	if (n == -1)
+		_exit(32);
+	do {
+		n = tls_read(cctx, req, sizeof(req));
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+
+	buf_init(&body);
+	make_sse(&body, text);
+
+	buf_init(&resp);
+	buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+	buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+	buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+	sl = body.len;
+	half = sl / 2;
+	add_chunk(&resp, body.data, half);
+	add_chunk(&resp, body.data + half, sl - half);
+	buf_puts_cstr(&resp, "0\r\n\r\n");
+	(void)tls_write_all(cctx, resp.data, resp.len);
+	buf_free(&resp);
+	buf_free(&body);
+	tls_close(cctx);
+	close(cfd);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+	struct tls_config	*cfg;
+	struct tls		*srv;
+
+	if ((cfg = tls_config_new()) == NULL)
+		_exit(21);
+	if (tls_config_set_cert_file(cfg, cert) == -1 ||
+	    tls_config_set_key_file(cfg, key) == -1)
+		_exit(22);
+	if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+		_exit(23);
+
+	serve_one(lfd, srv, "pong");			/* the normal turn */
+	serve_one(lfd, srv, "Compacted summary.");	/* the /compact call */
+
+	tls_free(srv);
+	_exit(0);
+}
+
+static void
+write_config(const char *home)
+{
+	char	dir[1024], path[1024];
+	FILE	*fp;
+	int	fd;
+
+	snprintf(dir, sizeof(dir), "%s/.fugu", home);
+	if (mkdir(dir, 0700) == -1)
+		err(1, "mkdir %s", dir);
+	snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
+	if (mkdir(dir, 0700) == -1)
+		err(1, "mkdir %s", dir);
+	snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+	if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+		err(1, "open %s", path);
+	if (fchmod(fd, 0600) == -1)
+		err(1, "fchmod");
+	if ((fp = fdopen(fd, "w")) == NULL)
+		err(1, "fdopen");
+	fputs("anthropic_key \"test-key\"\n", fp);
+	fputs("model \"claude-sonnet-4-6\"\n", fp);
+	fputs("max_tokens 64\n", fp);
+	fclose(fp);
+}
+
+/* Read the single session journal in HOME into buf (NUL-terminated). */
+static void
+read_journal(const char *home, char *out, size_t outsz)
+{
+	char		 dir[1100], path[2300];
+	DIR		*d;
+	struct dirent	*e;
+	FILE		*fp;
+	size_t		 n = 0;
+
+	out[0] = '\0';
+	snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
+	if ((d = opendir(dir)) == NULL)
+		return;
+	while ((e = readdir(d)) != NULL) {
+		if (e->d_name[0] == '.')
+			continue;
+		snprintf(path, sizeof(path), "%s/%s", dir, e->d_name);
+		if ((fp = fopen(path, "r")) != NULL) {
+			n += fread(out + n, 1, outsz - 1 - n, fp);
+			fclose(fp);
+		}
+	}
+	closedir(d);
+	out[n] = '\0';
+}
+
+static void
+rm_sessions(const char *home)
+{
+	char		 path[1100], f[2300];
+	DIR		*d;
+	struct dirent	*e;
+
+	snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+	if ((d = opendir(path)) != NULL) {
+		while ((e = readdir(d)) != NULL) {
+			if (e->d_name[0] == '.')
+				continue;
+			snprintf(f, sizeof(f), "%s/%s", path, e->d_name);
+			unlink(f);
+		}
+		closedir(d);
+	}
+	rmdir(path);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "compact_test: TIMED OUT\n");
+	_exit(97);
+}
+
+/* Read from fd into out (appending), stopping when needle appears or EOF. */
+static int
+read_until(int fd, char *out, size_t *len, size_t cap, const char *needle)
+{
+	ssize_t	k;
+
+	while (*len < cap - 1) {
+		if (strstr(out, needle) != NULL)
+			return (1);
+		k = read(fd, out + *len, cap - 1 - *len);
+		if (k <= 0)
+			break;
+		*len += (size_t)k;
+		out[*len] = '\0';
+	}
+	return (strstr(out, needle) != NULL);
+}
+
+int
+main(int argc, char *argv[])
+{
+	struct sockaddr_in	sa;
+	socklen_t		slen;
+	pid_t			sp, fp;
+	const char		*fugu_bin;
+	char			home[] = "/tmp/fugu-compact.XXXXXX";
+	char			out[16384], jrnl[16384], portstr[16];
+	size_t			outlen = 0;
+	int			lfd, one = 1, port, status;
+	int			in_pipe[2], out_pipe[2];
+
+	if (argc != 3)
+		errx(1, "usage: %s cert.pem key.pem", argv[0]);
+	if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+		errx(1, "FUGU_BIN not set");
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(90);
+
+	if (mkdtemp(home) == NULL)
+		err(1, "mkdtemp");
+	write_config(home);
+
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 2) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	port = ntohs(sa.sin_port);
+	snprintf(portstr, sizeof(portstr), "%d", port);
+
+	if ((sp = fork()) == -1)
+		err(1, "fork");
+	if (sp == 0) {
+		stub_server(lfd, argv[1], argv[2]);
+		_exit(0);
+	}
+	close(lfd);
+
+	if (pipe(in_pipe) == -1 || pipe(out_pipe) == -1)
+		err(1, "pipe");
+
+	if ((fp = fork()) == -1)
+		err(1, "fork");
+	if (fp == 0) {
+		dup2(in_pipe[0], STDIN_FILENO);
+		dup2(out_pipe[1], STDOUT_FILENO);
+		close(in_pipe[0]);
+		close(in_pipe[1]);
+		close(out_pipe[0]);
+		close(out_pipe[1]);
+		setenv("HOME", home, 1);
+		{	/* not setgid in the harness, so FUGU_CONF is honoured */
+			char	confpath[1100];
+
+			snprintf(confpath, sizeof(confpath), "%s/.fugu/fugu.conf",
+			    home);
+			setenv("FUGU_CONF", confpath, 1);
+		}
+		setenv("FUGU_NET_HOST", "localhost", 1);
+		setenv("FUGU_NET_PORT", portstr, 1);
+		setenv("FUGU_NET_CAFILE", argv[1], 1);
+		execl(fugu_bin, fugu_bin, (char *)NULL);
+		_exit(127);
+	}
+	close(in_pipe[0]);
+	close(out_pipe[1]);
+
+	out[0] = '\0';
+
+	/* A normal turn first, to give /compact something to summarise. */
+	if (write(in_pipe[1], "ping\n", 5) != 5)
+		CHECK(0, "write ping");
+	CHECK(read_until(out_pipe[0], out, &outlen, sizeof(out), "pong"),
+	    "normal turn streamed 'pong'");
+
+	/* Now compact the conversation. */
+	if (write(in_pipe[1], "/compact\n", 9) != 9)
+		CHECK(0, "write /compact");
+	CHECK(read_until(out_pipe[0], out, &outlen, sizeof(out),
+	    "Compacted summary."), "compact summary shown to the user");
+	CHECK(strstr(out, "compacted") != NULL, "compact divider shown");
+
+	close(in_pipe[1]);		/* EOF -> ui quits -> fugu shuts down */
+	while (read(out_pipe[0], out + outlen,
+	    outlen < sizeof(out) - 1 ? sizeof(out) - 1 - outlen : 0) > 0)
+		;			/* drain */
+	close(out_pipe[0]);
+
+	if (waitpid(fp, &status, 0) == -1)
+		err(1, "waitpid fugu");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "fugu exit status %d", status);
+	if (waitpid(sp, &status, 0) == -1)
+		err(1, "waitpid stub");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "stub server exit %d", status);
+
+	/* The journal must record the compact marker carrying the summary. */
+	read_journal(home, jrnl, sizeof(jrnl));
+	CHECK(strstr(jrnl, "\"fugu\":\"compact\"") != NULL,
+	    "journal has a compact marker");
+	CHECK(strstr(jrnl, "Compacted summary.") != NULL,
+	    "compact marker carries the summary text");
+
+	{
+		char	p[1100];
+
+		snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+		unlink(p);
+		rm_sessions(home);
+		snprintf(p, sizeof(p), "%s/.fugu", home);
+		rmdir(p);
+		rmdir(home);
+	}
+
+	printf("compact_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 5bdf2ee1a8d700b74d7cd8be68c8d376bb75262a (mode 644)
--- /dev/null
+++ regress/conf/Makefile
@@ -0,0 +1,20 @@
+# Unit tests for config resolution, scoping, and StrictModes (src/common/conf.c).
+# The parse.y grammar gets its own regress once it lands.
+
+PROG=		conf_test
+SRCS=		conf_test.c conf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-conf_test
+
+run-conf_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 775771f25e42fe29fa4e4e7c5361baff92a4e7b8 (mode 644)
--- /dev/null
+++ regress/conf/conf_test.c
@@ -0,0 +1,335 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/stat.h>
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+
+static int	fails;
+static int	checks;
+
+#define CHECK(cond, msg) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		fprintf(stderr, "FAIL %s:%d: %s\n",			\
+		    __func__, __LINE__, (msg));				\
+		fails++;						\
+	}								\
+} while (0)
+
+static void
+streq(const char *func, int line, const char *got, const char *want,
+    const char *msg)
+{
+	checks++;
+	if (got == NULL || strcmp(got, want) != 0) {
+		fprintf(stderr, "FAIL %s:%d: %s (got %s want %s)\n",
+		    func, line, msg, got ? got : "(null)", want);
+		fails++;
+	}
+}
+#define STREQ(got, want, msg) streq(__func__, __LINE__, (got), (want), (msg))
+
+/* Build a representative config:
+ *   global:               model sonnet, max_tokens 8192, allow_write no
+ *   match user isaac:      model opus, allow_write yes
+ *   match group interns:   model haiku, web_search no
+ *   match user isaac (2nd): model loser   <- must never win (first-match-wins)
+ */
+static void
+build(struct conf *c)
+{
+	struct conf_match	*m;
+
+	conf_init(c);
+
+	conf_set_string(&c->global, CS_MODEL, &c->global.model, "sonnet");
+	c->global.max_tokens = 8192;
+	conf_set_flag(&c->global, CS_MAX_TOKENS);
+	c->global.allow_write = 0;
+	conf_set_flag(&c->global, CS_ALLOW_WRITE);
+
+	m = conf_match_new(c, MATCH_USER, "isaac");
+	conf_set_string(&m->settings, CS_MODEL, &m->settings.model, "opus");
+	m->settings.allow_write = 1;
+	conf_set_flag(&m->settings, CS_ALLOW_WRITE);
+
+	m = conf_match_new(c, MATCH_GROUP, "interns");
+	conf_set_string(&m->settings, CS_MODEL, &m->settings.model, "haiku");
+	m->settings.web_search = 0;
+	conf_set_flag(&m->settings, CS_WEB_SEARCH);
+
+	m = conf_match_new(c, MATCH_USER, "isaac");
+	conf_set_string(&m->settings, CS_MODEL, &m->settings.model, "loser");
+}
+
+static void
+test_user_match(void)
+{
+	struct conf		c;
+	struct conf_settings	eff;
+
+	build(&c);
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "isaac", NULL, 0, &eff);
+
+	STREQ(eff.model, "opus", "user block overrides global, first wins");
+	CHECK((eff.set & CS_ALLOW_WRITE) && eff.allow_write == 1,
+	    "user block sets allow_write");
+	CHECK((eff.set & CS_MAX_TOKENS) && eff.max_tokens == 8192,
+	    "global max_tokens inherited");
+	CHECK(!(eff.set & CS_WEB_SEARCH), "web_search left unset");
+
+	conf_settings_clear(&eff);
+	conf_clear(&c);
+}
+
+static void
+test_group_match(void)
+{
+	struct conf		c;
+	struct conf_settings	eff;
+	char			*groups[] = { "wheel", "interns" };
+
+	build(&c);
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "bob", groups, 2, &eff);
+
+	STREQ(eff.model, "haiku", "group block matched");
+	CHECK((eff.set & CS_WEB_SEARCH) && eff.web_search == 0,
+	    "group block sets web_search no");
+	CHECK((eff.set & CS_ALLOW_WRITE) && eff.allow_write == 0,
+	    "allow_write falls back to global");
+
+	conf_settings_clear(&eff);
+	conf_clear(&c);
+}
+
+static void
+test_no_match(void)
+{
+	struct conf		c;
+	struct conf_settings	eff;
+
+	build(&c);
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "carol", NULL, 0, &eff);
+
+	STREQ(eff.model, "sonnet", "no block matches -> global default");
+	CHECK(eff.allow_write == 0, "global allow_write");
+
+	conf_settings_clear(&eff);
+	conf_clear(&c);
+}
+
+static void
+test_print_redacts(void)
+{
+	struct conf_settings	s;
+	char			*buf = NULL;
+	size_t			 sz = 0;
+	FILE			*fp;
+
+	memset(&s, 0, sizeof(s));
+	conf_set_string(&s, CS_MODEL, &s.model, "opus");
+	conf_set_string(&s, CS_SYSTEM, &s.system, "be terse");
+	conf_set_string(&s, CS_ANTHROPIC_KEY, &s.anthropic_key, "sk-TOPSECRET");
+	conf_set_string(&s, CS_PROVIDER, &s.provider, "openai");
+	conf_set_string(&s, CS_API_HOST, &s.api_host, "openrouter.ai");
+	conf_set_string(&s, CS_API_PATH, &s.api_path, "/api/v1/chat/completions");
+	conf_set_string(&s, CS_API_KEY, &s.api_key, "sk-or-TOPSECRET");
+
+	fp = open_memstream(&buf, &sz);
+	if (fp == NULL) {
+		CHECK(0, "open_memstream");
+		return;
+	}
+	conf_settings_print(&s, fp);
+	fclose(fp);
+
+	CHECK(strstr(buf, "model \"opus\"") != NULL, "print shows model");
+	CHECK(strstr(buf, "system \"be terse\"") != NULL, "print shows system");
+	CHECK(strstr(buf, "provider \"openai\"") != NULL, "print shows provider");
+	CHECK(strstr(buf, "api_host \"openrouter.ai\"") != NULL,
+	    "print shows api_host");
+	CHECK(strstr(buf, "api_path \"/api/v1/chat/completions\"") != NULL,
+	    "print shows api_path");
+	CHECK(strstr(buf, "sk-TOPSECRET") == NULL, "secret value not printed");
+	CHECK(strstr(buf, "sk-or-TOPSECRET") == NULL, "api_key value not printed");
+	CHECK(strstr(buf, "***") != NULL, "secret redaction marker present");
+
+	free(buf);
+	conf_settings_clear(&s);
+}
+
+static void
+test_secrecy(void)
+{
+	char	tmpl[] = "/tmp/fugu_secrecy.XXXXXX";
+	int	fd;
+	uid_t	me = getuid();
+
+	fd = mkstemp(tmpl);
+	CHECK(fd != -1, "mkstemp");
+	if (fd == -1)
+		return;
+
+	CHECK(fchmod(fd, 0600) == 0, "fchmod 0600");
+	CHECK(conf_check_secrecy(fd, tmpl, me) == 0, "0600 owned by us is ok");
+
+	/* Group read is allowed: that is how the setgid _fugu group reads it. */
+	CHECK(fchmod(fd, 0640) == 0, "fchmod 0640");
+	CHECK(conf_check_secrecy(fd, tmpl, me) == 0, "0640 (group read) is ok");
+
+	CHECK(fchmod(fd, 0644) == 0, "fchmod 0644");
+	CHECK(conf_check_secrecy(fd, tmpl, me) == -1, "0644 (world read) rejected");
+
+	CHECK(fchmod(fd, 0660) == 0, "fchmod 0660");
+	CHECK(conf_check_secrecy(fd, tmpl, me) == -1, "0660 (group write) rejected");
+
+	CHECK(fchmod(fd, 0600) == 0, "fchmod 0600 again");
+	CHECK(conf_check_secrecy(fd, tmpl, me + 1) == -1,
+	    "owner neither root nor us rejected");
+
+	close(fd);
+	unlink(tmpl);
+}
+
+/*
+ * Local mirror of src/parent/parent.c:resolve_secret() (file-path branch only).
+ * resolve_secret() is static in parent.c and the helper cannot be linked here
+ * without pulling in the parent's worker/imsg/json plumbing.  Keep this in
+ * sync with the production helper -- this test guards the empty-key-file
+ * regression where an empty first line was silently returned as a zero-length
+ * key, bypassing the caller's NULL check and disabling provider auth.
+ */
+static char *
+resolve_secret_file(const char *file_path)
+{
+	FILE	*fp;
+	char	*line = NULL;
+	size_t	 cap = 0;
+	ssize_t	 n;
+
+	if ((fp = fopen(file_path, "r")) == NULL)
+		return (NULL);
+	n = getline(&line, &cap, fp);
+	fclose(fp);
+	if (n <= 0) {
+		free(line);
+		return (NULL);
+	}
+	while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+		line[--n] = '\0';
+	if (n == 0) {
+		free(line);
+		return (NULL);
+	}
+	return (line);
+}
+
+/* Create a fresh temp key file with the given contents, return the path. */
+static char *
+make_keyfile(const char *content, size_t len)
+{
+	static const char	PATTERN[] = "/tmp/fugu_keyfile.XXXXXX";
+	char			*tmpl = strdup(PATTERN);
+	int			 fd;
+
+	if (tmpl == NULL)
+		return (NULL);
+	fd = mkstemp(tmpl);
+	if (fd == -1) {
+		free(tmpl);
+		return (NULL);
+	}
+	if (len > 0)
+		(void)write(fd, content, len);
+	close(fd);
+	return (tmpl);
+}
+
+static void
+test_resolve_secret_empty(void)
+{
+	char	*path, *got;
+
+	/* Truly empty file: getline() returns -1, must yield NULL. */
+	path = make_keyfile("", 0);
+	CHECK(path != NULL, "mkstemp empty");
+	if (path != NULL) {
+		got = resolve_secret_file(path);
+		CHECK(got == NULL, "empty key file -> NULL (no bytes)");
+		free(got);
+		unlink(path);
+		free(path);
+	}
+
+	/* File containing only a newline: getline() returns 1, trim leaves
+	 * length 0; the fix must convert this to NULL rather than returning
+	 * a zero-length string. */
+	path = make_keyfile("\n", 1);
+	CHECK(path != NULL, "mkstemp newline");
+	if (path != NULL) {
+		got = resolve_secret_file(path);
+		CHECK(got == NULL, "key file with only newline -> NULL");
+		free(got);
+		unlink(path);
+		free(path);
+	}
+
+	/* CRLF-only file: same expectation. */
+	path = make_keyfile("\r\n", 2);
+	CHECK(path != NULL, "mkstemp CRLF");
+	if (path != NULL) {
+		got = resolve_secret_file(path);
+		CHECK(got == NULL, "key file with only CRLF -> NULL");
+		free(got);
+		unlink(path);
+		free(path);
+	}
+
+	/* Non-empty file: must still return the trimmed contents. */
+	path = make_keyfile("sk-abc\n", 7);
+	CHECK(path != NULL, "mkstemp non-empty");
+	if (path != NULL) {
+		got = resolve_secret_file(path);
+		STREQ(got, "sk-abc", "non-empty key file returns trimmed key");
+		free(got);
+		unlink(path);
+		free(path);
+	}
+}
+
+int
+main(void)
+{
+	test_user_match();
+	test_group_match();
+	test_no_match();
+	test_print_redacts();
+	test_secrecy();
+	test_resolve_secret_empty();
+
+	printf("%d checks, %d failures\n", checks, fails);
+	return (fails != 0);
+}
blob - /dev/null
blob + 71237af4e1757c39acc15151488483fd7e1a7664 (mode 644)
--- /dev/null
+++ regress/exec/Makefile
@@ -0,0 +1,22 @@
+# Integration test for the exec tool worker (exec_run.c, tools_file.c,
+# tools_shell.c).
+
+PROG=		exec_test
+SRCS=		exec_test.c exec_run.c tools_file.c tools_shell.c \
+		json.c buf.c util.c log.c imsgproto.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+EXECDIR=	${.CURDIR}/../../src/exec
+CFLAGS+=	-I${COMMONDIR} -I${EXECDIR}
+.PATH:		${COMMONDIR} ${EXECDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-exec_test
+
+run-exec_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 38318ee15b719f0604141db433fb779e7624cf2e (mode 644)
--- /dev/null
+++ regress/exec/exec_test.c
@@ -0,0 +1,337 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Drives the exec worker over imsg: a forked, sandboxed exec_loop in a temp
+ * project dir, exercised through M_TOOL_REQUEST/M_TOOL_RESULT.  Verifies
+ * read/write/edit behaviour and file effects, the error cases (not found, not
+ * unique, missing file, unknown tool), the allow_write gate, and that a write
+ * outside the unveil'd cwd is refused.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "json.h"
+
+static int	checks;
+static int	failures;
+static char	tmpdir[256];
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static void
+writefile(const char *name, const char *content)
+{
+	char	p[512];
+	int	fd;
+
+	snprintf(p, sizeof(p), "%s/%s", tmpdir, name);
+	if ((fd = open(p, O_CREAT | O_WRONLY | O_TRUNC, 0644)) == -1)
+		err(1, "open %s", p);
+	if (write(fd, content, strlen(content)) != (ssize_t)strlen(content))
+		err(1, "write %s", p);
+	close(fd);
+}
+
+static int
+readfile(const char *name, char *buf, size_t cap)
+{
+	char	p[512];
+	ssize_t	n;
+	int	fd;
+
+	snprintf(p, sizeof(p), "%s/%s", tmpdir, name);
+	if ((fd = open(p, O_RDONLY)) == -1)
+		return (-1);
+	n = read(fd, buf, cap - 1);
+	close(fd);
+	if (n < 0)
+		return (-1);
+	buf[n] = '\0';
+	return ((int)n);
+}
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+	int	r, n;
+
+	for (;;) {
+		if ((r = ip_get(ip, m)) == 1)
+			return (1);
+		if (r == -1)
+			return (-1);
+		if ((n = ip_read(ip)) <= 0)
+			return (0);
+	}
+}
+
+static void
+send_req(struct imsgproto *ip, const char *id, const char *name,
+    const char *input_json)
+{
+	struct json_writer	w;
+	struct buf		b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id);
+	json_kv_string(&w, "name", name);
+	json_key(&w, "input");
+	json_raw(&w, input_json);
+	json_object_end(&w);
+	if (ip_compose(ip, M_TOOL_REQUEST, EP_EXEC, EP_NET, b.data,
+	    b.len) == -1 || ip_flush(ip) == -1)
+		errx(1, "send_req");
+	buf_free(&b);
+}
+
+/* Receive one M_TOOL_RESULT; fills content and *is_error.  Returns 0/-1. */
+static int
+recv_result(struct imsgproto *ip, char *content, size_t cap, int *is_error)
+{
+	struct fugu_msg	m;
+	struct json_doc	d;
+	char		*c;
+	int		t;
+
+	if (recv_msg(ip, &m) != 1 || m.type != M_TOOL_RESULT)
+		return (-1);
+	if (json_parse(&d, m.data, m.len) == -1) {
+		ip_msg_free(&m);
+		return (-1);
+	}
+	content[0] = '\0';
+	if ((t = json_obj_get(&d, 0, "content")) >= 0 &&
+	    (c = json_tok_strdup(&d, t)) != NULL) {
+		strlcpy(content, c, cap);
+		free(c);
+	}
+	t = json_obj_get(&d, 0, "is_error");
+	*is_error = (t >= 0 && json_tok_streq(&d, t, "true"));
+	json_doc_free(&d);
+	ip_msg_free(&m);
+	return (0);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "exec_test: TIMED OUT\n");
+	_exit(97);
+}
+
+int
+main(void)
+{
+	struct imsgproto	ip;
+	char			content[4096], fb[4096], td[] = "/tmp/fugu-exec.XXXXXX";
+	const char		*escape = "/tmp/fugu-exec-escape.txt";
+	pid_t			pid;
+	int			sv[2], status, is_err;
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(60);
+
+	if (mkdtemp(td) == NULL)
+		err(1, "mkdtemp");
+	strlcpy(tmpdir, td, sizeof(tmpdir));
+	unlink(escape);
+
+	writefile("hello.txt", "hello world");
+	writefile("dup.txt", "aXa");		/* 'a' twice -> not unique */
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+	if ((pid = fork()) == -1)
+		err(1, "fork");
+	if (pid == 0) {
+		struct imsgproto	eip;
+
+		close(sv[0]);
+		if (chdir(tmpdir) == -1)
+			_exit(40);
+		if (ip_init(&eip, sv[1]) == -1)
+			_exit(41);
+		exec_sandbox(0);		/* unveil "."=tmpdir, pledge */
+		exec_loop(&eip);
+		_exit(0);
+	}
+	close(sv[1]);
+	if (ip_init(&ip, sv[0]) == -1)
+		err(1, "ip_init");
+
+	/* read */
+	send_req(&ip, "t1", "read", "{\"path\":\"hello.txt\"}");
+	CHECK(recv_result(&ip, content, sizeof(content), &is_err) == 0, "t1 recv");
+	CHECK(!is_err && strcmp(content, "hello world") == 0,
+	    "read content '%s' err=%d", content, is_err);
+
+	/* write */
+	send_req(&ip, "t2", "write",
+	    "{\"path\":\"new.txt\",\"content\":\"created data\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err, "write ok (%s)", content);
+	CHECK(readfile("new.txt", fb, sizeof(fb)) >= 0 &&
+	    strcmp(fb, "created data") == 0, "write effect '%s'", fb);
+
+	/* edit (unique) */
+	send_req(&ip, "t3", "edit",
+	    "{\"path\":\"hello.txt\",\"old_string\":\"world\","
+	    "\"new_string\":\"fugu\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err, "edit ok (%s)", content);
+	CHECK(readfile("hello.txt", fb, sizeof(fb)) >= 0 &&
+	    strcmp(fb, "hello fugu") == 0, "edit effect '%s'", fb);
+
+	/* edit: old_string not found */
+	send_req(&ip, "t4", "edit",
+	    "{\"path\":\"hello.txt\",\"old_string\":\"zzz\","
+	    "\"new_string\":\"x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "edit not-found is error");
+
+	/* edit: old_string not unique */
+	send_req(&ip, "t5", "edit",
+	    "{\"path\":\"dup.txt\",\"old_string\":\"a\",\"new_string\":\"b\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "edit non-unique is error");
+
+	/* read missing file */
+	send_req(&ip, "t6", "read", "{\"path\":\"nope.txt\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "read missing is error");
+
+	/* write outside the unveil'd cwd is refused */
+	send_req(&ip, "t7", "write",
+	    "{\"path\":\"/tmp/fugu-exec-escape.txt\",\"content\":\"x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "escape write is error");
+	CHECK(access(escape, F_OK) == -1, "escape file not created");
+
+	/* unknown tool */
+	send_req(&ip, "t8", "frobnicate", "{}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "unknown tool is error");
+
+	/* shell: command output is captured */
+	send_req(&ip, "sh1", "shell", "{\"command\":\"echo hello-shell\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "hello-shell") != NULL,
+	    "shell echo '%s' err=%d", content, is_err);
+
+	/* shell: a non-zero exit status is reported, not an error */
+	send_req(&ip, "sh2", "shell", "{\"command\":\"exit 3\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "exit status 3") != NULL,
+	    "shell exit code '%s' err=%d", content, is_err);
+
+	/* grep: find a pattern with file:line output */
+	writefile("src.txt", "alpha\nneedle here\nomega\n");
+	send_req(&ip, "g1", "grep",
+	    "{\"pattern\":\"needle\",\"path\":\"src.txt\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "needle") != NULL,
+	    "grep match '%s' err=%d", content, is_err);
+
+	/* grep: no match exits 1 but is not a tool error */
+	send_req(&ip, "g2", "grep",
+	    "{\"pattern\":\"zzznomatch\",\"path\":\"src.txt\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err, "grep no-match not an error (%s)", content);
+
+	/* ls: lists the project directory */
+	send_req(&ip, "l1", "ls", "{\"path\":\".\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "src.txt") != NULL,
+	    "ls lists src.txt '%s' err=%d", content, is_err);
+
+	/* find: locate a file by -name glob */
+	send_req(&ip, "f1", "find", "{\"path\":\".\",\"name\":\"src.txt\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "src.txt") != NULL,
+	    "find by name '%s' err=%d", content, is_err);
+
+	/* allow_write gate */
+	{
+		struct buf	cfg;
+
+		buf_init(&cfg);
+		buf_puts_cstr(&cfg, "{\"allow_write\":false}");
+		if (ip_compose(&ip, M_CONFIG, EP_EXEC, EP_PARENT, cfg.data,
+		    cfg.len) == -1 || ip_flush(&ip) == -1)
+			errx(1, "config");
+		buf_free(&cfg);
+	}
+	send_req(&ip, "t9", "write",
+	    "{\"path\":\"blocked.txt\",\"content\":\"nope\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "write blocked when allow_write=false");
+	CHECK(readfile("blocked.txt", fb, sizeof(fb)) == -1,
+	    "blocked file not created");
+
+	if (ip_compose(&ip, M_SHUTDOWN, EP_EXEC, EP_PARENT, NULL, 0) != -1)
+		ip_flush(&ip);
+
+	if (waitpid(pid, &status, 0) == -1)
+		err(1, "waitpid");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "exec worker exit %d", status);
+
+	/* cleanup */
+	unlink(escape);
+	{
+		const char	*f[] = {
+			"hello.txt", "dup.txt", "new.txt", "src.txt"
+		};
+		char		p[512];
+		size_t		i;
+
+		for (i = 0; i < sizeof(f) / sizeof(f[0]); i++) {
+			snprintf(p, sizeof(p), "%s/%s", tmpdir, f[i]);
+			unlink(p);
+		}
+		rmdir(tmpdir);
+	}
+
+	printf("exec_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + fae966d10a6b766a5029120db5cd9189ea0b4215 (mode 644)
--- /dev/null
+++ regress/fetch/Makefile
@@ -0,0 +1,29 @@
+# Integration test for the fetch web worker (fetch_run.c, kagi.c,
+# html2text.c) against a local libtls stub server.  The url and httpacl
+# helpers were inlined into fetch_run.c and are exercised through it; the
+# tlsconn helper lives in src/common/tlsconn.c.
+
+PROG=		fetch_test
+SRCS=		fetch_test.c fetch_run.c kagi.c html2text.c http.c \
+		json.c buf.c util.c log.c imsgproto.c tlsconn.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+FETCHDIR=	${.CURDIR}/../../src/fetch
+CFLAGS+=	-I${COMMONDIR} -I${FETCHDIR}
+.PATH:		${COMMONDIR} ${FETCHDIR}
+
+LDADD+=		-ltls -lutil
+DPADD+=		${LIBTLS} ${LIBUTIL}
+
+REGRESS_TARGETS=	run-fetch_test
+
+run-fetch_test: ${PROG}
+	@td=`mktemp -d /tmp/fugu-fetch-cert.XXXXXX`; \
+	    openssl req -x509 -newkey rsa:2048 -nodes \
+	        -keyout $$td/key.pem -out $$td/cert.pem \
+	        -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+	    ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+	    rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 2e8fe2cad202c565c37a9709f79a256dffe9fce6 (mode 644)
--- /dev/null
+++ regress/fetch/fetch_test.c
@@ -0,0 +1,743 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Drives the fetch worker over imsg with a real libtls stub server: a forked,
+ * sandboxed fetch_loop is pointed (via M_CONFIG) at a local TLS server that
+ * answers the Kagi search path with canned JSON -- but only when the request
+ * carries the expected "Authorization: Bearer <token>" header, otherwise 401 -- and
+ * any other path with an HTML page.  Verifies web_search formatting and that the
+ * key is sent (a dropped header fails the search), that the missing-token path
+ * errors without a connection, web_fetch HTML->text, bad-URL rejection, and that
+ * http_request enforces the operator's host allow/block lists.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "fetch_run.h"
+#include "imsgproto.h"
+#include "json.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* A Kagi Search API v1 success envelope: results under data.search[]. */
+static const char KAGI_JSON[] =
+    "{\"meta\":{\"node\":\"test\"},\"data\":{"
+    "\"related_search\":[{\"url\":\"/search?q=x\",\"title\":\"related-thing\"}],"
+    "\"search\":["
+    "{\"url\":\"https://example.com/a\",\"title\":\"Example A\","
+    "\"snippet\":\"first snippet\"},"
+    "{\"url\":\"https://example.com/b\",\"title\":\"Example B\","
+    "\"snippet\":\"second snippet\"}],"
+    "\"video\":[{\"url\":\"https://v/1\",\"title\":\"a video\"}]}}";
+
+static const char HTML_PAGE[] =
+    "<html><head><title>T</title><style>.x{color:red}</style></head>"
+    "<body><h1>Heading</h1><p>Hello &amp; welcome to <b>fugu</b>.</p>"
+    "<script>alert('xss')</script></body></html>";
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+	const unsigned char	*p = buf;
+	size_t			 off = 0;
+	ssize_t			 n;
+
+	while (off < len) {
+		n = tls_write(t, p + off, len - off);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+			continue;
+		if (n < 0)
+			return (-1);
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+	struct tls_config	*cfg;
+	struct tls		*srv;
+
+	if ((cfg = tls_config_new()) == NULL)
+		_exit(21);
+	if (tls_config_set_cert_file(cfg, cert) == -1 ||
+	    tls_config_set_key_file(cfg, key) == -1)
+		_exit(22);
+	if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+		_exit(23);
+
+	for (;;) {
+		struct tls	*ctx;
+		struct buf	 resp;
+		char		 req[4096];
+		const char	*body, *ctype, *status = "200 OK";
+		size_t		 off = 0;
+		ssize_t		 n;
+		int		 cfd;
+
+		if ((cfd = accept(lfd, NULL, NULL)) == -1)
+			break;
+		if (tls_accept_socket(srv, &ctx, cfd) == -1) {
+			close(cfd);
+			continue;
+		}
+		do {
+			n = tls_handshake(ctx);
+		} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+		if (n == -1) {
+			tls_free(ctx);
+			close(cfd);
+			continue;
+		}
+		/* Read the headers, then the POST body (so we can see the query). */
+		while (off < sizeof(req) - 1) {
+			n = tls_read(ctx, req + off, sizeof(req) - 1 - off);
+			if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+				continue;
+			if (n <= 0)
+				break;
+			off += (size_t)n;
+			req[off] = '\0';
+			if (strstr(req, "\r\n\r\n") != NULL)
+				break;
+		}
+		{
+			char	*cl = strstr(req, "Content-Length:");
+			char	*end = strstr(req, "\r\n\r\n");
+			size_t	 need, have;
+
+			if (cl != NULL && end != NULL) {
+				need = (size_t)strtol(cl + 15, NULL, 10);
+				have = off - (size_t)(end + 4 - req);
+				while (have < need && off < sizeof(req) - 1) {
+					n = tls_read(ctx, req + off,
+					    sizeof(req) - 1 - off);
+					if (n == TLS_WANT_POLLIN ||
+					    n == TLS_WANT_POLLOUT)
+						continue;
+					if (n <= 0)
+						break;
+					off += (size_t)n;
+					have += (size_t)n;
+				}
+			}
+		}
+		req[off] = '\0';
+
+		/*
+		 * The /echo endpoint reports back the received body length and a
+		 * hex dump of its first bytes, so the test can prove a body with
+		 * an embedded NUL byte (e.g. "ab\0cd") survived end-to-end
+		 * rather than being truncated at the first NUL by a strlen-based
+		 * writer.  Used only by the embedded-NUL regress test.
+		 */
+		if (strstr(req, "POST /echo") != NULL) {
+			char		*end = strstr(req, "\r\n\r\n");
+			size_t		 blen, i;
+			static char	 echobody[256];
+
+			blen = (end != NULL) ?
+			    off - (size_t)(end + 4 - req) : 0;
+			snprintf(echobody, sizeof(echobody),
+			    "body_len=%zu hex=", blen);
+			for (i = 0; i < blen && i < 16; i++) {
+				char	hex[4];
+				snprintf(hex, sizeof(hex), "%02x ",
+				    (unsigned char)(end + 4)[i]);
+				strlcat(echobody, hex, sizeof(echobody));
+			}
+			body = echobody;
+			ctype = "text/plain";
+		} else if (strstr(req, "POST /api/v1/search") != NULL) {
+			ctype = "application/json";
+			/*
+			 * Authenticate like the real Kagi API: require the
+			 * "Authorization: Bearer <token>" header carrying the token
+			 * the parent delivered.  Without it the search fails, so
+			 * the success path asserts the key is sent correctly.
+			 */
+			if (strstr(req, "Bearer test-token") == NULL) {
+				status = "401 Unauthorized";
+				body = "{\"errors\":[{\"code\":\"unauthorized\","
+				    "\"message\":\"missing or bad token\"}]}";
+			} else if (strstr(req, "boom") != NULL) {
+				/* A non-200 with a v1 error envelope. */
+				status = "402 Payment Required";
+				body = "{\"errors\":[{\"code\":\"out_of_funds\","
+				    "\"message\":\"Out of credits\"}]}";
+			} else {
+				body = KAGI_JSON;
+			}
+		} else {
+			body = HTML_PAGE;
+			ctype = "text/html; charset=utf-8";
+		}
+		buf_init(&resp);
+		buf_printf(&resp, "HTTP/1.1 %s\r\nContent-Type: %s\r\n"
+		    "Content-Length: %zu\r\nConnection: close\r\n\r\n",
+		    status, ctype, strlen(body));
+		buf_puts_cstr(&resp, body);
+		(void)tls_write_all(ctx, resp.data, resp.len);
+		buf_free(&resp);
+		tls_close(ctx);
+		tls_free(ctx);
+		close(cfd);
+	}
+	tls_free(srv);
+	tls_config_free(cfg);
+	_exit(0);
+}
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+	int	r;
+
+	for (;;) {
+		if ((r = ip_get(ip, m)) == 1)
+			return (1);
+		if (r == -1)
+			return (-1);
+		if (ip_read(ip) <= 0)
+			return (0);
+	}
+}
+
+static void
+send_msg(struct imsgproto *ip, uint32_t type, const char *json)
+{
+	if (ip_compose(ip, type, EP_FETCH, EP_NET, json,
+	    json != NULL ? strlen(json) : 0) == -1 || ip_flush(ip) == -1)
+		errx(1, "send_msg");
+}
+
+static void
+send_req(struct imsgproto *ip, const char *id, const char *name,
+    const char *input_json)
+{
+	struct json_writer	w;
+	struct buf		b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id);
+	json_kv_string(&w, "name", name);
+	json_key(&w, "input");
+	json_raw(&w, input_json);
+	json_object_end(&w);
+	if (ip_compose(ip, M_TOOL_REQUEST, EP_FETCH, EP_NET, b.data,
+	    b.len) == -1 || ip_flush(ip) == -1)
+		errx(1, "send_req");
+	buf_free(&b);
+}
+
+static int
+recv_result(struct imsgproto *ip, char *content, size_t cap, int *is_error)
+{
+	struct fugu_msg	m;
+	struct json_doc	d;
+	char		*c;
+	int		t;
+
+	if (recv_msg(ip, &m) != 1 || m.type != M_TOOL_RESULT)
+		return (-1);
+	if (json_parse(&d, m.data, m.len) == -1) {
+		ip_msg_free(&m);
+		return (-1);
+	}
+	content[0] = '\0';
+	if ((t = json_obj_get(&d, 0, "content")) >= 0 &&
+	    (c = json_tok_strdup(&d, t)) != NULL) {
+		strlcpy(content, c, cap);
+		free(c);
+	}
+	t = json_obj_get(&d, 0, "is_error");
+	*is_error = (t >= 0 && json_tok_streq(&d, t, "true"));
+	json_doc_free(&d);
+	ip_msg_free(&m);
+	return (0);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "fetch_test: TIMED OUT\n");
+	_exit(97);
+}
+
+int
+main(int argc, char *argv[])
+{
+	struct imsgproto	ip;
+	struct sockaddr_in	sa;
+	struct fugu_msg		m;
+	socklen_t		slen;
+	char			content[8192], cfg[1024], url[256];
+	pid_t			sp, fp;
+	int			lfd, one = 1, port, sv[2], status, is_err;
+
+	if (argc != 3)
+		errx(1, "usage: %s cert.pem key.pem", argv[0]);
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(90);
+
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 4) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	port = ntohs(sa.sin_port);
+
+	if ((sp = fork()) == -1)
+		err(1, "fork stub");
+	if (sp == 0) {
+		stub_server(lfd, argv[1], argv[2]);
+		_exit(0);
+	}
+	close(lfd);
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+	if ((fp = fork()) == -1)
+		err(1, "fork fetch");
+	if (fp == 0) {
+		struct imsgproto	fip;
+
+		close(sv[0]);
+		if (ip_init(&fip, sv[1]) == -1)
+			_exit(41);
+		fetch_loop(&fip);
+		_exit(0);
+	}
+	close(sv[1]);
+	if (ip_init(&ip, sv[0]) == -1)
+		err(1, "ip_init");
+
+	/* fetch announces itself, then we point it at the stub. */
+	CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "expect M_READY");
+	ip_msg_free(&m);
+
+	/* allow_private: the stub is on loopback, which the SSRF guard blocks. */
+	snprintf(cfg, sizeof(cfg), "{\"kagi_host\":\"localhost\","
+	    "\"kagi_port\":\"%d\",\"cafile\":\"%s\",\"allow_private\":true}",
+	    port, argv[1]);
+	send_msg(&ip, M_CONFIG, cfg);
+
+	/* web_search before the token arrives: error, and no connection made. */
+	send_req(&ip, "s0", "web_search", "{\"query\":\"anything\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "token") != NULL,
+	    "web_search w/o token errors: '%s'", content);
+
+	send_msg(&ip, M_SECRET, "test-token");
+
+	/*
+	 * web_search with the token: the stub returns results only when the
+	 * request carried "Authorization: Bearer test-token", so a successful,
+	 * formatted result here also proves the Kagi key is sent correctly.
+	 */
+	send_req(&ip, "s1", "web_search", "{\"query\":\"fugu shell\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err, "web_search ok / Bearer auth header accepted (%s)", content);
+	CHECK(strstr(content, "Example A") != NULL &&
+	    strstr(content, "https://example.com/a") != NULL &&
+	    strstr(content, "first snippet") != NULL,
+	    "web_search result A present: '%s'", content);
+	CHECK(strstr(content, "Example B") != NULL,
+	    "web_search result B present");
+	CHECK(strstr(content, "related-thing") == NULL,
+	    "web_search drops related (t=1) entries");
+
+	/* A non-200 Kagi reply surfaces its error message, not just the code. */
+	send_req(&ip, "s2", "web_search", "{\"query\":\"boom\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "402") != NULL &&
+	    strstr(content, "Out of credits") != NULL,
+	    "web_search surfaces Kagi's error message: '%s'", content);
+
+	/* web_fetch: HTML reduced to text, scripts/styles dropped. */
+	snprintf(url, sizeof(url), "https://localhost:%d/page", port);
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"%s\"}", url);
+		buf_putc(&in, '\0');
+		send_req(&ip, "f1", "web_fetch", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err, "web_fetch ok (%s)", content);
+	CHECK(strstr(content, "Heading") != NULL &&
+	    strstr(content, "Hello & welcome to fugu") != NULL,
+	    "web_fetch text content: '%s'", content);
+	CHECK(strstr(content, "alert") == NULL &&
+	    strstr(content, "color") == NULL,
+	    "web_fetch drops script/style: '%s'", content);
+
+	/* web_fetch with an unsupported scheme: rejected before any connect. */
+	send_req(&ip, "f2", "web_fetch", "{\"url\":\"ftp://localhost/x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "web_fetch bad URL is error");
+
+	/* http_request: enforce the operator's host allow/block lists. */
+	send_msg(&ip, M_CONFIG,
+	    "{\"http_allow\":\"localhost\",\"http_block\":\"localhost/secret\"}");
+
+	/* Allowed host: reaches the stub; raw body returned with a status line. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/page\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h1", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP 200") != NULL,
+	    "http_request allowed host ok: '%s'", content);
+	CHECK(strstr(content, "Heading") != NULL && strstr(content, "alert") != NULL,
+	    "http_request returns the RAW body (no html2text): '%s'", content);
+
+	/* Host not in the allowlist: refused by the gate, before any connection. */
+	send_req(&ip, "h2", "http_request", "{\"url\":\"https://evil.example/x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "not allowed") != NULL,
+	    "http_request denies a non-allowlisted host: '%s'", content);
+
+	/* Blocked path on an allowed host: the blocklist wins. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/secret/data\"}",
+		    port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h3", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "not allowed") != NULL,
+	    "http_request blocklist denies a blocked path: '%s'", content);
+
+	/* Non-https URL: rejected. */
+	send_req(&ip, "h4", "http_request", "{\"url\":\"http://localhost/x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "http_request rejects a non-https URL");
+
+	/*
+	 * Embedded-NUL body: the JSON body  decodes to a real NUL byte.
+	 * Before the fix, do_http_request derived Content-Length from
+	 * strlen(reqbody) and truncated the body at the first NUL (so the
+	 * stub would see body_len=2, hex=68 69).  After the fix it must
+	 * survive: body_len=5, hex=68 69 00 21 21.  The stub's /echo path
+	 * responds with a plain-text trace of what it actually received.
+	 */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/echo\","
+		    "\"method\":\"POST\","
+		    "\"body\":\"hi\\u0000!!\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "n0", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err, "http_request with NUL-bearing body succeeds: '%s'",
+	    content);
+	CHECK(strstr(content, "body_len=5") != NULL,
+	    "http_request body NOT truncated at NUL: '%s'", content);
+	CHECK(strstr(content, "68 69 00 21 21") != NULL,
+	    "http_request body bytes intact incl. NUL: '%s'", content);
+
+	/* URL with an embedded NUL: rejected before any connect. */
+	send_req(&ip, "n1", "http_request",
+	    "{\"url\":\"https://localhost\\u0000evil/x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "NUL") != NULL,
+	    "http_request rejects URL with embedded NUL: '%s'", content);
+
+	/* Header value with an embedded NUL: rejected, no request sent. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/echo\","
+		    "\"method\":\"POST\","
+		    "\"headers\":{\"x-trace\":\"a\\u0000b\"},"
+		    "\"body\":\"x\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "n2", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "NUL") != NULL,
+	    "http_request rejects header value with NUL: '%s'", content);
+
+	/* web_search query with an embedded NUL: rejected, no Kagi call. */
+	send_req(&ip, "n3", "web_search",
+	    "{\"query\":\"foo\\u0000bar\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "NUL") != NULL,
+	    "web_search rejects query with NUL: '%s'", content);
+
+	/*
+	 * Path-prefix ACL boundary: an allowlist of "localhost/v1" must not let
+	 * a sibling path like "/v1admin/secrets" slip through on a raw prefix
+	 * match -- the prefix has to end at a path-segment boundary ('/' or end
+	 * of path).  Legitimate paths ("/v1", "/v1/", "/v1/whatever") are still
+	 * allowed.
+	 */
+	send_msg(&ip, M_CONFIG,
+	    "{\"http_allow\":\"localhost/v1\",\"http_block\":\"\"}");
+
+	/* BAD path: must be REJECTED (regression for the boundary bypass). */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in,
+		    "{\"url\":\"https://localhost:%d/v1admin/secrets\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h5", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "not allowed") != NULL,
+	    "http_request path-prefix boundary: /v1admin/secrets is rejected "
+	    "by an allowlist of /v1: '%s'", content);
+
+	/* GOOD: bare "/v1" still allowed. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/v1\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h6", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request path-prefix allows /v1 itself: '%s'", content);
+
+	/* GOOD: "/v1/" (trailing slash) still allowed. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/v1/\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h7", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request path-prefix allows /v1/ itself: '%s'", content);
+
+	/* GOOD: "/v1/whatever" (deeper path under the prefix) still allowed. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in,
+		    "{\"url\":\"https://localhost:%d/v1/whatever\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h8", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request path-prefix allows /v1/whatever: '%s'", content);
+
+	/*
+	 * GOOD: "/v1?token=abc" -- a query string sits at the prefix boundary,
+	 * just like '/'.  Before the boundary set was extended, '?' was treated
+	 * as a sibling-path byte and this URL was wrongly denied.
+	 */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in,
+		    "{\"url\":\"https://localhost:%d/v1?token=abc\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h9", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request path-prefix allows /v1?token=abc: '%s'", content);
+
+	/*
+	 * GOOD: "/v1#frag" -- a fragment also marks the prefix boundary.
+	 * In practice url_parse() already drops '#...' before the ACL sees
+	 * the path, so this case asserts the end-to-end behaviour rather than
+	 * exercising the boundary byte directly.
+	 */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in,
+		    "{\"url\":\"https://localhost:%d/v1#frag\"}", port);
+		buf_putc(&in, '\0');
+		send_req(&ip, "h10", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request path-prefix allows /v1#frag: '%s'", content);
+
+	/*
+	 * Host-match ACL boundary: with an EXACT pattern "localhost", a host
+	 * like "evillocalhost" must not match by string-contains; the rule is
+	 * full-equality on non-dot patterns, suffix match only when the
+	 * pattern starts with '.'.  These check both halves of the rule -- a
+	 * regression would let evilhosts.example.com slip past an allowlist of
+	 * "example.com".  Detected as the ACL "not allowed" rejection (which
+	 * fires before dial), so the lookup of the bad host never happens.
+	 */
+	send_msg(&ip, M_CONFIG,
+	    "{\"http_allow\":\"localhost\",\"http_block\":\"\"}");
+
+	/* BAD host: "evillocalhost" must NOT match the exact pattern "localhost". */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in,
+		    "{\"url\":\"https://evillocalhost:%d/v1\"}", port);
+		buf_terminate(&in);
+		send_req(&ip, "h11", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "not allowed") != NULL,
+	    "http_request host-exact rejects evillocalhost vs localhost: '%s'",
+	    content);
+
+	/* GOOD host: "localhost" matches itself exactly. */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/v1\"}", port);
+		buf_terminate(&in);
+		send_req(&ip, "h12", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request host-exact allows localhost: '%s'", content);
+
+	/*
+	 * Dot-suffix rule: ".localhost" matches the bare "localhost" AND any
+	 * "*.localhost", but NOT "evillocalhost" -- the leading '.' anchors
+	 * the suffix at a label boundary, so a pattern like ".example.com"
+	 * cannot be slipped by "evilexample.com".  We exercise both the
+	 * bare-domain branch (host equals pattern minus the dot) and the
+	 * boundary rejection.  The other branch (real "*.localhost") would
+	 * need a multi-SAN cert; the boundary case below is what catches the
+	 * historical bypass shape.
+	 */
+	send_msg(&ip, M_CONFIG,
+	    "{\"http_allow\":\".localhost\",\"http_block\":\"\"}");
+
+	/* GOOD host: bare "localhost" matches pattern ".localhost". */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in, "{\"url\":\"https://localhost:%d/v1\"}", port);
+		buf_terminate(&in);
+		send_req(&ip, "h13", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "HTTP") != NULL,
+	    "http_request dot-suffix allows bare localhost vs .localhost: '%s'",
+	    content);
+
+	/* BAD host: "evillocalhost" must NOT match dot-suffix ".localhost". */
+	{
+		struct buf	in;
+
+		buf_init(&in);
+		buf_printf(&in,
+		    "{\"url\":\"https://evillocalhost:%d/v1\"}", port);
+		buf_terminate(&in);
+		send_req(&ip, "h14", "http_request", in.data);
+		buf_free(&in);
+	}
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err && strstr(content, "not allowed") != NULL,
+	    "http_request dot-suffix rejects evillocalhost vs .localhost "
+	    "(label-boundary anchor): '%s'", content);
+
+	send_msg(&ip, M_SHUTDOWN, NULL);
+	if (waitpid(fp, &status, 0) == -1)
+		err(1, "waitpid fetch");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "fetch worker exit %d", status);
+
+	kill(sp, SIGTERM);
+	waitpid(sp, &status, 0);
+
+	printf("fetch_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 8dd94e5c75ff95ca3a535ca4cf4d8b4422654569 (mode 644)
--- /dev/null
+++ regress/fuzzy/Makefile
@@ -0,0 +1,16 @@
+# Unit tests for the fuzzy matcher (src/common/fuzzy.c).
+
+PROG=		fuzzy_test
+SRCS=		fuzzy_test.c fuzzy.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+REGRESS_TARGETS=	run-fuzzy_test
+
+run-fuzzy_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6af9917ae56270df7a40241160b02ff68e1a8df7 (mode 644)
--- /dev/null
+++ regress/fuzzy/fuzzy_test.c
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+
+#include "fuzzy.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static void
+test_basic(void)
+{
+	int	s;
+
+	CHECK(fuzzy_match("", "anything", &s) == 1 && s == 0,
+	    "empty needle matches everything, score 0");
+	CHECK(fuzzy_match("son", "claude-sonnet-4-6", &s) == 1,
+	    "subsequence matches");
+	CHECK(fuzzy_match("gpt4", "gpt-4o-mini", &s) == 1,
+	    "scattered subsequence matches (g p t 4)");
+	CHECK(fuzzy_match("xyz", "claude-sonnet", &s) == 0 && s == 0,
+	    "no subsequence -> no match, score 0");
+	CHECK(fuzzy_match("sonnetx", "claude-sonnet", &s) == 0,
+	    "needle longer than available tail -> no match");
+	CHECK(fuzzy_match("SON", "claude-sonnet", &s) == 1,
+	    "case-insensitive (upper needle)");
+	CHECK(fuzzy_match("son", "CLAUDE-SONNET", &s) == 1,
+	    "case-insensitive (upper haystack)");
+}
+
+static void
+test_scoring(void)
+{
+	int	a, b;
+
+	/* A boundary-aligned hit outscores a mid-word one. */
+	CHECK(fuzzy_match("son", "claude-sonnet", &a) == 1 &&
+	    fuzzy_match("son", "personals", &b) == 1 && a > b,
+	    "boundary-aligned 'son' (a=%d) beats mid-word (b=%d)", a, b);
+
+	/* A consecutive run outscores the same chars scattered mid-word. */
+	CHECK(fuzzy_match("gpt", "gpt-4o", &a) == 1 &&
+	    fuzzy_match("gpt", "gxpxtx", &b) == 1 && a > b,
+	    "consecutive 'gpt' (a=%d) beats scattered mid-word (b=%d)", a, b);
+
+	/* Matching each segment start (acronym-like) also scores well. */
+	CHECK(fuzzy_match("gpt", "g-p-t-x", &a) == 1 && a > 12,
+	    "boundary-aligned acronym 'gpt' scores high (a=%d)", a);
+
+	/* A full prefix is a strong score (boundary + run). */
+	CHECK(fuzzy_match("claude", "claude-sonnet-4-6", &a) == 1 && a > 20,
+	    "prefix 'claude' scores high (a=%d)", a);
+
+	/* NULL score pointer is tolerated. */
+	CHECK(fuzzy_match("son", "claude-sonnet", NULL) == 1,
+	    "NULL score pointer ok");
+}
+
+int
+main(void)
+{
+	test_basic();
+	test_scoring();
+	printf("fuzzy_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 740d8b5359f0a873e9531869ba1f8096ee7fa7fc (mode 644)
--- /dev/null
+++ regress/html2text/Makefile
@@ -0,0 +1,19 @@
+# Unit test for the HTML-to-text reducer (src/common/html2text.c).
+
+PROG=		html2text_test
+SRCS=		html2text_test.c html2text.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-html2text_test
+
+run-html2text_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6a0f6a9bcc0791688503c5e0ffa08803d42b4c84 (mode 644)
--- /dev/null
+++ regress/html2text/html2text_test.c
@@ -0,0 +1,299 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "html2text.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* Run html2text and compare the (NUL-terminated) result exactly. */
+static void
+eq(const char *html, const char *want)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	html2text(html, strlen(html), &b);
+	buf_putc(&b, '\0');
+	CHECK(strcmp(b.data, want) == 0, "in=<%s> got=<%s> want=<%s>",
+	    html, b.data, want);
+	buf_free(&b);
+}
+
+/* Run html2text and check a substring is present / absent. */
+static void
+has(const char *html, const char *needle, int present)
+{
+	struct buf	b;
+	int		found;
+
+	buf_init(&b);
+	html2text(html, strlen(html), &b);
+	buf_putc(&b, '\0');
+	found = (strstr(b.data, needle) != NULL);
+	CHECK(found == present, "in=<%s> expected %s'%s' in <%s>", html,
+	    present ? "" : "no ", needle, b.data);
+	buf_free(&b);
+}
+
+/*
+ * Validate that bytes[0..n) is a well-formed UTF-8 sequence (no truncated
+ * multi-byte codepoints, no stray continuation bytes, no overlongs).
+ */
+static int
+is_well_formed_utf8(const unsigned char *bytes, size_t n)
+{
+	size_t	i = 0;
+
+	while (i < n) {
+		unsigned char	c = bytes[i];
+		size_t		need, k;
+		unsigned long	cp;
+
+		if (c < 0x80) {
+			i++;
+			continue;
+		}
+		if ((c & 0xe0) == 0xc0) {
+			need = 2;
+			cp = c & 0x1f;
+		} else if ((c & 0xf0) == 0xe0) {
+			need = 3;
+			cp = c & 0x0f;
+		} else if ((c & 0xf8) == 0xf0) {
+			need = 4;
+			cp = c & 0x07;
+		} else
+			return (0);	/* stray continuation or invalid lead */
+		if (i + need > n)
+			return (0);	/* truncated sequence */
+		for (k = 1; k < need; k++) {
+			if ((bytes[i + k] & 0xc0) != 0x80)
+				return (0);
+			cp = (cp << 6) | (bytes[i + k] & 0x3f);
+		}
+		/* reject overlongs and surrogates / out-of-range */
+		if (need == 2 && cp < 0x80)
+			return (0);
+		if (need == 3 && cp < 0x800)
+			return (0);
+		if (need == 4 && cp < 0x10000)
+			return (0);
+		if (cp >= 0xd800 && cp <= 0xdfff)
+			return (0);
+		if (cp > 0x10ffff)
+			return (0);
+		i += need;
+	}
+	return (1);
+}
+
+/*
+ * Regress for the low-UTF-8 boundary bug: drive html2text right up to
+ * HTML2TEXT_MAXOUT and then emit a 3-byte UTF-8 codepoint.  Before the fix the
+ * cap was checked one byte at a time, so the lead byte slipped through and the
+ * continuation bytes were dropped, leaving a malformed sequence at the tail.
+ * After the fix the whole codepoint is dropped and the output stays
+ * well-formed UTF-8 of length <= HTML2TEXT_MAXOUT.
+ */
+static void
+test_utf8_boundary(void)
+{
+	const char	*emdash = "&#8212;";	/* U+2014, 3 bytes UTF-8 */
+	size_t		 emlen = strlen(emdash);
+	size_t		 fill, total, off;
+	char		*html;
+	struct buf	 b;
+
+	/*
+	 * Fill so the first emitted byte of the em dash lands at offset
+	 * HTML2TEXT_MAXOUT - 1.  With the old code byte 1 fits, bytes 2-3 are
+	 * dropped, and the buffer ends in a malformed UTF-8 lead byte.
+	 */
+	fill = HTML2TEXT_MAXOUT - 1;
+	total = fill + emlen + 1;
+	html = malloc(total);
+	CHECK(html != NULL, "malloc(%zu) failed", total);
+	if (html == NULL)
+		return;
+	memset(html, 'a', fill);
+	memcpy(html + fill, emdash, emlen);
+	html[total - 1] = '\0';
+
+	buf_init(&b);
+	html2text(html, fill + emlen, &b);
+	CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap %u", b.len,
+	    HTML2TEXT_MAXOUT);
+	CHECK(is_well_formed_utf8((const unsigned char *)b.data, b.len),
+	    "output not well-formed UTF-8 (len=%zu, last byte=0x%02x)",
+	    b.len, b.len > 0 ?
+	    (unsigned char)b.data[b.len - 1] : 0);
+	/*
+	 * Also try a 2-byte (U+00A9 copyright) and a 4-byte (U+1F600) codepoint
+	 * at the same boundary to exercise every encoding width.
+	 */
+	buf_free(&b);
+	buf_init(&b);
+	memcpy(html + fill, "&#169;", 6);
+	html2text(html, fill + 6, &b);
+	CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap (2-byte)",
+	    b.len);
+	CHECK(is_well_formed_utf8((const unsigned char *)b.data, b.len),
+	    "2-byte boundary: output not well-formed UTF-8");
+
+	buf_free(&b);
+	buf_init(&b);
+	memcpy(html + fill, "&#128512;", 9);
+	html2text(html, fill + 9, &b);
+	CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap (4-byte)",
+	    b.len);
+	CHECK(is_well_formed_utf8((const unsigned char *)b.data, b.len),
+	    "4-byte boundary: output not well-formed UTF-8");
+
+	/*
+	 * And a sweep across a few offsets just before the cap so we hit the
+	 * case where the first byte fits but the rest do not, regardless of
+	 * exactly which offset the writes started from.
+	 */
+	for (off = 2; off <= 5; off++) {
+		fill = HTML2TEXT_MAXOUT - off;
+		memset(html, 'a', fill);
+		memcpy(html + fill, emdash, emlen);
+		buf_free(&b);
+		buf_init(&b);
+		html2text(html, fill + emlen, &b);
+		CHECK(b.len <= HTML2TEXT_MAXOUT,
+		    "sweep off=%zu: len %zu exceeds cap", off, b.len);
+		CHECK(is_well_formed_utf8((const unsigned char *)b.data,
+		    b.len),
+		    "sweep off=%zu: output not well-formed UTF-8", off);
+	}
+	buf_free(&b);
+	free(html);
+}
+
+/*
+ * Regress for the named-entity boundary bug: emit_named used to write its
+ * replacement one byte at a time, so a 3-byte expansion like "..." for &hellip;
+ * could be cut mid-sequence at HTML2TEXT_MAXOUT, leaving a stray "." or ".."
+ * that is logically half a replacement.  After the fix the whole replacement
+ * is dropped when it doesn't fit, so the output never ends in a fragment.
+ */
+static void
+test_named_boundary(void)
+{
+	const char	*hellip = "&hellip;";	/* expands to "..." (3 bytes) */
+	size_t		 hlen = strlen(hellip);
+	size_t		 fill, total;
+	char		*html;
+	struct buf	 b;
+
+	/*
+	 * Fill so the output is at the cap minus 2 bytes; the 3-byte "..."
+	 * replacement does not fit.  Before the fix the first two '.' bytes
+	 * slipped through and the third was dropped, leaving a stray ".." at
+	 * the tail.  After the fix the whole "..." is dropped.
+	 */
+	fill = HTML2TEXT_MAXOUT - 2;
+	total = fill + hlen + 1;
+	html = malloc(total);
+	CHECK(html != NULL, "malloc(%zu) failed", total);
+	if (html == NULL)
+		return;
+	memset(html, 'a', fill);
+	memcpy(html + fill, hellip, hlen);
+	html[total - 1] = '\0';
+
+	buf_init(&b);
+	html2text(html, fill + hlen, &b);
+	CHECK(b.len <= HTML2TEXT_MAXOUT, "len %zu exceeds cap %u", b.len,
+	    HTML2TEXT_MAXOUT);
+	/*
+	 * The replacement must be all-or-nothing: a trailing '.' would mean a
+	 * fragment of "..." leaked through.  The fill bytes are 'a', so any
+	 * trailing '.' can only have come from the truncated replacement.
+	 */
+	CHECK(b.len == 0 || b.data[b.len - 1] != '.',
+	    "output ends in stray '.' (len=%zu)", b.len);
+	buf_free(&b);
+	free(html);
+}
+
+int
+main(void)
+{
+	eq("<p>Hello <b>world</b></p>", "Hello world");
+	eq("a&amp;b", "a&b");
+	eq("x&#65;y", "xAy");
+	eq("x&#x41;y", "xAy");
+	eq("a&nbsp;b", "a b");
+	eq("  spaced   out  ", "spaced out");
+	eq("<div>a</div><div>b</div>", "a\n\nb");
+	eq("&foo;", "&foo;");			/* unknown entity kept */
+	eq("plain text", "plain text");
+	eq("<a href=\"x\">link</a>", "link");	/* attribute scanning */
+	eq("a<br>b", "a\nb");
+
+	/* script / style / comment content is dropped */
+	has("<script>alert(1)</script>hi", "alert", 0);
+	has("<script>alert(1)</script>hi", "hi", 1);
+	has("<style>.x{color:red}</style>Y", "color", 0);
+	has("<style>.x{color:red}</style>Y", "Y", 1);
+	has("<!-- secret -->visible", "secret", 0);
+	has("<!-- secret -->visible", "visible", 1);
+
+	/* a tag with '>' inside a quoted attribute is one tag */
+	has("<img alt=\"a>b\">text", "text", 1);
+	has("<img alt=\"a>b\">text", "a>b", 0);
+
+	/* entity that decodes to a multibyte codepoint stays intact */
+	has("&#8212;", "\xe2\x80\x94", 1);	/* em dash U+2014 */
+
+	/* a fake close tag (prefix without delimiter) must NOT end the skip */
+	has("<script>HIDDEN</scriptx>LEAK</script>VISIBLE", "LEAK", 0);
+	has("<script>HIDDEN</scriptx>LEAK</script>VISIBLE", "HIDDEN", 0);
+	has("<script>HIDDEN</scriptx>LEAK</script>VISIBLE", "VISIBLE", 1);
+	/* a real close with trailing space/attrs still ends the skip */
+	eq("<script>s</script >after", "after");
+	eq("<style>a</style\n>b", "b");
+
+	/* surrogate and out-of-range numeric refs are dropped (valid UTF-8 only) */
+	eq("a&#xD800;b", "ab");
+	eq("a&#xFFFFFFFF;b", "ab");
+
+	/* a multi-byte codepoint right at HTML2TEXT_MAXOUT must not be split */
+	test_utf8_boundary();
+
+	/* a named-entity expansion must not be cut mid-sequence at the cap */
+	test_named_boundary();
+
+	printf("html2text_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 6340205bc9c51f176e89e2c52968657b04470a55 (mode 644)
--- /dev/null
+++ regress/http/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the HTTP/1.1 request builder + response parser (common/http.c).
+
+PROG=		http_test
+SRCS=		http_test.c http.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-http_test
+
+run-http_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + a2fda5a98f5fdffeeaf3985b7ef56c46a57957d6 (mode 644)
--- /dev/null
+++ regress/http/http_test.c
@@ -0,0 +1,322 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static char	body[1 << 16];
+static size_t	bodylen;
+
+static void
+on_body(const void *d, size_t n, void *arg)
+{
+	(void)arg;
+	if (n == 0) {
+		failures++;		/* the contract forbids empty calls */
+		return;
+	}
+	if (bodylen + n > sizeof(body)) {
+		failures++;
+		return;
+	}
+	memcpy(body + bodylen, d, n);
+	bodylen += n;
+}
+
+static void
+reset_body(void)
+{
+	bodylen = 0;
+}
+
+/* Push a whole response in fixed-size pieces to stress incremental parsing. */
+static int
+push_chunks(struct http_resp *r, const char *s, size_t step)
+{
+	size_t	off, n, total = strlen(s);
+
+	for (off = 0; off < total; off += n) {
+		n = total - off;
+		if (n > step)
+			n = step;
+		if (http_resp_push(r, s + off, n) == -1)
+			return (-1);
+	}
+	return (0);
+}
+
+static void
+test_build_request(void)
+{
+	struct buf		out;
+	struct http_header	h[] = {
+		{ "x-api-key", "secret" },
+		{ "anthropic-version", "2023-06-01" },
+		{ "content-type", "application/json" },
+	};
+	const char		*jb = "{\"x\":1}";
+	const char		*expect =
+	    "POST /v1/messages HTTP/1.1\r\n"
+	    "Host: api.anthropic.com\r\n"
+	    "Connection: close\r\n"
+	    "x-api-key: secret\r\n"
+	    "anthropic-version: 2023-06-01\r\n"
+	    "content-type: application/json\r\n"
+	    "Content-Length: 7\r\n"
+	    "\r\n"
+	    "{\"x\":1}";
+
+	buf_init(&out);
+	http_build_request(&out, "POST", "api.anthropic.com", "/v1/messages",
+	    h, 3, jb, strlen(jb));
+	buf_terminate(&out);
+	CHECK(strcmp(out.data, expect) == 0, "build request:\n%s", out.data);
+	buf_free(&out);
+}
+
+static void
+test_content_length(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Content-Type: application/json\r\n"
+	    "Content-Length: 13\r\n"
+	    "\r\n"
+	    "hello, world!";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "len: push");
+	CHECK(r.status == 200, "len: status=%d", r.status);
+	CHECK(http_resp_done(&r), "len: not done");
+	CHECK(bodylen == 13 && memcmp(body, "hello, world!", 13) == 0,
+	    "len: body");
+	CHECK(r.ctype.len == 16 &&
+	    memcmp(r.ctype.data, "application/json", 16) == 0, "len: ctype");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_chunked(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Transfer-Encoding: chunked\r\n"
+	    "\r\n"
+	    "5\r\nhello\r\n"
+	    "7\r\n, world\r\n"
+	    "0\r\n\r\n";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "chunked: push");
+	CHECK(http_resp_done(&r), "chunked: not done");
+	CHECK(bodylen == 12 && memcmp(body, "hello, world", 12) == 0,
+	    "chunked: body");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_chunked_bytewise(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Transfer-Encoding: chunked\r\n"
+	    "\r\n"
+	    "5\r\nhello\r\n7\r\n, world\r\n0\r\n\r\n";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(push_chunks(&r, resp, 1) == 0, "bytewise: push");
+	CHECK(http_resp_done(&r), "bytewise: not done");
+	CHECK(bodylen == 12 && memcmp(body, "hello, world", 12) == 0,
+	    "bytewise: body");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_chunk_extension(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Transfer-Encoding: chunked\r\n"
+	    "\r\n"
+	    "5;foo=bar\r\nhello\r\n0\r\n\r\n";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "ext: push");
+	CHECK(http_resp_done(&r) && bodylen == 5 &&
+	    memcmp(body, "hello", 5) == 0, "ext: body");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_status_codes(void)
+{
+	struct http_resp	r;
+	const char		*r404 =
+	    "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
+	const char		*r500 =
+	    "HTTP/2 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, r404, strlen(r404)) == 0, "404: push");
+	CHECK(r.status == 404 && http_resp_done(&r), "404: status/done");
+	http_resp_clear(&r);
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, r500, strlen(r500)) == 0, "500: push");
+	CHECK(r.status == 500 && http_resp_done(&r), "500: status/done");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_eof_framed(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Content-Type: text/plain\r\n"
+	    "\r\n"
+	    "streamed bytes";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "eof: push");
+	CHECK(!http_resp_done(&r), "eof: done too early");
+	CHECK(http_resp_eof(&r) == 0, "eof: close");
+	CHECK(http_resp_done(&r), "eof: not done after close");
+	CHECK(bodylen == 14 && memcmp(body, "streamed bytes", 14) == 0,
+	    "eof: body");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_malformed_status(void)
+{
+	struct http_resp	r;
+	const char		*resp = "NOPE\r\n\r\n";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == -1, "malformed status");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_no_body_status(void)
+{
+	struct http_resp	r;
+	const char		*r204 =
+	    "HTTP/1.1 204 No Content\r\n"
+	    "Content-Type: text/plain\r\n"
+	    "\r\n"
+	    "SURPRISE";		/* a hostile server's smuggled bytes */
+	const char		*r304 =
+	    "HTTP/1.1 304 Not Modified\r\n\r\nXYZ";
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, r204, strlen(r204)) == 0, "204: push");
+	CHECK(r.status == 204 && http_resp_done(&r), "204: done immediately");
+	CHECK(bodylen == 0, "204: no body delivered (bodylen=%zu)", bodylen);
+	http_resp_clear(&r);
+	reset_body();
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, r304, strlen(r304)) == 0, "304: push");
+	CHECK(r.status == 304 && http_resp_done(&r) && bodylen == 0,
+	    "304: done, no body");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_bad_chunk_terminator(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Transfer-Encoding: chunked\r\n"
+	    "\r\n"
+	    "5\r\nhelloX\r\n0\r\n\r\n";	/* 'X' where the CRLF must be */
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == -1,
+	    "bad chunk terminator rejected");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+static void
+test_truncated_chunk(void)
+{
+	struct http_resp	r;
+	const char		*resp =
+	    "HTTP/1.1 200 OK\r\n"
+	    "Transfer-Encoding: chunked\r\n"
+	    "\r\n"
+	    "5\r\nhel";	/* claims 5 bytes, delivers 3, then closes */
+
+	http_resp_init(&r, on_body, NULL);
+	CHECK(http_resp_push(&r, resp, strlen(resp)) == 0, "trunc: push");
+	CHECK(http_resp_eof(&r) == -1, "trunc: eof should error");
+	http_resp_clear(&r);
+	reset_body();
+}
+
+int
+main(void)
+{
+	test_build_request();
+	test_content_length();
+	test_chunked();
+	test_chunked_bytewise();
+	test_chunk_extension();
+	test_status_codes();
+	test_eof_framed();
+	test_malformed_status();
+	test_no_body_status();
+	test_bad_chunk_terminator();
+	test_truncated_chunk();
+
+	printf("http_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 1b1cb374e3903245244047b5afb8252208d2dbed (mode 644)
--- /dev/null
+++ regress/imsg/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the imsg chunked message layer (src/common/imsgproto.c).
+
+PROG=		imsg_test
+SRCS=		imsg_test.c imsgproto.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-imsg_test
+
+run-imsg_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 2cf997ad5d164b10e68555279af35dff40fa3c6d (mode 644)
--- /dev/null
+++ regress/imsg/imsg_test.c
@@ -0,0 +1,443 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Round-trip the imsgproto chunked layer through a forked echo peer over a
+ * socketpair: small (single-chunk) and large (multi-chunk) messages, the exact
+ * chunk-size boundary, and an empty message, all verified byte-for-byte.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "imsgproto.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static void
+fill(uint8_t *b, size_t n, uint32_t seed)
+{
+	size_t	i;
+
+	for (i = 0; i < n; i++)
+		b[i] = (uint8_t)(seed + i * 31u + (i >> 8));
+}
+
+/* Block until one complete logical message arrives.  1 ok, 0 EOF, -1 err. */
+static int
+recv_one(struct imsgproto *ip, struct fugu_msg *m)
+{
+	int	r, n;
+
+	for (;;) {
+		if ((r = ip_get(ip, m)) == 1)
+			return (1);
+		if (r == -1)
+			return (-1);
+		if ((n = ip_read(ip)) == 0)
+			return (0);
+		if (n == -1)
+			return (-1);
+	}
+}
+
+/* Echo peer: bounce every message back with src/dst swapped, until SHUTDOWN. */
+static void
+child_main(int fd)
+{
+	struct imsgproto	ip;
+	struct fugu_msg		m;
+
+	if (ip_init(&ip, fd) == -1)
+		_exit(2);
+
+	for (;;) {
+		if (recv_one(&ip, &m) != 1)
+			break;
+		if (m.type == M_SHUTDOWN) {
+			ip_msg_free(&m);
+			break;
+		}
+		if (ip_compose(&ip, m.type, m.src, m.dst, m.data, m.len) == -1)
+			_exit(3);
+		ip_msg_free(&m);
+		if (ip_flush(&ip) == -1)
+			_exit(4);
+	}
+	ip_clear(&ip);
+	_exit(0);
+}
+
+static void
+roundtrip(struct imsgproto *ip, size_t n, uint32_t seed)
+{
+	struct fugu_msg	 m;
+	uint8_t		*out = NULL;
+	int		 r;
+
+	if (n > 0) {
+		out = malloc(n);
+		if (out == NULL)
+			err(1, "malloc");
+		fill(out, n, seed);
+	}
+
+	CHECK(ip_compose(ip, M_SUBMIT, EP_NET, EP_UI, out, n) == 0,
+	    "compose n=%zu", n);
+	CHECK(ip_flush(ip) == 0, "flush n=%zu", n);
+
+	r = recv_one(ip, &m);
+	CHECK(r == 1, "recv n=%zu returned %d", n, r);
+	if (r == 1) {
+		CHECK(m.type == M_SUBMIT, "n=%zu type=%u", n, m.type);
+		CHECK(m.src == EP_NET, "n=%zu src=%u (echo should come from net)",
+		    n, m.src);
+		CHECK(m.dst == EP_UI, "n=%zu dst=%u", n, m.dst);
+		CHECK(m.len == n, "n=%zu echoed len=%zu", n, m.len);
+		if (n == 0)
+			CHECK(m.data == NULL, "n=0 data should be NULL");
+		else {
+			CHECK(m.data != NULL, "n=%zu data NULL", n);
+			CHECK(m.data != NULL && memcmp(m.data, out, n) == 0,
+			    "n=%zu payload mismatch", n);
+		}
+		ip_msg_free(&m);
+	}
+
+	free(out);
+}
+
+/*
+ * Negative coverage: a compromised peer can put arbitrary bytes on the wire,
+ * so the receive path must reject every malformed frame with a protocol error
+ * (ip_get == -1) rather than crashing, leaking, or accepting bad data.  These
+ * cases inject hand-built frames directly with imsg_composev(), bypassing the
+ * well-formed framing that ip_compose() guarantees.
+ */
+
+/*
+ * These cases inject and drain on one thread, so a single ip_flush() must not
+ * block waiting for a reader that cannot run.  Enlarge the socket buffers so an
+ * entire (multi-chunk) message fits without back-pressure.
+ */
+static void
+setbuf_big(int fd)
+{
+	int	sz = 1 << 20;
+
+	(void)setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sz, sizeof(sz));
+	(void)setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &sz, sizeof(sz));
+}
+
+static void
+mkpair(struct imsgproto *s, struct imsgproto *r)
+{
+	int	sv[2];
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+	setbuf_big(sv[0]);
+	setbuf_big(sv[1]);
+	if (ip_init(s, sv[1]) == -1 || ip_init(r, sv[0]) == -1)
+		err(1, "ip_init");
+}
+
+static void
+delpair(struct imsgproto *s, struct imsgproto *r)
+{
+	int	sfd, rfd;
+
+	sfd = ip_fd(s);
+	rfd = ip_fd(r);
+	ip_clear(s);		/* must also release any in-progress reassembly */
+	ip_clear(r);
+	close(sfd);
+	close(rfd);
+}
+
+/* Inject one hand-built frame: optional chunk header + optional body bytes. */
+static void
+send_raw(struct imsgproto *s, uint32_t type, uint32_t id,
+    const struct fugu_chdr *chdr, const void *body, size_t bodylen)
+{
+	struct iovec	iov[2];
+	int		n = 0;
+
+	if (chdr != NULL) {
+		iov[n].iov_base = (void *)(uintptr_t)chdr;
+		iov[n].iov_len = sizeof(*chdr);
+		n++;
+	}
+	if (bodylen > 0) {
+		iov[n].iov_base = (void *)(uintptr_t)body;
+		iov[n].iov_len = bodylen;
+		n++;
+	}
+	if (imsg_composev(&s->ibuf, type, id, 0, -1, iov, n) == -1)
+		err(1, "imsg_composev");
+	if (imsgbuf_flush(&s->ibuf) == -1)
+		err(1, "imsgbuf_flush");
+}
+
+/* Drain already-buffered frames: returns ip_get status (1, 0 = EOF, -1). */
+static int
+recv_status(struct imsgproto *r)
+{
+	struct fugu_msg	m;
+	int		rc, n;
+
+	for (;;) {
+		if ((rc = ip_get(r, &m)) == 1) {
+			ip_msg_free(&m);
+			return (1);
+		}
+		if (rc == -1)
+			return (-1);
+		if ((n = ip_read(r)) == 0)
+			return (0);
+		if (n == -1)
+			return (-1);
+	}
+}
+
+/* One read pass, then drain: returns completed-message count, or -1 on error. */
+static int
+drain_once(struct imsgproto *r)
+{
+	struct fugu_msg	m;
+	int		rc, completed = 0;
+
+	if (ip_read(r) <= 0)
+		return (-1);
+	for (;;) {
+		rc = ip_get(r, &m);
+		if (rc == 1) {
+			ip_msg_free(&m);
+			completed++;
+			continue;
+		}
+		if (rc == -1)
+			return (-1);
+		break;
+	}
+	return (completed);
+}
+
+static void
+neg_cases(void)
+{
+	struct imsgproto	s, r;
+	struct fugu_chdr	c;
+	struct fugu_msg		m;
+	uint8_t			body[64];
+	uint8_t			big[40000];
+	size_t			k;
+	int			i, rc;
+
+	memset(body, 0xab, sizeof(body));
+
+	/* A. payload shorter than the chunk header */
+	mkpair(&s, &r);
+	send_raw(&s, M_SUBMIT, EP_UI, NULL, body, 4);
+	CHECK(recv_status(&r) == -1, "reject: short frame");
+	delpair(&s, &r);
+
+	/* B. advertised total exceeds the IP_MAXMSG cap */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 1; c.total = IP_MAXMSG + 1; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 4);
+	CHECK(recv_status(&r) == -1, "reject: oversize total");
+	delpair(&s, &r);
+
+	/* C. chunk runs past the advertised total */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 1; c.total = 10; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 20);
+	CHECK(recv_status(&r) == -1, "reject: chunk past end");
+	delpair(&s, &r);
+
+	/* D. first chunk does not start at offset 0 */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 1; c.total = 100; c.off = 5;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 10);
+	CHECK(recv_status(&r) == -1, "reject: missing start chunk");
+	delpair(&s, &r);
+
+	/* E. empty chunk inside a non-empty message */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 1; c.total = 100; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, NULL, 0);
+	CHECK(recv_status(&r) == -1, "reject: empty chunk in non-empty msg");
+	delpair(&s, &r);
+
+	/* F1. continuation advertising a different total */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+	c.total = 200; c.off = 50;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+	CHECK(recv_status(&r) == -1, "reject: continuation total mismatch");
+	delpair(&s, &r);
+
+	/* F2. continuation at an out-of-order offset (a gap) */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+	c.off = 70;		/* expected 50 */
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 30);
+	CHECK(recv_status(&r) == -1, "reject: continuation offset gap");
+	delpair(&s, &r);
+
+	/* F3. continuation changing the logical message type */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+	c.off = 50;
+	send_raw(&s, M_DELTA, EP_UI, &c, body, 50);
+	CHECK(recv_status(&r) == -1, "reject: continuation type mismatch");
+	delpair(&s, &r);
+
+	/* G. a flood of stalled partials must not wedge the table: the oldest is
+	 *    evicted and a fresh multi-chunk message still completes. */
+	mkpair(&s, &r);
+	for (i = 0; i < IP_MAXASM; i++) {	/* fill every slot, none completed */
+		memset(&c, 0, sizeof(c));
+		c.src = EP_UI; c.msgid = 100 + i; c.total = 100; c.off = 0;
+		send_raw(&s, M_SUBMIT, EP_UI, &c, body, 60);
+	}
+	CHECK(drain_once(&r) == 0,
+	    "eviction: stalled partials accepted without error");
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 300; c.total = 100; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 60);	/* evicts an old partial */
+	c.off = 60;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 40);	/* completes msgid 300 */
+	rc = recv_one(&r, &m);
+	CHECK(rc == 1, "eviction: fresh message completes despite full table");
+	if (rc == 1) {
+		CHECK(m.len == 100, "eviction: fresh message length %zu", m.len);
+		ip_msg_free(&m);
+	}
+	delpair(&s, &r);
+
+	/* H. channel stays usable after a protocol error frees its slot */
+	mkpair(&s, &r);
+	memset(&c, 0, sizeof(c));
+	c.src = EP_UI; c.msgid = 7; c.total = 100; c.off = 0;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 50);
+	c.off = 70;
+	send_raw(&s, M_SUBMIT, EP_UI, &c, body, 30);
+	CHECK(recv_status(&r) == -1, "recovery: protocol error observed");
+	for (k = 0; k < sizeof(big); k++)
+		big[k] = (uint8_t)(k * 7u + 1u);
+	CHECK(ip_compose(&s, M_SUBMIT, EP_NET, EP_UI, big, sizeof(big)) == 0,
+	    "recovery: compose well-formed msg");
+	CHECK(ip_flush(&s) == 0, "recovery: flush well-formed msg");
+	CHECK(recv_one(&r, &m) == 1, "recovery: well-formed msg received");
+	if (m.len == sizeof(big)) {
+		CHECK(memcmp(m.data, big, sizeof(big)) == 0,
+		    "recovery: payload intact after prior error");
+		ip_msg_free(&m);
+	} else {
+		CHECK(0, "recovery: len=%zu want %zu", m.len, sizeof(big));
+	}
+	delpair(&s, &r);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "imsg_test: TIMED OUT (deadlock?)\n");
+	_exit(97);
+}
+
+int
+main(void)
+{
+	struct imsgproto	ip;
+	size_t			sizes[] = {
+		0, 1, 2, 100, 4096,
+		(size_t)IP_MAXCHUNK - 1, (size_t)IP_MAXCHUNK,
+		(size_t)IP_MAXCHUNK + 1, 2 * (size_t)IP_MAXCHUNK,
+		50000, 200000, 1000003
+	};
+	size_t			i;
+	pid_t			pid;
+	int			sv[2], status;
+
+	signal(SIGALRM, on_alarm);
+	alarm(60);		/* a deadlock should fail the test, not hang CI */
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+	if ((pid = fork()) == -1)
+		err(1, "fork");
+	if (pid == 0) {
+		close(sv[0]);
+		child_main(sv[1]);
+		_exit(0);	/* not reached */
+	}
+	close(sv[1]);
+
+	if (ip_init(&ip, sv[0]) == -1)
+		err(1, "ip_init");
+
+	for (i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++)
+		roundtrip(&ip, sizes[i], (uint32_t)(i + 1));
+
+	CHECK(ip_compose(&ip, M_SHUTDOWN, EP_NET, EP_UI, NULL, 0) == 0,
+	    "shutdown compose");
+	CHECK(ip_flush(&ip) == 0, "shutdown flush");
+	ip_clear(&ip);
+
+	if (waitpid(pid, &status, 0) == -1)
+		err(1, "waitpid");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "child exited abnormally (status %d)", status);
+
+	neg_cases();
+
+	printf("imsg_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + f534fdd41586c8d8c11fc41992545f71391a5cce (mode 644)
--- /dev/null
+++ regress/json/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the JSON emitter + jsmn wrapper (src/common/json.c).
+
+PROG=		json_test
+SRCS=		json_test.c json.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-json_test
+
+run-json_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6ec4a6175a2664dfce9b49e00c77dd9aaf547401 (mode 644)
--- /dev/null
+++ regress/json/json_test.c
@@ -0,0 +1,304 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "json.h"
+
+static int	fails;
+static int	checks;
+
+#define CHECK(cond, msg) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		fprintf(stderr, "FAIL %s:%d: %s\n",			\
+		    __func__, __LINE__, (msg));				\
+		fails++;						\
+	}								\
+} while (0)
+
+static void
+check_str(const char *func, int line, const char *got, const char *want,
+    const char *msg)
+{
+	checks++;
+	if (strcmp(got, want) != 0) {
+		fprintf(stderr, "FAIL %s:%d: %s\n  got:  %s\n  want: %s\n",
+		    func, line, msg, got, want);
+		fails++;
+	}
+}
+
+#define CHECK_STR(got, want, msg)					\
+	check_str(__func__, __LINE__, (got), (want), (msg))
+
+/* Finish an emitted buffer as a C string. */
+static const char *
+done(struct buf *b)
+{
+	buf_putc(b, '\0');
+	return (b->data);
+}
+
+static void
+test_emit_simple(void)
+{
+	struct buf		b;
+	struct json_writer	w;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "model", "claude-sonnet-4-6");
+	json_kv_int(&w, "max_tokens", 8192);
+	json_kv_bool(&w, "stream", 1);
+	json_object_end(&w);
+	CHECK_STR(done(&b),
+	    "{\"model\":\"claude-sonnet-4-6\",\"max_tokens\":8192,"
+	    "\"stream\":true}", "emit simple object");
+	buf_free(&b);
+}
+
+static void
+test_emit_nested(void)
+{
+	struct buf		b;
+	struct json_writer	w;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_key(&w, "messages");
+	json_array_start(&w);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "user");
+	json_kv_string(&w, "content", "hi");
+	json_object_end(&w);
+	json_array_end(&w);
+	json_object_end(&w);
+	CHECK_STR(done(&b),
+	    "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}",
+	    "emit nested object/array");
+	buf_free(&b);
+}
+
+static void
+test_emit_empty(void)
+{
+	struct buf		b;
+	struct json_writer	w;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_key(&w, "a");
+	json_array_start(&w);
+	json_array_end(&w);
+	json_key(&w, "b");
+	json_object_start(&w);
+	json_object_end(&w);
+	json_object_end(&w);
+	CHECK_STR(done(&b), "{\"a\":[],\"b\":{}}",
+	    "emit empty containers + separators");
+	buf_free(&b);
+}
+
+static void
+test_escaping(void)
+{
+	struct buf		b;
+	struct json_writer	w;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	/* a " b \ c <LF> <TAB> <0x01> */
+	json_string(&w, "a\"b\\c\n\t\001");
+	CHECK_STR(done(&b), "\"a\\\"b\\\\c\\n\\t\\u0001\"",
+	    "string escaping");
+	buf_free(&b);
+}
+
+static void
+test_numbers(void)
+{
+	struct buf		b;
+	struct json_writer	w;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_array_start(&w);
+	json_int(&w, -5);
+	json_int(&w, 9223372036854775807LL);
+	json_null(&w);
+	json_array_end(&w);
+	CHECK_STR(done(&b), "[-5,9223372036854775807,null]",
+	    "integers and null in array");
+	buf_free(&b);
+}
+
+/* Emit {"s": input}, parse it back, and confirm the value round-trips. */
+static void
+roundtrip_one(const char *input)
+{
+	struct buf		b;
+	struct json_writer	w;
+	struct json_doc		d;
+	char			*got;
+	int			 v;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "s", input);
+	json_object_end(&w);
+	buf_putc(&b, '\0');
+
+	CHECK(json_parse(&d, b.data, strlen(b.data)) == 0, "roundtrip parse");
+	v = json_obj_get(&d, 0, "s");
+	CHECK(v > 0, "roundtrip find s");
+	got = json_tok_strdup(&d, v);
+	CHECK_STR(got, input, "roundtrip value");
+	free(got);
+	json_doc_free(&d);
+	buf_free(&b);
+}
+
+static void
+test_roundtrip(void)
+{
+	roundtrip_one("hello");
+	roundtrip_one("");
+	roundtrip_one("a\"b\\c\n\t\001");
+	roundtrip_one("caf\xc3\xa9 \xe2\x9c\x93");	/* UTF-8 passthrough */
+	roundtrip_one("line1\nline2\ttabbed \"quoted\" \\slash\\");
+}
+
+static void
+test_unicode_unescape(void)
+{
+	const char	*js = "{\"a\":\"\\u0041\\u00e9\",\"b\":\"\\ud83d\\ude00\"}";
+	struct json_doc	 d;
+	char		*a, *bb;
+
+	CHECK(json_parse(&d, js, strlen(js)) == 0, "unicode parse");
+	a = json_tok_strdup(&d, json_obj_get(&d, 0, "a"));
+	bb = json_tok_strdup(&d, json_obj_get(&d, 0, "b"));
+	CHECK_STR(a, "\x41\xc3\xa9", "\\u BMP decode (A + e-acute)");
+	CHECK_STR(bb, "\xf0\x9f\x98\x80", "\\u surrogate pair decode (emoji)");
+	free(a);
+	free(bb);
+	json_doc_free(&d);
+}
+
+/*
+ * json_tok_strdup_n must be binary-safe: a JSON backslash-u-0000 escape
+ * decodes to a real NUL byte inside the returned heap string.  The length-
+ * aware variant must report the true decoded byte count rather than stopping
+ * at the first embedded NUL (which is what strlen would do).
+ */
+static void
+test_strdup_n_embedded_nul(void)
+{
+	const char	*js = "{\"s\":\"a\\u0000b\"}";	/* decoded: a\0b */
+	struct json_doc	 d;
+	char		*got;
+	size_t		 n;
+	int		 v;
+
+	CHECK(json_parse(&d, js, strlen(js)) == 0, "embedded NUL parse");
+	v = json_obj_get(&d, 0, "s");
+	CHECK(v > 0, "embedded NUL find s");
+
+	got = json_tok_strdup_n(&d, v, &n);
+	CHECK(n == 3, "decoded length is true byte count (3), not strlen (1)");
+	CHECK(strlen(got) == 1, "strlen would silently truncate at first NUL");
+	CHECK(got[0] == 'a' && got[1] == '\0' && got[2] == 'b',
+	    "decoded bytes are a, NUL, b");
+	CHECK(got[3] == '\0', "trailing NUL terminator present after content");
+	free(got);
+
+	/* lenp == NULL contract: must not crash, must still return the data. */
+	got = json_tok_strdup_n(&d, v, NULL);
+	CHECK(got != NULL, "strdup_n with NULL lenp returns a buffer");
+	CHECK(got[0] == 'a' && got[1] == '\0' && got[2] == 'b',
+	    "NULL-lenp call still decodes content correctly");
+	free(got);
+
+	json_doc_free(&d);
+}
+
+static void
+test_lookup(void)
+{
+	const char	*js =
+	    "{\"type\":\"message\",\"content\":[{\"type\":\"text\","
+	    "\"text\":\"hi\"}],\"usage\":{\"input_tokens\":10}}";
+	struct json_doc	 d;
+	char		*type;
+	int		 content, usage, in;
+
+	CHECK(json_parse(&d, js, strlen(js)) == 0, "lookup parse");
+
+	type = json_tok_strdup(&d, json_obj_get(&d, 0, "type"));
+	CHECK_STR(type, "message", "top-level string member");
+	free(type);
+
+	content = json_obj_get(&d, 0, "content");
+	CHECK(content > 0 && json_tok_type(&d, content) == JSON_ARRAY,
+	    "content is array");
+	CHECK(json_tok_size(&d, content) == 1, "content has one element");
+
+	usage = json_obj_get(&d, 0, "usage");
+	CHECK(usage > 0 && json_tok_type(&d, usage) == JSON_OBJECT,
+	    "usage is object");
+	in = json_obj_get(&d, usage, "input_tokens");
+	CHECK(in > 0 && json_tok_type(&d, in) == JSON_PRIMITIVE,
+	    "nested primitive");
+	CHECK(json_tok_streq(&d, in, "10"), "nested primitive value");
+
+	CHECK(json_obj_get(&d, 0, "missing") == -1, "missing key -> -1");
+	json_doc_free(&d);
+}
+
+static void
+test_malformed(void)
+{
+	struct json_doc	d;
+
+	CHECK(json_parse(&d, "{", 1) == -1, "truncated object rejected");
+	CHECK(json_parse(&d, "[1,2", 4) == -1, "truncated array rejected");
+}
+
+int
+main(void)
+{
+	test_emit_simple();
+	test_emit_nested();
+	test_emit_empty();
+	test_escaping();
+	test_numbers();
+	test_roundtrip();
+	test_unicode_unescape();
+	test_strdup_n_embedded_nul();
+	test_lookup();
+	test_malformed();
+
+	printf("%d checks, %d failures\n", checks, fails);
+	return (fails != 0);
+}
blob - /dev/null
blob + 6529164e01ad315ef1200790da4242511de40b00 (mode 644)
--- /dev/null
+++ regress/md/Makefile
@@ -0,0 +1,19 @@
+# Unit test for the markdown-to-styled-text renderer (src/common/mdrender.c).
+
+PROG=		md_test
+SRCS=		md_test.c mdrender.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-md_test
+
+run-md_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 4e67fe290e99c14dcadb545140dce30a53efcf4c (mode 644)
--- /dev/null
+++ regress/md/md_test.c
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <wchar.h>
+
+#include "mdrender.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* Map a flag-spec character to the MD_* bitmask it stands for. */
+static int
+flagbits(char c)
+{
+	switch (c) {
+	case 'b':	return (MD_BOLD);
+	case 'i':	return (MD_ITALIC);
+	case 'c':	return (MD_CODE);
+	case 'd':	return (MD_CODEBLOCK);
+	case 'h':	return (MD_HEADING | MD_BOLD);
+	case 'B':	return (MD_BOLD | MD_ITALIC);	/* nested bold+italic */
+	default:	return (0);			/* '.' = unstyled */
+	}
+}
+
+/*
+ * Render `in` and compare both the output text and (when flagspec != NULL) the
+ * per-character style flags.  `flagspec` is an ASCII string the same length as
+ * `want`, one char per output char (see flagbits()).
+ */
+static void
+md(const char *label, const wchar_t *in, int ascii, const wchar_t *want,
+    const char *flagspec)
+{
+	wchar_t	*out;
+	int	*st;
+	size_t	 n, wn = wcslen(want), i;
+
+	n = md_render(in, wcslen(in), ascii, &out, &st);
+	CHECK(n == wn, "%s: length got=%zu want=%zu", label, n, wn);
+	if (n == wn) {
+		for (i = 0; i < n; i++) {
+			CHECK(out[i] == want[i],
+			    "%s: char[%zu] got=U+%04X want=U+%04X", label, i,
+			    (unsigned)out[i], (unsigned)want[i]);
+			if (flagspec != NULL)
+				CHECK(st[i] == flagbits(flagspec[i]),
+				    "%s: flag[%zu] got=0x%02x want=0x%02x",
+				    label, i, st[i],
+				    flagbits(flagspec[i]));
+		}
+	}
+	free(out);
+	free(st);
+}
+
+int
+main(void)
+{
+	/* plain text passes through unchanged */
+	md("plain", L"hello world", 0, L"hello world", "...........");
+	md("multiline", L"a\nb", 0, L"a\nb", "...");
+
+	/* inline emphasis: markers removed, styles applied */
+	md("bold", L"a **b** c", 0, L"a b c", "..b..");
+	md("italic-star", L"a *b* c", 0, L"a b c", "..i..");
+	md("italic-underscore", L"_x_", 0, L"x", "i");
+	md("bold-underscore", L"__x__", 0, L"x", "b");
+	md("inline-code", L"a `b` c", 0, L"a b c", "..c..");
+	md("nested", L"**a _b_ c**", 0, L"a b c", "bbBbb");
+
+	/* unmatched / escaped markers stay literal */
+	md("loose-star", L"a * b", 0, L"a * b", ".....");
+	md("escape", L"\\*x\\*", 0, L"*x*", "...");
+
+	/* headings: hashes stripped, text bold */
+	md("h1", L"# Title", 0, L"Title", "hhhhh");
+	md("h3", L"### Hi", 0, L"Hi", "hh");
+	md("not-heading", L"#tag", 0, L"#tag", "....");
+
+	/* bullets: marker -> glyph (Unicode vs ASCII) */
+	md("bullet-utf8", L"- item", 0, L"• item", "......");
+	md("bullet-ascii", L"- item", 1, L"- item", "......");
+	md("bullet-star", L"* item", 0, L"• item", "......");
+	md("bullet-not", L"*x*", 0, L"x", "i");	/* no space -> italic */
+	md("numbered", L"1. first", 0, L"1. first", "........");
+
+	/* fenced code: fence lines dropped, body dimmed, no inline parse */
+	md("fence", L"```\nco**de**\n```", 0, L"co**de**\n", "dddddddd.");
+
+	/* GFM tables: the |---| delimiter row is dropped, rows kept and styled */
+	md("table-basic", L"| A | B |\n|---|---|\n| 1 | 2 |", 0,
+	    L"| A | B |\n| 1 | 2 |", NULL);
+	md("table-align", L"| A | B |\n| :-- | --: |\n| 1 | 2 |", 0,
+	    L"| A | B |\n| 1 | 2 |", NULL);
+	md("table-styled-cell", L"| **a** |\n|---|", 0, L"| a |\n", "..b...");
+	md("escaped-pipe", L"a \\| b", 0, L"a | b", ".....");
+	/* not delimiter rows: a thematic break and ordinary piped text survive */
+	md("rule-not-table", L"---", 0, L"---", "...");
+	md("text-not-table", L"a | b", 0, L"a | b", ".....");
+
+	if (failures == 0)
+		printf("ok: %d checks passed\n", checks);
+	else
+		printf("FAILED: %d/%d checks\n", failures, checks);
+	return (failures == 0 ? 0 : 1);
+}
blob - /dev/null
blob + c9a6f39b385217dfbc3e880232c48bbfc3f33b47 (mode 644)
--- /dev/null
+++ regress/net/Makefile
@@ -0,0 +1,28 @@
+# Whole-worker integration test for the net worker (src/net/net_run.c).
+# Generates a throwaway self-signed cert at run time with openssl(1).
+
+PROG=		net_test
+SRCS=		net_test.c net_run.c net_api.c \
+		anthropic.c openai.c http.c sse.c json.c \
+		buf.c util.c log.c imsgproto.c tlsconn.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+NETDIR=		${.CURDIR}/../../src/net
+CFLAGS+=	-I${COMMONDIR} -I${NETDIR}
+.PATH:		${COMMONDIR} ${NETDIR}
+
+LDADD+=		-ltls -lutil
+DPADD+=		${LIBTLS} ${LIBUTIL}
+
+REGRESS_TARGETS=	run-net_test
+
+run-net_test: ${PROG}
+	@td=`mktemp -d /tmp/fugu-net.XXXXXX`; \
+	    openssl req -x509 -newkey rsa:2048 -nodes \
+	        -keyout $$td/key.pem -out $$td/cert.pem \
+	        -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+	    ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+	    rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 57a5ea561045f022b319f604b5a8c3003093ab0d (mode 644)
--- /dev/null
+++ regress/net/net_test.c
@@ -0,0 +1,817 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Whole-worker integration of net_loop over three processes:
+ *   - H (this process) plays parent + UI: drives the imsg channel and checks
+ *     the streamed-back messages;
+ *   - N is the net worker: ip_init + net_loop (pledges, connects over TLS);
+ *   - S is a libtls stub server on 127.0.0.1 serving a canned response.
+ *
+ * H tells N (via M_CONFIG) to talk to S with S's self-signed cert as the CA, so
+ * the worker exercises real certificate verification.  H then submits a turn
+ * and asserts the M_DELTA/M_USAGE/M_TURN_DONE it receives.  The scenario runs
+ * twice -- once for the Anthropic provider, once for OpenAI -- and for OpenAI
+ * the stub additionally asserts the on-wire request (path, Bearer auth, body).
+ *
+ * argv: <cert.pem> <key.pem>  (generated by the Makefile run target).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "net_api.h"
+#include "net_run.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* The Anthropic provider's streamed reply: "pong", then end_turn. */
+static const char *const ANTHROPIC_SSE =
+    "event: message_start\n"
+    "data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":5,\"output_tokens\":1}}}\n"
+    "\n"
+    "event: content_block_start\n"
+    "data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+    "\n"
+    "event: content_block_delta\n"
+    "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"text_delta\",\"text\":\"pong\"}}\n"
+    "\n"
+    "event: content_block_stop\n"
+    "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+    "\n"
+    "event: message_delta\n"
+    "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n"
+    "\n"
+    "event: message_stop\n"
+    "data: {\"type\":\"message_stop\"}\n"
+    "\n";
+
+/* The OpenAI provider's equivalent: bare data lines and a [DONE] sentinel. */
+static const char *const OPENAI_SSE =
+    "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\","
+    "\"content\":\"\"}}]}\n"
+    "\n"
+    "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"pong\"}}]}\n"
+    "\n"
+    "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":"
+    "\"stop\"}]}\n"
+    "\n"
+    "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,"
+    "\"completion_tokens\":4}}\n"
+    "\n"
+    "data: [DONE]\n"
+    "\n";
+
+/* The /v1/models reply (both providers use {"data":[{"id":...}]}). */
+static const char *const MODELS_JSON =
+    "{\"object\":\"list\",\"data\":[{\"id\":\"model-alpha\"},"
+    "{\"id\":\"model-beta\"},{\"id\":\"model-gamma\"}]}";
+
+/* ---- stub TLS server (process S) ---- */
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+	buf_printf(b, "%zx\r\n", n);
+	buf_append(b, data, n);
+	buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+	const unsigned char	*p = buf;
+	size_t			 off = 0;
+	ssize_t			 n;
+
+	while (off < len) {
+		n = tls_write(t, p + off, len - off);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+			continue;
+		if (n < 0)
+			return (-1);
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key, const char *sse_body,
+    int assert_openai, const char *want_path)
+{
+	struct tls_config	*cfg;
+	struct tls		*srv, *cctx;
+	struct buf		 resp;
+	char			 req[4096];
+	char			 reqline[256];
+	ssize_t			 n;
+	int			 cfd;
+	size_t			 sl, half;
+
+	if ((cfd = accept(lfd, NULL, NULL)) == -1)
+		_exit(20);
+	if ((cfg = tls_config_new()) == NULL)
+		_exit(21);
+	if (tls_config_set_cert_file(cfg, cert) == -1 ||
+	    tls_config_set_key_file(cfg, key) == -1)
+		_exit(22);
+	if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+		_exit(23);
+	if (tls_accept_socket(srv, &cctx, cfd) == -1)
+		_exit(24);
+	do {
+		n = tls_handshake(cctx);
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+	if (n == -1)
+		_exit(25);
+	do {
+		n = tls_read(cctx, req, sizeof(req) - 1);
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+	if (n < 0)
+		_exit(27);
+	req[n] = '\0';
+
+	/* A GET is the /model fetch: assert path+auth and serve a models list. */
+	if (strncmp(req, "GET ", 4) == 0) {
+		if (strstr(req, "GET /v1/models ") == NULL)
+			_exit(44);		/* wrong models path */
+		if (strstr(req, "x-api-key: test-key") == NULL)
+			_exit(45);		/* auth header missing */
+		buf_init(&resp);
+		buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+		buf_puts_cstr(&resp, "Content-Type: application/json\r\n");
+		buf_printf(&resp, "Content-Length: %zu\r\n\r\n",
+		    strlen(MODELS_JSON));
+		buf_puts_cstr(&resp, MODELS_JSON);
+		if (tls_write_all(cctx, resp.data, resp.len) == -1)
+			_exit(26);
+		buf_free(&resp);
+		tls_close(cctx);
+		_exit(0);
+	}
+
+	/* The first record carries the request line, headers, and body start. */
+	if (assert_openai) {
+		snprintf(reqline, sizeof(reqline), "POST %s ", want_path);
+		if (strstr(req, reqline) == NULL)
+			_exit(40);		/* wrong endpoint path */
+		if (strstr(req, "authorization: Bearer test-key") == NULL)
+			_exit(41);		/* wrong auth scheme/value */
+		if (strstr(req, "\"stream\":true") == NULL)
+			_exit(42);		/* body not an OpenAI request */
+		if (strstr(req, "\"role\":\"system\"") == NULL)
+			_exit(43);		/* built-in default system prompt missing */
+	}
+
+	buf_init(&resp);
+	buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+	buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+	buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+	sl = strlen(sse_body);
+	half = sl / 2;
+	add_chunk(&resp, sse_body, half);
+	add_chunk(&resp, sse_body + half, sl - half);
+	buf_puts_cstr(&resp, "0\r\n\r\n");
+	if (tls_write_all(cctx, resp.data, resp.len) == -1)
+		_exit(26);
+	buf_free(&resp);
+	tls_close(cctx);
+	_exit(0);
+}
+
+/* ---- harness imsg helpers ---- */
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+	int	r, n;
+
+	for (;;) {
+		if ((r = ip_get(ip, m)) == 1)
+			return (1);
+		if (r == -1)
+			return (-1);
+		if ((n = ip_read(ip)) <= 0)
+			return (0);
+	}
+}
+
+static void
+send_msg(struct imsgproto *ip, uint32_t type, const void *data, size_t len)
+{
+	if (ip_compose(ip, type, EP_NET, EP_PARENT, data, len) == -1 ||
+	    ip_flush(ip) == -1)
+		errx(1, "harness: send imsg");
+}
+
+/* M_SECRET to net: a uint32 provider index (host order) then the key bytes. */
+static void
+send_secret(struct imsgproto *ip, uint32_t idx, const char *key)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_append(&b, &idx, sizeof(idx));
+	buf_append(&b, key, strlen(key));
+	send_msg(ip, M_SECRET, b.data, b.len);
+	buf_free(&b);
+}
+
+/* A loopback TCP listener on an ephemeral port (written into portstr). */
+static int
+make_listener(char *portstr, size_t cap)
+{
+	struct sockaddr_in	sa;
+	socklen_t		slen;
+	int			lfd, one = 1;
+
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	sa.sin_port = 0;
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 1) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	snprintf(portstr, cap, "%d", ntohs(sa.sin_port));
+	return (lfd);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "net_test: TIMED OUT\n");
+	_exit(97);
+}
+
+/*
+ * Run one full turn against a freshly-forked stub server and net worker.
+ * provider is NULL for Anthropic or "openai"; the streamed text is always
+ * "pong" with usage output_tokens=4 and stop end_turn, regardless of provider.
+ */
+static void
+run_scenario(const char *cert, const char *key, const char *provider,
+    const char *cfg_path, const char *sse_body, int assert_openai,
+    const char *want_path, const char *label)
+{
+	struct imsgproto	ip;
+	struct fugu_msg		m;
+	struct buf		text, cfg;
+	struct sockaddr_in	sa;
+	socklen_t		slen;
+	pid_t			sp, np;
+	char			portstr[16];
+	char			stop[64];
+	int			lfd, sv[2], one = 1, status, port;
+	int			got_usage = 0, got_done = 0;
+
+	/* TCP listener for the stub server on an ephemeral 127.0.0.1 port */
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	sa.sin_port = 0;
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 1) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	port = ntohs(sa.sin_port);
+	snprintf(portstr, sizeof(portstr), "%d", port);
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+
+	/* S: stub server */
+	if ((sp = fork()) == -1)
+		err(1, "fork");
+	if (sp == 0) {
+		close(sv[0]);
+		close(sv[1]);
+		stub_server(lfd, cert, key, sse_body, assert_openai, want_path);
+		_exit(0);
+	}
+
+	/* N: net worker */
+	if ((np = fork()) == -1)
+		err(1, "fork");
+	if (np == 0) {
+		struct imsgproto	nip;
+
+		close(sv[0]);
+		close(lfd);
+		if (ip_init(&nip, sv[1]) == -1)
+			_exit(30);
+		net_loop(&nip);
+		_exit(0);
+	}
+
+	/* H: parent + UI */
+	close(sv[1]);
+	close(lfd);
+	if (ip_init(&ip, sv[0]) == -1)
+		err(1, "ip_init");
+
+	CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "%s: worker READY",
+	    label);
+	ip_msg_free(&m);
+
+	/* Config first (it allocates the provider slots), then the keyed secret. */
+	buf_init(&cfg);
+	buf_printf(&cfg, "{\"max_tokens\":64,\"cafile\":\"%s\",\"active\":0,"
+	    "\"providers\":[{\"name\":\"default\",", cert);
+	if (provider != NULL) {
+		buf_printf(&cfg,
+		    "\"type\":\"%s\",\"model\":\"gpt-4o-mini\","
+		    "\"host\":\"localhost\",\"port\":\"%s\"", provider, portstr);
+		if (cfg_path != NULL)
+			buf_printf(&cfg, ",\"path\":\"%s\"", cfg_path);
+	} else
+		buf_printf(&cfg,
+		    "\"type\":\"anthropic\",\"model\":\"claude-sonnet-4-6\","
+		    "\"host\":\"localhost\",\"port\":\"%s\"", portstr);
+	buf_puts_cstr(&cfg, "}]}");
+	send_msg(&ip, M_CONFIG, cfg.data, cfg.len);
+	buf_free(&cfg);
+
+	send_secret(&ip, 0, "test-key");
+
+	send_msg(&ip, M_SUBMIT,
+	    "[{\"role\":\"user\",\"content\":\"ping\"}]",
+	    strlen("[{\"role\":\"user\",\"content\":\"ping\"}]"));
+
+	buf_init(&text);
+	stop[0] = '\0';
+	for (;;) {
+		if (recv_msg(&ip, &m) != 1) {
+			CHECK(0, "%s: lost worker before TURN_DONE", label);
+			break;
+		}
+		if (m.type == M_DELTA)
+			buf_append(&text, m.data, m.len);
+		else if (m.type == M_USAGE) {
+			char	ub[256];
+			size_t	k = m.len < sizeof(ub) - 1 ? m.len : sizeof(ub) - 1;
+
+			memcpy(ub, m.data, k);
+			ub[k] = '\0';
+			if (strstr(ub, "\"output_tokens\":4") != NULL)
+				got_usage = 1;
+		} else if (m.type == M_TURN_DONE) {
+			size_t	k = m.len < sizeof(stop) - 1 ? m.len : sizeof(stop) - 1;
+
+			memcpy(stop, m.data, k);
+			stop[k] = '\0';
+			got_done = 1;
+			ip_msg_free(&m);
+			break;
+		} else if (m.type == M_ERROR) {
+			CHECK(0, "%s: worker error: %.*s", label, (int)m.len,
+			    (char *)m.data);
+			ip_msg_free(&m);
+			break;
+		}
+		ip_msg_free(&m);
+	}
+
+	buf_terminate(&text);
+	CHECK(strcmp(text.data, "pong") == 0, "%s: assembled text '%s'", label,
+	    text.data);
+	CHECK(got_usage, "%s: usage output_tokens=4 received", label);
+	CHECK(got_done, "%s: TURN_DONE received", label);
+	CHECK(strcmp(stop, "end_turn") == 0, "%s: stop reason '%s'", label, stop);
+
+	send_msg(&ip, M_SHUTDOWN, NULL, 0);
+
+	if (waitpid(np, &status, 0) == -1)
+		err(1, "waitpid net");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "%s: net worker exit %d", label, status);
+	if (waitpid(sp, &status, 0) == -1)
+		err(1, "waitpid stub");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "%s: stub server exit %d (40=path 41=auth 42=body 43=system)", label,
+	    status);
+
+	buf_free(&text);
+}
+
+/*
+ * /model: drive M_MODELS through the real net worker.  The worker GETs the
+ * provider's /v1/models from the stub (which asserts the path + auth) and
+ * replies M_MODELS with the parsed, newline-separated id list.
+ */
+static void
+run_models_scenario(const char *cert, const char *key)
+{
+	struct imsgproto	ip;
+	struct fugu_msg		m;
+	struct buf		cfg;
+	struct sockaddr_in	sa;
+	socklen_t		slen;
+	pid_t			sp, np;
+	char			portstr[16];
+	char			out[4096];
+	int			lfd, sv[2], one = 1, status, port, got = 0;
+
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	sa.sin_port = 0;
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 1) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	port = ntohs(sa.sin_port);
+	snprintf(portstr, sizeof(portstr), "%d", port);
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+
+	if ((sp = fork()) == -1)
+		err(1, "fork");
+	if (sp == 0) {
+		close(sv[0]);
+		close(sv[1]);
+		stub_server(lfd, cert, key, ANTHROPIC_SSE, 0, NULL);
+		_exit(0);
+	}
+	if ((np = fork()) == -1)
+		err(1, "fork");
+	if (np == 0) {
+		struct imsgproto	nip;
+
+		close(sv[0]);
+		close(lfd);
+		if (ip_init(&nip, sv[1]) == -1)
+			_exit(30);
+		net_loop(&nip);
+		_exit(0);
+	}
+
+	close(sv[1]);
+	close(lfd);
+	if (ip_init(&ip, sv[0]) == -1)
+		err(1, "ip_init");
+
+	CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "models: worker READY");
+	ip_msg_free(&m);
+	buf_init(&cfg);
+	buf_printf(&cfg, "{\"max_tokens\":64,\"cafile\":\"%s\",\"active\":0,"
+	    "\"providers\":[{\"name\":\"default\",\"type\":\"anthropic\","
+	    "\"model\":\"claude-sonnet-4-6\",\"host\":\"localhost\","
+	    "\"port\":\"%s\"}]}", cert, portstr);
+	send_msg(&ip, M_CONFIG, cfg.data, cfg.len);
+	buf_free(&cfg);
+	send_secret(&ip, 0, "test-key");
+
+	send_msg(&ip, M_MODELS, NULL, 0);
+
+	out[0] = '\0';
+	for (;;) {
+		if (recv_msg(&ip, &m) != 1) {
+			CHECK(0, "models: lost worker before reply");
+			break;
+		}
+		if (m.type == M_MODELS) {
+			size_t	k = m.len < sizeof(out) - 1 ? m.len : sizeof(out) - 1;
+
+			memcpy(out, m.data, k);
+			out[k] = '\0';
+			got = 1;
+			ip_msg_free(&m);
+			break;
+		}
+		if (m.type == M_ERROR) {
+			CHECK(0, "models: worker error: %.*s", (int)m.len,
+			    (char *)m.data);
+			ip_msg_free(&m);
+			break;
+		}
+		ip_msg_free(&m);
+	}
+	CHECK(got, "models: M_MODELS reply received");
+	CHECK(strstr(out, "model-alpha") != NULL &&
+	    strstr(out, "model-beta") != NULL &&
+	    strstr(out, "model-gamma") != NULL,
+	    "models: list parsed from /v1/models: '%s'", out);
+
+	send_msg(&ip, M_SHUTDOWN, NULL, 0);
+	if (waitpid(np, &status, 0) == -1)
+		err(1, "waitpid net");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "models: net worker exit %d", status);
+	if (waitpid(sp, &status, 0) == -1)
+		err(1, "waitpid stub");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "models: stub exit %d (44=path 45=auth)", status);
+}
+
+/*
+ * Two providers at once: /model must query each and tag every id "name\tmodel".
+ * Two anthropic providers ("default", "work") point at two stub endpoints; net
+ * fetches both, and the merged reply carries both providers' tagged ids.
+ */
+static void
+run_multi_models_scenario(const char *cert, const char *key)
+{
+	struct imsgproto	ip;
+	struct fugu_msg		m;
+	struct buf		cfg;
+	pid_t			sa_pid, sb_pid, np;
+	char			pa[16], pb[16], out[8192];
+	int			lfda, lfdb, sv[2], status, got = 0;
+
+	lfda = make_listener(pa, sizeof(pa));
+	lfdb = make_listener(pb, sizeof(pb));
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+
+	if ((sa_pid = fork()) == -1)
+		err(1, "fork");
+	if (sa_pid == 0) {
+		close(sv[0]); close(sv[1]); close(lfdb);
+		stub_server(lfda, cert, key, ANTHROPIC_SSE, 0, NULL);
+		_exit(0);
+	}
+	if ((sb_pid = fork()) == -1)
+		err(1, "fork");
+	if (sb_pid == 0) {
+		close(sv[0]); close(sv[1]); close(lfda);
+		stub_server(lfdb, cert, key, ANTHROPIC_SSE, 0, NULL);
+		_exit(0);
+	}
+	if ((np = fork()) == -1)
+		err(1, "fork");
+	if (np == 0) {
+		struct imsgproto	nip;
+
+		close(sv[0]); close(lfda); close(lfdb);
+		if (ip_init(&nip, sv[1]) == -1)
+			_exit(30);
+		net_loop(&nip);
+		_exit(0);
+	}
+
+	close(sv[1]); close(lfda); close(lfdb);
+	if (ip_init(&ip, sv[0]) == -1)
+		err(1, "ip_init");
+
+	CHECK(recv_msg(&ip, &m) == 1 && m.type == M_READY, "multi: worker READY");
+	ip_msg_free(&m);
+
+	buf_init(&cfg);
+	buf_printf(&cfg, "{\"cafile\":\"%s\",\"active\":0,\"providers\":["
+	    "{\"name\":\"default\",\"type\":\"anthropic\",\"host\":\"localhost\","
+	    "\"port\":\"%s\"},"
+	    "{\"name\":\"work\",\"type\":\"anthropic\",\"host\":\"localhost\","
+	    "\"port\":\"%s\"}]}", cert, pa, pb);
+	send_msg(&ip, M_CONFIG, cfg.data, cfg.len);
+	buf_free(&cfg);
+	send_secret(&ip, 0, "test-key");
+	send_secret(&ip, 1, "test-key");
+
+	send_msg(&ip, M_MODELS, NULL, 0);
+	out[0] = '\0';
+	for (;;) {
+		if (recv_msg(&ip, &m) != 1) {
+			CHECK(0, "multi: lost worker before reply");
+			break;
+		}
+		if (m.type == M_MODELS) {
+			size_t	k = m.len < sizeof(out) - 1 ? m.len : sizeof(out) - 1;
+
+			memcpy(out, m.data, k);
+			out[k] = '\0';
+			got = 1;
+			ip_msg_free(&m);
+			break;
+		}
+		if (m.type == M_ERROR) {
+			CHECK(0, "multi: worker error: %.*s", (int)m.len,
+			    (char *)m.data);
+			ip_msg_free(&m);
+			break;
+		}
+		ip_msg_free(&m);
+	}
+	CHECK(got, "multi: M_MODELS reply received");
+	CHECK(strstr(out, "default\tmodel-alpha") != NULL,
+	    "multi: default provider's ids are tagged: '%s'", out);
+	CHECK(strstr(out, "work\tmodel-beta") != NULL,
+	    "multi: second provider's ids are tagged: '%s'", out);
+
+	send_msg(&ip, M_SHUTDOWN, NULL, 0);
+	if (waitpid(np, &status, 0) == -1)
+		err(1, "waitpid net");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "multi: net worker exit %d", status);
+	if (waitpid(sa_pid, &status, 0) == -1 || waitpid(sb_pid, &status, 0) == -1)
+		err(1, "waitpid stub");
+}
+
+int
+main(int argc, char *argv[])
+{
+	if (argc != 3)
+		errx(1, "usage: %s cert.pem key.pem", argv[0]);
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(60);
+
+	/* tools_schema(): a valid JSON array, with the web tools gated by the flag. */
+	{
+		struct buf	tb;
+		struct json_doc	jd;
+
+		buf_init(&tb);
+		tools_schema(&tb, 1, 0, NULL);
+		CHECK(json_parse(&jd, tb.data, strlen(tb.data)) == 0 &&
+		    jd.ntok >= 1 && json_tok_type(&jd, 0) == JSON_ARRAY,
+		    "tools_schema(web=1) is a valid JSON array");
+		/* Match the tool DEFINITION, not a bare word: a base tool's
+		 * description may mention the web tools by name (shell does). */
+		CHECK(strstr(tb.data, "\"name\":\"web_search\"") != NULL &&
+		    strstr(tb.data, "\"name\":\"web_fetch\"") != NULL,
+		    "tools_schema(web=1) includes the web tools");
+		CHECK(strstr(tb.data, "\"name\":\"http_request\"") == NULL,
+		    "tools_schema(http=0) omits the http_request tool");
+		json_doc_free(&jd);
+
+		buf_reset(&tb);
+		tools_schema(&tb, 0, 0, NULL);
+		CHECK(json_parse(&jd, tb.data, strlen(tb.data)) == 0 &&
+		    jd.ntok >= 1 && json_tok_type(&jd, 0) == JSON_ARRAY,
+		    "tools_schema(web=0) is a valid JSON array");
+		CHECK(strstr(tb.data, "\"name\":\"web_search\"") == NULL &&
+		    strstr(tb.data, "\"name\":\"web_fetch\"") == NULL,
+		    "tools_schema(web=0) omits the web tools");
+		CHECK(strstr(tb.data, "\"name\":\"read\"") != NULL &&
+		    strstr(tb.data, "\"name\":\"shell\"") != NULL,
+		    "tools_schema(web=0) keeps the base tools");
+		json_doc_free(&jd);
+
+		/* http_request gated independently of web_search. */
+		buf_reset(&tb);
+		tools_schema(&tb, 0, 1, NULL);
+		CHECK(json_parse(&jd, tb.data, strlen(tb.data)) == 0 &&
+		    jd.ntok >= 1 && json_tok_type(&jd, 0) == JSON_ARRAY,
+		    "tools_schema(http=1) is a valid JSON array");
+		CHECK(strstr(tb.data, "\"name\":\"http_request\"") != NULL,
+		    "tools_schema(http=1) includes the http_request tool");
+		CHECK(strstr(tb.data, "\"name\":\"web_search\"") == NULL,
+		    "tools_schema(web=0,http=1) still omits the web tools");
+		json_doc_free(&jd);
+		buf_free(&tb);
+	}
+
+	/*
+	 * Tool-call input storage must not bleed dirty trailing-capacity bytes
+	 * into json_raw().  Repro for the parallel-tool overrun fixed in
+	 * on_tool_call (net_api.c): the per-call input buf was filled via
+	 * buf_puts_cstr() (no NUL after the bytes), and the subsequent json_raw()
+	 * used strlen() to read .data -- which on a freshly xreallocarray'd
+	 * buf would walk into uninitialised heap.  This pre-pollutes the
+	 * capacity with '!' so the bug, were it present, would emit garbage.
+	 */
+	{
+		struct buf	input, out;
+		struct json_doc	jd2;
+		struct json_writer	w;
+		const char	*src = "{\"command\":\"ls -la\"}";
+		int		input_t, cmd_t;
+		char		*cmd;
+
+		buf_init(&input);
+		buf_reserve(&input, 64);
+		memset(input.data, '!', 64);
+		/* The pattern on_tool_call now uses: append + uncounted NUL. */
+		buf_append(&input, src, strlen(src));
+		buf_terminate(&input);
+
+		buf_init(&out);
+		json_writer_init(&w, &out);
+		json_object_start(&w);
+		json_key(&w, "input");
+		json_raw(&w, input.data);
+		json_object_end(&w);
+
+		CHECK(json_parse(&jd2, out.data, out.len) == 0 &&
+		    jd2.ntok >= 1 && json_tok_type(&jd2, 0) == JSON_OBJECT,
+		    "tool input survives dirty-capacity buf");
+		input_t = json_obj_get(&jd2, 0, "input");
+		cmd_t = input_t >= 0 ? json_obj_get(&jd2, input_t, "command") : -1;
+		cmd = cmd_t >= 0 ? json_tok_strdup(&jd2, cmd_t) : NULL;
+		CHECK(cmd != NULL && strcmp(cmd, "ls -la") == 0,
+		    "tool input.command preserved (got '%s')",
+		    cmd != NULL ? cmd : "(null)");
+		free(cmd);
+		json_doc_free(&jd2);
+		buf_free(&out);
+		buf_free(&input);
+	}
+
+	/* net_parse_models(): both providers' {"data":[{"id":...}]} shape. */
+	{
+		struct buf	mb;
+
+		buf_init(&mb);
+		net_parse_models(MODELS_JSON, strlen(MODELS_JSON), &mb);
+		buf_terminate(&mb);
+		CHECK(strcmp(mb.data,
+		    "model-alpha\nmodel-beta\nmodel-gamma") == 0,
+		    "net_parse_models: newline-separated ids '%s'", mb.data);
+
+		buf_reset(&mb);
+		net_parse_models("{\"data\":[]}", 11, &mb);
+		CHECK(mb.len == 0, "net_parse_models: empty data -> empty");
+
+		buf_reset(&mb);
+		net_parse_models("not json", 8, &mb);
+		CHECK(mb.len == 0, "net_parse_models: malformed -> empty");
+
+		buf_reset(&mb);
+		net_parse_models("{\"error\":{\"message\":\"x\"}}", 25, &mb);
+		CHECK(mb.len == 0, "net_parse_models: no data array -> empty");
+		buf_free(&mb);
+	}
+
+	/* The full worker path: Anthropic, OpenAI at its default path, and
+	 * OpenAI with an api_path override (as OpenRouter/Groq/Gemini need). */
+	run_scenario(argv[1], argv[2], NULL, NULL, ANTHROPIC_SSE, 0, NULL,
+	    "anthropic");
+	run_scenario(argv[1], argv[2], "openai", NULL, OPENAI_SSE, 1,
+	    "/v1/chat/completions", "openai");
+	run_scenario(argv[1], argv[2], "openai", "/api/v1/chat/completions",
+	    OPENAI_SSE, 1, "/api/v1/chat/completions", "openai-pathoverride");
+
+	/* /model: fetch the provider's model list end-to-end. */
+	run_models_scenario(argv[1], argv[2]);
+
+	/* Two providers at once: the merged, per-provider-tagged model list. */
+	run_multi_models_scenario(argv[1], argv[2]);
+
+	printf("net_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 438a24d18318a85d1a70a5328df3c8b0ceb1cbae (mode 644)
--- /dev/null
+++ regress/openai/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the OpenAI provider layer (src/common/openai.c).
+
+PROG=		openai_test
+SRCS=		openai_test.c openai.c sse.c json.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-openai_test
+
+run-openai_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + b42e7e2ccc8004571ecc0ae1ebb3f673c5918ddf (mode 644)
--- /dev/null
+++ regress/openai/openai_test.c
@@ -0,0 +1,510 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "openai.h"
+#include "sse.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* ---- callback recorder ---- */
+
+#define MAXTOOLS	8
+
+static struct buf	rec_text;
+static char		*rec_id[MAXTOOLS], *rec_name[MAXTOOLS], *rec_input[MAXTOOLS];
+static size_t		 rec_input_len[MAXTOOLS];
+static int		 rec_tools;
+static long long	 rec_in, rec_out;
+static int		 rec_usage;
+static char		*rec_stop;
+static int		 rec_done;
+static char		*rec_err_type, *rec_err_msg;
+static int		 rec_errors;
+
+static void
+rec_reset(void)
+{
+	int	i;
+
+	buf_reset(&rec_text);
+	for (i = 0; i < MAXTOOLS; i++) {
+		free(rec_id[i]);    rec_id[i] = NULL;
+		free(rec_name[i]);  rec_name[i] = NULL;
+		free(rec_input[i]); rec_input[i] = NULL;
+		rec_input_len[i] = 0;
+	}
+	rec_tools = 0;
+	rec_in = rec_out = 0;
+	rec_usage = 0;
+	free(rec_stop);      rec_stop = NULL;
+	rec_done = 0;
+	free(rec_err_type);  rec_err_type = NULL;
+	free(rec_err_msg);   rec_err_msg = NULL;
+	rec_errors = 0;
+}
+
+static void
+cb_text(const char *t, size_t n, void *a)
+{
+	(void)a;
+	buf_append(&rec_text, t, n);
+}
+
+static void
+cb_tool(const char *id, const char *name, const char *input, size_t n, void *a)
+{
+	(void)a;
+	if (rec_tools >= MAXTOOLS)
+		return;
+	rec_id[rec_tools] = strdup(id);
+	rec_name[rec_tools] = strdup(name);
+	rec_input[rec_tools] = malloc(n + 1);
+	memcpy(rec_input[rec_tools], input, n);
+	rec_input[rec_tools][n] = '\0';
+	rec_input_len[rec_tools] = n;
+	rec_tools++;
+}
+
+static void
+cb_usage(long long in, long long out, void *a)
+{
+	(void)a;
+	rec_in = in;
+	rec_out = out;
+	rec_usage++;
+}
+
+static void
+cb_stop(const char *sr, void *a)
+{
+	(void)a;
+	free(rec_stop);
+	rec_stop = strdup(sr);
+}
+
+static void
+cb_error(const char *type, const char *msg, void *a)
+{
+	(void)a;
+	free(rec_err_type); rec_err_type = strdup(type);
+	free(rec_err_msg);  rec_err_msg = strdup(msg);
+	rec_errors++;
+}
+
+static void
+cb_done(void *a)
+{
+	(void)a;
+	rec_done++;
+}
+
+static const struct llm_cbs RECORDER = {
+	cb_text, cb_tool, cb_usage, cb_stop, cb_error, cb_done
+};
+
+static void
+ev(struct openai_stream *s, const char *data)
+{
+	openai_stream_event(s, "message", data, strlen(data));
+}
+
+/* ---- request builder (Anthropic shape -> OpenAI) ---- */
+
+static void
+check_build(const char *what, const char *model, int max_tokens,
+    const char *system, const char *messages, const char *tools,
+    const char *expect)
+{
+	struct buf	out;
+
+	buf_init(&out);
+	openai_build_request(&out, model, max_tokens, system, messages, tools);
+	buf_terminate(&out);
+	CHECK(strcmp(out.data, expect) == 0, "%s:\n got: %s\nwant: %s",
+	    what, out.data, expect);
+	buf_free(&out);
+}
+
+static void
+test_build_basic(void)
+{
+	check_build("build basic", "gpt-4o", 1024, NULL,
+	    "[{\"role\":\"user\",\"content\":\"hello\"}]", NULL,
+	    "{\"model\":\"gpt-4o\",\"max_tokens\":1024,\"stream\":true,"
+	    "\"stream_options\":{\"include_usage\":true},"
+	    "\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}");
+}
+
+static void
+test_build_system_tools(void)
+{
+	check_build("build sys+tools", "m", 8, "be brief",
+	    "[{\"role\":\"user\",\"content\":\"hi\"}]",
+	    "[{\"name\":\"read\",\"description\":\"Read\","
+	    "\"input_schema\":{\"type\":\"object\"}}]",
+	    "{\"model\":\"m\",\"max_tokens\":8,\"stream\":true,"
+	    "\"stream_options\":{\"include_usage\":true},"
+	    "\"messages\":[{\"role\":\"system\",\"content\":\"be brief\"},"
+	    "{\"role\":\"user\",\"content\":\"hi\"}],"
+	    "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\","
+	    "\"description\":\"Read\",\"parameters\":{\"type\":\"object\"}}}]}");
+}
+
+static void
+test_build_assistant_toolcall(void)
+{
+	check_build("build assistant tool_use", "m", 4, NULL,
+	    "[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+	    "\"text\":\"let me check\"},{\"type\":\"tool_use\",\"id\":\"call_1\","
+	    "\"name\":\"read\",\"input\":{\"path\":\"x\"}}]}]", NULL,
+	    "{\"model\":\"m\",\"max_tokens\":4,\"stream\":true,"
+	    "\"stream_options\":{\"include_usage\":true},"
+	    "\"messages\":[{\"role\":\"assistant\",\"content\":\"let me check\","
+	    "\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\","
+	    "\"function\":{\"name\":\"read\","
+	    "\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\"}}]}]}");
+}
+
+static void
+test_build_tool_result(void)
+{
+	check_build("build tool_result", "m", 4, NULL,
+	    "[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\","
+	    "\"tool_use_id\":\"call_1\",\"content\":\"file body\","
+	    "\"is_error\":false}]}]", NULL,
+	    "{\"model\":\"m\",\"max_tokens\":4,\"stream\":true,"
+	    "\"stream_options\":{\"include_usage\":true},"
+	    "\"messages\":[{\"role\":\"tool\",\"tool_call_id\":\"call_1\","
+	    "\"content\":\"file body\"}]}");
+}
+
+static void
+test_build_empty_tools(void)
+{
+	/* An empty tool list is omitted entirely (some servers reject []). */
+	check_build("build empty tools", "m", 4, NULL,
+	    "[{\"role\":\"user\",\"content\":\"hi\"}]", "[]",
+	    "{\"model\":\"m\",\"max_tokens\":4,\"stream\":true,"
+	    "\"stream_options\":{\"include_usage\":true},"
+	    "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}");
+}
+
+/* ---- streaming event interpreter ---- */
+
+static void
+test_text_stream(void)
+{
+	struct openai_stream	s;
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\","
+	    "\"content\":\"\"},\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},"
+	    "\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\", world\"},"
+	    "\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+	    "\"finish_reason\":\"stop\"}]}");
+	ev(&s, "{\"choices\":[],\"usage\":{\"prompt_tokens\":7,"
+	    "\"completion_tokens\":9}}");
+	ev(&s, "[DONE]");
+
+	buf_terminate(&rec_text);
+	CHECK(strcmp(rec_text.data, "Hello, world") == 0, "text: '%s'",
+	    rec_text.data);
+	CHECK(rec_tools == 0, "text: no tool call");
+	CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "text: stop");
+	CHECK(rec_usage == 1 && rec_in == 7 && rec_out == 9,
+	    "text: usage in=%lld out=%lld", rec_in, rec_out);
+	CHECK(rec_done == 1, "text: done");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_tool_call(void)
+{
+	struct openai_stream	s;
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\","
+	    "\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_1\","
+	    "\"type\":\"function\",\"function\":{\"name\":\"read\","
+	    "\"arguments\":\"\"}}]},\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"function\":{\"arguments\":\"{\\\"path\\\":\"}}]},"
+	    "\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"function\":{\"arguments\":\"\\\"x\\\"}\"}}]},"
+	    "\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+	    "\"finish_reason\":\"tool_calls\"}]}");
+
+	CHECK(rec_tools == 1, "tool: one call (%d)", rec_tools);
+	CHECK(rec_id[0] && strcmp(rec_id[0], "call_1") == 0, "tool: id");
+	CHECK(rec_name[0] && strcmp(rec_name[0], "read") == 0, "tool: name");
+	CHECK(rec_input[0] && strcmp(rec_input[0], "{\"path\":\"x\"}") == 0,
+	    "tool: input '%s'", rec_input[0] ? rec_input[0] : "(null)");
+	CHECK(rec_stop && strcmp(rec_stop, "tool_use") == 0, "tool: stop '%s'",
+	    rec_stop ? rec_stop : "(null)");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_parallel_tool_calls(void)
+{
+	struct openai_stream	s;
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":["
+	    "{\"index\":0,\"id\":\"a\",\"function\":{\"name\":\"read\","
+	    "\"arguments\":\"{}\"}},"
+	    "{\"index\":1,\"id\":\"b\",\"function\":{\"name\":\"ls\","
+	    "\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+	    "\"finish_reason\":\"tool_calls\"}]}");
+
+	CHECK(rec_tools == 2, "parallel: two calls (%d)", rec_tools);
+	CHECK(rec_id[0] && strcmp(rec_id[0], "a") == 0 &&
+	    rec_name[0] && strcmp(rec_name[0], "read") == 0, "parallel: call 0");
+	CHECK(rec_id[1] && strcmp(rec_id[1], "b") == 0 &&
+	    rec_name[1] && strcmp(rec_name[1], "ls") == 0, "parallel: call 1");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_tool_call_null_continuation(void)
+{
+	struct openai_stream	s;
+
+	/*
+	 * Some OpenAI-emulating servers send an explicit "id":null / "name":null
+	 * on continuation deltas (only the first delta carries the real value).
+	 * Those nulls must not clobber the id/name captured earlier.
+	 */
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"id\":\"call_9\",\"function\":{\"name\":\"read\","
+	    "\"arguments\":\"\"}}]},\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"id\":null,\"function\":{\"name\":null,"
+	    "\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+	    "\"finish_reason\":\"tool_calls\"}]}");
+
+	CHECK(rec_tools == 1, "null-cont: one call (%d)", rec_tools);
+	CHECK(rec_id[0] && strcmp(rec_id[0], "call_9") == 0,
+	    "null-cont: id kept '%s'", rec_id[0] ? rec_id[0] : "(null)");
+	CHECK(rec_name[0] && strcmp(rec_name[0], "read") == 0,
+	    "null-cont: name kept '%s'", rec_name[0] ? rec_name[0] : "(null)");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_tool_call_embedded_nul(void)
+{
+	struct openai_stream	s;
+	/*
+	 * Tool-call arguments JSON containing a literal backslash-u-0000
+	 * escape decodes to a byte sequence with an embedded NUL.  The
+	 * streaming accumulator must preserve the full length end-to-end (no
+	 * strlen-based truncation at the NUL byte).  Regression test for the
+	 * silent truncation in accumulate_toolcalls() that swallowed
+	 * everything past the first NUL.
+	 */
+	static const char	expect_input[] = "{\"x\":\"a\0b\"}";
+	const size_t	expect_input_n = sizeof(expect_input) - 1;
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"id\":\"call_n\",\"type\":\"function\","
+	    "\"function\":{\"name\":\"write\","
+	    "\"arguments\":\"\"}}]},\"finish_reason\":null}]}");
+	/* args fragment contains a literal backslash-u-0000 escape. */
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"function\":{\"arguments\":"
+	    "\"{\\\"x\\\":\\\"a\\u0000b\\\"}\"}}]},"
+	    "\"finish_reason\":null}]}");
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{},"
+	    "\"finish_reason\":\"tool_calls\"}]}");
+
+	CHECK(rec_tools == 1, "embedded-nul: one call (%d)", rec_tools);
+	CHECK(rec_input_len[0] == expect_input_n,
+	    "embedded-nul: input length got %zu want %zu",
+	    rec_input_len[0], expect_input_n);
+	CHECK(rec_input[0] != NULL &&
+	    memcmp(rec_input[0], expect_input, expect_input_n) == 0,
+	    "embedded-nul: input bytes mismatch");
+	CHECK(rec_id[0] && strcmp(rec_id[0], "call_n") == 0,
+	    "embedded-nul: id '%s'", rec_id[0] ? rec_id[0] : "(null)");
+	CHECK(rec_name[0] && strcmp(rec_name[0], "write") == 0,
+	    "embedded-nul: name '%s'", rec_name[0] ? rec_name[0] : "(null)");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_tool_call_done_without_finish(void)
+{
+	struct openai_stream	s;
+
+	/* A server that jumps straight to [DONE] must still flush tool calls. */
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":"
+	    "0,\"id\":\"call_x\",\"function\":{\"name\":\"ls\","
+	    "\"arguments\":\"{}\"}}]}}]}");
+	ev(&s, "[DONE]");
+
+	CHECK(rec_tools == 1 && rec_id[0] && strcmp(rec_id[0], "call_x") == 0 &&
+	    rec_name[0] && strcmp(rec_name[0], "ls") == 0,
+	    "done-flush: tool delivered (%d)", rec_tools);
+	CHECK(rec_done == 1, "done-flush: done fired");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_error_event(void)
+{
+	struct openai_stream	s;
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{\"error\":{\"message\":\"bad key\","
+	    "\"type\":\"invalid_request_error\"}}");
+	CHECK(rec_errors == 1 && rec_err_type &&
+	    strcmp(rec_err_type, "invalid_request_error") == 0, "error type");
+	CHECK(rec_err_msg && strcmp(rec_err_msg, "bad key") == 0, "error msg");
+	/* after a fatal error, further events are ignored */
+	ev(&s, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"x\"}}]}");
+	CHECK(rec_text.len == 0, "post-error events ignored");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+static void
+test_badjson(void)
+{
+	struct openai_stream	s;
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	ev(&s, "{");
+	CHECK(rec_errors == 1 && rec_err_type &&
+	    strcmp(rec_err_type, "parse_error") == 0, "bad json -> parse_error");
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+/* ---- integration: SSE wire -> sse -> openai ---- */
+
+static void
+sse_trampoline(const char *event, const char *data, size_t datalen, void *arg)
+{
+	openai_stream_event(arg, event, data, datalen);
+}
+
+static void
+test_integration(void)
+{
+	struct openai_stream	s;
+	struct sse_parser	p;
+	const char		*wire =
+	    "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":"
+	    "\"assistant\",\"content\":\"\"}}]}\n"
+	    "\n"
+	    "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":"
+	    "\"It is \"}}]}\n"
+	    "\n"
+	    "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":"
+	    "\"sunny.\"}}]}\n"
+	    "\n"
+	    "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":"
+	    "\"stop\"}]}\n"
+	    "\n"
+	    "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,"
+	    "\"completion_tokens\":4}}\n"
+	    "\n"
+	    "data: [DONE]\n"
+	    "\n";
+	size_t			i, len = strlen(wire);
+
+	openai_stream_init(&s, &RECORDER, NULL);
+	sse_init(&p, sse_trampoline, &s);
+
+	for (i = 0; i < len; i += 7) {
+		size_t	n = (len - i < 7) ? len - i : 7;
+
+		if (sse_push(&p, wire + i, n) != 0)
+			failures++;
+	}
+
+	buf_terminate(&rec_text);
+	CHECK(strcmp(rec_text.data, "It is sunny.") == 0,
+	    "integration text: '%s'", rec_text.data);
+	CHECK(rec_in == 5 && rec_out == 4, "integration usage in=%lld out=%lld",
+	    rec_in, rec_out);
+	CHECK(rec_stop && strcmp(rec_stop, "end_turn") == 0, "integration stop");
+	CHECK(rec_done == 1, "integration done");
+
+	sse_clear(&p);
+	openai_stream_clear(&s);
+	rec_reset();
+}
+
+int
+main(void)
+{
+	buf_init(&rec_text);
+
+	test_build_basic();
+	test_build_system_tools();
+	test_build_assistant_toolcall();
+	test_build_tool_result();
+	test_build_empty_tools();
+	test_text_stream();
+	test_tool_call();
+	test_parallel_tool_calls();
+	test_tool_call_null_continuation();
+	test_tool_call_embedded_nul();
+	test_tool_call_done_without_finish();
+	test_error_event();
+	test_badjson();
+	test_integration();
+
+	buf_free(&rec_text);
+	printf("openai_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 6f262c50b5dcd8f2464db1002dc2b9e9c3470f1d (mode 644)
--- /dev/null
+++ regress/parse/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the config grammar (src/common/parse.y) + resolution.
+
+PROG=		parse_test
+SRCS=		parse_test.c parse.y conf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-parse_test
+
+run-parse_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + ac06cdd61926629f986f47b6f445c89d6fc2d003 (mode 644)
--- /dev/null
+++ regress/parse/parse_test.c
@@ -0,0 +1,282 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+
+static int	fails;
+static int	checks;
+
+#define CHECK(cond, msg) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		fprintf(stderr, "FAIL %s:%d: %s\n",			\
+		    __func__, __LINE__, (msg));				\
+		fails++;						\
+	}								\
+} while (0)
+
+static void
+streq(const char *func, int line, const char *got, const char *want,
+    const char *msg)
+{
+	checks++;
+	if (got == NULL || strcmp(got, want) != 0) {
+		fprintf(stderr, "FAIL %s:%d: %s (got %s want %s)\n",
+		    func, line, msg, got ? got : "(null)", want);
+		fails++;
+	}
+}
+#define STREQ(got, want, msg) streq(__func__, __LINE__, (got), (want), (msg))
+
+/* Write content to a fresh temp file; caller frees + unlinks the path. */
+static char *
+mktmp(const char *content)
+{
+	char	*path = strdup("/tmp/fugu_parse.XXXXXX");
+	int	 fd;
+	size_t	 n = strlen(content);
+
+	if (path == NULL || (fd = mkstemp(path)) == -1) {
+		perror("mkstemp");
+		exit(2);
+	}
+	if (write(fd, content, n) != (ssize_t)n) {
+		perror("write");
+		exit(2);
+	}
+	close(fd);
+	return (path);
+}
+
+static void
+test_parse_resolve(void)
+{
+	static const char	*cfg =
+	    "# fugu test config\n"
+	    "default_model = \"claude-sonnet-4-6\"\n"
+	    "model $default_model\n"
+	    "max_tokens 8192\n"
+	    "allow_write no\n"
+	    "search_provider \"kagi\"\n"
+	    "system \"be terse\"\n"
+	    "provider \"openai\"\n"
+	    "api_host \"openrouter.ai\"\n"
+	    "api_port \"443\"\n"
+	    "api_path \"/api/v1/chat/completions\"\n"
+	    "api_key \"sk-or-test\"\n"
+	    "\n"
+	    "match user \"isaac\" {\n"
+	    "\tmodel \"claude-opus-4-8\"\n"
+	    "\tallow_write yes\n"
+	    "}\n"
+	    "\n"
+	    "match group \"interns\" {\n"
+	    "\tmodel \"claude-haiku-4-5\"\n"
+	    "\tweb_search no\n"
+	    "}\n";
+	char			*path = mktmp(cfg);
+	char			*groups[] = { "interns" };
+	struct conf		 c;
+	struct conf_settings	 eff;
+
+	conf_init(&c);
+	CHECK(conf_parse_file(&c, path, 0) == 0, "parse valid config");
+
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "isaac", NULL, 0, &eff);
+	STREQ(eff.model, "claude-opus-4-8", "isaac: user block model");
+	CHECK(eff.allow_write == 1, "isaac: allow_write yes");
+	CHECK((eff.set & CS_MAX_TOKENS) && eff.max_tokens == 8192,
+	    "isaac: global max_tokens");
+	STREQ(eff.search_provider, "kagi", "isaac: global search_provider");
+	STREQ(eff.system, "be terse", "isaac: global system prompt");
+	STREQ(eff.provider, "openai", "isaac: global provider");
+	STREQ(eff.api_host, "openrouter.ai", "isaac: global api_host");
+	STREQ(eff.api_port, "443", "isaac: global api_port");
+	STREQ(eff.api_path, "/api/v1/chat/completions", "isaac: global api_path");
+	STREQ(eff.api_key, "sk-or-test", "isaac: global api_key");
+	conf_settings_clear(&eff);
+
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "bob", groups, 1, &eff);
+	STREQ(eff.model, "claude-haiku-4-5", "bob: group block model");
+	CHECK((eff.set & CS_WEB_SEARCH) && eff.web_search == 0,
+	    "bob: group web_search no");
+	CHECK(eff.allow_write == 0, "bob: global allow_write");
+	conf_settings_clear(&eff);
+
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "carol", NULL, 0, &eff);
+	STREQ(eff.model, "claude-sonnet-4-6", "carol: macro-expanded default");
+	CHECK(eff.allow_write == 0, "carol: global allow_write");
+	conf_settings_clear(&eff);
+
+	conf_clear(&c);
+	unlink(path);
+	free(path);
+}
+
+static void
+test_include(void)
+{
+	char			*sub = mktmp("context_limit 160000\n");
+	char			 main_cfg[256];
+	char			*path;
+	struct conf		 c;
+	struct conf_settings	 eff;
+
+	snprintf(main_cfg, sizeof(main_cfg),
+	    "model \"x\"\ninclude \"%s\"\n", sub);
+	path = mktmp(main_cfg);
+
+	conf_init(&c);
+	CHECK(conf_parse_file(&c, path, 0) == 0, "parse with include");
+
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "anyone", NULL, 0, &eff);
+	STREQ(eff.model, "x", "main file setting applied");
+	CHECK((eff.set & CS_CONTEXT_LIMIT) && eff.context_limit == 160000,
+	    "included file setting applied");
+	conf_settings_clear(&eff);
+
+	conf_clear(&c);
+	unlink(path);
+	unlink(sub);
+	free(path);
+	free(sub);
+}
+
+static void
+test_malformed(void)
+{
+	char		*path = mktmp("this is not valid\n");
+	struct conf	 c;
+
+	conf_init(&c);
+	CHECK(conf_parse_file(&c, path, 0) == -1, "malformed config rejected");
+	conf_clear(&c);
+	unlink(path);
+	free(path);
+}
+
+/*
+ * Named provider blocks parse into c.providers, in order, alongside the flat
+ * "default" keys -- and the flat `provider "openai"` setting (no brace) still
+ * works, proving the block syntax does not shadow it.
+ */
+static void
+test_provider_blocks(void)
+{
+	static const char	*cfg =
+	    "provider \"anthropic\"\n"		/* flat setting: default's type */
+	    "api_key \"sk-ant-default\"\n"
+	    "\n"
+	    "provider \"gpt\" {\n"
+	    "\ttype \"openai\"\n"
+	    "\tapi_key \"sk-openai\"\n"
+	    "\tmodel \"gpt-5\"\n"
+	    "}\n"
+	    "\n"
+	    "provider \"groq\" {\n"
+	    "\ttype \"openai\"\n"
+	    "\tapi_host \"api.groq.com\"\n"
+	    "\tapi_path \"/openai/v1/chat/completions\"\n"
+	    "}\n";
+	char			*path = mktmp(cfg);
+	struct conf		 c;
+	struct conf_settings	 eff;
+	struct conf_provider	*p;
+	int			 n = 0;
+
+	conf_init(&c);
+	CHECK(conf_parse_file(&c, path, 0) == 0, "parse config with provider blocks");
+
+	/* The flat default provider's type/key resolve normally. */
+	memset(&eff, 0, sizeof(eff));
+	conf_resolve(&c, "anyone", NULL, 0, &eff);
+	STREQ(eff.provider, "anthropic", "default: flat provider setting");
+	STREQ(eff.api_key, "sk-ant-default", "default: flat api_key");
+	conf_settings_clear(&eff);
+
+	TAILQ_FOREACH(p, &c.providers, entry)
+		n++;
+	CHECK(n == 2, "two named provider blocks parsed");
+
+	p = TAILQ_FIRST(&c.providers);
+	STREQ(p->name, "gpt", "first block name");
+	STREQ(p->settings.provider, "openai", "first block: type -> provider field");
+	STREQ(p->settings.api_key, "sk-openai", "first block api_key");
+	STREQ(p->settings.model, "gpt-5", "first block model");
+
+	p = TAILQ_NEXT(p, entry);
+	STREQ(p->name, "groq", "second block name");
+	STREQ(p->settings.api_host, "api.groq.com", "second block api_host");
+	STREQ(p->settings.api_path, "/openai/v1/chat/completions",
+	    "second block api_path");
+	CHECK((p->settings.set & CS_API_KEY) == 0, "second block has no key");
+
+	conf_clear(&c);
+	unlink(path);
+	free(path);
+}
+
+/*
+ * Empty-string macros must lex without undefined behavior: the macro-expansion
+ * path in parse.c used to compute `val + strlen(val) - 1` unconditionally,
+ * which is UB (forms a pointer one before the start of the array) when the
+ * macro value is empty. Reaching the expansion site at all is enough to
+ * exercise the fix; the surrounding directive may or may not parse cleanly,
+ * but the lexer must not invoke UB on the way through.
+ */
+static void
+test_empty_macro(void)
+{
+	static const char	*cfg =
+	    "empty = \"\"\n"
+	    "model $empty\n";
+	char			*path = mktmp(cfg);
+	struct conf		 c;
+
+	conf_init(&c);
+	/*
+	 * We don't assert the return value: an empty expansion may leave the
+	 * `model` directive without a string, which is fine to reject. The
+	 * point is that we get here without tripping UBSan / crashing.
+	 */
+	(void)conf_parse_file(&c, path, 0);
+	CHECK(1, "empty-macro expansion did not crash");
+	conf_clear(&c);
+	unlink(path);
+	free(path);
+}
+
+int
+main(void)
+{
+	test_parse_resolve();
+	test_include();
+	test_malformed();
+	test_provider_blocks();
+	test_empty_macro();
+
+	printf("%d checks, %d failures\n", checks, fails);
+	return (fails != 0);
+}
blob - /dev/null
blob + 47456b2e14f3144166a860ac61f60a5cdcac086f (mode 644)
--- /dev/null
+++ regress/resume/Makefile
@@ -0,0 +1,24 @@
+# Unit tests for the session resume path (src/ui/convo.c + journal.c) plus a
+# `fugu -l` listing smoke test.  The smoke test runs the parent binary, so run
+# `make` at the top level first; the unit tests need no built binaries.
+
+PROG=		resume_test
+SRCS=		resume_test.c convo.c journal.c json.c buf.c util.c log.c
+NOMAN=		yes
+
+UIDIR=		${.CURDIR}/../../src/ui
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${UIDIR} -I${COMMONDIR}
+.PATH:		${UIDIR} ${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+FUGU_BIN=	${.CURDIR}/../../src/parent/fugu
+
+REGRESS_TARGETS=	run-resume_test
+
+run-resume_test: ${PROG}
+	FUGU_BIN=${FUGU_BIN} ./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + cf300812821ebdfa3bc3852a79490ab3c58b1c79 (mode 644)
--- /dev/null
+++ regress/resume/resume_test.c
@@ -0,0 +1,964 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Unit tests for the session resume path (src/ui/convo.c + journal.c):
+ *   - convo_replay decomposes stored message objects into display parts;
+ *   - the journal write path and journal_foreach round-trip exactly;
+ *   - convo_resume rebuilds a conversation and replays it for display;
+ *   - a resumed journal keeps appending to the same file.
+ * Plus a smoke test of `fugu -l` if FUGU_BIN is set (the parent's listing path).
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <dirent.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "journal.h"
+#include "ui.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* Records the parts convo_replay hands back, for assertions. */
+struct rec {
+	enum convo_part	part[32];
+	char		*text[32];
+	int		 n;
+};
+
+static void
+rec_cb(void *ctx, enum convo_part p, const char *text, size_t len)
+{
+	struct rec	*r = ctx;
+
+	if (r->n < 32) {
+		r->part[r->n] = p;
+		r->text[r->n] = strndup(text, len);
+		r->n++;
+	}
+}
+
+static void
+rec_free(struct rec *r)
+{
+	int	i;
+
+	for (i = 0; i < r->n; i++)
+		free(r->text[i]);
+	r->n = 0;
+}
+
+static void
+test_replay(void)
+{
+	struct rec	r;
+	const char	*m;
+
+	/* A typed user message is one CP_USER part. */
+	memset(&r, 0, sizeof(r));
+	m = "{\"role\":\"user\",\"content\":\"hello world\"}";
+	convo_replay(m, strlen(m), rec_cb, &r);
+	CHECK(r.n == 1 && r.part[0] == CP_USER &&
+	    strcmp(r.text[0], "hello world") == 0, "user string -> CP_USER");
+	rec_free(&r);
+
+	/* Assistant text block -> one CP_ASSIST part. */
+	memset(&r, 0, sizeof(r));
+	m = "{\"role\":\"assistant\",\"content\":["
+	    "{\"type\":\"text\",\"text\":\"hi there\"}]}";
+	convo_replay(m, strlen(m), rec_cb, &r);
+	CHECK(r.n == 1 && r.part[0] == CP_ASSIST &&
+	    strcmp(r.text[0], "hi there") == 0, "assistant text -> CP_ASSIST");
+	rec_free(&r);
+
+	/* tool_use -> one CP_TOOL part formatted "name input". */
+	memset(&r, 0, sizeof(r));
+	m = "{\"role\":\"assistant\",\"content\":["
+	    "{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\","
+	    "\"input\":{\"path\":\"foo\"}}]}";
+	convo_replay(m, strlen(m), rec_cb, &r);
+	CHECK(r.n == 1 && r.part[0] == CP_TOOL &&
+	    strcmp(r.text[0], "read {\"path\":\"foo\"}") == 0,
+	    "tool_use -> CP_TOOL (got %s)", r.n ? r.text[0] : "(none)");
+	rec_free(&r);
+
+	/* tool_result (synth-pair skill body) -> one CP_TOOL part. */
+	memset(&r, 0, sizeof(r));
+	m = "{\"role\":\"user\",\"content\":["
+	    "{\"type\":\"tool_result\",\"tool_use_id\":\"t1\","
+	    "\"content\":\"file data\"}]}";
+	convo_replay(m, strlen(m), rec_cb, &r);
+	CHECK(r.n == 1 && r.part[0] == CP_TOOL &&
+	    strcmp(r.text[0], "file data") == 0,
+	    "tool_result -> CP_TOOL (got %d parts)", r.n);
+	rec_free(&r);
+
+	/* Mixed assistant text + tool_use -> two parts in order. */
+	memset(&r, 0, sizeof(r));
+	m = "{\"role\":\"assistant\",\"content\":["
+	    "{\"type\":\"text\",\"text\":\"let me look\"},"
+	    "{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"ls\","
+	    "\"input\":{}}]}";
+	convo_replay(m, strlen(m), rec_cb, &r);
+	CHECK(r.n == 2 && r.part[0] == CP_ASSIST && r.part[1] == CP_TOOL,
+	    "mixed assistant -> text then tool (got %d)", r.n);
+	if (r.n == 2)
+		CHECK(strcmp(r.text[1], "ls {}") == 0, "tool with empty input");
+	rec_free(&r);
+
+	/* Malformed input must not crash and yields nothing. */
+	memset(&r, 0, sizeof(r));
+	m = "{not json";
+	convo_replay(m, strlen(m), rec_cb, &r);
+	CHECK(r.n == 0, "malformed message -> no parts");
+	rec_free(&r);
+}
+
+static void
+test_journal_roundtrip(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct rec	r;
+	struct buf	proj;
+	char		id[64];
+	const char	*l0 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*l1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*l2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+
+	/* Write a fresh session. */
+	journal_open(&j, NULL);
+	CHECK(j.fd >= 0, "journal_open mints and opens a session");
+	if (j.fd < 0)
+		return;
+	strlcpy(id, j.id, sizeof(id));
+	journal_append(&j, l0, strlen(l0));
+	journal_append(&j, l1, strlen(l1));
+	journal_append(&j, l2, strlen(l2));
+	journal_close(&j);
+
+	/* Resume it: convo is rebuilt and replayed. */
+	memset(&cv, 0, sizeof(cv));
+	memset(&r, 0, sizeof(r));
+	CHECK(convo_resume(&cv, id, rec_cb, &r) == 0, "convo_resume reads back");
+	CHECK(cv.n == 3, "all three messages restored (got %zu)", cv.n);
+
+	/* Replay drew: user q1, assistant a1, user q2. */
+	CHECK(r.n == 3, "three display parts (got %d)", r.n);
+	if (r.n == 3) {
+		CHECK(r.part[0] == CP_USER && strcmp(r.text[0], "q1") == 0,
+		    "part 0 user q1");
+		CHECK(r.part[1] == CP_ASSIST && strcmp(r.text[1], "a1") == 0,
+		    "part 1 assistant a1");
+		CHECK(r.part[2] == CP_USER && strcmp(r.text[2], "q2") == 0,
+		    "part 2 user q2");
+	}
+
+	/* The rebuilt projection is the three raw lines as a JSON array. */
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	{
+		struct buf	want;
+
+		buf_init(&want);
+		buf_printf(&want, "[%s,%s,%s]", l0, l1, l2);
+		buf_putc(&want, '\0');
+		CHECK(strcmp(proj.data, want.data) == 0,
+		    "projection matches the journal");
+		buf_free(&want);
+	}
+	buf_free(&proj);
+	rec_free(&r);
+	convo_free(&cv);
+
+	/* Reopening the same id appends to the same file (continuity). */
+	journal_open(&j, id);
+	CHECK(j.fd >= 0 && strcmp(j.id, id) == 0, "reopen keeps the id");
+	journal_append(&j, l2, strlen(l2));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+	CHECK(cv.n == 4, "appended message extends the file (got %zu)", cv.n);
+	convo_free(&cv);
+}
+
+static void
+test_markers(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct rec	r;
+	struct buf	mk, proj;
+	const char	*id = "markers-1";	/* explicit id: no mint collision */
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*a1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+	const char	*u3 = "{\"role\":\"user\",\"content\":\"q3\"}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for markers");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	journal_append(&j, a1, strlen(a1));
+	buf_init(&mk);				/* a /clear marker */
+	convo_marker_clear(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u2, strlen(u2));
+	buf_init(&mk);				/* a /compact marker (summary "SUM") */
+	convo_marker_compact(&mk, "SUM");
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u3, strlen(u3));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	memset(&r, 0, sizeof(r));
+	convo_resume(&cv, id, rec_cb, &r);
+
+	/* Active context = the summary pair (user frame + assistant SUM) + q3. */
+	CHECK(cv.n == 3, "markers: active context is summary pair + q3 (got %zu)",
+	    cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "SUM") != NULL, "summary kept in projection");
+	CHECK(strstr(proj.data, "q3") != NULL, "post-compact msg in projection");
+	CHECK(strstr(proj.data, "q1") == NULL, "pre-clear msg dropped");
+	CHECK(strstr(proj.data, "q2") == NULL, "pre-compact msg dropped");
+	buf_free(&proj);
+
+	/* Display replays everything with dividers: 7 parts in order. */
+	CHECK(r.n == 7, "markers: 7 display parts (got %d)", r.n);
+	if (r.n == 7) {
+		CHECK(r.part[2] == CP_INFO, "clear shown as a divider");
+		CHECK(r.part[4] == CP_INFO && r.part[5] == CP_ASSIST,
+		    "compact shown as divider + summary");
+		CHECK(r.part[6] == CP_USER && strcmp(r.text[6], "q3") == 0,
+		    "post-compact message shown");
+	}
+	rec_free(&r);
+	convo_free(&cv);
+}
+
+/*
+ * A Phase-2 synth pair: a skill marker, an assistant tool_use, and a user
+ * tool_result whose `content` is the skill body.  Resume must surface the body
+ * as a CP_TOOL part so the rebuilt transcript shows what the user saw live.
+ */
+static void
+test_skill_synth_pair(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct rec	r;
+	const char	*id = "synth-1";
+	const char	*mk = "{\"fugu\":\"skill\",\"name\":\"/hello\"}";
+	const char	*au = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"tool_use\",\"id\":\"tu1\","
+			    "\"name\":\"hello\",\"input\":{}}]}";
+	const char	*ur = "{\"role\":\"user\",\"content\":["
+			    "{\"type\":\"tool_result\",\"tool_use_id\":\"tu1\","
+			    "\"content\":\"HELLO BODY\"}]}";
+	int		 i, saw_body = 0;
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for synth pair");
+		return;
+	}
+	journal_append(&j, mk, strlen(mk));
+	journal_append(&j, au, strlen(au));
+	journal_append(&j, ur, strlen(ur));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	memset(&r, 0, sizeof(r));
+	convo_resume(&cv, id, rec_cb, &r);
+
+	/* Walk the replayed parts; the tool_result body must be surfaced. */
+	for (i = 0; i < r.n; i++)
+		if (r.part[i] == CP_TOOL &&
+		    strcmp(r.text[i], "HELLO BODY") == 0)
+			saw_body = 1;
+	CHECK(saw_body, "synth pair: tool_result body surfaced as CP_TOOL");
+	rec_free(&r);
+	convo_free(&cv);
+}
+
+/*
+ * A turn that failed mid-flight wrote three records and then a `turn_failed`
+ * marker.  Resume must roll the convo back to the boundary that opened the
+ * failed turn (the skill marker), drop the orphan trio, and emit a CP_INFO
+ * divider so the user sees what happened.  A fresh user message that follows
+ * is preserved.
+ */
+static void
+test_turn_failed_orphan_trio(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct rec	r;
+	struct buf	mk, proj;
+	const char	*id = "tf-orphan-1";
+	const char	*sk = "{\"fugu\":\"skill\",\"name\":\"/hello\"}";
+	const char	*up = "{\"role\":\"user\",\"content\":\"/hello\"}";
+	const char	*am = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"tool_use\",\"id\":\"tu1\","
+			    "\"name\":\"hello\",\"input\":{}}]}";
+	const char	*um = "{\"role\":\"user\",\"content\":["
+			    "{\"type\":\"tool_result\",\"tool_use_id\":\"tu1\","
+			    "\"content\":\"HELLO BODY\"}]}";
+	const char	*u2 = "{\"role\":\"user\",\"content\":\"q after\"}";
+	int		 i, saw_info = 0;
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for turn-failed orphan");
+		return;
+	}
+	journal_append(&j, sk, strlen(sk));
+	journal_append(&j, up, strlen(up));
+	journal_append(&j, am, strlen(am));
+	journal_append(&j, um, strlen(um));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u2, strlen(u2));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	memset(&r, 0, sizeof(r));
+	convo_resume(&cv, id, rec_cb, &r);
+
+	CHECK(cv.n == 1, "orphan trio dropped, only fresh user kept (got %zu)",
+	    cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "q after") != NULL, "fresh user preserved");
+	CHECK(strstr(proj.data, "HELLO BODY") == NULL,
+	    "orphan tool_result body dropped");
+	CHECK(strstr(proj.data, "tool_use") == NULL,
+	    "orphan tool_use dropped");
+	buf_free(&proj);
+
+	for (i = 0; i < r.n; i++)
+		if (r.part[i] == CP_INFO &&
+		    strcmp(r.text[i], "previous failed turn discarded") == 0)
+			saw_info = 1;
+	CHECK(saw_info, "CP_INFO 'previous failed turn discarded' emitted");
+	rec_free(&r);
+	convo_free(&cv);
+}
+
+/*
+ * A plain-user turn (no skill marker) that fails leaves a typed-user message
+ * + partial assistant text in the journal followed by a turn_failed marker.
+ * Resume rolls back to the typed-user boundary -- dropping both -- and keeps
+ * earlier conversation intact.
+ */
+static void
+test_turn_failed_plain_user(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct buf	mk, proj;
+	const char	*id = "tf-plain-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*a1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+	const char	*a2p = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"partial\"}]}";
+	const char	*u3 = "{\"role\":\"user\",\"content\":\"q3 after\"}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for plain-user turn failed");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	journal_append(&j, a1, strlen(a1));
+	journal_append(&j, u2, strlen(u2));
+	journal_append(&j, a2p, strlen(a2p));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u3, strlen(u3));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+
+	CHECK(cv.n == 3, "plain failed turn rolled back to q1+a1+q3 (got %zu)",
+	    cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "q1") != NULL, "q1 preserved");
+	CHECK(strstr(proj.data, "a1") != NULL, "a1 preserved");
+	CHECK(strstr(proj.data, "q3 after") != NULL, "post-failure q3 preserved");
+	CHECK(strstr(proj.data, "q2") == NULL, "failed-turn q2 dropped");
+	CHECK(strstr(proj.data, "partial") == NULL,
+	    "partial assistant dropped");
+	buf_free(&proj);
+	convo_free(&cv);
+}
+
+/*
+ * Two failed turns in a row.  Each turn_failed rolls back to its own boundary;
+ * we should be left with the unrelated context before either turn started.
+ */
+static void
+test_turn_failed_back_to_back(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct buf	mk, proj;
+	const char	*id = "tf-double-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*a1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+	const char	*u3 = "{\"role\":\"user\",\"content\":\"q3\"}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for back-to-back turn failed");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	journal_append(&j, a1, strlen(a1));
+	journal_append(&j, u2, strlen(u2));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u3, strlen(u3));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+
+	CHECK(cv.n == 2, "back-to-back failures leave q1+a1 (got %zu)", cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "q1") != NULL, "q1 preserved");
+	CHECK(strstr(proj.data, "a1") != NULL, "a1 preserved");
+	CHECK(strstr(proj.data, "q2") == NULL, "first failed turn dropped");
+	CHECK(strstr(proj.data, "q3") == NULL, "second failed turn dropped");
+	buf_free(&proj);
+	convo_free(&cv);
+}
+
+/*
+ * Two turn_failed markers with NO intervening message: the second one runs
+ * against the already-truncated state and must be a clean no-op (truncate to
+ * the same boundary).  Pins down that a queued/retried error event can't roll
+ * back further than the first one already did.
+ */
+static void
+test_turn_failed_adjacent_markers(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct buf	mk, proj;
+	const char	*id = "tf-adj-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*a1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*u2 = "{\"role\":\"user\",\"content\":\"q2\"}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for adjacent markers");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	journal_append(&j, a1, strlen(a1));
+	journal_append(&j, u2, strlen(u2));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	journal_append(&j, mk.data, mk.len);	/* second, adjacent */
+	buf_free(&mk);
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+
+	CHECK(cv.n == 2, "adjacent markers leave q1+a1 (got %zu)", cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "q1") != NULL && strstr(proj.data, "a1") != NULL,
+	    "q1+a1 survive the doubled marker");
+	CHECK(strstr(proj.data, "q2") == NULL,
+	    "second marker did not roll back past the first");
+	buf_free(&proj);
+	convo_free(&cv);
+}
+
+/*
+ * A bare turn_failed marker with no prior content (journal starts with it) is
+ * a no-op: the boundary is 0, the convo is already empty, and we still emit
+ * the CP_INFO divider.  This must not crash or leave malformed state, and the
+ * subsequent typed-user message must reach the rebuilt convo intact.
+ */
+static void
+test_turn_failed_at_start(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct rec	r;
+	struct buf	mk, proj;
+	const char	*id = "tf-start-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"hello\"}";
+	int		 i, info_count = 0;
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for turn-failed at start");
+		return;
+	}
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u1, strlen(u1));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	memset(&r, 0, sizeof(r));
+	convo_resume(&cv, id, rec_cb, &r);
+
+	CHECK(cv.n == 1, "turn_failed at journal start is a no-op (got %zu)",
+	    cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "hello") != NULL,
+	    "post-marker user message survives intact");
+	buf_free(&proj);
+	for (i = 0; i < r.n; i++)
+		if (r.part[i] == CP_INFO &&
+		    strcmp(r.text[i], "previous failed turn discarded") == 0)
+			info_count++;
+	CHECK(info_count == 1, "exactly one 'discarded' CP_INFO (got %d)",
+	    info_count);
+	rec_free(&r);
+	convo_free(&cv);
+}
+
+/*
+ * A failed turn AFTER a /compact must roll back ONLY the failed turn, leaving
+ * the compact's summary pair intact.  Pins down that RK_COMPACT sets boundary
+ * to cv->n (= 2 post-summary), so a subsequent turn_failed truncates to 2 and
+ * preserves the synthetic user/assistant pair carrying the summary.
+ */
+static void
+test_turn_failed_after_compact(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct buf	mk, proj;
+	const char	*id = "tf-compact-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"early\"}";
+	const char	*u2 = "{\"role\":\"user\",\"content\":\"after\"}";
+	const char	*a2p = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"partial\"}]}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for after-compact");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	buf_init(&mk);
+	convo_marker_compact(&mk, "SUMMARY");
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, u2, strlen(u2));
+	journal_append(&j, a2p, strlen(a2p));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+
+	CHECK(cv.n == 2, "compact summary pair survives the failure (got %zu)",
+	    cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "SUMMARY") != NULL,
+	    "summary text preserved across the failure");
+	CHECK(strstr(proj.data, "after") == NULL,
+	    "failed-turn user dropped");
+	CHECK(strstr(proj.data, "partial") == NULL,
+	    "failed-turn partial assistant dropped");
+	CHECK(strstr(proj.data, "early") == NULL,
+	    "pre-compact content gone (compact already cleared it)");
+	buf_free(&proj);
+	convo_free(&cv);
+}
+
+/*
+ * A failed turn AFTER a /clear must roll back to the cleared state -- the
+ * convo stays empty.  Pins down that RK_CLEAR resets boundary to 0 so a
+ * subsequent turn_failed can't reintroduce pre-clear content.
+ */
+static void
+test_turn_failed_after_clear(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct buf	mk;
+	const char	*id = "tf-clear-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*a1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*a2p = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"partial\"}]}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for after-clear");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	journal_append(&j, a1, strlen(a1));
+	buf_init(&mk);
+	convo_marker_clear(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, a2p, strlen(a2p));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+
+	CHECK(cv.n == 0,
+	    "post-clear failure leaves cleared state (got %zu)", cv.n);
+	convo_free(&cv);
+}
+
+/*
+ * Recovery: a skill turn failed, then the user retried with another skill
+ * turn that succeeded.  Only the first trio is gone; the second replays
+ * intact.  The canonical real-world resume shape after a transient error.
+ */
+static void
+test_recovery_after_failure(void)
+{
+	struct journal	j;
+	struct convo	cv;
+	struct buf	mk, proj;
+	const char	*id = "tf-recovery-1";
+	const char	*sk = "{\"fugu\":\"skill\",\"name\":\"/hello\"}";
+	const char	*up = "{\"role\":\"user\",\"content\":\"/hello\"}";
+	const char	*am = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"tool_use\",\"id\":\"tu1\","
+			    "\"name\":\"hello\",\"input\":{}}]}";
+	const char	*um = "{\"role\":\"user\",\"content\":["
+			    "{\"type\":\"tool_result\",\"tool_use_id\":\"tu1\","
+			    "\"content\":\"BODY1\"}]}";
+	const char	*sk2 = "{\"fugu\":\"skill\",\"name\":\"/world\"}";
+	const char	*up2 = "{\"role\":\"user\",\"content\":\"/world\"}";
+	const char	*am2 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"tool_use\",\"id\":\"tu2\","
+			    "\"name\":\"world\",\"input\":{}}]}";
+	const char	*um2 = "{\"role\":\"user\",\"content\":["
+			    "{\"type\":\"tool_result\",\"tool_use_id\":\"tu2\","
+			    "\"content\":\"BODY2\"}]}";
+	const char	*a2 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"ok\"}]}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for recovery");
+		return;
+	}
+	journal_append(&j, sk, strlen(sk));
+	journal_append(&j, up, strlen(up));
+	journal_append(&j, am, strlen(am));
+	journal_append(&j, um, strlen(um));
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(&j, mk.data, mk.len);
+	buf_free(&mk);
+	journal_append(&j, sk2, strlen(sk2));
+	journal_append(&j, up2, strlen(up2));
+	journal_append(&j, am2, strlen(am2));
+	journal_append(&j, um2, strlen(um2));
+	journal_append(&j, a2, strlen(a2));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	convo_resume(&cv, id, NULL, NULL);
+
+	CHECK(cv.n == 4, "second trio + assistant survive (got %zu)", cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "BODY2") != NULL,
+	    "second skill body preserved");
+	CHECK(strstr(proj.data, "BODY1") == NULL,
+	    "first (failed) skill body dropped");
+	CHECK(strstr(proj.data, "tu2") != NULL,
+	    "second tool_use survives");
+	buf_free(&proj);
+	convo_free(&cv);
+}
+
+/*
+ * Old-format journal (no turn_failed marker) containing the actual orphan
+ * shape -- a skill marker + tool_use + tool_result with no terminating
+ * turn_failed -- must still replay without crashing.  We don't auto-clean
+ * retroactively (the buggy convo is what we get), but no new code may regress
+ * the no-crash guarantee or start dropping the orphan synth trio silently.
+ */
+static void
+test_legacy_journal_no_marker(void)
+{
+	struct convo	cv;
+	struct buf	proj;
+	struct journal	j;
+	const char	*id = "tf-legacy-1";
+	const char	*u1 = "{\"role\":\"user\",\"content\":\"q1\"}";
+	const char	*a1 = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"text\",\"text\":\"a1\"}]}";
+	const char	*sk = "{\"fugu\":\"skill\",\"name\":\"/legacy\"}";
+	const char	*am = "{\"role\":\"assistant\",\"content\":["
+			    "{\"type\":\"tool_use\",\"id\":\"L1\","
+			    "\"name\":\"legacy\",\"input\":{}}]}";
+	const char	*um = "{\"role\":\"user\",\"content\":["
+			    "{\"type\":\"tool_result\",\"tool_use_id\":\"L1\","
+			    "\"content\":\"LEGACY BODY\"}]}";
+
+	journal_open(&j, id);
+	if (j.fd < 0) {
+		CHECK(0, "journal_open for legacy");
+		return;
+	}
+	journal_append(&j, u1, strlen(u1));
+	journal_append(&j, a1, strlen(a1));
+	journal_append(&j, sk, strlen(sk));
+	journal_append(&j, am, strlen(am));
+	journal_append(&j, um, strlen(um));
+	journal_close(&j);
+
+	memset(&cv, 0, sizeof(cv));
+	CHECK(convo_resume(&cv, id, NULL, NULL) == 0,
+	    "legacy journal still replays");
+	CHECK(cv.n == 4,
+	    "legacy orphan synth trio kept (no auto-clean) (got %zu)", cv.n);
+	buf_init(&proj);
+	convo_build(&cv, &proj);
+	buf_putc(&proj, '\0');
+	CHECK(strstr(proj.data, "LEGACY BODY") != NULL,
+	    "legacy orphan tool_result body preserved");
+	CHECK(strstr(proj.data, "tool_use") != NULL,
+	    "legacy orphan tool_use preserved");
+	buf_free(&proj);
+	convo_free(&cv);
+}
+
+static void
+test_journal_errors(void)
+{
+	struct convo	cv;
+
+	memset(&cv, 0, sizeof(cv));
+	CHECK(convo_resume(&cv, "does-not-exist", NULL, NULL) == -1,
+	    "missing session -> -1");
+	CHECK(convo_resume(&cv, "../escape", NULL, NULL) == -1,
+	    "path-traversal id rejected");
+	CHECK(convo_resume(&cv, "a/b", NULL, NULL) == -1, "slash id rejected");
+	CHECK(cv.n == 0, "failed resume left convo empty");
+	convo_free(&cv);
+}
+
+/* Smoke test of `fugu -l`: write a session, run the parent, check the listing. */
+static void
+test_list_smoke(const char *home)
+{
+	char	path[1024], out[4096];
+	const char *fugu_bin;
+	FILE	*fp;
+	pid_t	 pid;
+	ssize_t	 k;
+	size_t	 outlen = 0;
+	int	 fd, pfd[2], status;
+
+	if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+		return;				/* unit-only run: skip */
+
+	/* A minimal config so the parent's conf_load is satisfied. */
+	snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+	if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+		return;
+	(void)fchmod(fd, 0600);
+	if ((fp = fdopen(fd, "w")) != NULL) {
+		fputs("anthropic_key \"test-key\"\n", fp);
+		fclose(fp);
+	} else
+		close(fd);
+
+	/* A session file with a known id and first message. */
+	snprintf(path, sizeof(path), "%s/.fugu/sessions/9999999999-1.jsonl",
+	    home);
+	if ((fp = fopen(path, "w")) == NULL)
+		return;
+	fputs("{\"role\":\"user\",\"content\":\"hello from resume test\"}\n", fp);
+	fputs("{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+	    "\"text\":\"hi\"}]}\n", fp);
+	fclose(fp);
+
+	if (pipe(pfd) == -1)
+		return;
+	if ((pid = fork()) == -1) {
+		close(pfd[0]);
+		close(pfd[1]);
+		return;
+	}
+	if (pid == 0) {
+		dup2(pfd[1], STDOUT_FILENO);
+		close(pfd[0]);
+		close(pfd[1]);
+		execl(fugu_bin, fugu_bin, "-l", (char *)NULL);
+		_exit(127);
+	}
+	close(pfd[1]);
+	while (outlen < sizeof(out) - 1) {
+		k = read(pfd[0], out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+	}
+	out[outlen] = '\0';
+	close(pfd[0]);
+	waitpid(pid, &status, 0);
+
+	CHECK(strstr(out, "9999999999-1") != NULL,
+	    "fugu -l lists the session id");
+	CHECK(strstr(out, "hello from resume test") != NULL,
+	    "fugu -l shows the first-message preview");
+}
+
+int
+main(void)
+{
+	char	home[] = "/tmp/fugu-resume.XXXXXX";
+	char	path[1024];
+
+	if (mkdtemp(home) == NULL) {
+		perror("mkdtemp");
+		return (1);
+	}
+	setenv("HOME", home, 1);
+	/* Not setgid in the harness, so the parent honours FUGU_CONF. */
+	snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+	setenv("FUGU_CONF", path, 1);
+	snprintf(path, sizeof(path), "%s/.fugu", home);
+	mkdir(path, 0700);
+	snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+	mkdir(path, 0700);
+
+	test_replay();
+	test_journal_roundtrip();
+	test_markers();
+	test_skill_synth_pair();
+	test_turn_failed_orphan_trio();
+	test_turn_failed_plain_user();
+	test_turn_failed_back_to_back();
+	test_turn_failed_adjacent_markers();
+	test_turn_failed_at_start();
+	test_turn_failed_after_compact();
+	test_turn_failed_after_clear();
+	test_recovery_after_failure();
+	test_legacy_journal_no_marker();
+	test_journal_errors();
+	test_list_smoke(home);
+
+	/* Best-effort cleanup of the temp HOME tree. */
+	{
+		char		 p[1100];
+		DIR		*d;
+		struct dirent	*e;
+
+		snprintf(p, sizeof(p), "%s/.fugu/sessions", home);
+		if ((d = opendir(p)) != NULL) {
+			while ((e = readdir(d)) != NULL) {
+				char	f[2200];
+
+				if (e->d_name[0] == '.')
+					continue;
+				snprintf(f, sizeof(f), "%s/%s", p, e->d_name);
+				unlink(f);
+			}
+			closedir(d);
+		}
+		rmdir(p);
+		snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+		unlink(p);
+		snprintf(p, sizeof(p), "%s/.fugu", home);
+		rmdir(p);
+		rmdir(home);
+	}
+
+	printf("resume_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + effefbadac2b39672752754e3dca229d60666d13 (mode 644)
--- /dev/null
+++ regress/sandbox/Makefile
@@ -0,0 +1,33 @@
+# Sandbox verification for the exec worker: the pledge/unveil boundary holds
+# (no network for tool subprocesses by default, no writes outside the project
+# dir), driving the real exec_sandbox()/exec_loop().
+
+PROG=		sandbox_test
+SRCS=		sandbox_test.c exec_run.c tools_file.c tools_shell.c \
+		json.c buf.c util.c log.c imsgproto.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+EXECDIR=	${.CURDIR}/../../src/exec
+CFLAGS+=	-I${COMMONDIR} -I${EXECDIR}
+.PATH:		${COMMONDIR} ${EXECDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-sandbox_test
+
+run-sandbox_test: ${PROG}
+	./${PROG}
+
+# Syscall-level view (run manually): trace the worker and its children, then
+# show the denied network syscalls that enforce the no-network boundary.
+ktrace: ${PROG}
+	@rm -f ktrace.out
+	@ktrace -di -f ktrace.out ./${PROG} >/dev/null 2>&1 || true
+	@echo "--- denied syscalls in the sandboxed children ---"
+	@kdump -f ktrace.out | \
+	    grep -iE "pledge|Operation not permitted" | head
+	@rm -f ktrace.out
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 64a161c1377c83da3e0f36be75926f1d7cb1f3d7 (mode 644)
--- /dev/null
+++ regress/sandbox/sandbox_test.c
@@ -0,0 +1,232 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Sandbox verification for the exec worker.  Drives the real exec_sandbox() +
+ * exec_loop() and asserts the security boundary holds:
+ *
+ *   - with the default policy, a network-using base tool (ftp) run by the shell
+ *     tool is denied: ftp inherits an execpromises ceiling without "inet", so
+ *     its own pledge(2) fails ("pledge: Operation not permitted");
+ *   - a write outside the unveil'd project directory is refused;
+ *   - ordinary commands still run (the sandbox is confining, not broken);
+ *   - with allow_subprocess_net, the same ftp is NOT pledge-denied (the network
+ *     is permitted at the pledge level; the connection itself may still fail).
+ *
+ * This exercises the same code path fugu-exec uses; for a syscall-level view,
+ * run it under ktrace(1) and inspect with kdump(1) (see the Makefile).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "json.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/* The shell command used to probe the network: ftp pledges "inet" itself. */
+#define FTP_PROBE \
+	"{\"command\":\"ftp -o /dev/null http://127.0.0.1:9/ 2>&1; true\"}"
+
+static int
+recv_msg(struct imsgproto *ip, struct fugu_msg *m)
+{
+	int	r, n;
+
+	for (;;) {
+		if ((r = ip_get(ip, m)) == 1)
+			return (1);
+		if (r == -1)
+			return (-1);
+		if ((n = ip_read(ip)) <= 0)
+			return (0);
+	}
+}
+
+static void
+send_req(struct imsgproto *ip, const char *id, const char *name,
+    const char *input_json)
+{
+	struct json_writer	w;
+	struct buf		b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id);
+	json_kv_string(&w, "name", name);
+	json_key(&w, "input");
+	json_raw(&w, input_json);
+	json_object_end(&w);
+	if (ip_compose(ip, M_TOOL_REQUEST, EP_EXEC, EP_NET, b.data,
+	    b.len) == -1 || ip_flush(ip) == -1)
+		errx(1, "send_req");
+	buf_free(&b);
+}
+
+static int
+recv_result(struct imsgproto *ip, char *content, size_t cap, int *is_error)
+{
+	struct fugu_msg	m;
+	struct json_doc	d;
+	char		*c;
+	int		t;
+
+	if (recv_msg(ip, &m) != 1 || m.type != M_TOOL_RESULT)
+		return (-1);
+	if (json_parse(&d, m.data, m.len) == -1) {
+		ip_msg_free(&m);
+		return (-1);
+	}
+	content[0] = '\0';
+	if ((t = json_obj_get(&d, 0, "content")) >= 0 &&
+	    (c = json_tok_strdup(&d, t)) != NULL) {
+		strlcpy(content, c, cap);
+		free(c);
+	}
+	t = json_obj_get(&d, 0, "is_error");
+	*is_error = (t >= 0 && json_tok_streq(&d, t, "true"));
+	json_doc_free(&d);
+	ip_msg_free(&m);
+	return (0);
+}
+
+/* Fork a sandboxed exec worker chdir'd to cwd; return its pid and parent imsg. */
+static pid_t
+spawn_worker(int allow_net, const char *cwd, struct imsgproto *ip)
+{
+	pid_t	pid;
+	int	sv[2];
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
+		err(1, "socketpair");
+	if ((pid = fork()) == -1)
+		err(1, "fork");
+	if (pid == 0) {
+		struct imsgproto	eip;
+
+		close(sv[0]);
+		if (chdir(cwd) == -1)
+			_exit(40);
+		if (ip_init(&eip, sv[1]) == -1)
+			_exit(41);
+		exec_sandbox(allow_net);
+		exec_loop(&eip);
+		_exit(0);
+	}
+	close(sv[1]);
+	if (ip_init(ip, sv[0]) == -1)
+		err(1, "ip_init");
+	return (pid);
+}
+
+static void
+stop_worker(struct imsgproto *ip, pid_t pid)
+{
+	int	status;
+
+	if (ip_compose(ip, M_SHUTDOWN, EP_EXEC, EP_PARENT, NULL, 0) != -1)
+		ip_flush(ip);
+	if (waitpid(pid, &status, 0) == -1)
+		err(1, "waitpid");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "exec worker exit %d", status);
+	ip_clear(ip);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "sandbox_test: TIMED OUT\n");
+	_exit(97);
+}
+
+int
+main(void)
+{
+	struct imsgproto	ip;
+	char			content[8192], td[] = "/tmp/fugu-sbx.XXXXXX";
+	const char		*escape = "/tmp/fugu-sbx-escape.txt";
+	pid_t			pid;
+	int			is_err;
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(60);
+
+	if (mkdtemp(td) == NULL)
+		err(1, "mkdtemp");
+	unlink(escape);
+
+	/* ---- default policy: no network for the worker or its children ---- */
+	pid = spawn_worker(0, td, &ip);
+
+	send_req(&ip, "n1", "shell", FTP_PROBE);
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(strstr(content, "pledge") != NULL,
+	    "no-net: a network command is denied by pledge (%s)", content);
+
+	send_req(&ip, "n2", "write",
+	    "{\"path\":\"/tmp/fugu-sbx-escape.txt\",\"content\":\"x\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(is_err, "no-net: write outside the project dir is refused");
+	CHECK(access(escape, F_OK) == -1, "no-net: escape file not created");
+
+	send_req(&ip, "n3", "shell", "{\"command\":\"echo ok-sandbox\"}");
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(!is_err && strstr(content, "ok-sandbox") != NULL,
+	    "no-net: ordinary commands still run (%s)", content);
+
+	stop_worker(&ip, pid);
+
+	/* ---- allow_subprocess_net: the network is permitted at pledge ---- */
+	pid = spawn_worker(1, td, &ip);
+
+	send_req(&ip, "y1", "shell", FTP_PROBE);
+	recv_result(&ip, content, sizeof(content), &is_err);
+	CHECK(strstr(content, "pledge: Operation not permitted") == NULL,
+	    "subproc-net: ftp is not pledge-denied (%s)", content);
+
+	stop_worker(&ip, pid);
+
+	unlink(escape);
+	rmdir(td);
+
+	printf("sandbox_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 524cf3327ed5273977877a793bd703692d21fdb0 (mode 644)
--- /dev/null
+++ regress/skills/Makefile
@@ -0,0 +1,20 @@
+# Unit tests for the skills loader/match/complete (src/ui/skills.c).
+
+PROG=		skills_test
+SRCS=		skills_test.c skills.c json.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+UIDIR=		${.CURDIR}/../../src/ui
+CFLAGS+=	-I${COMMONDIR} -I${UIDIR}
+.PATH:		${COMMONDIR} ${UIDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-skills_test
+
+run-skills_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 1cdba1fc1bc799035aa80854b43c8ac30d8d7bf1 (mode 644)
--- /dev/null
+++ regress/skills/skills_test.c
@@ -0,0 +1,566 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Unit tests for the skills loader.  Builds a throwaway ~/.fugu/skills layout
+ * under a temp HOME, exercises skills_load on it, then checks the registry
+ * shape, match/complete behaviour, and the journal marker.
+ */
+
+#include <sys/stat.h>
+
+#include <err.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "json.h"
+#include "skills.h"
+
+static int	fails;
+static int	checks;
+
+#define CHECK(cond, msg) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		fprintf(stderr, "FAIL %s:%d: %s\n",			\
+		    __func__, __LINE__, (msg));				\
+		fails++;						\
+	}								\
+} while (0)
+
+static void
+check_str(const char *func, int line, const char *got, const char *want,
+    const char *msg)
+{
+	checks++;
+	if (got == NULL || strcmp(got, want) != 0) {
+		fprintf(stderr, "FAIL %s:%d: %s\n  got:  %s\n  want: %s\n",
+		    func, line, msg, got != NULL ? got : "(null)", want);
+		fails++;
+	}
+}
+
+#define CHECK_STR(got, want, msg)					\
+	check_str(__func__, __LINE__, (got), (want), (msg))
+
+/* Write a file (creating parent dirs as needed under the temp HOME). */
+static void
+drop_skill(const char *home, const char *dirname, const char *content)
+{
+	char	path[1024];
+	FILE	*f;
+
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/%s", home, dirname);
+	if (mkdir(path, 0755) == -1 && errno != EEXIST)
+		err(1, "mkdir %s", path);
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/%s/SKILL.md",
+	    home, dirname);
+	if ((f = fopen(path, "w")) == NULL)
+		err(1, "fopen %s", path);
+	if (content != NULL)
+		fputs(content, f);
+	fclose(f);
+}
+
+static char *
+locate_skill(const struct skills *sk, const char *slash_name)
+{
+	size_t	i;
+
+	for (i = 0; i < sk->n; i++)
+		if (strcmp(sk->items[i].name, slash_name) == 0)
+			return (sk->items[i].body);
+	return (NULL);
+}
+
+static void
+test_load_basic(const char *home)
+{
+	struct skills		sk;
+	const struct skill	*m;
+	const char		*trailing;
+	const char		*matches[8];
+	int			n;
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+
+	/* Three valid skills were dropped; the malformed and empty-body skills are skipped. */
+	CHECK(sk.n == 3, "loaded count = 3");
+
+	/* Alphabetical order. */
+	if (sk.n >= 3) {
+		CHECK_STR(sk.items[0].name, "/alpha", "first sorted");
+		CHECK_STR(sk.items[1].name, "/bravo", "second sorted");
+		CHECK_STR(sk.items[2].name, "/named", "header name wins");
+	}
+
+	/* Body trimming + content. */
+	CHECK_STR(locate_skill(&sk, "/alpha"), "alpha body", "alpha body");
+	CHECK_STR(locate_skill(&sk, "/bravo"),
+	    "bravo body\nsecond line", "bravo body multi-line");
+
+	/* skills_match: exact, with trailing, no leading slash, miss. */
+	m = skills_match(&sk, "/alpha", &trailing);
+	CHECK(m != NULL && strcmp(m->name, "/alpha") == 0, "match /alpha");
+	CHECK(trailing == NULL, "no trailing for bare name");
+
+	m = skills_match(&sk, "/bravo rest of line", &trailing);
+	CHECK(m != NULL && strcmp(m->name, "/bravo") == 0, "match /bravo with arg");
+	CHECK_STR(trailing, "rest of line", "trailing captured");
+
+	m = skills_match(&sk, "bravo", &trailing);
+	CHECK(m == NULL, "no match without leading slash");
+
+	m = skills_match(&sk, "/nope", &trailing);
+	CHECK(m == NULL, "no match for unknown");
+
+	m = skills_match(&sk, "/alphabet", &trailing);
+	CHECK(m == NULL, "no prefix match (name must equal first token)");
+
+	/* skills_complete. */
+	n = skills_complete(&sk, "/", matches, 8);
+	CHECK(n == 3, "complete '/' returns all three");
+
+	n = skills_complete(&sk, "/b", matches, 8);
+	CHECK(n == 1 && strcmp(matches[0], "/bravo") == 0, "complete '/b' -> bravo");
+
+	n = skills_complete(&sk, "/z", matches, 8);
+	CHECK(n == 0, "no match completes to 0");
+
+	skills_free(&sk);
+	CHECK(sk.n == 0 && sk.items == NULL, "free zeroes registry");
+}
+
+static void
+test_marker(void)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	skills_marker(&b, "/triage");
+	buf_terminate(&b);
+	CHECK_STR(b.data, "{\"fugu\":\"skill\",\"name\":\"/triage\"}",
+	    "marker shape");
+	buf_free(&b);
+}
+
+/* The tool fragment registers `skill` as a real tool the model can invoke. */
+static void
+test_tool_fragment(void)
+{
+	struct skills	sk;
+	struct buf	b;
+	struct json_doc	d;
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+
+	buf_init(&b);
+	skills_tool_fragment(&sk, &b);
+	CHECK(b.len > 0, "fragment is non-empty when skills are loaded");
+	CHECK(b.data[0] == ',', "fragment is comma-prefixed for splicing");
+
+	/*
+	 * Parse the fragment by wrapping it in array brackets (with a sentinel
+	 * placeholder so the leading comma is between two elements) -- the
+	 * parser checks well-formedness end-to-end.
+	 */
+	{
+		struct buf	wrap;
+
+		buf_init(&wrap);
+		buf_puts_cstr(&wrap, "[1");
+		buf_append(&wrap, b.data, b.len);
+		buf_putc(&wrap, ']');
+		CHECK(json_parse(&d, wrap.data, wrap.len) == 0 &&
+		    json_tok_type(&d, 0) == JSON_ARRAY,
+		    "fragment is well-formed JSON when spliced");
+		json_doc_free(&d);
+		buf_free(&wrap);
+	}
+
+	/* Contains the tool name and at least one expected skill in the enum. */
+	CHECK(strstr(b.data, "\"name\":\"skill\"") != NULL,
+	    "fragment names the `skill` tool");
+	CHECK(strstr(b.data, "\"alpha\"") != NULL &&
+	    strstr(b.data, "\"bravo\"") != NULL,
+	    "fragment enumerates loaded skill names");
+	CHECK(strstr(b.data, "alpha: first one") != NULL,
+	    "fragment includes the description for matching");
+
+	buf_free(&b);
+	skills_free(&sk);
+}
+
+/*
+ * The synth triple is a user(typed slash) + assistant(tool_use) +
+ * user(tool_result) sequence sharing the generated tool_use_id, with the body
+ * delivered as the tool_result content.  The leading user message ensures the
+ * messages array starts with a user role even on the first turn or right after
+ * /compact.
+ */
+static void
+test_synth_invocation(void)
+{
+	struct skills		sk;
+	const struct skill	*s;
+	struct buf		up, am, um;
+	struct json_doc		d;
+	char			id[40];
+	const char		*trailing;
+	int			role_t, content_t, blk0, type_t, id_t;
+	char			*name_t, *got;
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+	s = skills_match(&sk, "/alpha", &trailing);
+	CHECK(s != NULL, "synth: located /alpha");
+	if (s == NULL) {
+		skills_free(&sk);
+		return;
+	}
+
+	buf_init(&up);
+	buf_init(&am);
+	buf_init(&um);
+	skills_synth_invocation(s, "/alpha the rest", "the rest",
+	    &up, &am, &um, id, sizeof(id));
+
+	CHECK(id[0] != '\0', "synth: generated a non-empty id");
+
+	/* User-pre message: role=user, content == typed slash command. */
+	CHECK(json_parse(&d, up.data, up.len) == 0,
+	    "synth: user-pre is valid JSON");
+	role_t = json_obj_get(&d, 0, "role");
+	CHECK(role_t > 0 && json_tok_streq(&d, role_t, "user"),
+	    "synth: user-pre role is user");
+	got = json_tok_strdup(&d, json_obj_get(&d, 0, "content"));
+	CHECK_STR(got, "/alpha the rest",
+	    "synth: user-pre content is the typed slash command");
+	free(got);
+	json_doc_free(&d);
+
+	/* Assistant message: tool_use(skill, name="alpha"), id matches. */
+	CHECK(json_parse(&d, am.data, am.len) == 0,
+	    "synth: assistant is valid JSON");
+	content_t = json_obj_get(&d, 0, "content");
+	CHECK(content_t > 0 && json_tok_type(&d, content_t) == JSON_ARRAY,
+	    "synth: assistant content is an array");
+	blk0 = content_t + 1;
+	type_t = json_obj_get(&d, blk0, "type");
+	CHECK(type_t > 0 && json_tok_streq(&d, type_t, "tool_use"),
+	    "synth: first block is tool_use");
+	name_t = json_tok_strdup(&d, json_obj_get(&d, blk0, "name"));
+	CHECK_STR(name_t, "skill", "synth: tool_use names `skill`");
+	free(name_t);
+	id_t = json_obj_get(&d, blk0, "id");
+	CHECK(id_t > 0 && json_tok_streq(&d, id_t, id),
+	    "synth: tool_use id matches generated id");
+	json_doc_free(&d);
+
+	/* User message: tool_result with matching tool_use_id and body+trailing. */
+	CHECK(json_parse(&d, um.data, um.len) == 0,
+	    "synth: user is valid JSON");
+	content_t = json_obj_get(&d, 0, "content");
+	blk0 = content_t + 1;
+	type_t = json_obj_get(&d, blk0, "type");
+	CHECK(type_t > 0 && json_tok_streq(&d, type_t, "tool_result"),
+	    "synth: first user block is tool_result");
+	id_t = json_obj_get(&d, blk0, "tool_use_id");
+	CHECK(id_t > 0 && json_tok_streq(&d, id_t, id),
+	    "synth: tool_result id matches tool_use id");
+	got = json_tok_strdup(&d, json_obj_get(&d, blk0, "content"));
+	CHECK_STR(got, "alpha body\n\nthe rest",
+	    "synth: tool_result content is body + trailing");
+	free(got);
+	json_doc_free(&d);
+
+	buf_free(&up);
+	buf_free(&am);
+	buf_free(&um);
+	skills_free(&sk);
+}
+
+/* Model-issued tool_use("skill", name=...) -> body or is_error. */
+static void
+test_handle_tool_request(void)
+{
+	struct skills	sk;
+	struct buf	out;
+	struct json_doc	d;
+	char		*content;
+	const char	*req_ok = "{\"id\":\"x1\",\"name\":\"skill\","
+	    "\"input\":{\"name\":\"alpha\"}}";
+	const char	*req_bad = "{\"id\":\"x2\",\"name\":\"skill\","
+	    "\"input\":{\"name\":\"nope\"}}";
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+
+	/* Hit. */
+	buf_init(&out);
+	skills_handle_tool_request(&sk, req_ok, strlen(req_ok), &out);
+	CHECK(json_parse(&d, out.data, out.len) == 0,
+	    "handle: hit result is valid JSON");
+	content = json_tok_strdup(&d, json_obj_get(&d, 0, "content"));
+	CHECK_STR(content, "alpha body", "handle: hit content is body");
+	free(content);
+	{
+		int	t = json_obj_get(&d, 0, "is_error");
+		CHECK(t > 0 && json_tok_streq(&d, t, "false"),
+		    "handle: hit is not an error");
+	}
+	json_doc_free(&d);
+	buf_free(&out);
+
+	/* Miss. */
+	buf_init(&out);
+	skills_handle_tool_request(&sk, req_bad, strlen(req_bad), &out);
+	CHECK(json_parse(&d, out.data, out.len) == 0,
+	    "handle: miss result is valid JSON");
+	{
+		int	t = json_obj_get(&d, 0, "is_error");
+		CHECK(t > 0 && json_tok_streq(&d, t, "true"),
+		    "handle: miss is an error");
+	}
+	json_doc_free(&d);
+	buf_free(&out);
+
+	skills_free(&sk);
+}
+
+/* disable-model-invocation excludes the skill from the tool's enum. */
+static void
+test_disable_model_invocation(const char *home)
+{
+	struct skills	sk;
+	struct buf	b;
+	char		path[1024];
+
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+	mkdir(path, 0755);
+	(void)snprintf(path, sizeof(path),
+	    "%s/.fugu/skills/private/SKILL.md", home);
+	{
+		FILE	*f = fopen(path, "w");
+
+		fputs("---\nname: private\ndisable-model-invocation: true\n---\n"
+		    "secret\n", f);
+		fclose(f);
+	}
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+
+	/* Skill loads, slash-invocation still works. */
+	CHECK(skills_match(&sk, "/private", NULL) != NULL,
+	    "disabled skill still user-invocable via slash");
+
+	/* But the tool fragment must not include it. */
+	buf_init(&b);
+	skills_tool_fragment(&sk, &b);
+	CHECK(strstr(b.data, "\"private\"") == NULL,
+	    "disabled skill is excluded from the tool enum");
+	CHECK(strstr(b.data, "private: ") == NULL,
+	    "disabled skill is excluded from the description");
+
+	buf_free(&b);
+	skills_free(&sk);
+
+	/*
+	 * Clean up the on-disk skill we dropped so later tests (or future
+	 * tests added between this one and test_no_home) still see exactly
+	 * the three skills set up in main().
+	 */
+	(void)snprintf(path, sizeof(path),
+	    "%s/.fugu/skills/private/SKILL.md", home);
+	(void)unlink(path);
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+	(void)rmdir(path);
+}
+
+/*
+ * A skill with disable-model-invocation MUST NOT be reachable via the model's
+ * tool_use("skill", name=...) path -- even though it loads and is reachable
+ * via the user's slash command.  The handler returns an is_error result naming
+ * the skill as not found.
+ */
+static void
+test_disabled_skill_not_invocable(const char *home)
+{
+	struct skills	sk;
+	struct buf	out;
+	struct json_doc	d;
+	const char	*req = "{\"id\":\"x9\",\"name\":\"skill\","
+	    "\"input\":{\"name\":\"private\"}}";
+	int		t;
+	char		path[1024];
+
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+	mkdir(path, 0755);
+	(void)snprintf(path, sizeof(path),
+	    "%s/.fugu/skills/private/SKILL.md", home);
+	{
+		FILE	*f = fopen(path, "w");
+
+		fputs("---\nname: private\ndisable-model-invocation: true\n"
+		    "---\nsecret body\n", f);
+		fclose(f);
+	}
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+
+	buf_init(&out);
+	skills_handle_tool_request(&sk, req, strlen(req), &out);
+	CHECK(json_parse(&d, out.data, out.len) == 0,
+	    "disabled-tool: result is valid JSON");
+	t = json_obj_get(&d, 0, "is_error");
+	CHECK(t > 0 && json_tok_streq(&d, t, "true"),
+	    "disabled-tool: result is_error=true");
+	json_doc_free(&d);
+	buf_free(&out);
+
+	skills_free(&sk);
+
+	/* Clean up the on-disk private skill so later tests see a fresh registry. */
+	(void)snprintf(path, sizeof(path),
+	    "%s/.fugu/skills/private/SKILL.md", home);
+	(void)unlink(path);
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/private", home);
+	(void)rmdir(path);
+}
+
+/*
+ * A SKILL.md whose body carries an internal NUL byte must be refused at load
+ * time -- otherwise xstrdup() would silently truncate and the model would see
+ * a half-body.
+ */
+static void
+test_skill_with_internal_nul(const char *home)
+{
+	struct skills	sk;
+	char		path[1024];
+	FILE		*f;
+	size_t		i;
+	int		found = 0;
+	static const char	hdr[] = "---\nname: nulbody\n---\n";
+	static const char	body[] = "AAA\0BBB\n";
+
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/nulbody", home);
+	mkdir(path, 0755);
+	(void)snprintf(path, sizeof(path),
+	    "%s/.fugu/skills/nulbody/SKILL.md", home);
+	if ((f = fopen(path, "w")) == NULL)
+		err(1, "fopen %s", path);
+	fwrite(hdr, 1, sizeof(hdr) - 1, f);
+	fwrite(body, 1, sizeof(body) - 1, f);
+	fclose(f);
+
+	memset(&sk, 0, sizeof(sk));
+	skills_load(&sk);
+
+	for (i = 0; i < sk.n; i++)
+		if (strcmp(sk.items[i].name, "/nulbody") == 0)
+			found = 1;
+	CHECK(!found, "internal NUL: skill is rejected at load time");
+
+	skills_free(&sk);
+
+	/* Clean up so later tests are not affected by this skill. */
+	(void)snprintf(path, sizeof(path),
+	    "%s/.fugu/skills/nulbody/SKILL.md", home);
+	unlink(path);
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/nulbody", home);
+	rmdir(path);
+}
+
+static void
+test_no_home(void)
+{
+	struct skills	sk;
+	char		*saved;
+
+	memset(&sk, 0, sizeof(sk));
+	saved = getenv("HOME");
+	if (saved != NULL)
+		saved = strdup(saved);
+	unsetenv("HOME");
+	skills_load(&sk);
+	CHECK(sk.n == 0, "no HOME -> empty registry");
+	skills_free(&sk);
+	if (saved != NULL) {
+		setenv("HOME", saved, 1);
+		free(saved);
+	}
+}
+
+int
+main(void)
+{
+	char		tmpl[] = "/tmp/fugu-skills-test.XXXXXX";
+	char		*home;
+	char		path[1024];
+
+	if ((home = mkdtemp(tmpl)) == NULL)
+		err(1, "mkdtemp");
+	(void)snprintf(path, sizeof(path), "%s/.fugu", home);
+	mkdir(path, 0755);
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills", home);
+	mkdir(path, 0755);
+
+	/* Three valid skills (one with header name, two using dirname). */
+	drop_skill(home, "alpha",
+	    "---\nname: alpha\ndescription: first one\n---\nalpha body\n");
+	drop_skill(home, "bravo", "bravo body\nsecond line\n");
+	drop_skill(home, "from-dirname",
+	    "---\nname: named\n---\nbody for the named one\n");
+
+	/* Malformed/empty -- should be silently skipped. */
+	drop_skill(home, "empty-body",
+	    "---\nname: emp\n---\n\n   \n");
+	drop_skill(home, "unterminated-header",
+	    "---\nname: bad\nno closing fence ever follows\n");
+	(void)snprintf(path, sizeof(path), "%s/.fugu/skills/.dotdir", home);
+	mkdir(path, 0755);				/* dotfiles ignored */
+
+	setenv("HOME", home, 1);
+	test_load_basic(home);
+	test_marker();
+	test_tool_fragment();
+	test_synth_invocation();
+	test_handle_tool_request();
+	test_disable_model_invocation(home);
+	test_disabled_skill_not_invocable(home);
+	test_skill_with_internal_nul(home);
+	test_no_home();
+
+	/* Cleanup (best-effort). */
+	{
+		char	cmd[1100];
+
+		(void)snprintf(cmd, sizeof(cmd), "rm -rf %s", home);
+		(void)system(cmd);
+	}
+
+	printf("skills_test: %d checks, %d failures\n", checks, fails);
+	return (fails == 0 ? 0 : 1);
+}
blob - /dev/null
blob + 2922a3b397de0e899c6a4669b68d0211aff08847 (mode 644)
--- /dev/null
+++ regress/sse/Makefile
@@ -0,0 +1,19 @@
+# Unit tests for the Server-Sent Events parser (src/common/sse.c).
+
+PROG=		sse_test
+SRCS=		sse_test.c sse.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-sse_test
+
+run-sse_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 815a632d82a59c4a5a4a357444feba94aee4f67d (mode 644)
--- /dev/null
+++ regress/sse/sse_test.c
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "sse.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+#define MAXEV	32
+
+static struct {
+	char	*event;
+	char	*data;
+	size_t	 datalen;
+} evs[MAXEV];
+static int	nev;
+
+static void
+on_event(const char *event, const char *data, size_t datalen, void *arg)
+{
+	(void)arg;
+	if (nev >= MAXEV) {
+		failures++;
+		return;
+	}
+	evs[nev].event = strdup(event);
+	evs[nev].data = malloc(datalen + 1);
+	memcpy(evs[nev].data, data, datalen);
+	evs[nev].data[datalen] = '\0';
+	evs[nev].datalen = datalen;
+	nev++;
+}
+
+static void
+reset_events(void)
+{
+	int	i;
+
+	for (i = 0; i < nev; i++) {
+		free(evs[i].event);
+		free(evs[i].data);
+	}
+	nev = 0;
+}
+
+static void
+push_str(struct sse_parser *p, const char *s)
+{
+	if (sse_push(p, s, strlen(s)) != 0)
+		failures++;
+}
+
+int
+main(void)
+{
+	struct sse_parser	p;
+
+	/* 1. event + data */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "event: ping\ndata: hello\n\n");
+	CHECK(nev == 1, "basic: nev=%d", nev);
+	if (nev == 1) {
+		CHECK(strcmp(evs[0].event, "ping") == 0, "basic event");
+		CHECK(strcmp(evs[0].data, "hello") == 0, "basic data");
+		CHECK(evs[0].datalen == 5, "basic datalen=%zu", evs[0].datalen);
+	}
+	sse_clear(&p);
+	reset_events();
+
+	/* 2. default event type is "message" */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: hi\n\n");
+	CHECK(nev == 1 && strcmp(evs[0].event, "message") == 0, "default event");
+	CHECK(nev == 1 && strcmp(evs[0].data, "hi") == 0, "default data");
+	sse_clear(&p);
+	reset_events();
+
+	/* 3. multiple data lines are joined with '\n' */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: a\ndata: b\n\n");
+	CHECK(nev == 1 && strcmp(evs[0].data, "a\nb") == 0, "multi-data join");
+	sse_clear(&p);
+	reset_events();
+
+	/* 4. comment lines are ignored */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, ": keep-alive\ndata: x\n\n");
+	CHECK(nev == 1 && strcmp(evs[0].data, "x") == 0, "comment ignored");
+	sse_clear(&p);
+	reset_events();
+
+	/* 5. CRLF line endings */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "event: e\r\ndata: d\r\n\r\n");
+	CHECK(nev == 1 && strcmp(evs[0].event, "e") == 0 &&
+	    strcmp(evs[0].data, "d") == 0, "crlf endings");
+	sse_clear(&p);
+	reset_events();
+
+	/* 6. exactly one leading space after the colon is stripped */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data:  x\n\n");
+	CHECK(nev == 1 && strcmp(evs[0].data, " x") == 0, "one-space strip");
+	sse_clear(&p);
+	reset_events();
+
+	/* 7. embedded colons in the value (JSON) are preserved */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: {\"a\":1,\"b\":2}\n\n");
+	CHECK(nev == 1 && strcmp(evs[0].data, "{\"a\":1,\"b\":2}") == 0,
+	    "json colons");
+	sse_clear(&p);
+	reset_events();
+
+	/* 8. an event split across two pushes */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: hel");
+	push_str(&p, "lo\n\n");
+	CHECK(nev == 1 && strcmp(evs[0].data, "hello") == 0, "split feed");
+	sse_clear(&p);
+	reset_events();
+
+	/* 9. a blank line with no data dispatches nothing */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "event: x\n\n");
+	CHECK(nev == 0, "no-data no-dispatch: nev=%d", nev);
+	sse_clear(&p);
+	reset_events();
+
+	/* 10. an empty data value yields a zero-length payload */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data:\n\n");
+	CHECK(nev == 1 && evs[0].datalen == 0, "empty data value");
+	sse_clear(&p);
+	reset_events();
+
+	/* 11. bare-CR line endings */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: r\r\r");
+	CHECK(nev == 1 && strcmp(evs[0].data, "r") == 0, "bare-cr endings");
+	sse_clear(&p);
+	reset_events();
+
+	/* 12. two events back to back */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: a\n\ndata: b\n\n");
+	CHECK(nev == 2, "two events: nev=%d", nev);
+	if (nev == 2) {
+		CHECK(strcmp(evs[0].data, "a") == 0, "two events first");
+		CHECK(strcmp(evs[1].data, "b") == 0, "two events second");
+	}
+	sse_clear(&p);
+	reset_events();
+
+	/* 13b. a CRLF split across two pushes (CR ends one, LF starts next) */
+	sse_init(&p, on_event, NULL);
+	push_str(&p, "data: split\r");
+	push_str(&p, "\n\r\n");
+	CHECK(nev == 1 && strcmp(evs[0].data, "split") == 0,
+	    "crlf split across push");
+	sse_clear(&p);
+	reset_events();
+
+	/* 13. an Anthropic-shaped stream */
+	sse_init(&p, on_event, NULL);
+	push_str(&p,
+	    "event: message_start\n"
+	    "data: {\"type\":\"message_start\"}\n"
+	    "\n"
+	    "event: content_block_delta\n"
+	    "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n"
+	    "\n"
+	    "event: content_block_delta\n"
+	    "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n"
+	    "\n"
+	    "event: message_stop\n"
+	    "data: {\"type\":\"message_stop\"}\n"
+	    "\n");
+	CHECK(nev == 4, "anthropic: nev=%d", nev);
+	if (nev == 4) {
+		CHECK(strcmp(evs[0].event, "message_start") == 0, "anth ev0");
+		CHECK(strcmp(evs[1].event, "content_block_delta") == 0,
+		    "anth ev1");
+		CHECK(strstr(evs[1].data, "Hello") != NULL, "anth ev1 text");
+		CHECK(strcmp(evs[3].event, "message_stop") == 0, "anth ev3");
+	}
+	sse_clear(&p);
+	reset_events();
+
+	/* 14. an event that never terminates is capped, not grown forever */
+	sse_init(&p, on_event, NULL);
+	p.maxtotal = 1000;
+	{
+		int	r = 0, k;
+
+		for (k = 0; k < 2000 && r == 0; k++)
+			r = sse_push(&p, "data: x\n", 8);
+		CHECK(r == -1, "data cap enforced");
+		CHECK(nev == 0, "data cap: nothing dispatched (nev=%d)", nev);
+	}
+	sse_clear(&p);
+	reset_events();
+
+	printf("sse_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 3d8cad373ca2e72ad1cebeed1b3d55164986de4f (mode 644)
--- /dev/null
+++ regress/stream/Makefile
@@ -0,0 +1,19 @@
+# Integration test: chunked HTTP carrying an SSE stream (http.c + sse.c).
+
+PROG=		stream_test
+SRCS=		stream_test.c http.c sse.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+REGRESS_TARGETS=	run-stream_test
+
+run-stream_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 6df085c99c0018c6a5f5371d0105f751a2be6715 (mode 644)
--- /dev/null
+++ regress/stream/stream_test.c
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Integration: a chunked HTTP/1.1 response whose body is an Anthropic-shaped
+ * text/event-stream, fed to the parser in tiny pieces.  http de-chunks into
+ * the SSE parser, which frames events across both the network-read boundaries
+ * and the HTTP chunk boundaries.  Proves the portable half of the net path end
+ * to end (JSON interpretation of each event is the net worker's job, later).
+ */
+
+#include <sys/types.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+#include "sse.h"
+
+static int	checks;
+static int	failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+#define MAXEV	16
+
+static char	*ev_name[MAXEV];
+static char	*ev_data[MAXEV];
+static int	 nev;
+
+static void
+on_event(const char *event, const char *data, size_t datalen, void *arg)
+{
+	(void)arg;
+	if (nev >= MAXEV) {
+		failures++;
+		return;
+	}
+	ev_name[nev] = strdup(event);
+	ev_data[nev] = malloc(datalen + 1);
+	memcpy(ev_data[nev], data, datalen);
+	ev_data[nev][datalen] = '\0';
+	nev++;
+}
+
+/* http body callback: forward decoded body bytes into the SSE parser. */
+static void
+on_body(const void *d, size_t n, void *arg)
+{
+	struct sse_parser	*sse = arg;
+
+	if (sse_push(sse, d, n) != 0)
+		failures++;
+}
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+	buf_printf(b, "%zx\r\n", n);
+	buf_append(b, data, n);
+	buf_append(b, "\r\n", 2);
+}
+
+int
+main(void)
+{
+	struct http_resp	r;
+	struct sse_parser	sse;
+	struct buf		resp;
+	const char		*sse_body =
+	    "event: message_start\n"
+	    "data: {\"type\":\"message_start\"}\n"
+	    "\n"
+	    "event: content_block_delta\n"
+	    "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n"
+	    "\n"
+	    "event: content_block_delta\n"
+	    "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\", world!\"}}\n"
+	    "\n"
+	    "event: message_stop\n"
+	    "data: {\"type\":\"message_stop\"}\n"
+	    "\n";
+	size_t			 bl, mid, off, n;
+
+	sse_init(&sse, on_event, NULL);
+	http_resp_init(&r, on_body, &sse);
+
+	/* Build a chunked response, splitting the SSE body mid-stream so a
+	 * chunk boundary falls inside an event. */
+	bl = strlen(sse_body);
+	mid = bl / 2;
+	buf_init(&resp);
+	buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+	buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+	buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n");
+	buf_puts_cstr(&resp, "\r\n");
+	add_chunk(&resp, sse_body, mid);
+	add_chunk(&resp, sse_body + mid, bl - mid);
+	buf_puts_cstr(&resp, "0\r\n\r\n");
+
+	/* Feed the whole thing through http in 5-byte network-sized pieces. */
+	for (off = 0; off < resp.len; off += n) {
+		n = resp.len - off;
+		if (n > 5)
+			n = 5;
+		if (http_resp_push(&r, resp.data + off, n) == -1) {
+			CHECK(0, "http push failed at off=%zu", off);
+			break;
+		}
+	}
+
+	CHECK(http_resp_done(&r), "stream: response not complete");
+	CHECK(r.status == 200, "stream: status=%d", r.status);
+	CHECK(nev == 4, "stream: nev=%d", nev);
+	if (nev == 4) {
+		CHECK(strcmp(ev_name[0], "message_start") == 0, "stream ev0");
+		CHECK(strcmp(ev_name[1], "content_block_delta") == 0,
+		    "stream ev1 name");
+		CHECK(strstr(ev_data[1], "\"text\":\"Hello\"") != NULL,
+		    "stream ev1 text");
+		CHECK(strstr(ev_data[2], ", world!") != NULL,
+		    "stream ev2 text");
+		CHECK(strcmp(ev_name[3], "message_stop") == 0, "stream ev3");
+	}
+
+	for (off = 0; off < (size_t)nev; off++) {
+		free(ev_name[off]);
+		free(ev_data[off]);
+	}
+	http_resp_clear(&r);
+	sse_clear(&sse);
+	buf_free(&resp);
+
+	printf("stream_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + ca0c06fea4d8edf222d96ee0478588d7658ea04f (mode 644)
--- /dev/null
+++ regress/tlsconn/Makefile
@@ -0,0 +1,22 @@
+# Regression tests for src/common/tlsconn.c.  Drives the failure paths
+# (connect-to-nothing, TCP-accept-then-close) directly -- no full
+# net_loop choreography -- and asserts the contract documented in
+# tlsconn.h: *errstr is heap-allocated and outlives tls_free / tls_close.
+
+PROG=		tlsconn_test
+SRCS=		tlsconn_test.c tlsconn.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-ltls -lutil
+DPADD+=		${LIBTLS} ${LIBUTIL}
+
+REGRESS_TARGETS=	run-tlsconn_test
+
+run-tlsconn_test: ${PROG}
+	./${PROG}
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 010a439baa44b1b3fd4ca54d4458eb40e47dfb69 (mode 644)
--- /dev/null
+++ regress/tlsconn/tlsconn_test.c
@@ -0,0 +1,435 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Regression tests for tlsconn_connect's documented contract:
+ *
+ *   "*errstr is heap-allocated and NUL-terminated; the caller free()s it
+ *    because the underlying libtls context-owned buffer is released before
+ *    the function returns."
+ *
+ * The motivating bug (pre-fix) was a use-after-free: *errstr was set to
+ * tls_error(ctx) -- a borrowed pointer into context-owned memory -- and
+ * then tls_free() released that memory before returning to the caller.  On
+ * OpenBSD, default malloc.conf junk-fills freed allocations with 0xDB, so a
+ * later read would see garbage; this regress proves the fix by capturing
+ * the error string, churning the heap aggressively, and re-reading.
+ *
+ * Two failure paths are exercised:
+ *
+ *   Mode 2 (TCP-accept-then-close): a fork()ed stub binds port 0, accepts
+ *   the connection, and immediately closes the socket.  tlsconn_connect's
+ *   tls_connect or tls_handshake fails inside libtls with a real error
+ *   message; this is the closest analogue to a peer dropping mid-handshake.
+ *
+ *   Mode 3 (connect-to-nothing): point tlsconn_connect at a port nothing
+ *   is listening on.  tls_connect fails at the TCP layer with a different
+ *   libtls error.  No stub needed.
+ *
+ * Mode 1 (cert mismatch) would need a real cert+key; out of scope for
+ * this initial harness.  Add later as a separate test alongside the
+ * Mode 2 stub if peer-verify failure modes need coverage.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <errno.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "tlsconn.h"
+
+static int	checks, failures;
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+/*
+ * Churn the heap aggressively to force OpenBSD's junk-on-free to overwrite
+ * any region a stale tls_error() pointer might have referenced.  If the
+ * fixed *errstr ownership ever regresses, the captured bytes will no
+ * longer match after this call -- the test then fails loudly.  Each
+ * iteration allocates a fresh region, writes a recognisable byte to every
+ * byte, then frees, so the entire allocation is dirtied before the
+ * allocator can hand it out again.
+ */
+static void
+heap_churn(void)
+{
+	enum { ROUNDS = 32, BLOCK = 64 * 1024 };
+	int	i;
+
+	for (i = 0; i < ROUNDS; i++) {
+		void	*p = malloc(BLOCK);
+
+		if (p == NULL)
+			continue;
+		memset(p, 0xA5, BLOCK);
+		free(p);
+	}
+}
+
+/*
+ * Common assertions for a -1 return from tlsconn_connect: errstr is a
+ * heap string, non-empty, and survives heap churn (the post-fix
+ * ownership contract).  Frees errstr at the end so the test can call
+ * this repeatedly without leaking.  Returns 1 if every assertion held.
+ */
+static int
+assert_errstr_owned(const char *what, char *err)
+{
+	size_t	len_before;
+	char	saved[256];
+	int	ok = 1;
+
+	if (err == NULL) {
+		CHECK(0, "%s: errstr is NULL after -1 return", what);
+		return (0);
+	}
+	len_before = strlen(err);
+	if (len_before == 0) {
+		CHECK(0, "%s: errstr is empty after -1 return", what);
+		free(err);
+		return (0);
+	}
+	strlcpy(saved, err, sizeof(saved));
+
+	heap_churn();
+
+	if (strlen(err) != len_before) {
+		CHECK(0, "%s: errstr length changed across heap churn "
+		    "(was %zu, now %zu) -- looks like a use-after-free",
+		    what, len_before, strlen(err));
+		ok = 0;
+	} else {
+		CHECK(1, "%s: errstr length preserved (%zu)", what, len_before);
+	}
+	if (strcmp(err, saved) != 0) {
+		CHECK(0, "%s: errstr bytes changed across heap churn "
+		    "(was \"%s\", now \"%s\") -- USE-AFTER-FREE",
+		    what, saved, err);
+		ok = 0;
+	} else {
+		CHECK(1, "%s: errstr bytes preserved across heap churn", what);
+	}
+	free(err);
+	return (ok);
+}
+
+/*
+ * Mode 3: connect to a port nothing is listening on.  tls_connect fails
+ * at the TCP layer; tlsconn_connect's first fail-label runs (the one
+ * after tls_connect).  Validates the most common production failure
+ * shape (peer down).  Uses an explicit high-port that is overwhelmingly
+ * likely to be unbound (close to ephemeral-range top, no privileged-port
+ * concerns).
+ */
+static void
+test_mode3_connect_to_nothing(void)
+{
+	struct tls_config	*cfg;
+	struct tlsconn		 c;
+	char			*err = NULL;
+	int			 rc;
+
+	cfg = tlsconn_client_config(NULL);
+	if (cfg == NULL) {
+		CHECK(0, "tlsconn_client_config(NULL) failed");
+		return;
+	}
+
+	rc = tlsconn_connect(&c, cfg, "127.0.0.1", "1", &err);
+	CHECK(rc == -1, "Mode 3: connect to unbound port returns -1 (got %d)",
+	    rc);
+	(void)assert_errstr_owned("Mode 3", err);
+
+	CHECK(c.fd == -1, "Mode 3: c.fd cleared to -1 on failure (got %d)",
+	    c.fd);
+	CHECK(c.ctx == NULL, "Mode 3: c.ctx NULLed on failure");
+
+	tls_config_free(cfg);
+}
+
+/*
+ * Bind a fresh TCP socket on 127.0.0.1:0 and return the kernel-assigned
+ * port through *out_port.  The caller owns the fd and is responsible for
+ * passing it to the fork()ed stub (which closes the parent's copy after
+ * fork).  Returns -1 if any of the setup syscalls fail.
+ */
+static int
+bind_loopback_listener(int *out_port)
+{
+	struct sockaddr_in	sa;
+	socklen_t		slen = sizeof(sa);
+	int			fd, on = 1;
+
+	if ((fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		return (-1);
+	(void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_port = 0;	/* kernel picks */
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	if (bind(fd, (struct sockaddr *)&sa, sizeof(sa)) == -1 ||
+	    listen(fd, 4) == -1 ||
+	    getsockname(fd, (struct sockaddr *)&sa, &slen) == -1) {
+		close(fd);
+		return (-1);
+	}
+	*out_port = ntohs(sa.sin_port);
+	return (fd);
+}
+
+/*
+ * Mode 2: a stub that accept()s the TCP connection and then immediately
+ * close()s without exchanging any TLS bytes.  tlsconn_connect's tls
+ * handshake either fails outright (peer closed) or fails to read the
+ * ServerHello.  Either way libtls reports a real error message.
+ *
+ * Fork model: parent runs tlsconn_connect, child runs accept+close.  The
+ * child SIGTERMs back to the parent if anything goes wrong so a failure
+ * is visible rather than hanging.
+ */
+static void
+test_mode2_accept_then_close(void)
+{
+	struct tls_config	*cfg;
+	struct tlsconn		 c;
+	char			*err = NULL;
+	char			 portstr[16];
+	pid_t			 stub;
+	int			 listen_fd, port, rc, status;
+
+	if ((listen_fd = bind_loopback_listener(&port)) == -1) {
+		CHECK(0, "bind_loopback_listener: %s", strerror(errno));
+		return;
+	}
+	snprintf(portstr, sizeof(portstr), "%d", port);
+
+	if ((stub = fork()) == -1) {
+		close(listen_fd);
+		CHECK(0, "fork: %s", strerror(errno));
+		return;
+	}
+	if (stub == 0) {
+		/* child: accept ONE then drop without TLS bytes. */
+		int	a = accept(listen_fd, NULL, NULL);
+
+		if (a >= 0)
+			close(a);
+		close(listen_fd);
+		_exit(0);
+	}
+	close(listen_fd);		/* parent does not need the listener */
+
+	cfg = tlsconn_client_config(NULL);
+	if (cfg == NULL) {
+		CHECK(0, "tlsconn_client_config(NULL) failed");
+		(void)kill(stub, SIGTERM);
+		(void)waitpid(stub, &status, 0);
+		return;
+	}
+
+	rc = tlsconn_connect(&c, cfg, "127.0.0.1", portstr, &err);
+	CHECK(rc == -1, "Mode 2: accept-then-close returns -1 (got %d)", rc);
+	(void)assert_errstr_owned("Mode 2", err);
+
+	CHECK(c.fd == -1, "Mode 2: c.fd cleared on failure");
+	CHECK(c.ctx == NULL, "Mode 2: c.ctx NULLed on failure");
+
+	tls_config_free(cfg);
+	(void)waitpid(stub, &status, 0);
+}
+
+/*
+ * Calling tlsconn_close on a never-connected (memset-zeroed) tlsconn must
+ * be a clean no-op.  Pre-fix code that NULLs c->ctx in tlsconn_connect's
+ * fail label already covers the "we ran and failed" case; this asserts
+ * the freshly-initialised case is also safe.
+ */
+static void
+test_close_on_uninitialised(void)
+{
+	struct tlsconn	c;
+
+	memset(&c, 0, sizeof(c));
+	c.fd = -1;
+	tlsconn_close(&c);		/* must not crash */
+	CHECK(c.fd == -1, "close on uninitialised leaves fd -1");
+	CHECK(c.ctx == NULL, "close on uninitialised leaves ctx NULL");
+
+	tlsconn_close(&c);		/* idempotent */
+	CHECK(c.fd == -1, "second close is idempotent (fd)");
+	CHECK(c.ctx == NULL, "second close is idempotent (ctx)");
+}
+
+/*
+ * Calling tlsconn_connect twice in a row -- a real path on retry-after-error
+ * code -- must produce two independently-owned errstr allocations, neither
+ * of which dangles into a shared libtls buffer.  Frees both at the end and
+ * asserts they were distinct heap regions (different pointers) so a future
+ * regression that returned a static buffer would be caught.
+ */
+static void
+test_repeated_failures_owned_independently(void)
+{
+	struct tls_config	*cfg = tlsconn_client_config(NULL);
+	struct tlsconn		 c1, c2;
+	char			*e1 = NULL, *e2 = NULL;
+	int			 r1, r2;
+
+	if (cfg == NULL) {
+		CHECK(0, "tlsconn_client_config(NULL) failed");
+		return;
+	}
+
+	r1 = tlsconn_connect(&c1, cfg, "127.0.0.1", "1", &e1);
+	r2 = tlsconn_connect(&c2, cfg, "127.0.0.1", "1", &e2);
+	CHECK(r1 == -1 && r2 == -1, "both connects fail");
+	CHECK(e1 != NULL && e2 != NULL, "both errstrs populated");
+	CHECK(e1 != e2,
+	    "two failures produce distinct heap pointers (no shared static)");
+	if (e1 != NULL && e2 != NULL && strlen(e1) > 0 && strlen(e2) > 0) {
+		/*
+		 * The bytes should be identical (same failure mode, same
+		 * libtls message), but the pointers must differ.  This proves
+		 * each call dup'd into its own allocation.
+		 */
+		CHECK(strcmp(e1, e2) == 0,
+		    "same failure mode yields same message text "
+		    "(\"%s\" vs \"%s\")", e1, e2);
+	}
+	free(e1);
+	free(e2);
+	tls_config_free(cfg);
+}
+
+/*
+ * tlsconn_attach is the fetch-worker analog of tlsconn_connect: the caller
+ * has already dialed the TCP socket (with its own SSRF/timeout guard) and
+ * hands the fd in.  Same *errstr ownership contract.  Same heap-stability
+ * invariant.  We exercise the FAILURE path -- a fork()ed stub that closes
+ * the socket before any TLS bytes flow -- and assert that on -1 the fd is
+ * NOT touched (the caller's responsibility to close it), since the
+ * asymmetric ownership rule is easy to break in future edits.
+ */
+static void
+test_attach_caller_keeps_fd_on_failure(void)
+{
+	struct tls_config	*cfg;
+	struct tlsconn		 c;
+	struct sockaddr_in	 sa;
+	socklen_t		 slen = sizeof(sa);
+	char			*err = NULL;
+	pid_t			 stub;
+	int			 listen_fd, sock, port, rc, status;
+
+	if ((listen_fd = bind_loopback_listener(&port)) == -1) {
+		CHECK(0, "bind_loopback_listener: %s", strerror(errno));
+		return;
+	}
+
+	if ((stub = fork()) == -1) {
+		close(listen_fd);
+		CHECK(0, "fork: %s", strerror(errno));
+		return;
+	}
+	if (stub == 0) {
+		int	a = accept(listen_fd, NULL, NULL);
+
+		if (a >= 0)
+			close(a);
+		close(listen_fd);
+		_exit(0);
+	}
+	close(listen_fd);
+
+	/* The caller-dialed socket -- a plain blocking TCP connect. */
+	if ((sock = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
+		CHECK(0, "socket: %s", strerror(errno));
+		(void)kill(stub, SIGTERM);
+		(void)waitpid(stub, &status, 0);
+		return;
+	}
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_port = htons(port);
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	if (connect(sock, (struct sockaddr *)&sa, slen) == -1) {
+		CHECK(0, "connect: %s", strerror(errno));
+		close(sock);
+		(void)waitpid(stub, &status, 0);
+		return;
+	}
+
+	cfg = tlsconn_client_config(NULL);
+	if (cfg == NULL) {
+		CHECK(0, "tlsconn_client_config(NULL) failed");
+		close(sock);
+		(void)waitpid(stub, &status, 0);
+		return;
+	}
+
+	rc = tlsconn_attach(&c, cfg, sock, "127.0.0.1", &err);
+	CHECK(rc == -1, "tlsconn_attach against closed peer returns -1 "
+	    "(got %d)", rc);
+	CHECK(c.fd == -1,
+	    "attach failure must leave c.fd at -1 (caller still owns fd) "
+	    "(got %d)", c.fd);
+	CHECK(c.ctx == NULL, "attach failure leaves c.ctx NULL");
+	(void)assert_errstr_owned("tlsconn_attach", err);
+
+	/* The caller is still responsible for the fd. */
+	close(sock);
+	tls_config_free(cfg);
+	(void)waitpid(stub, &status, 0);
+}
+
+int
+main(void)
+{
+	if (tls_init() == -1) {
+		fprintf(stderr, "tls_init failed\n");
+		return (1);
+	}
+	/* Don't let a peer SIGPIPE us in the middle of a write loop. */
+	signal(SIGPIPE, SIG_IGN);
+
+	test_mode3_connect_to_nothing();
+	test_mode2_accept_then_close();
+	test_close_on_uninitialised();
+	test_repeated_failures_owned_independently();
+	test_attach_caller_keeps_fd_on_failure();
+
+	printf("tlsconn_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + 70de1abaded0e9229b4a2dd8aa65f5dec9584cd0 (mode 644)
--- /dev/null
+++ regress/tui/Makefile
@@ -0,0 +1,34 @@
+# Smoke test for the curses front-end: run fugu(1) under a pty (src/ui/*).
+# Requires the worker binaries to be built (run `make` at the top level first).
+
+PROG=		tui_test
+SRCS=		tui_test.c buf.c util.c log.c
+NOMAN=		yes
+
+COMMONDIR=	${.CURDIR}/../../src/common
+CFLAGS+=	-I${COMMONDIR}
+.PATH:		${COMMONDIR}
+
+LDADD+=		-ltls -lutil
+DPADD+=		${LIBTLS} ${LIBUTIL}
+
+FUGU_BIN=	${.CURDIR}/../../src/parent/fugu
+FUGU_UI_BIN=	${.CURDIR}/../../src/ui/fugu-ui
+FUGU_NET_BIN=	${.CURDIR}/../../src/net/fugu-net
+FUGU_EXEC_BIN=	${.CURDIR}/../../src/exec/fugu-exec
+FUGU_FETCH_BIN=	${.CURDIR}/../../src/fetch/fugu-fetch
+
+REGRESS_TARGETS=	run-tui_test
+
+run-tui_test: ${PROG}
+	@td=`mktemp -d /tmp/fugu-tui-cert.XXXXXX`; \
+	    openssl req -x509 -newkey rsa:2048 -nodes \
+	        -keyout $$td/key.pem -out $$td/cert.pem \
+	        -days 1 -subj '/CN=localhost' >/dev/null 2>&1; \
+	    FUGU_BIN=${FUGU_BIN} FUGU_UI_BIN=${FUGU_UI_BIN} \
+	        FUGU_NET_BIN=${FUGU_NET_BIN} FUGU_EXEC_BIN=${FUGU_EXEC_BIN} \
+	        FUGU_FETCH_BIN=${FUGU_FETCH_BIN} \
+	        ./${PROG} $$td/cert.pem $$td/key.pem; rc=$$?; \
+	    rm -rf $$td; exit $$rc
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + b8a45c266a5c07ecf436de66d915c8412739f9a9 (mode 644)
--- /dev/null
+++ regress/tui/tui_test.c
@@ -0,0 +1,436 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Smoke test for the curses front-end: run the real fugu under a pseudo-terminal
+ * (so isatty() selects curses), pointed at a libtls stub server.  A session is
+ * pre-seeded and fugu is launched with -r, so the test also verifies that the
+ * resumed transcript ("Paris") renders before then typing "ping" and confirming
+ * the streamed reply ("pong") is shown and that fugu exits cleanly when the
+ * master side is closed.
+ */
+
+#include <sys/types.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <netinet/in.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <fcntl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+#include <util.h>
+
+#include <tls.h>
+
+#include "buf.h"
+
+static int	checks, failures;
+
+/* Remove the session journals the run leaves behind, then the directory. */
+static void
+rm_sessions(const char *home)
+{
+	char		 path[1100], f[1200];
+	DIR		*d;
+	struct dirent	*e;
+
+	snprintf(path, sizeof(path), "%s/.fugu/sessions", home);
+	if ((d = opendir(path)) != NULL) {
+		while ((e = readdir(d)) != NULL) {
+			if (e->d_name[0] == '.')
+				continue;
+			snprintf(f, sizeof(f), "%s/%s", path, e->d_name);
+			unlink(f);
+		}
+		closedir(d);
+	}
+	rmdir(path);
+}
+
+#define CHECK(cond, ...) do {						\
+	checks++;							\
+	if (!(cond)) {							\
+		failures++;						\
+		fprintf(stderr, "FAIL %s:%d: ", __func__, __LINE__);	\
+		fprintf(stderr, __VA_ARGS__);				\
+		fprintf(stderr, "\n");					\
+	}								\
+} while (0)
+
+static const char SSE_BODY[] =
+    "event: message_start\n"
+    "data: {\"type\":\"message_start\",\"message\":{\"id\":\"x\","
+    "\"role\":\"assistant\",\"usage\":{\"input_tokens\":5,"
+    "\"output_tokens\":0}}}\n"
+    "\n"
+    "event: content_block_start\n"
+    "data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n"
+    "\n"
+    "event: content_block_delta\n"
+    "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"text_delta\",\"text\":\"**pong**\"}}\n"
+    "\n"
+    "event: content_block_stop\n"
+    "data: {\"type\":\"content_block_stop\",\"index\":0}\n"
+    "\n"
+    "event: message_delta\n"
+    "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n"
+    "\n"
+    "event: message_stop\n"
+    "data: {\"type\":\"message_stop\"}\n"
+    "\n";
+
+static void
+add_chunk(struct buf *b, const char *data, size_t n)
+{
+	buf_printf(b, "%zx\r\n", n);
+	buf_append(b, data, n);
+	buf_append(b, "\r\n", 2);
+}
+
+static int
+tls_write_all(struct tls *t, const void *buf, size_t len)
+{
+	const unsigned char	*p = buf;
+	size_t			 off = 0;
+	ssize_t			 n;
+
+	while (off < len) {
+		n = tls_write(t, p + off, len - off);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT)
+			continue;
+		if (n < 0)
+			return (-1);
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+static void
+stub_server(int lfd, const char *cert, const char *key)
+{
+	struct tls_config	*cfg;
+	struct tls		*srv, *cctx;
+	struct buf		 resp;
+	char			 req[2048];
+	ssize_t			 n;
+	int			 cfd;
+	size_t			 sl, half;
+
+	if ((cfd = accept(lfd, NULL, NULL)) == -1)
+		_exit(20);
+	if ((cfg = tls_config_new()) == NULL)
+		_exit(21);
+	if (tls_config_set_cert_file(cfg, cert) == -1 ||
+	    tls_config_set_key_file(cfg, key) == -1)
+		_exit(22);
+	if ((srv = tls_server()) == NULL || tls_configure(srv, cfg) == -1)
+		_exit(23);
+	if (tls_accept_socket(srv, &cctx, cfd) == -1)
+		_exit(24);
+	do {
+		n = tls_handshake(cctx);
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+	if (n == -1)
+		_exit(25);
+	do {
+		n = tls_read(cctx, req, sizeof(req));
+	} while (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT);
+
+	buf_init(&resp);
+	buf_puts_cstr(&resp, "HTTP/1.1 200 OK\r\n");
+	buf_puts_cstr(&resp, "Content-Type: text/event-stream\r\n");
+	buf_puts_cstr(&resp, "Transfer-Encoding: chunked\r\n\r\n");
+	sl = strlen(SSE_BODY);
+	half = sl / 2;
+	add_chunk(&resp, SSE_BODY, half);
+	add_chunk(&resp, SSE_BODY + half, sl - half);
+	buf_puts_cstr(&resp, "0\r\n\r\n");
+	(void)tls_write_all(cctx, resp.data, resp.len);
+	buf_free(&resp);
+	tls_close(cctx);
+	_exit(0);
+}
+
+static void
+write_config(const char *home)
+{
+	char	dir[1024], path[1024];
+	FILE	*fp;
+	int	fd;
+
+	snprintf(dir, sizeof(dir), "%s/.fugu", home);
+	if (mkdir(dir, 0700) == -1)
+		err(1, "mkdir %s", dir);
+	snprintf(path, sizeof(path), "%s/.fugu/fugu.conf", home);
+	if ((fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600)) == -1)
+		err(1, "open %s", path);
+	if (fchmod(fd, 0600) == -1)
+		err(1, "fchmod");
+	if ((fp = fdopen(fd, "w")) == NULL)
+		err(1, "fdopen");
+	fputs("anthropic_key \"test-key\"\n", fp);
+	fputs("model \"claude-sonnet-4-6\"\n", fp);
+	fputs("max_tokens 64\n", fp);
+	fclose(fp);
+}
+
+/* Pre-seed one resumable session so the -r path has prior history to replay. */
+static void
+write_session(const char *home, const char *id)
+{
+	char	dir[1024], path[1100];
+	FILE	*fp;
+
+	snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home);
+	if (mkdir(dir, 0700) == -1)
+		err(1, "mkdir %s", dir);
+	snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id);
+	if ((fp = fopen(path, "w")) == NULL)
+		err(1, "open %s", path);
+	fputs("{\"role\":\"user\",\"content\":\"capital of France\"}\n", fp);
+	fputs("{\"role\":\"assistant\",\"content\":[{\"type\":\"text\","
+	    "\"text\":\"Paris\"}]}\n", fp);
+	fclose(fp);
+}
+
+static void
+on_alarm(int sig)
+{
+	(void)sig;
+	dprintf(STDERR_FILENO, "tui_test: TIMED OUT\n");
+	_exit(97);
+}
+
+int
+main(int argc, char *argv[])
+{
+	struct sockaddr_in	sa;
+	struct winsize		ws;
+	socklen_t		slen;
+	const char		*fugu_bin;
+	char			home[] = "/tmp/fugu-tui.XXXXXX";
+	char			out[16384], portstr[16];
+	size_t			outlen = 0;
+	ssize_t			k;
+	pid_t			sp, fp;
+	int			lfd, one = 1, port, master, status, found = 0;
+
+	if (argc != 3)
+		errx(1, "usage: %s cert.pem key.pem", argv[0]);
+	if ((fugu_bin = getenv("FUGU_BIN")) == NULL)
+		errx(1, "FUGU_BIN not set");
+
+	signal(SIGALRM, on_alarm);
+	signal(SIGPIPE, SIG_IGN);
+	alarm(90);
+
+	if (mkdtemp(home) == NULL)
+		err(1, "mkdtemp");
+	write_config(home);
+	write_session(home, "55-55");
+
+	if ((lfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
+		err(1, "socket");
+	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+	memset(&sa, 0, sizeof(sa));
+	sa.sin_family = AF_INET;
+	sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+	if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) == -1)
+		err(1, "bind");
+	if (listen(lfd, 1) == -1)
+		err(1, "listen");
+	slen = sizeof(sa);
+	if (getsockname(lfd, (struct sockaddr *)&sa, &slen) == -1)
+		err(1, "getsockname");
+	port = ntohs(sa.sin_port);
+	snprintf(portstr, sizeof(portstr), "%d", port);
+
+	if ((sp = fork()) == -1)
+		err(1, "fork");
+	if (sp == 0) {
+		stub_server(lfd, argv[1], argv[2]);
+		_exit(0);
+	}
+	close(lfd);
+
+	memset(&ws, 0, sizeof(ws));
+	ws.ws_row = 24;
+	ws.ws_col = 80;
+	if ((fp = forkpty(&master, NULL, NULL, &ws)) == -1)
+		err(1, "forkpty");
+	if (fp == 0) {
+		setenv("HOME", home, 1);
+		setenv("TERM", "vt100", 1);
+		{	/* not setgid in the harness, so FUGU_CONF is honoured */
+			char	confpath[1100];
+
+			snprintf(confpath, sizeof(confpath), "%s/.fugu/fugu.conf",
+			    home);
+			setenv("FUGU_CONF", confpath, 1);
+		}
+		setenv("FUGU_NET_HOST", "localhost", 1);
+		setenv("FUGU_NET_PORT", portstr, 1);
+		setenv("FUGU_NET_CAFILE", argv[1], 1);
+		execl(fugu_bin, fugu_bin, "-r", "55-55", (char *)NULL);
+		_exit(127);
+	}
+
+	/* Type a message; curses reads '\r' for Return in raw mode. */
+	if (write(master, "ping\r", 5) != 5)
+		CHECK(0, "write to pty");
+
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "pong") != NULL) {
+			found = 1;
+			break;
+		}
+	}
+	CHECK(found, "assistant reply 'pong' rendered to the terminal");
+	/* The curses chrome should be present too (status bar / prompt). */
+	CHECK(strstr(out, "fugu") != NULL, "transcript shows the fugu label");
+	/* The resumed session's prior assistant reply should be on screen too. */
+	CHECK(strstr(out, "Paris") != NULL,
+	    "resumed transcript rendered before the new turn");
+	/* Markdown is styled, not shown literally: "**pong**" renders as "pong". */
+	CHECK(strstr(out, "**") == NULL,
+	    "markdown bold markers stripped from the rendered transcript");
+	/* The editor starts in vi insert mode; the status bar says so. */
+	CHECK(strstr(out, "[INSERT]") != NULL,
+	    "vi insert-mode indicator shown in the status bar");
+
+	/*
+	 * Esc switches to normal mode; the status indicator changes from
+	 * INSERT to NORMAL.  curses only re-emits the cells that changed, so
+	 * force a full repaint with Ctrl-L and match the bare word.
+	 */
+	outlen = 0;			/* fresh capture for the post-Esc redraw */
+	out[0] = '\0';
+	if (write(master, "\033\014", 2) != 2)	/* Esc, then Ctrl-L */
+		CHECK(0, "write Esc + Ctrl-L");
+	found = 0;
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "NORMAL") != NULL) {
+			found = 1;
+			break;
+		}
+	}
+	CHECK(found, "Esc switches the editor to vi normal mode");
+
+	/*
+	 * A terminal resize (font-size change -> SIGWINCH) must not wedge the UI.
+	 * The worker once spun forever re-reading the KEY_RESIZE that resizeterm()
+	 * ungetch'd; if that regresses, the reads below never complete and the
+	 * alarm fires.  Resize the pty, then confirm the editor still responds:
+	 * 'i' returns to insert mode and the indicator changes back.
+	 */
+	ws.ws_row = 40;
+	ws.ws_col = 100;
+	if (ioctl(master, TIOCSWINSZ, &ws) == -1)
+		CHECK(0, "TIOCSWINSZ resize");
+	outlen = 0;
+	out[0] = '\0';
+	if (write(master, "i\014", 2) != 2)	/* 'i' -> insert, then Ctrl-L */
+		CHECK(0, "write i + Ctrl-L after resize");
+	found = 0;
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "INSERT") != NULL) {
+			found = 1;
+			break;
+		}
+	}
+	CHECK(found, "UI still responds after a terminal resize");
+
+	/*
+	 * Tab completes a partial slash command.  "/ex" + Tab -> "/exit"; force a
+	 * repaint with Ctrl-L so the whole line is re-emitted (curses otherwise
+	 * sends only changed cells).  "/exit" is not in the help text, so a match
+	 * means the input line actually completed.
+	 */
+	outlen = 0;
+	out[0] = '\0';
+	if (write(master, "/ex\t\014", 5) != 5)
+		CHECK(0, "write /ex + Tab + Ctrl-L");
+	found = 0;
+	while (outlen < sizeof(out) - 1) {
+		k = read(master, out + outlen, sizeof(out) - 1 - outlen);
+		if (k <= 0)
+			break;
+		outlen += (size_t)k;
+		out[outlen] = '\0';
+		if (strstr(out, "/exit") != NULL) {
+			found = 1;
+			break;
+		}
+	}
+	CHECK(found, "Tab completes /ex to /exit");
+	if (write(master, "\003", 1) != 1)	/* Ctrl-C clears the input line */
+		CHECK(0, "write Ctrl-C to clear");
+
+	/* Ctrl-D on the (now empty) input line quits gracefully. */
+	if (write(master, "\004", 1) != 1)
+		CHECK(0, "write Ctrl-D");
+	while ((k = read(master, out, sizeof(out))) > 0)
+		;			/* drain until fugu closes the pty */
+	if (waitpid(fp, &status, 0) == -1)
+		err(1, "waitpid fugu");
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0,
+	    "fugu exit status %d", status);
+	close(master);
+
+	kill(sp, SIGTERM);
+	waitpid(sp, &status, 0);
+
+	{
+		char	p[1100];
+
+		snprintf(p, sizeof(p), "%s/.fugu/fugu.conf", home);
+		unlink(p);
+		rm_sessions(home);
+		snprintf(p, sizeof(p), "%s/.fugu", home);
+		rmdir(p);
+		rmdir(home);
+	}
+
+	printf("tui_test: %d checks, %d failures\n", checks, failures);
+	return (failures ? 1 : 0);
+}
blob - /dev/null
blob + ac3af4b3c4e21e269bceda1c412ef96518680f06 (mode 755)
--- /dev/null
+++ run-fugu
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Convenience launcher for the not-yet-installed fugu build: points the parent
+# at the freshly built worker binaries.  Run from anywhere; it locates itself.
+cd "$(dirname "$0")" || exit 1
+exec env \
+    FUGU_UI_BIN="$PWD/src/ui/fugu-ui" \
+    FUGU_NET_BIN="$PWD/src/net/fugu-net" \
+    FUGU_EXEC_BIN="$PWD/src/exec/fugu-exec" \
+    FUGU_FETCH_BIN="$PWD/src/fetch/fugu-fetch" \
+    ./src/parent/fugu "$@"
blob - /dev/null
blob + 00c50ebd85a219ad8f71dab5bd6241eb4ea182b2 (mode 644)
--- /dev/null
+++ src/Makefile
@@ -0,0 +1,6 @@
+# Build each privilege-separated binary. "common" is not a program; its sources
+# are pulled into every binary via .PATH (see Makefile.inc).
+
+SUBDIR=	parent ui net exec fetch
+
+.include <bsd.subdir.mk>
blob - /dev/null
blob + 3b70e4d9d0a6a935800a5748ebcd12906d8641d1 (mode 644)
--- /dev/null
+++ src/Makefile.inc
@@ -0,0 +1,52 @@
+# Shared build configuration, included by every program Makefile in src/*/.
+# Guarded so it applies exactly once even if bsd.prog.mk re-includes it.
+
+.if !defined(FUGU_MAKEFILE_INC)
+FUGU_MAKEFILE_INC=1
+
+PREFIX?=	/usr/local
+
+COMMONDIR=	${.CURDIR}/../common
+
+CFLAGS+=	-Wall -I${.CURDIR} -I${COMMONDIR}
+CFLAGS+=	-Wstrict-prototypes -Wmissing-prototypes
+CFLAGS+=	-Wmissing-declarations
+CFLAGS+=	-Wshadow -Wpointer-arith -Wcast-qual
+CFLAGS+=	-Wsign-compare
+
+#
+# `make FUGU_DEBUG=1` builds a hardened build for catching flakiness: UndefinedBehaviorSanitizer
+# in trap mode (no runtime needed on OpenBSD -- traps SIGILL on UB) plus libc
+# fortifications and a few extra warnings.  Run the regress suite under this
+# build to surface signed overflow, alignment, null deref, format mismatch and
+# similar bugs that the default warnings miss.
+#
+# OpenBSD's clang ships without ASan/MSan runtimes; UBSan-trap and Fortify are
+# what's portable here.  OpenBSD's malloc already junk-fills on alloc/free, so
+# uninitialised-read bugs (e.g. strlen past a buf's .len) manifest as garbage
+# downstream -- catch those via regress tests, not a sanitizer.
+#
+.if defined(FUGU_DEBUG)
+CFLAGS+=	-g3
+CFLAGS+=	-D_FORTIFY_SOURCE=2
+CFLAGS+=	-fsanitize=undefined -fsanitize-trap=undefined
+CFLAGS+=	-fno-omit-frame-pointer
+CFLAGS+=	-Wextra -Wno-unused-parameter
+CFLAGS+=	-Wformat=2 -Wno-format-nonliteral	# log.c wraps printf(3)
+.endif
+
+.PATH:		${COMMONDIR}
+
+# Code shared by every process.
+SRCS+=		log.c util.c buf.c json.c imsgproto.c
+
+# imsg(3) lives in libutil; every fugu process speaks imsg.
+LDADD+=		-lutil
+DPADD+=		${LIBUTIL}
+
+# bsd.prog.mk does not create BINDIR; the workers install into the non-standard
+# ${PREFIX}/libexec/fugu, so create the destination before installing.
+beforeinstall:
+	${INSTALL} -d ${DESTDIR}${BINDIR}
+
+.endif
blob - /dev/null
blob + 12a42db8540e6ec1de1f61f06a944aa35e869891 (mode 644)
--- /dev/null
+++ src/common/anthropic.c
@@ -0,0 +1,404 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "anthropic.h"
+#include "buf.h"
+#include "http.h"
+#include "json.h"
+#include "provider.h"
+#include "provider_ops.h"
+
+static void	ev_message_start(struct anthropic_stream *, struct json_doc *);
+static void	ev_block_start(struct anthropic_stream *, struct json_doc *);
+static void	ev_block_delta(struct anthropic_stream *, struct json_doc *);
+static void	ev_block_stop(struct anthropic_stream *);
+static void	ev_message_delta(struct anthropic_stream *, struct json_doc *);
+static void	ev_error(struct anthropic_stream *, struct json_doc *);
+
+static char	   *obj_getstr(struct json_doc *, int, const char *);
+static long long    obj_getint(struct json_doc *, int, const char *, long long);
+static const char  *bufcstr(struct buf *);
+
+void
+anthropic_build_request(struct buf *out, const char *model, int max_tokens,
+    const char *system, const struct anth_msg *msgs, size_t nmsgs,
+    const char *tools_json)
+{
+	struct json_writer	w;
+	size_t			i;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "model", model);
+	json_kv_int(&w, "max_tokens", max_tokens);
+	json_kv_bool(&w, "stream", 1);
+	if (system != NULL)
+		json_kv_string(&w, "system", system);
+	json_key(&w, "messages");
+	json_array_start(&w);
+	for (i = 0; i < nmsgs; i++) {
+		json_object_start(&w);
+		json_kv_string(&w, "role", msgs[i].role);
+		json_kv_string(&w, "content", msgs[i].text);
+		json_object_end(&w);
+	}
+	json_array_end(&w);
+	if (tools_json != NULL) {
+		json_key(&w, "tools");
+		json_raw(&w, tools_json);
+	}
+	json_object_end(&w);
+}
+
+void
+anthropic_build_request_raw(struct buf *out, const char *model, int max_tokens,
+    const char *system, const char *messages_json, const char *tools_json)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "model", model);
+	json_kv_int(&w, "max_tokens", max_tokens);
+	json_kv_bool(&w, "stream", 1);
+	if (system != NULL)
+		json_kv_string(&w, "system", system);
+	json_key(&w, "messages");
+	json_raw(&w, messages_json);
+	if (tools_json != NULL) {
+		json_key(&w, "tools");
+		json_raw(&w, tools_json);
+	}
+	json_object_end(&w);
+}
+
+void
+anthropic_stream_init(struct anthropic_stream *s, const struct llm_cbs *cbs,
+    void *arg)
+{
+	memset(s, 0, sizeof(*s));
+	s->cbs = *cbs;
+	s->arg = arg;
+	s->cur_index = -1;
+	buf_init(&s->tool_id);
+	buf_init(&s->tool_name);
+	buf_init(&s->tool_json);
+}
+
+void
+anthropic_stream_clear(struct anthropic_stream *s)
+{
+	buf_free(&s->tool_id);
+	buf_free(&s->tool_name);
+	buf_free(&s->tool_json);
+	memset(s, 0, sizeof(*s));
+}
+
+void
+anthropic_stream_event(struct anthropic_stream *s, const char *event,
+    const char *data, size_t datalen)
+{
+	struct json_doc	doc;
+
+	if (s->error)
+		return;
+	if (json_parse(&doc, data, datalen) == -1 || doc.ntok < 1 ||
+	    json_tok_type(&doc, 0) != JSON_OBJECT) {
+		json_doc_free(&doc);
+		s->error = 1;
+		if (s->cbs.on_error != NULL)
+			s->cbs.on_error("parse_error", "invalid event JSON",
+			    s->arg);
+		return;
+	}
+
+	if (strcmp(event, "content_block_delta") == 0)
+		ev_block_delta(s, &doc);
+	else if (strcmp(event, "content_block_start") == 0)
+		ev_block_start(s, &doc);
+	else if (strcmp(event, "content_block_stop") == 0)
+		ev_block_stop(s);
+	else if (strcmp(event, "message_start") == 0)
+		ev_message_start(s, &doc);
+	else if (strcmp(event, "message_delta") == 0)
+		ev_message_delta(s, &doc);
+	else if (strcmp(event, "message_stop") == 0) {
+		if (s->cbs.on_done != NULL)
+			s->cbs.on_done(s->arg);
+	} else if (strcmp(event, "error") == 0)
+		ev_error(s, &doc);
+	/* "ping" and any unknown event are ignored. */
+
+	json_doc_free(&doc);
+}
+
+static void
+ev_message_start(struct anthropic_stream *s, struct json_doc *d)
+{
+	int	msg, usage;
+
+	if ((msg = json_obj_get(d, 0, "message")) < 0)
+		return;
+	if ((usage = json_obj_get(d, msg, "usage")) < 0)
+		return;
+	s->input_tokens = obj_getint(d, usage, "input_tokens", s->input_tokens);
+	s->output_tokens = obj_getint(d, usage, "output_tokens",
+	    s->output_tokens);
+	if (s->cbs.on_usage != NULL)
+		s->cbs.on_usage(s->input_tokens, s->output_tokens, s->arg);
+}
+
+static void
+ev_block_start(struct anthropic_stream *s, struct json_doc *d)
+{
+	char	*type, *id, *name;
+	size_t	 n;
+	int	 cb, id_t, name_t;
+
+	s->cur_index = (int)obj_getint(d, 0, "index", -1);
+	s->cur_is_tool = 0;
+	buf_reset(&s->tool_id);
+	buf_reset(&s->tool_name);
+	buf_reset(&s->tool_json);
+
+	if ((cb = json_obj_get(d, 0, "content_block")) < 0)
+		return;
+	if ((type = obj_getstr(d, cb, "type")) == NULL)
+		return;
+	if (strcmp(type, "tool_use") == 0) {
+		s->cur_is_tool = 1;
+		if ((id_t = json_obj_get(d, cb, "id")) >= 0 &&
+		    (id = json_tok_strdup_n(d, id_t, &n)) != NULL) {
+			buf_append(&s->tool_id, id, n);
+			free(id);
+		}
+		if ((name_t = json_obj_get(d, cb, "name")) >= 0 &&
+		    (name = json_tok_strdup_n(d, name_t, &n)) != NULL) {
+			buf_append(&s->tool_name, name, n);
+			free(name);
+		}
+	}
+	free(type);
+}
+
+static void
+ev_block_delta(struct anthropic_stream *s, struct json_doc *d)
+{
+	char	*dtype, *frag;
+	size_t	 n;
+	int	 delta, tt;
+
+	if ((delta = json_obj_get(d, 0, "delta")) < 0)
+		return;
+	if ((dtype = obj_getstr(d, delta, "type")) == NULL)
+		return;
+
+	if (strcmp(dtype, "text_delta") == 0) {
+		if ((tt = json_obj_get(d, delta, "text")) >= 0 &&
+		    (frag = json_tok_strdup_n(d, tt, &n)) != NULL) {
+			if (s->cbs.on_text != NULL)
+				s->cbs.on_text(frag, n, s->arg);
+			free(frag);
+		}
+	} else if (strcmp(dtype, "input_json_delta") == 0) {
+		if ((tt = json_obj_get(d, delta, "partial_json")) >= 0 &&
+		    (frag = json_tok_strdup_n(d, tt, &n)) != NULL) {
+			buf_append(&s->tool_json, frag, n);
+			free(frag);
+		}
+	}
+	/* thinking_delta and other delta types are ignored in v1. */
+	free(dtype);
+}
+
+static void
+ev_block_stop(struct anthropic_stream *s)
+{
+	const char	*id, *name, *jin;
+	size_t		 jlen;
+
+	if (s->cur_is_tool && s->cbs.on_tool_call != NULL) {
+		id = bufcstr(&s->tool_id);
+		name = bufcstr(&s->tool_name);
+		if (s->tool_json.len > 0) {
+			jin = bufcstr(&s->tool_json);
+			jlen = s->tool_json.len;
+		} else {
+			jin = "{}";	/* a tool call with no arguments */
+			jlen = 2;
+		}
+		s->cbs.on_tool_call(id, name, jin, jlen, s->arg);
+	}
+	s->cur_is_tool = 0;
+	s->cur_index = -1;
+	buf_reset(&s->tool_id);
+	buf_reset(&s->tool_name);
+	buf_reset(&s->tool_json);
+}
+
+static void
+ev_message_delta(struct anthropic_stream *s, struct json_doc *d)
+{
+	char	*sr;
+	int	 delta, usage, t;
+
+	if ((usage = json_obj_get(d, 0, "usage")) >= 0) {
+		s->output_tokens = obj_getint(d, usage, "output_tokens",
+		    s->output_tokens);
+		if (s->cbs.on_usage != NULL)
+			s->cbs.on_usage(s->input_tokens, s->output_tokens,
+			    s->arg);
+	}
+	if ((delta = json_obj_get(d, 0, "delta")) >= 0) {
+		t = json_obj_get(d, delta, "stop_reason");
+		if (t >= 0 && json_tok_type(d, t) == JSON_STRING &&
+		    (sr = json_tok_strdup(d, t)) != NULL) {
+			if (s->cbs.on_stop != NULL)
+				s->cbs.on_stop(sr, s->arg);
+			free(sr);
+		}
+	}
+}
+
+static void
+ev_error(struct anthropic_stream *s, struct json_doc *d)
+{
+	char	*type = NULL, *msg = NULL;
+	int	 err;
+
+	if ((err = json_obj_get(d, 0, "error")) >= 0) {
+		type = obj_getstr(d, err, "type");
+		msg = obj_getstr(d, err, "message");
+	}
+	s->error = 1;
+	if (s->cbs.on_error != NULL)
+		s->cbs.on_error(type != NULL ? type : "error",
+		    msg != NULL ? msg : "", s->arg);
+	free(type);
+	free(msg);
+}
+
+/* Duplicate an object member as a C string (string or primitive); NULL if absent. */
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+	int	t = json_obj_get(d, obj, key);
+
+	if (t < 0)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
+
+static long long
+obj_getint(struct json_doc *d, int obj, const char *key, long long dflt)
+{
+	const char	*errstr;
+	char		*s;
+	long long	 v;
+	int		 t = json_obj_get(d, obj, key);
+
+	if (t < 0 || (s = json_tok_strdup(d, t)) == NULL)
+		return (dflt);
+	v = strtonum(s, LLONG_MIN, LLONG_MAX, &errstr);
+	free(s);
+	return (errstr != NULL ? dflt : v);
+}
+
+/* Borrow a buf's bytes as a NUL-terminated C string (the NUL stays uncounted). */
+static const char *
+bufcstr(struct buf *b)
+{
+	buf_terminate(b);
+	return (b->data);
+}
+
+/* ===== vtable adapters ===== */
+
+/*
+ * Default request path for the Messages API.  Also the input net_run's models-
+ * endpoint derivation swaps "/messages" out of when synthesising the models
+ * list URL.
+ */
+#define ANTHROPIC_PATH		"/v1/messages"
+
+/* Wire-format version the Messages API requires on every request. */
+#define ANTHROPIC_VERSION	"2023-06-01"
+
+/*
+ * Build a streaming Messages request: body via anthropic_build_request_raw,
+ * plus the Anthropic-specific header set (x-api-key, anthropic-version,
+ * content-type, accept).  authval is unused for Anthropic (the key rides in a
+ * dedicated header, not Authorization) but the signature is uniform across
+ * adapters; the caller still buf_freezero()s it on the way out.
+ */
+static void
+anthropic_prep_request(struct buf *body, struct http_header *h, int hmax,
+    int *nh, struct buf *authval, const char **path, const char *key,
+    const char *model, int max_tokens, const char *system, const char *conv,
+    const char *tools)
+{
+	(void)authval;
+	if (hmax < 4)
+		return;			/* caller bug; net worker sizes h[4] */
+	anthropic_build_request_raw(body, model, max_tokens, system, conv,
+	    tools);
+	h[0].name = "x-api-key";	h[0].value = key;
+	h[1].name = "anthropic-version"; h[1].value = ANTHROPIC_VERSION;
+	h[2].name = "content-type";	h[2].value = "application/json";
+	h[3].name = "accept";		h[3].value = "text/event-stream";
+	*nh = 4;
+	*path = ANTHROPIC_PATH;
+}
+
+static void
+anthropic_stream_init_op(union llm_stream_storage *s,
+    const struct llm_cbs *cbs, void *arg)
+{
+	anthropic_stream_init(&s->a, cbs, arg);
+}
+
+static void
+anthropic_stream_event_op(union llm_stream_storage *s, const char *event,
+    const char *data, size_t len)
+{
+	anthropic_stream_event(&s->a, event, data, len);
+}
+
+static void
+anthropic_stream_clear_op(union llm_stream_storage *s)
+{
+	anthropic_stream_clear(&s->a);
+}
+
+static const char *
+anthropic_models_path_op(void)
+{
+	return (ANTHROPIC_PATH);
+}
+
+const struct llm_provider_ops anthropic_ops = {
+	.prep_request	= anthropic_prep_request,
+	.stream_init	= anthropic_stream_init_op,
+	.stream_event	= anthropic_stream_event_op,
+	.stream_clear	= anthropic_stream_clear_op,
+	.models_path	= anthropic_models_path_op,
+};
blob - /dev/null
blob + 8ca7ed4053218eb174b4c2ef8c9c63a1efb31eed (mode 644)
--- /dev/null
+++ src/common/anthropic.h
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * anthropic: the provider layer for the Anthropic Messages API.  Two pure,
+ * socket-free halves: anthropic_build_request() serialises a streaming request
+ * body, and struct anthropic_stream interprets the Server-Sent Events of a
+ * streaming response, turning Anthropic's event vocabulary into a handful of
+ * semantic callbacks (text deltas, a completed tool call, usage, stop, done,
+ * error).  The net worker drives the request over libtls and feeds the response
+ * through http -> sse -> anthropic_stream_event(); these callbacks become the
+ * imsg messages relayed to the UI and the tool workers.
+ */
+
+#ifndef FUGU_ANTHROPIC_H
+#define FUGU_ANTHROPIC_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+#include "provider.h"
+
+/* One conversation turn, with plain-text content (v1 chat). */
+struct anth_msg {
+	const char	*role;		/* "user" or "assistant"		*/
+	const char	*text;		/* content as a single text block	*/
+};
+
+/*
+ * Serialise a streaming Messages request body into out.  "stream" is always
+ * true.  system is emitted only when non-NULL; tools_json, when non-NULL, is a
+ * pre-formatted JSON array spliced in as the "tools" value.
+ */
+void	anthropic_build_request(struct buf *out, const char *model,
+	    int max_tokens, const char *system, const struct anth_msg *msgs,
+	    size_t nmsgs, const char *tools_json);
+
+/*
+ * Same, but the messages are supplied as a pre-formatted JSON array (the
+ * conversation projection the net worker receives in an M_SUBMIT), spliced in
+ * verbatim as the "messages" value.
+ */
+void	anthropic_build_request_raw(struct buf *out, const char *model,
+	    int max_tokens, const char *system, const char *messages_json,
+	    const char *tools_json);
+
+struct anthropic_stream {
+	struct llm_cbs		cbs;
+	void		       *arg;
+	int			cur_index;	/* index of the open content block */
+	int			cur_is_tool;	/* the open block is a tool_use	  */
+	long long		input_tokens;
+	long long		output_tokens;
+	struct buf		tool_id;	/* current tool_use id		  */
+	struct buf		tool_name;	/* current tool_use name	  */
+	struct buf		tool_json;	/* accumulated partial_json	  */
+	int			error;		/* a fatal stream error was seen  */
+};
+
+void	anthropic_stream_init(struct anthropic_stream *,
+	    const struct llm_cbs *, void *);
+void	anthropic_stream_clear(struct anthropic_stream *);
+
+/*
+ * Feed one SSE event (the event type and its data payload).  Shaped to be
+ * called from an sse_event_cb trampoline.
+ */
+void	anthropic_stream_event(struct anthropic_stream *, const char *event,
+	    const char *data, size_t datalen);
+
+/*
+ * Vtable instance -- see provider.h.  Defined in anthropic.c; resolved onto a
+ * struct net_provider at config-apply time so the net worker can dispatch
+ * prep_request / stream_init / stream_event / stream_clear / models_path
+ * without branching on enum llm_provider.  Forward-declared here because
+ * provider.h pulls this header in before completing struct llm_provider_ops.
+ */
+struct llm_provider_ops;
+extern const struct llm_provider_ops anthropic_ops;
+
+#endif /* FUGU_ANTHROPIC_H */
blob - /dev/null
blob + 2c6b77ae367659f8940a90be3baa7930ea557ef7 (mode 644)
--- /dev/null
+++ src/common/buf.c
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "log.h"
+#include "util.h"
+
+#define BUF_MIN	64
+
+void
+buf_init(struct buf *b)
+{
+	b->data = NULL;
+	b->len = 0;
+	b->cap = 0;
+}
+
+void
+buf_reserve(struct buf *b, size_t need)
+{
+	size_t	ncap;
+
+	if (b->cap - b->len >= need)
+		return;
+
+	ncap = (b->cap != 0) ? b->cap : BUF_MIN;
+	while (ncap - b->len < need) {
+		if (ncap > (size_t)-1 / 2)
+			fatalx("buf_reserve: capacity overflow");
+		ncap *= 2;
+	}
+	b->data = xreallocarray(b->data, ncap, 1);
+	b->cap = ncap;
+}
+
+void
+buf_append(struct buf *b, const void *data, size_t len)
+{
+	if (len == 0)
+		return;
+	buf_reserve(b, len);
+	memcpy(b->data + b->len, data, len);
+	b->len += len;
+}
+
+void
+buf_putc(struct buf *b, int c)
+{
+	unsigned char	ch = (unsigned char)c;
+
+	buf_append(b, &ch, 1);
+}
+
+void
+buf_puts_cstr(struct buf *b, const char *s)
+{
+	buf_append(b, s, strlen(s));
+}
+
+/*
+ * Append a NUL byte but leave .len unchanged -- the canonical "now readable
+ * as a C string via .data" idiom in one place.  Centralising it kills the
+ * scattered `buf_putc(b, '\0'); b->len--;` lines that easily drift out of
+ * sync if a future caller forgets the decrement.
+ */
+void
+buf_terminate(struct buf *b)
+{
+	buf_putc(b, '\0');
+	b->len--;
+}
+
+void
+buf_printf(struct buf *b, const char *fmt, ...)
+{
+	va_list	ap, ap2;
+	int	n;
+
+	va_start(ap, fmt);
+	va_copy(ap2, ap);
+	n = vsnprintf(NULL, 0, fmt, ap);
+	va_end(ap);
+	if (n < 0)
+		fatalx("buf_printf: vsnprintf");
+
+	buf_reserve(b, (size_t)n + 1);
+	n = vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap2);
+	va_end(ap2);
+	if (n < 0)
+		fatalx("buf_printf: vsnprintf");
+
+	/* The trailing NUL is written but deliberately not counted in len. */
+	b->len += (size_t)n;
+}
+
+void
+buf_reset(struct buf *b)
+{
+	b->len = 0;
+}
+
+void
+buf_free(struct buf *b)
+{
+	free(b->data);
+	b->data = NULL;
+	b->len = 0;
+	b->cap = 0;
+}
+
+/*
+ * Like buf_free, but scrub the whole allocation first -- for buffers that have
+ * held a secret (an API key materialised into a request).  freezero() handles
+ * a NULL pointer, and the size is the allocation, not the logical length.
+ */
+void
+buf_freezero(struct buf *b)
+{
+	freezero(b->data, b->cap);
+	b->data = NULL;
+	b->len = 0;
+	b->cap = 0;
+}
blob - /dev/null
blob + 8b55ca69fb0540cdaa85778d5060b9ae810cb3ba (mode 644)
--- /dev/null
+++ src/common/buf.h
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_BUF_H
+#define FUGU_BUF_H
+
+#include <sys/types.h>
+
+#include <assert.h>
+#include <stdarg.h>
+
+/*
+ * A growable byte buffer.  Not implicitly NUL-terminated; callers that need a
+ * C string call buf_terminate(b) and then read .data via buf_cstr(b).  The
+ * trailing NUL is NOT counted in .len.  All growth goes through xreallocarray,
+ * so OOM is fatal, never silent.
+ *
+ * Naming convention is load-bearing:
+ *   - buf_append(b, p, n)   -- length-aware, NUL-safe
+ *   - buf_puts_cstr(b, s)   -- the `_cstr` suffix declares "this uses strlen,
+ *                              so embedded NULs in `s` truncate".  Audit
+ *                              every call site whose bytes flow to wire or
+ *                              storage; prefer buf_append with a known length.
+ *   - buf_terminate(b)      -- the canonical "append NUL, don't count it"
+ *                              idiom, in one place so it can never drift.
+ *   - buf_cstr(b)           -- read .data as a C string with a debug assert
+ *                              that the trailing NUL is actually there.
+ */
+struct buf {
+	char	*data;
+	size_t	 len;
+	size_t	 cap;
+};
+
+void	 buf_init(struct buf *);
+void	 buf_reserve(struct buf *, size_t);
+void	 buf_append(struct buf *, const void *, size_t);
+void	 buf_putc(struct buf *, int);
+void	 buf_puts_cstr(struct buf *, const char *);
+void	 buf_printf(struct buf *, const char *, ...)
+	    __attribute__((__format__ (printf, 2, 3)));
+/* Append a '\0' but leave .len pointing at the last logical byte. */
+void	 buf_terminate(struct buf *);
+void	 buf_reset(struct buf *);
+void	 buf_free(struct buf *);
+void	 buf_freezero(struct buf *);	/* zero the bytes before freeing (secrets) */
+
+/*
+ * Read .data as a C string.  An empty buf returns "" so callers never need
+ * to null-check, and assert() catches the missing-terminate bug at the read
+ * site instead of the producer.  Inline-in-header so the assertion line
+ * number lands at the call site under the default (non-NDEBUG) build.
+ */
+static inline const char *
+buf_cstr(const struct buf *b)
+{
+	if (b->data == NULL)
+		return ("");
+	assert(b->data[b->len] == '\0');
+	return (b->data);
+}
+
+#endif /* FUGU_BUF_H */
blob - /dev/null
blob + 7377c2ce089135786d03599b3dc08af5722c0474 (mode 644)
--- /dev/null
+++ src/common/conf.c
@@ -0,0 +1,333 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "conf.h"
+#include "log.h"
+#include "util.h"
+
+static void	settings_merge(struct conf_settings *,
+		    const struct conf_settings *, unsigned int);
+static int	match_applies(const struct conf_match *, const char *,
+		    char *const *, int);
+
+static void
+setstr(char **field, const char *val)
+{
+	free(*field);
+	*field = (val != NULL) ? xstrdup(val) : NULL;
+}
+
+void
+conf_settings_clear(struct conf_settings *s)
+{
+	free(s->model);
+	free(s->system);
+	free(s->provider);
+	free(s->search_provider);
+	free(s->anthropic_key);
+	free(s->kagi_token);
+	free(s->anthropic_key_file);
+	free(s->kagi_token_file);
+	free(s->api_key);
+	free(s->api_key_file);
+	free(s->api_host);
+	free(s->api_port);
+	free(s->api_path);
+	free(s->http_allow);
+	free(s->http_block);
+	memset(s, 0, sizeof(*s));
+}
+
+void
+conf_set_string(struct conf_settings *s, unsigned int flag, char **field,
+    const char *val)
+{
+	setstr(field, val);
+	s->set |= flag;
+}
+
+void
+conf_set_flag(struct conf_settings *s, unsigned int flag)
+{
+	s->set |= flag;
+}
+
+void
+conf_init(struct conf *c)
+{
+	memset(&c->global, 0, sizeof(c->global));
+	TAILQ_INIT(&c->matches);
+	TAILQ_INIT(&c->providers);
+}
+
+struct conf_match *
+conf_match_new(struct conf *c, enum match_kind kind, const char *name)
+{
+	struct conf_match	*m;
+
+	m = xcalloc(1, sizeof(*m));
+	m->kind = kind;
+	m->name = xstrdup(name);
+	TAILQ_INSERT_TAIL(&c->matches, m, entry);
+	return (m);
+}
+
+struct conf_provider *
+conf_provider_new(struct conf *c, const char *name)
+{
+	struct conf_provider	*p;
+
+	p = xcalloc(1, sizeof(*p));
+	p->name = xstrdup(name);
+	TAILQ_INSERT_TAIL(&c->providers, p, entry);
+	return (p);
+}
+
+void
+conf_provider_copy(struct conf_providers *out, const struct conf_provider *src)
+{
+	struct conf_provider	*p;
+
+	/* First definition of a name wins: skip a later duplicate. */
+	TAILQ_FOREACH(p, out, entry)
+		if (strcmp(p->name, src->name) == 0)
+			return;
+	p = xcalloc(1, sizeof(*p));
+	p->name = xstrdup(src->name);
+	settings_merge(&p->settings, &src->settings, ~0u);
+	TAILQ_INSERT_TAIL(out, p, entry);
+}
+
+void
+conf_providers_clear(struct conf_providers *provs)
+{
+	struct conf_provider	*p;
+
+	while ((p = TAILQ_FIRST(provs)) != NULL) {
+		TAILQ_REMOVE(provs, p, entry);
+		conf_settings_clear(&p->settings);
+		free(p->name);
+		free(p);
+	}
+}
+
+void
+conf_clear(struct conf *c)
+{
+	struct conf_match	*m;
+
+	while ((m = TAILQ_FIRST(&c->matches)) != NULL) {
+		TAILQ_REMOVE(&c->matches, m, entry);
+		conf_settings_clear(&m->settings);
+		free(m->name);
+		free(m);
+	}
+	conf_providers_clear(&c->providers);
+	conf_settings_clear(&c->global);
+}
+
+/* Copy fields present in src and allowed by mask into dst. */
+static void
+settings_merge(struct conf_settings *d, const struct conf_settings *s,
+    unsigned int allow)
+{
+	unsigned int	a = s->set & allow;
+
+	if (a & CS_MODEL)
+		setstr(&d->model, s->model);
+	if (a & CS_SYSTEM)
+		setstr(&d->system, s->system);
+	if (a & CS_MAX_TOKENS)
+		d->max_tokens = s->max_tokens;
+	if (a & CS_CONTEXT_LIMIT)
+		d->context_limit = s->context_limit;
+	if (a & CS_ALLOW_SUBPROC_NET)
+		d->allow_subprocess_net = s->allow_subprocess_net;
+	if (a & CS_ALLOW_WRITE)
+		d->allow_write = s->allow_write;
+	if (a & CS_WEB_SEARCH)
+		d->web_search = s->web_search;
+	if (a & CS_ASCII)
+		d->ascii = s->ascii;
+	if (a & CS_PROVIDER)
+		setstr(&d->provider, s->provider);
+	if (a & CS_SEARCH_PROVIDER)
+		setstr(&d->search_provider, s->search_provider);
+	if (a & CS_ANTHROPIC_KEY)
+		setstr(&d->anthropic_key, s->anthropic_key);
+	if (a & CS_KAGI_TOKEN)
+		setstr(&d->kagi_token, s->kagi_token);
+	if (a & CS_ANTHROPIC_KEY_FILE)
+		setstr(&d->anthropic_key_file, s->anthropic_key_file);
+	if (a & CS_KAGI_TOKEN_FILE)
+		setstr(&d->kagi_token_file, s->kagi_token_file);
+	if (a & CS_API_KEY)
+		setstr(&d->api_key, s->api_key);
+	if (a & CS_API_KEY_FILE)
+		setstr(&d->api_key_file, s->api_key_file);
+	if (a & CS_API_HOST)
+		setstr(&d->api_host, s->api_host);
+	if (a & CS_API_PORT)
+		setstr(&d->api_port, s->api_port);
+	if (a & CS_API_PATH)
+		setstr(&d->api_path, s->api_path);
+	if (a & CS_HTTP_ALLOW)
+		setstr(&d->http_allow, s->http_allow);
+	if (a & CS_HTTP_BLOCK)
+		setstr(&d->http_block, s->http_block);
+	d->set |= a;
+}
+
+static int
+match_applies(const struct conf_match *m, const char *user,
+    char *const *groups, int ngroups)
+{
+	int	i;
+
+	if (m->kind == MATCH_USER)
+		return (user != NULL && strcmp(m->name, user) == 0);
+
+	for (i = 0; i < ngroups; i++)
+		if (strcmp(m->name, groups[i]) == 0)
+			return (1);
+	return (0);
+}
+
+void
+conf_resolve(const struct conf *c, const char *user, char *const *groups,
+    int ngroups, struct conf_settings *out)
+{
+	const struct conf_match	*m;
+	unsigned int		 locked;
+
+	conf_settings_clear(out);
+
+	/* Global defaults form the base layer. */
+	settings_merge(out, &c->global, ~0u);
+
+	/* Match blocks override, first matching block winning per key. */
+	locked = 0;
+	TAILQ_FOREACH(m, &c->matches, entry) {
+		unsigned int	apply;
+
+		if (!match_applies(m, user, groups, ngroups))
+			continue;
+		apply = m->settings.set & ~locked;
+		settings_merge(out, &m->settings, apply);
+		locked |= apply;
+	}
+}
+
+int
+conf_check_secrecy(int fd, const char *path, uid_t uid)
+{
+	struct stat	st;
+
+	/*
+	 * Stat the open descriptor (not the path) so the file we check is the
+	 * one we read -- closing the TOCTOU window, as base check_file_secrecy
+	 * does (usr.sbin/httpd/parse.y).  The config holds secrets, so: it must be
+	 * owned by root (the admin) or by the invoking user; it must never be
+	 * world-accessible; and it must not be writable by its group or the world.
+	 * Group READ is allowed -- that is exactly how the parent, running setgid
+	 * _fugu, reads a root:_fugu 0640 /etc/fugu.conf.
+	 */
+	if (fstat(fd, &st) == -1) {
+		log_warn("%s", path);
+		return (-1);
+	}
+	if (st.st_uid != 0 && st.st_uid != uid) {
+		log_warnx("%s: bad ownership (uid %u); must be owned by root or "
+		    "you; refusing to read secrets", path, (unsigned)st.st_uid);
+		return (-1);
+	}
+	if (st.st_mode & (S_IRWXO | S_IWGRP)) {
+		log_warnx("%s: permissions %04o too open; must not be "
+		    "world-accessible or group-writable", path,
+		    (unsigned)(st.st_mode & 07777));
+		return (-1);
+	}
+	return (0);
+}
+
+void
+conf_settings_print(const struct conf_settings *s, FILE *fp)
+{
+	if (s->set & CS_MODEL)
+		fprintf(fp, "model \"%s\"\n", s->model);
+	if (s->set & CS_SYSTEM)
+		fprintf(fp, "system \"%s\"\n", s->system);
+	if (s->set & CS_MAX_TOKENS)
+		fprintf(fp, "max_tokens %lld\n", s->max_tokens);
+	if (s->set & CS_CONTEXT_LIMIT)
+		fprintf(fp, "context_limit %lld\n", s->context_limit);
+	if (s->set & CS_ALLOW_SUBPROC_NET)
+		fprintf(fp, "allow_subprocess_net %s\n",
+		    s->allow_subprocess_net ? "yes" : "no");
+	if (s->set & CS_ALLOW_WRITE)
+		fprintf(fp, "allow_write %s\n", s->allow_write ? "yes" : "no");
+	if (s->set & CS_WEB_SEARCH)
+		fprintf(fp, "web_search %s\n", s->web_search ? "yes" : "no");
+	if (s->set & CS_ASCII)
+		fprintf(fp, "ascii %s\n", s->ascii ? "yes" : "no");
+	if (s->set & CS_PROVIDER)
+		fprintf(fp, "provider \"%s\"\n", s->provider);
+	if (s->set & CS_SEARCH_PROVIDER)
+		fprintf(fp, "search_provider \"%s\"\n", s->search_provider);
+	if (s->set & CS_API_HOST)
+		fprintf(fp, "api_host \"%s\"\n", s->api_host);
+	if (s->set & CS_API_PORT)
+		fprintf(fp, "api_port \"%s\"\n", s->api_port);
+	if (s->set & CS_API_PATH)
+		fprintf(fp, "api_path \"%s\"\n", s->api_path);
+	if (s->set & CS_HTTP_ALLOW)
+		fprintf(fp, "http_allow \"%s\"\n", s->http_allow);
+	if (s->set & CS_HTTP_BLOCK)
+		fprintf(fp, "http_block \"%s\"\n", s->http_block);
+	/* Secrets are never printed verbatim. */
+	if (s->set & CS_ANTHROPIC_KEY)
+		fprintf(fp, "anthropic_key \"***\"\n");
+	if (s->set & CS_KAGI_TOKEN)
+		fprintf(fp, "kagi_token \"***\"\n");
+	if (s->set & CS_ANTHROPIC_KEY_FILE)
+		fprintf(fp, "anthropic_key_file \"%s\"\n",
+		    s->anthropic_key_file);
+	if (s->set & CS_KAGI_TOKEN_FILE)
+		fprintf(fp, "kagi_token_file \"%s\"\n", s->kagi_token_file);
+	if (s->set & CS_API_KEY)
+		fprintf(fp, "api_key \"***\"\n");
+	if (s->set & CS_API_KEY_FILE)
+		fprintf(fp, "api_key_file \"%s\"\n", s->api_key_file);
+}
+
+void
+conf_providers_print(const struct conf_providers *provs, FILE *fp)
+{
+	const struct conf_provider	*p;
+
+	TAILQ_FOREACH(p, provs, entry) {
+		fprintf(fp, "provider \"%s\" {\n", p->name);
+		conf_settings_print(&p->settings, fp);
+		fprintf(fp, "}\n");
+	}
+}
blob - /dev/null
blob + a0641682800b8d8de6e48bda80eb52960c954b19 (mode 644)
--- /dev/null
+++ src/common/conf.h
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_CONF_H
+#define FUGU_CONF_H
+
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <stdio.h>
+
+/*
+ * The fugu configuration model. A parsed config holds global settings plus an
+ * ordered list of "match" scope blocks; conf_resolve() collapses them into the
+ * effective settings for a particular user. Each settings struct carries a
+ * bitmask recording which fields were explicitly set, so merges only touch what
+ * was actually given.
+ */
+
+#define CS_MODEL		(1u << 0)
+#define CS_MAX_TOKENS		(1u << 1)
+#define CS_CONTEXT_LIMIT	(1u << 2)
+#define CS_ALLOW_SUBPROC_NET	(1u << 3)
+#define CS_ALLOW_WRITE		(1u << 4)
+#define CS_WEB_SEARCH		(1u << 5)
+#define CS_SEARCH_PROVIDER	(1u << 6)
+#define CS_ANTHROPIC_KEY	(1u << 7)
+#define CS_KAGI_TOKEN		(1u << 8)
+#define CS_ANTHROPIC_KEY_FILE	(1u << 9)
+#define CS_KAGI_TOKEN_FILE	(1u << 10)
+#define CS_ASCII		(1u << 11)
+#define CS_SYSTEM		(1u << 12)
+#define CS_PROVIDER		(1u << 13)
+#define CS_API_KEY		(1u << 14)
+#define CS_API_KEY_FILE		(1u << 15)
+#define CS_API_HOST		(1u << 16)
+#define CS_API_PORT		(1u << 17)
+#define CS_API_PATH		(1u << 18)
+#define CS_HTTP_ALLOW		(1u << 19)
+#define CS_HTTP_BLOCK		(1u << 20)
+
+#define CS_SECRETS	(CS_ANTHROPIC_KEY | CS_KAGI_TOKEN |	\
+			 CS_ANTHROPIC_KEY_FILE | CS_KAGI_TOKEN_FILE |	\
+			 CS_API_KEY | CS_API_KEY_FILE)
+
+struct conf_settings {
+	unsigned int	 set;		/* bitmask of CS_* present */
+	char		*model;
+	char		*system;	/* system prompt sent to the model */
+	long long	 max_tokens;
+	long long	 context_limit;
+	int		 allow_subprocess_net;
+	int		 allow_write;
+	int		 web_search;
+	int		 ascii;		/* render the TUI as ASCII only */
+	char		*provider;	/* "anthropic" (default) or "openai" */
+	char		*search_provider;
+	char		*anthropic_key;
+	char		*kagi_token;
+	char		*anthropic_key_file;
+	char		*kagi_token_file;
+	char		*api_key;	/* generic model API key (any provider) */
+	char		*api_key_file;
+	char		*api_host;	/* model endpoint host[:port] override */
+	char		*api_port;
+	char		*api_path;	/* request path override (e.g. /api/v1/...) */
+	char		*http_allow;	/* http_request tool: allowed host patterns */
+	char		*http_block;	/* http_request tool: blocked host patterns */
+};
+
+enum match_kind { MATCH_USER, MATCH_GROUP };
+
+struct conf_match {
+	TAILQ_ENTRY(conf_match)	 entry;
+	enum match_kind		 kind;
+	char			*name;
+	struct conf_settings	 settings;
+};
+TAILQ_HEAD(conf_matches, conf_match);
+
+/*
+ * A named model provider (a `provider "name" { ... }` block).  Its settings
+ * reuse conf_settings, but only the model-endpoint fields are meaningful:
+ * provider (the wire type, set via the `type` keyword), model, api_key,
+ * api_key_file, api_host, api_port, api_path.  The flat top-level/match keys
+ * still define an implicit "default" provider, so single-provider configs are
+ * unchanged; named blocks add more.
+ */
+struct conf_provider {
+	TAILQ_ENTRY(conf_provider)	 entry;
+	char				*name;
+	struct conf_settings		 settings;
+};
+TAILQ_HEAD(conf_providers, conf_provider);
+
+struct conf {
+	struct conf_settings	global;
+	struct conf_matches	matches;
+	struct conf_providers	providers;
+};
+
+void	 conf_init(struct conf *);
+void	 conf_clear(struct conf *);
+struct conf_match *conf_match_new(struct conf *, enum match_kind, const char *);
+struct conf_provider *conf_provider_new(struct conf *, const char *);
+/* Append a deep copy of src to out unless a provider of that name is present. */
+void	 conf_provider_copy(struct conf_providers *out,
+	    const struct conf_provider *src);
+void	 conf_providers_clear(struct conf_providers *);
+
+void	 conf_settings_clear(struct conf_settings *);
+
+/* Set a string field, free any previous value, and record its CS_* bit. */
+void	 conf_set_string(struct conf_settings *, unsigned int, char **,
+	    const char *);
+/* Set a scalar field's CS_* bit (the field itself is assigned by the caller). */
+void	 conf_set_flag(struct conf_settings *, unsigned int);
+
+/* Effective settings for a user (sshd Match-style: first matching block wins). */
+void	 conf_resolve(const struct conf *, const char *user,
+	    char *const *groups, int ngroups, struct conf_settings *out);
+
+/*
+ * StrictModes for the open secrets file (fd): owned by root or uid, not
+ * world-accessible, not group-writable (group read is allowed, for the setgid
+ * _fugu group).  Checks the descriptor, not the path, so it is TOCTOU-safe.
+ * 0 ok, -1 bad.
+ */
+int	 conf_check_secrecy(int fd, const char *path, uid_t uid);
+
+/* Print effective config for `fugu -n`, redacting secret values. */
+void	 conf_settings_print(const struct conf_settings *, FILE *);
+/* Print the named provider blocks for `fugu -n`, redacting secret values. */
+void	 conf_providers_print(const struct conf_providers *, FILE *);
+
+/*
+ * Parser entry point (defined in parse.y); appends to *c.  When secret is
+ * nonzero the file (and any it includes) is StrictModes-checked.  0 ok, -1.
+ */
+int	 conf_parse_file(struct conf *, const char *path, int secret);
+
+/*
+ * Resolve the effective config for the invoking user (parent only).  The named
+ * provider blocks are appended to *provs, which the caller inits and owns.
+ */
+int	 conf_load(struct conf_settings *, struct conf_providers *provs);
+
+#endif /* FUGU_CONF_H */
blob - /dev/null
blob + 0c82c1237e7f7cc10e7b479a27c7fec2ed45ec7d (mode 644)
--- /dev/null
+++ src/common/confload.c
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * conf_load: resolve the effective configuration for the invoking user from the
+ * single system file (/etc/fugu.conf), which holds the secrets and is
+ * StrictModes-checked.  A normal install runs the parent setgid _fugu so it can
+ * read that root-owned file; the model's tools, which run as the user without
+ * that group, cannot.  This is the parent's view; resolved values are delegated
+ * to the workers over imsg.  FUGU_CONF overrides the path only when the binary
+ * is NOT setgid (issetugid()) -- i.e. for development and the regress harness,
+ * which run with no extra privilege -- so an installed fugu cannot be coaxed
+ * into reading another _fugu-readable file.
+ */
+
+#include <sys/types.h>
+
+#include <fcntl.h>
+#include <grp.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+#include "log.h"
+#include "util.h"
+
+#define SYS_CONF	"/etc/fugu.conf"
+
+int
+conf_load(struct conf_settings *out, struct conf_providers *provs)
+{
+	struct passwd		*pw;
+	struct conf		 c;
+	struct conf_provider	*p;
+	const char		*path = SYS_CONF, *env, *user;
+	gid_t			*gids;
+	char			**gnames;
+	int			 ngroups, ngn, i, rc = 0;
+
+	if ((pw = getpwuid(getuid())) == NULL) {
+		log_warnx("cannot determine current user");
+		return (-1);
+	}
+	user = pw->pw_name;
+
+	/* Resolve the user's supplementary groups to names for Match group. */
+	ngroups = 64;
+	gids = xreallocarray(NULL, ngroups, sizeof(*gids));
+	if (getgrouplist(user, pw->pw_gid, gids, &ngroups) == -1) {
+		gids = xreallocarray(gids, ngroups, sizeof(*gids));
+		if (getgrouplist(user, pw->pw_gid, gids, &ngroups) == -1)
+			ngroups = 0;
+	}
+	gnames = (ngroups > 0) ?
+	    xreallocarray(NULL, ngroups, sizeof(*gnames)) : NULL;
+	ngn = 0;
+	for (i = 0; i < ngroups; i++) {
+		struct group	*gr = getgrgid(gids[i]);
+
+		if (gr != NULL)
+			gnames[ngn++] = xstrdup(gr->gr_name);
+	}
+	free(gids);
+
+	if (!issetugid() && (env = getenv("FUGU_CONF")) != NULL && *env != '\0')
+		path = env;
+
+	/*
+	 * The file is optional.  Gate on effective-id readability (faccessat with
+	 * AT_EACCESS, since we read it via the setgid group, not the real gid);
+	 * absent or unreadable means no config, hence no key -- a friendly error
+	 * at turn time.  The open file is then StrictModes-checked in pushfile.
+	 */
+	conf_init(&c);
+	if (faccessat(AT_FDCWD, path, R_OK, AT_EACCESS) == 0) {
+		if (conf_parse_file(&c, path, 1) == -1) {
+			rc = -1;
+			goto done;
+		}
+	}
+	conf_resolve(&c, user, gnames, ngn, out);
+	TAILQ_FOREACH(p, &c.providers, entry)
+		conf_provider_copy(provs, p);
+
+done:
+	conf_clear(&c);
+	for (i = 0; i < ngn; i++)
+		free(gnames[i]);
+	free(gnames);
+	return (rc);
+}
blob - /dev/null
blob + da00000cf7a3d7e9d630cfe7d3e9d341933e54cc (mode 644)
--- /dev/null
+++ src/common/fuzzy.c
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <ctype.h>
+#include <stddef.h>
+
+#include "fuzzy.h"
+
+/* A word boundary in an id like "anthropic/claude-3.5" or "gpt-4o_mini". */
+static int
+is_sep(int c)
+{
+	return (c == '-' || c == '/' || c == '_' || c == '.' || c == ' ' ||
+	    c == ':' || c == '\t');
+}
+
+int
+fuzzy_match(const char *needle, const char *haystack, int *score)
+{
+	const char	*h = haystack;
+	const char	*n;
+	int		 s = 0;
+	int		 consec = 0;	/* the previous needle char also matched here */
+	int		 prev_sep = 1;	/* char before h is a boundary (start counts) */
+
+	for (n = needle; *n != '\0'; n++) {
+		int	nc = tolower((unsigned char)*n);
+		int	matched = 0;
+
+		while (*h != '\0') {
+			int	hc = tolower((unsigned char)*h);
+			int	sep = is_sep((unsigned char)*h);
+
+			if (hc == nc) {
+				s += 1;
+				if (consec)
+					s += 3;		/* run of adjacent matches */
+				if (prev_sep)
+					s += 5;		/* aligned to a boundary */
+				consec = 1;
+				prev_sep = sep;
+				h++;
+				matched = 1;
+				break;
+			}
+			consec = 0;
+			prev_sep = sep;
+			h++;
+		}
+		if (!matched) {			/* haystack exhausted mid-needle */
+			if (score != NULL)
+				*score = 0;
+			return (0);
+		}
+	}
+
+	if (score != NULL)
+		*score = s;
+	return (1);
+}
blob - /dev/null
blob + 6340b5253e27e8ca18570684ca691be0bf802aad (mode 644)
--- /dev/null
+++ src/common/fuzzy.h
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_FUZZY_H
+#define FUGU_FUZZY_H
+
+/*
+ * fuzzy_match: case-insensitive subsequence match of needle within haystack.
+ * Returns 1 if every character of needle appears, in order, in haystack (an
+ * empty needle always matches), 0 otherwise.  When it returns 1 and score is
+ * non-NULL, *score is set to a relevance score (higher is better): consecutive
+ * characters and matches at word boundaries (start, or after - / _ . space)
+ * score higher, so the ranking favours prefix- and segment-aligned hits.
+ */
+int	fuzzy_match(const char *needle, const char *haystack, int *score);
+
+#endif /* FUGU_FUZZY_H */
blob - /dev/null
blob + 49eac1f9aaef15ffc2ffc308c0c4c1767bafb2c9 (mode 644)
--- /dev/null
+++ src/common/html2text.c
@@ -0,0 +1,397 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <ctype.h>
+#include <string.h>
+#include <strings.h>
+
+#include "buf.h"
+#include "html2text.h"
+
+/*
+ * Whitespace is deferred so trailing spaces and runs of blank lines never reach
+ * the output: emit_space()/emit_break() record an intent, and the next visible
+ * byte flushes the strongest pending whitespace (a break beats a space, two
+ * breaks cap a paragraph gap).
+ *
+ * Invariant: out->data[0..out->len) is always well-formed UTF-8.  The
+ * HTML2TEXT_MAXOUT cap is enforced atomically per codepoint by emit_codepoint,
+ * so a multi-byte sequence is never truncated between its lead and continuation
+ * bytes.
+ */
+struct emit {
+	struct buf	*out;
+	int		 want_space;
+	int		 want_break;	/* pending newlines, capped at 2	*/
+	int		 started;	/* any visible byte emitted yet		*/
+};
+
+static void
+emit_space(struct emit *e)
+{
+	if (e->started)
+		e->want_space = 1;
+}
+
+static void
+emit_break(struct emit *e, int paragraph)
+{
+	int	n = paragraph ? 2 : 1;
+
+	if (!e->started)
+		return;
+	if (n > e->want_break)
+		e->want_break = n;
+}
+
+/*
+ * Atomically write a run of `n` bytes plus any pending whitespace, or drop the
+ * whole run if it would exceed HTML2TEXT_MAXOUT.  Callers must pass complete
+ * UTF-8 sequences so the output buffer stays well-formed (see struct emit).
+ */
+static void
+emit_bytes(struct emit *e, const unsigned char *bytes, size_t n)
+{
+	size_t	ws;
+	size_t	i;
+
+	ws = (e->want_break > 0) ? (size_t)e->want_break :
+	    (e->want_space ? 1 : 0);
+	if (e->out->len + ws + n > HTML2TEXT_MAXOUT)
+		return;
+	if (e->want_break > 0) {
+		while (e->want_break-- > 0)
+			buf_putc(e->out, '\n');
+		e->want_break = 0;
+	} else if (e->want_space)
+		buf_putc(e->out, ' ');
+	e->want_space = 0;
+	for (i = 0; i < n; i++)
+		buf_putc(e->out, (char)bytes[i]);
+	e->started = 1;
+}
+
+static void
+emit_byte(struct emit *e, unsigned char c)
+{
+	emit_bytes(e, &c, 1);
+}
+
+static void
+emit_codepoint(struct emit *e, long cp)
+{
+	unsigned char	seq[4];
+	size_t		need;
+
+	if (cp == 9 || cp == 10 || cp == 13 || cp == 32 || cp == 160) {
+		emit_space(e);
+		return;
+	}
+	if (cp < 32 || cp == 127)
+		return;			/* drop other controls */
+	if ((cp >= 0xd800 && cp <= 0xdfff) || cp > 0x10ffff)
+		return;			/* surrogates / out of range: not UTF-8 */
+	if (cp < 0x80) {
+		seq[0] = (unsigned char)cp;
+		need = 1;
+	} else if (cp < 0x800) {
+		seq[0] = (unsigned char)(0xc0 | (cp >> 6));
+		seq[1] = (unsigned char)(0x80 | (cp & 0x3f));
+		need = 2;
+	} else if (cp < 0x10000) {
+		seq[0] = (unsigned char)(0xe0 | (cp >> 12));
+		seq[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3f));
+		seq[2] = (unsigned char)(0x80 | (cp & 0x3f));
+		need = 3;
+	} else {
+		seq[0] = (unsigned char)(0xf0 | (cp >> 18));
+		seq[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3f));
+		seq[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3f));
+		seq[3] = (unsigned char)(0x80 | (cp & 0x3f));
+		need = 4;
+	}
+	/*
+	 * Atomic boundary check: if the whole sequence (plus any pending
+	 * whitespace flush) doesn't fit under the cap, drop the whole codepoint
+	 * so the buffer never ends mid-sequence.
+	 */
+	emit_bytes(e, seq, need);
+}
+
+/* A named character reference; emit it, or emit "&name;" literally on no match. */
+static void
+emit_named(struct emit *e, const char *name, size_t n)
+{
+	static const struct {
+		const char	*name;
+		const char	*rep;
+	} ents[] = {
+		{ "amp", "&" }, { "lt", "<" }, { "gt", ">" }, { "quot", "\"" },
+		{ "apos", "'" }, { "nbsp", " " }, { "copy", "(c)" },
+		{ "reg", "(r)" }, { "trade", "(tm)" }, { "mdash", "--" },
+		{ "ndash", "-" }, { "hellip", "..." }, { "lsquo", "'" },
+		{ "rsquo", "'" }, { "ldquo", "\"" }, { "rdquo", "\"" },
+		{ "middot", "*" },
+	};
+	size_t	i;
+	const char *r;
+
+	for (i = 0; i < sizeof(ents) / sizeof(ents[0]); i++) {
+		if (strlen(ents[i].name) == n &&
+		    strncasecmp(name, ents[i].name, n) == 0) {
+			size_t	need = strlen(ents[i].rep);
+
+			/*
+			 * Atomic boundary check: if the full replacement plus
+			 * any pending whitespace flush would exceed the cap,
+			 * drop the whole replacement so the output never ends
+			 * in a fragment like ".." from a truncated "...".
+			 */
+			if (e->out->len + (size_t)e->want_break +
+			    (size_t)e->want_space + need > HTML2TEXT_MAXOUT)
+				return;
+			for (r = ents[i].rep; *r != '\0'; r++) {
+				if (*r == ' ')
+					emit_space(e);
+				else
+					emit_byte(e, (unsigned char)*r);
+			}
+			return;
+		}
+	}
+	/*
+	 * Unknown name: keep the literal "&name;".  Same atomic gate as the
+	 * known-entity branch -- if the whole token won't fit, drop it all
+	 * rather than write a truncated fragment.
+	 */
+	if (e->out->len + (size_t)e->want_break + (size_t)e->want_space +
+	    n + 2 > HTML2TEXT_MAXOUT)
+		return;
+	emit_byte(e, '&');
+	for (i = 0; i < n; i++)
+		emit_byte(e, (unsigned char)name[i]);
+	emit_byte(e, ';');
+}
+
+/* Decode the reference starting at html[i]=='&'; return the index past it. */
+static size_t
+decode_entity(const char *html, size_t len, size_t i, struct emit *e)
+{
+	size_t	j = i + 1, s;
+
+	if (j < len && html[j] == '#') {
+		unsigned long	cp = 0;
+		int		hexmode = 0, digits = 0;
+
+		j++;
+		if (j < len && (html[j] == 'x' || html[j] == 'X')) {
+			hexmode = 1;
+			j++;
+		}
+		s = j;
+		while (j < len && digits < 8) {
+			unsigned char d = (unsigned char)html[j];
+
+			if (hexmode && isxdigit(d))
+				cp = cp * 16 + (isdigit(d) ? d - '0' :
+				    (tolower(d) - 'a' + 10));
+			else if (!hexmode && isdigit(d))
+				cp = cp * 10 + (d - '0');
+			else
+				break;
+			if (cp > 0x110000UL)	/* clamp: avoid overflow (ILP32) */
+				cp = 0x110000UL;
+			j++;
+			digits++;
+		}
+		if (j > s && j < len && html[j] == ';') {
+			emit_codepoint(e, (long)cp);
+			return (j + 1);
+		}
+		emit_byte(e, '&');
+		return (i + 1);
+	}
+	s = j;
+	while (j < len && isalnum((unsigned char)html[j]) && j - s < 12)
+		j++;
+	if (j > s && j < len && html[j] == ';') {
+		emit_named(e, html + s, j - s);
+		return (j + 1);
+	}
+	emit_byte(e, '&');
+	return (i + 1);
+}
+
+static int
+tag_is(const char *p, size_t n, const char *name)
+{
+	return (strlen(name) == n && strncasecmp(p, name, n) == 0);
+}
+
+/* Tags after which a line break reads naturally. */
+static int
+is_break_tag(const char *p, size_t n)
+{
+	static const char *const tags[] = {
+		"p", "br", "div", "li", "ul", "ol", "tr", "table", "thead",
+		"tbody", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote",
+		"section", "article", "header", "footer", "nav", "aside",
+		"pre", "hr", "dd", "dt", "dl", "figure", "figcaption", "form",
+		"fieldset", "address", "main", "option",
+	};
+	size_t	i;
+
+	for (i = 0; i < sizeof(tags) / sizeof(tags[0]); i++)
+		if (tag_is(p, n, tags[i]))
+			return (1);
+	return (0);
+}
+
+static int
+is_paragraph_tag(const char *p, size_t n)
+{
+	return (tag_is(p, n, "p") || tag_is(p, n, "div") ||
+	    tag_is(p, n, "h1") || tag_is(p, n, "h2") || tag_is(p, n, "h3") ||
+	    tag_is(p, n, "h4") || tag_is(p, n, "h5") || tag_is(p, n, "h6") ||
+	    tag_is(p, n, "blockquote") || tag_is(p, n, "section") ||
+	    tag_is(p, n, "article") || tag_is(p, n, "pre") ||
+	    tag_is(p, n, "table") || tag_is(p, n, "hr"));
+}
+
+/* Skip raw content up to the matching </name>; return the index past it. */
+static size_t
+skip_raw(const char *html, size_t len, size_t i, const char *name, size_t nlen)
+{
+	while (i < len) {
+		if (html[i] == '<' && i + 1 < len && html[i + 1] == '/' &&
+		    i + 2 + nlen <= len &&
+		    strncasecmp(html + i + 2, name, nlen) == 0) {
+			size_t	after = i + 2 + nlen;
+
+			/*
+			 * A real close tag ends the name with a delimiter; a bare
+			 * prefix like </scriptx> is NOT a close, so the rest stays
+			 * raw (otherwise hidden script text could leak out).
+			 */
+			if (after >= len || html[after] == '>' ||
+			    html[after] == '/' ||
+			    isspace((unsigned char)html[after])) {
+				i = after;
+				while (i < len && html[i] != '>')
+					i++;
+				return (i < len ? i + 1 : len);
+			}
+		}
+		i++;
+	}
+	return (len);
+}
+
+void
+html2text(const char *html, size_t len, struct buf *out)
+{
+	struct emit	e;
+	size_t		i = 0;
+
+	memset(&e, 0, sizeof(e));
+	e.out = out;
+
+	while (i < len) {
+		unsigned char	c = (unsigned char)html[i];
+
+		if (c == '<') {
+			size_t	j, ns, ne, k;
+			int	closing = 0;
+
+			/* Comment <!-- ... --> */
+			if (i + 3 < len && html[i + 1] == '!' &&
+			    html[i + 2] == '-' && html[i + 3] == '-') {
+				i += 4;
+				while (i < len) {
+					if (i + 2 < len && html[i] == '-' &&
+					    html[i + 1] == '-' &&
+					    html[i + 2] == '>') {
+						i += 3;
+						break;
+					}
+					i++;
+				}
+				continue;
+			}
+			/* Declaration <!doctype ...> or PI <? ... > */
+			if (i + 1 < len &&
+			    (html[i + 1] == '!' || html[i + 1] == '?')) {
+				i += 2;
+				while (i < len && html[i] != '>')
+					i++;
+				i = (i < len ? i + 1 : len);
+				continue;
+			}
+
+			j = i + 1;
+			if (j < len && html[j] == '/') {
+				closing = 1;
+				j++;
+			}
+			ns = j;
+			while (j < len && isalnum((unsigned char)html[j]))
+				j++;
+			ne = j;
+			/* Scan to '>', honouring quoted attribute values. */
+			k = j;
+			while (k < len && html[k] != '>') {
+				if (html[k] == '"' || html[k] == '\'') {
+					char	q = html[k++];
+
+					while (k < len && html[k] != q)
+						k++;
+				}
+				if (k < len)
+					k++;
+			}
+			k = (k < len ? k + 1 : len);
+
+			if (ne > ns) {
+				size_t	nlen = ne - ns;
+				const char *nm = html + ns;
+
+				if (!closing && (tag_is(nm, nlen, "script") ||
+				    tag_is(nm, nlen, "style"))) {
+					i = skip_raw(html, len, k, nm, nlen);
+					continue;
+				}
+				if (is_break_tag(nm, nlen))
+					emit_break(&e, is_paragraph_tag(nm,
+					    nlen));
+			}
+			i = k;
+			continue;
+		}
+		if (c == '&') {
+			i = decode_entity(html, len, i, &e);
+			continue;
+		}
+		if (isspace(c)) {
+			emit_space(&e);
+			i++;
+			continue;
+		}
+		emit_byte(&e, c);
+		i++;
+	}
+}
blob - /dev/null
blob + 21f8d5ab33135233a60fed1385d36aba9559a5fa (mode 644)
--- /dev/null
+++ src/common/html2text.h
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * html2text: a single-pass, allocation-light reduction of HTML to readable
+ * plain text.  It is deliberately not a parser: it drops tags, skips
+ * script/style/comment content, decodes the common character references,
+ * inserts line breaks at block-level elements, and collapses runs of
+ * whitespace.  Input is hostile web content, so the scan is strictly linear in
+ * the input length and the output is capped.
+ */
+
+#ifndef FUGU_HTML2TEXT_H
+#define FUGU_HTML2TEXT_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+#define HTML2TEXT_MAXOUT	(256 * 1024)	/* cap the produced text */
+
+/* Append the plain-text rendering of html[0..len) to out. */
+void	html2text(const char *html, size_t len, struct buf *out);
+
+#endif /* FUGU_HTML2TEXT_H */
blob - /dev/null
blob + ef8ce6cec11fae1103d8dceffe173b0403fd2623 (mode 644)
--- /dev/null
+++ src/common/http.c
@@ -0,0 +1,345 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+
+#include "buf.h"
+#include "http.h"
+
+#define HTTP_MAXLINE_DEFAULT	(64U * 1024)	/* status/header/chunk line cap */
+
+static int	http_line(struct http_resp *, const char *, size_t);
+static int	http_status(struct http_resp *, const char *, size_t);
+static int	http_header(struct http_resp *, const char *, size_t);
+static int	http_endhdrs(struct http_resp *);
+static int	http_chunksize(struct http_resp *, const char *, size_t);
+static int	ci_contains(const char *, size_t, const char *);
+
+void
+http_build_request(struct buf *out, const char *method, const char *host,
+    const char *path, const struct http_header *hdrs, size_t nhdrs,
+    const void *body, size_t bodylen)
+{
+	size_t	i;
+
+	buf_printf(out, "%s %s HTTP/1.1\r\n", method, path);
+	/* Bracket an IPv6 literal in the Host header (RFC 7230 sec 5.4). */
+	if (strchr(host, ':') != NULL && host[0] != '[')
+		buf_printf(out, "Host: [%s]\r\n", host);
+	else
+		buf_printf(out, "Host: %s\r\n", host);
+	buf_puts_cstr(out, "Connection: close\r\n");
+	for (i = 0; i < nhdrs; i++)
+		buf_printf(out, "%s: %s\r\n", hdrs[i].name, hdrs[i].value);
+	if (bodylen > 0)
+		buf_printf(out, "Content-Length: %zu\r\n", bodylen);
+	buf_append(out, "\r\n", 2);
+	if (bodylen > 0)
+		buf_append(out, body, bodylen);
+}
+
+void
+http_resp_init(struct http_resp *r, http_body_cb cb, void *arg)
+{
+	memset(r, 0, sizeof(*r));
+	r->state = HTTP_STATUS;
+	r->content_length = -1;
+	r->maxline = HTTP_MAXLINE_DEFAULT;
+	buf_init(&r->line);
+	buf_init(&r->ctype);
+	r->body_cb = cb;
+	r->arg = arg;
+}
+
+void
+http_resp_clear(struct http_resp *r)
+{
+	buf_free(&r->line);
+	buf_free(&r->ctype);
+	memset(r, 0, sizeof(*r));
+}
+
+int
+http_resp_done(const struct http_resp *r)
+{
+	return (r->state == HTTP_DONE);
+}
+
+/*
+ * Feed response bytes.  Returns 0, or -1 on a malformed message (bad status
+ * line, oversize line, bad chunk size).  Decoded body bytes are delivered to
+ * the body callback as they are produced.
+ */
+int
+http_resp_push(struct http_resp *r, const void *vp, size_t len)
+{
+	const unsigned char	*p = vp;
+	size_t			 i = 0, l;
+	const char		*ld;
+
+	while (i < len) {
+		switch (r->state) {
+		case HTTP_STATUS:
+		case HTTP_HEADER:
+		case HTTP_CHUNK_SIZE:
+		case HTTP_TRAILER:
+			while (i < len && p[i] != '\n') {
+				if (r->line.len >= r->maxline)
+					return (-1);
+				buf_putc(&r->line, p[i++]);
+			}
+			if (i == len)
+				return (0);	/* line continues next push */
+			i++;			/* consume '\n' */
+			l = r->line.len;
+			ld = r->line.data;
+			if (l > 0 && ld[l - 1] == '\r')
+				l--;
+			if (http_line(r, ld, l) == -1)
+				return (-1);
+			buf_reset(&r->line);
+			break;
+		case HTTP_BODY_LEN: {
+			long long	want = r->content_length - r->body_seen;
+			size_t		avail = len - i, n;
+
+			n = ((long long)avail < want) ? avail : (size_t)want;
+			if (n > 0 && r->body_cb != NULL)
+				r->body_cb(p + i, n, r->arg);
+			i += n;
+			r->body_seen += n;
+			if (r->body_seen >= r->content_length)
+				r->state = HTTP_DONE;
+			break;
+		}
+		case HTTP_CHUNK_DATA: {
+			size_t	avail = len - i, n;
+
+			n = ((long long)avail < r->chunk_remaining) ?
+			    avail : (size_t)r->chunk_remaining;
+			if (n > 0 && r->body_cb != NULL)
+				r->body_cb(p + i, n, r->arg);
+			i += n;
+			r->chunk_remaining -= n;
+			if (r->chunk_remaining == 0)
+				r->state = HTTP_CHUNK_CRLF;
+			break;
+		}
+		case HTTP_CHUNK_CRLF:
+			/* a chunk's data must be terminated by exactly CRLF */
+			if (!r->chunk_saw_cr) {
+				if (p[i] != '\r')
+					return (-1);
+				r->chunk_saw_cr = 1;
+				i++;
+			} else {
+				if (p[i] != '\n')
+					return (-1);
+				r->chunk_saw_cr = 0;
+				i++;
+				r->state = HTTP_CHUNK_SIZE;
+			}
+			break;
+		case HTTP_BODY_EOF:
+			if (r->body_cb != NULL && len - i > 0)
+				r->body_cb(p + i, len - i, r->arg);
+			i = len;
+			break;
+		case HTTP_DONE:
+			i = len;		/* ignore trailing bytes */
+			break;
+		}
+	}
+	return (0);
+}
+
+/*
+ * Signal that the connection closed.  Completes an EOF-framed body; otherwise
+ * a close mid-message is a truncation error.
+ */
+int
+http_resp_eof(struct http_resp *r)
+{
+	if (r->state == HTTP_BODY_EOF) {
+		r->state = HTTP_DONE;
+		return (0);
+	}
+	return (r->state == HTTP_DONE ? 0 : -1);
+}
+
+/* Dispatch one complete line according to the current state. */
+static int
+http_line(struct http_resp *r, const char *line, size_t len)
+{
+	switch (r->state) {
+	case HTTP_STATUS:
+		if (http_status(r, line, len) == -1)
+			return (-1);
+		r->state = HTTP_HEADER;
+		return (0);
+	case HTTP_HEADER:
+		if (len == 0)
+			return (http_endhdrs(r));
+		return (http_header(r, line, len));
+	case HTTP_CHUNK_SIZE:
+		return (http_chunksize(r, line, len));
+	case HTTP_TRAILER:
+		if (len == 0)
+			r->state = HTTP_DONE;
+		return (0);
+	default:
+		return (-1);
+	}
+}
+
+static int
+http_status(struct http_resp *r, const char *line, size_t len)
+{
+	size_t	i = 0;
+	int	code = 0, ndig = 0;
+
+	if (len < 5 || strncmp(line, "HTTP/", 5) != 0)
+		return (-1);
+	while (i < len && line[i] != ' ')	/* skip "HTTP/x.y" */
+		i++;
+	while (i < len && line[i] == ' ')
+		i++;
+	while (i < len && ndig < 3 && line[i] >= '0' && line[i] <= '9') {
+		code = code * 10 + (line[i] - '0');
+		i++;
+		ndig++;
+	}
+	if (ndig != 3)
+		return (-1);
+	r->status = code;
+	return (0);
+}
+
+static int
+http_header(struct http_resp *r, const char *line, size_t len)
+{
+	const char	*val;
+	size_t		 nlen, vlen, i = 0;
+
+	while (i < len && line[i] != ':')
+		i++;
+	if (i == len)			/* no colon: tolerate, ignore */
+		return (0);
+	nlen = i;
+	val = line + i + 1;
+	vlen = len - (i + 1);
+	while (vlen > 0 && (*val == ' ' || *val == '\t')) {
+		val++;
+		vlen--;
+	}
+	while (vlen > 0 && (val[vlen - 1] == ' ' || val[vlen - 1] == '\t'))
+		vlen--;
+
+	if (nlen == 17 && strncasecmp(line, "Transfer-Encoding", 17) == 0) {
+		if (ci_contains(val, vlen, "chunked"))
+			r->chunked = 1;
+	} else if (nlen == 14 && strncasecmp(line, "Content-Length", 14) == 0) {
+		char		 num[32];
+		const char	*errstr;
+
+		if (vlen == 0 || vlen >= sizeof(num))
+			return (-1);
+		memcpy(num, val, vlen);
+		num[vlen] = '\0';
+		r->content_length = strtonum(num, 0, LLONG_MAX, &errstr);
+		if (errstr != NULL)
+			return (-1);
+	} else if (nlen == 12 && strncasecmp(line, "Content-Type", 12) == 0) {
+		buf_reset(&r->ctype);
+		buf_append(&r->ctype, val, vlen);
+	}
+	return (0);
+}
+
+static int
+http_endhdrs(struct http_resp *r)
+{
+	/* 1xx, 204 and 304 carry no body regardless of header framing. */
+	if (r->status == 204 || r->status == 304 ||
+	    (r->status >= 100 && r->status < 200)) {
+		r->state = HTTP_DONE;
+		return (0);
+	}
+	/* Chunked takes precedence over Content-Length per RFC 7230. */
+	if (r->chunked)
+		r->state = HTTP_CHUNK_SIZE;
+	else if (r->content_length == 0)
+		r->state = HTTP_DONE;
+	else if (r->content_length > 0)
+		r->state = HTTP_BODY_LEN;
+	else
+		r->state = HTTP_BODY_EOF;
+	return (0);
+}
+
+static int
+http_chunksize(struct http_resp *r, const char *line, size_t len)
+{
+	long long	size = 0;
+	size_t		i;
+	int		ndig = 0, d;
+
+	for (i = 0; i < len; i++) {
+		char	ch = line[i];
+
+		if (ch >= '0' && ch <= '9')
+			d = ch - '0';
+		else if (ch >= 'a' && ch <= 'f')
+			d = ch - 'a' + 10;
+		else if (ch >= 'A' && ch <= 'F')
+			d = ch - 'A' + 10;
+		else
+			break;		/* ';' extension or end of digits */
+		if (size > (LLONG_MAX - d) / 16)
+			return (-1);	/* overflow */
+		size = size * 16 + d;
+		ndig++;
+	}
+	if (ndig == 0)
+		return (-1);
+	if (size == 0)
+		r->state = HTTP_TRAILER;
+	else {
+		r->chunk_remaining = size;
+		r->state = HTTP_CHUNK_DATA;
+	}
+	return (0);
+}
+
+/* Case-insensitive substring search over a non-NUL-terminated haystack. */
+static int
+ci_contains(const char *hay, size_t haylen, const char *needle)
+{
+	size_t	nlen = strlen(needle), i;
+
+	if (nlen == 0 || haylen < nlen)
+		return (0);
+	for (i = 0; i + nlen <= haylen; i++) {
+		if (strncasecmp(hay + i, needle, nlen) == 0)
+			return (1);
+	}
+	return (0);
+}
blob - /dev/null
blob + 22201dbb2c4501ce476df0ec0a6017e8efcd3b90 (mode 644)
--- /dev/null
+++ src/common/http.h
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * http: a minimal HTTP/1.1 client, split into pure pieces so the protocol
+ * logic is testable without a socket.  http_build_request() serialises a
+ * request into a buf; struct http_resp is an incremental parser that consumes
+ * response bytes as they arrive off the wire, tracks the status line and the
+ * headers we care about, de-frames the body (Content-Length, chunked transfer
+ * encoding, or read-until-close), and hands decoded body bytes to a callback.
+ *
+ * The libtls transport that drives this lives separately; both the Anthropic
+ * (net) and Kagi/web (fetch) paths share this code, so the response parser is
+ * written to tolerate hostile servers: every line is length-capped and the
+ * chunk-size arithmetic is overflow-checked.
+ */
+
+#ifndef FUGU_HTTP_H
+#define FUGU_HTTP_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+struct http_header {
+	const char	*name;
+	const char	*value;
+};
+
+/*
+ * Serialise an HTTP/1.1 request into out.  Host is always emitted; so is
+ * "Connection: close" (fugu uses one request per connection) and, when
+ * bodylen > 0, Content-Length.  The caller supplies any other headers (auth,
+ * content-type, accept, ...).  The body, if any, is appended verbatim.
+ */
+void	http_build_request(struct buf *out, const char *method,
+	    const char *host, const char *path, const struct http_header *hdrs,
+	    size_t nhdrs, const void *body, size_t bodylen);
+
+/* Called with successive decoded body bytes; never with len 0. */
+typedef void (*http_body_cb)(const void *data, size_t len, void *arg);
+
+enum http_state {
+	HTTP_STATUS,		/* reading the status line			*/
+	HTTP_HEADER,		/* reading header lines				*/
+	HTTP_BODY_LEN,		/* Content-Length body				*/
+	HTTP_CHUNK_SIZE,	/* reading a chunk-size line			*/
+	HTTP_CHUNK_DATA,	/* copying a chunk's data			*/
+	HTTP_CHUNK_CRLF,	/* the CRLF after a chunk's data		*/
+	HTTP_TRAILER,		/* trailer headers after the last chunk		*/
+	HTTP_BODY_EOF,		/* body framed by connection close		*/
+	HTTP_DONE		/* message fully received			*/
+};
+
+struct http_resp {
+	enum http_state	state;
+	int		status;		/* parsed status code			*/
+	int		chunked;	/* Transfer-Encoding: chunked seen	*/
+	long long	content_length;	/* -1 if absent				*/
+	long long	body_seen;	/* bytes delivered (length mode)	*/
+	long long	chunk_remaining;/* bytes left in the current chunk	*/
+	int		chunk_saw_cr;	/* saw the CR of a chunk's trailing CRLF	*/
+	struct buf	line;		/* accumulates the current line		*/
+	struct buf	ctype;		/* Content-Type value			*/
+	size_t		maxline;	/* per-line cap (hostile-server guard)	*/
+	http_body_cb	body_cb;
+	void	       *arg;
+};
+
+void	http_resp_init(struct http_resp *, http_body_cb, void *);
+void	http_resp_clear(struct http_resp *);
+int	http_resp_push(struct http_resp *, const void *, size_t); /* 0 / -1 */
+int	http_resp_eof(struct http_resp *);	/* connection closed; 0 / -1 */
+int	http_resp_done(const struct http_resp *);
+
+#endif /* FUGU_HTTP_H */
blob - /dev/null
blob + 8b46e44d0a2dec67be78600478328199e5266c84 (mode 644)
--- /dev/null
+++ src/common/imsgproto.c
@@ -0,0 +1,353 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/uio.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <imsg.h>
+
+#include "imsgproto.h"
+#include "log.h"
+#include "util.h"
+
+static struct ip_reasm	*reasm_find(struct imsgproto *, uint32_t, uint32_t);
+static struct ip_reasm	*reasm_alloc(struct imsgproto *);
+static void		 reasm_reserve(struct ip_reasm *, size_t);
+static void		 reasm_release(struct ip_reasm *);
+static int		 handle_chunk(struct imsgproto *, struct imsg *,
+			    struct fugu_msg *);
+
+int
+ip_init(struct imsgproto *ip, int fd)
+{
+	memset(ip, 0, sizeof(*ip));
+	if (imsgbuf_init(&ip->ibuf, fd) == -1)
+		return (-1);
+	return (0);
+}
+
+void
+ip_clear(struct imsgproto *ip)
+{
+	int	i;
+
+	for (i = 0; i < IP_MAXASM; i++)
+		reasm_release(&ip->reasm[i]);
+	imsgbuf_clear(&ip->ibuf);
+}
+
+int
+ip_fd(struct imsgproto *ip)
+{
+	return (ip->ibuf.fd);
+}
+
+/*
+ * Frame a logical message as one or more imsg chunks and queue them for
+ * transmission.  A zero-length message is sent as a single empty chunk so the
+ * far side still observes it.  Returns 0 on success, -1 on failure.
+ */
+int
+ip_compose(struct imsgproto *ip, uint32_t type, uint32_t dst, uint32_t src,
+    const void *data, size_t len)
+{
+	struct fugu_chdr	 chdr;
+	struct iovec		 iov[2];
+	const uint8_t		*p = data;
+	size_t			 off, chunklen;
+	uint32_t		 msgid;
+
+	if (len > IP_MAXMSG) {
+		log_warnx("ip_compose: message too large (%zu > %u)", len,
+		    IP_MAXMSG);
+		return (-1);
+	}
+
+	msgid = ip->next_msgid++;
+	off = 0;
+	do {
+		chunklen = len - off;
+		if (chunklen > IP_MAXCHUNK)
+			chunklen = IP_MAXCHUNK;
+
+		memset(&chdr, 0, sizeof(chdr));
+		chdr.src = src;
+		chdr.msgid = msgid;
+		chdr.total = (uint32_t)len;
+		chdr.off = (uint32_t)off;
+
+		iov[0].iov_base = &chdr;
+		iov[0].iov_len = sizeof(chdr);
+		/* iov_base is non-const by POSIX; this buffer is only read. */
+		iov[1].iov_base = (void *)(uintptr_t)(p + off);
+		iov[1].iov_len = chunklen;
+
+		if (imsg_composev(&ip->ibuf, type, dst, 0, -1, iov, 2) == -1)
+			return (-1);
+
+		off += chunklen;
+	} while (off < len);
+
+	return (0);
+}
+
+/* Block until every queued message is written.  0 on success, -1 on error. */
+int
+ip_flush(struct imsgproto *ip)
+{
+	return (imsgbuf_flush(&ip->ibuf));
+}
+
+/* Do a single non-blocking write pass.  0 on success, -1 on error. */
+int
+ip_write(struct imsgproto *ip)
+{
+	return (imsgbuf_write(&ip->ibuf));
+}
+
+/* Read pending data into the buffer.  1 ok, 0 on EOF, -1 on error. */
+int
+ip_read(struct imsgproto *ip)
+{
+	return (imsgbuf_read(&ip->ibuf));
+}
+
+/*
+ * Pull the next fully reassembled logical message, consuming as many buffered
+ * imsg chunks as needed.  Returns 1 and fills *msg on success (caller frees via
+ * ip_msg_free()), 0 if no complete message is ready, -1 on protocol error.
+ */
+int
+ip_get(struct imsgproto *ip, struct fugu_msg *msg)
+{
+	struct imsg	imsg;
+	int		n, rc;
+
+	for (;;) {
+		if ((n = imsgbuf_get(&ip->ibuf, &imsg)) == -1)
+			return (-1);
+		if (n == 0)
+			return (0);
+
+		rc = handle_chunk(ip, &imsg, msg);
+		imsg_free(&imsg);
+
+		if (rc != 0)
+			return (rc);	/* 1 = completed, -1 = protocol error */
+	}
+}
+
+void
+ip_msg_free(struct fugu_msg *msg)
+{
+	free(msg->data);
+	memset(msg, 0, sizeof(*msg));
+}
+
+/* Pull one raw frame for the relay (no reassembly).  1 / 0 / -1. */
+int
+ip_frame_get(struct imsgproto *ip, struct imsg *imsg)
+{
+	return (imsgbuf_get(&ip->ibuf, imsg));
+}
+
+/*
+ * Relay a just-received frame to another channel, unaltered, without
+ * reassembling it.  This is the parent's routing primitive: forward by
+ * imsg_get_id() and never touch the payload.
+ */
+int
+ip_forward(struct imsgproto *dst, struct imsg *imsg)
+{
+	return (imsg_forward(&dst->ibuf, imsg));
+}
+
+static struct ip_reasm *
+reasm_find(struct imsgproto *ip, uint32_t src, uint32_t msgid)
+{
+	int	i;
+
+	for (i = 0; i < IP_MAXASM; i++) {
+		if (ip->reasm[i].inuse && ip->reasm[i].src == src &&
+		    ip->reasm[i].msgid == msgid)
+			return (&ip->reasm[i]);
+	}
+	return (NULL);
+}
+
+static struct ip_reasm *
+reasm_alloc(struct imsgproto *ip)
+{
+	struct ip_reasm	*oldest = NULL;
+	int		 i;
+
+	for (i = 0; i < IP_MAXASM; i++) {
+		if (!ip->reasm[i].inuse) {
+			memset(&ip->reasm[i], 0, sizeof(ip->reasm[i]));
+			ip->reasm[i].inuse = 1;
+			ip->reasm[i].seq = ip->asm_seq++;
+			return (&ip->reasm[i]);
+		}
+		if (oldest == NULL || ip->reasm[i].seq < oldest->seq)
+			oldest = &ip->reasm[i];
+	}
+
+	/*
+	 * Table full: a peer is holding more partials open than we keep slots
+	 * for.  Evict the oldest so legitimate new traffic still makes progress
+	 * instead of the channel wedging.
+	 */
+	log_warnx("ip: evicting stalled reassembly (msgid %u)", oldest->msgid);
+	reasm_release(oldest);
+	oldest->inuse = 1;
+	oldest->seq = ip->asm_seq++;
+	return (oldest);
+}
+
+/* Grow a partial's buffer to hold at least need bytes, never past its total. */
+static void
+reasm_reserve(struct ip_reasm *r, size_t need)
+{
+	size_t	newcap;
+
+	if (need <= r->cap)
+		return;
+	newcap = (r->cap == 0) ? need : (size_t)r->cap * 2;
+	if (newcap < need)
+		newcap = need;
+	if (newcap > r->total)
+		newcap = r->total;
+	r->data = xreallocarray(r->data, newcap, 1);
+	r->cap = (uint32_t)newcap;
+}
+
+static void
+reasm_release(struct ip_reasm *r)
+{
+	free(r->data);
+	memset(r, 0, sizeof(*r));
+}
+
+/*
+ * Fold one received imsg (a single chunk) into the reassembly state.
+ * Returns 1 if it completed a logical message (filled *msg), 0 if the chunk was
+ * consumed but the message is still incomplete, -1 on any protocol violation.
+ */
+static int
+handle_chunk(struct imsgproto *ip, struct imsg *imsg, struct fugu_msg *msg)
+{
+	struct fugu_chdr	 chdr;
+	struct ip_reasm		*r;
+	uint32_t		 type, dst;
+	size_t			 plen, clen;
+
+	type = imsg_get_type(imsg);
+	dst = imsg_get_id(imsg);
+	plen = imsg_get_len(imsg);
+
+	if (plen < sizeof(chdr)) {
+		log_warnx("ip: short frame (%zu bytes)", plen);
+		return (-1);
+	}
+	if (imsg_get_buf(imsg, &chdr, sizeof(chdr)) == -1)
+		return (-1);
+	clen = plen - sizeof(chdr);
+
+	/* Bound the advertised total and validate this chunk against it. */
+	if (chdr.total > IP_MAXMSG) {
+		log_warnx("ip: oversize message (%u > %u)", chdr.total,
+		    IP_MAXMSG);
+		return (-1);
+	}
+	if ((size_t)chdr.off + clen > (size_t)chdr.total) {
+		log_warnx("ip: chunk past end (off %u + %zu > total %u)",
+		    chdr.off, clen, chdr.total);
+		return (-1);
+	}
+	if (clen == 0 && chdr.total != 0) {
+		log_warnx("ip: empty chunk in non-empty message");
+		return (-1);
+	}
+
+	r = reasm_find(ip, chdr.src, chdr.msgid);
+	if (r == NULL) {
+		/* First chunk of a logical message must start at offset 0. */
+		if (chdr.off != 0) {
+			log_warnx("ip: missing start of message %u", chdr.msgid);
+			return (-1);
+		}
+		/* Fast path: the whole message fits in this single chunk. */
+		if (clen == chdr.total) {
+			msg->type = type;
+			msg->dst = dst;
+			msg->src = chdr.src;
+			msg->len = chdr.total;
+			msg->data = NULL;
+			if (chdr.total > 0) {
+				msg->data = xmalloc(chdr.total);
+				if (imsg_get_buf(imsg, msg->data,
+				    chdr.total) == -1) {
+					ip_msg_free(msg);
+					return (-1);
+				}
+			}
+			return (1);
+		}
+
+		/* Multi-chunk: take a slot; its buffer grows as chunks land. */
+		r = reasm_alloc(ip);
+		r->src = chdr.src;
+		r->msgid = chdr.msgid;
+		r->type = type;
+		r->dst = dst;
+		r->total = chdr.total;
+	}
+
+	/* Continuation: enforce strict in-order, consistent framing. */
+	if (type != r->type || dst != r->dst || chdr.total != r->total ||
+	    chdr.off != r->have) {
+		log_warnx("ip: inconsistent chunk for message %u", chdr.msgid);
+		reasm_release(r);
+		return (-1);
+	}
+	/*
+	 * Grow only to the bytes actually delivered (continuation is append-only
+	 * since off == have), so a tiny first chunk cannot reserve a huge buffer.
+	 */
+	reasm_reserve(r, (size_t)r->have + clen);
+	if (imsg_get_buf(imsg, r->data + r->have, clen) == -1) {
+		reasm_release(r);
+		return (-1);
+	}
+	r->have += (uint32_t)clen;
+
+	if (r->have < r->total)
+		return (0);
+
+	/* Complete: hand the assembled buffer to the caller. */
+	msg->type = r->type;
+	msg->dst = r->dst;
+	msg->src = r->src;
+	msg->data = r->data;
+	msg->len = r->total;
+	r->data = NULL;		/* ownership transferred to msg */
+	reasm_release(r);
+	return (1);
+}
blob - /dev/null
blob + 5292ed8e90f98116b4860ae731105becd5d608d9 (mode 644)
--- /dev/null
+++ src/common/imsgproto.h
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * imsgproto: fugu's logical message layer over imsg(3).
+ *
+ * imsg payloads are bounded (MAX_IMSGSIZE, 16 KiB including header), but a
+ * conversation projection or a tool's output can be far larger.  This layer
+ * frames a logical message of arbitrary length as one or more imsg "chunks"
+ * and reassembles them on the far side.
+ *
+ * Routing is carried in the imsg header so the parent relay never has to look
+ * at, let alone reassemble, a payload it forwards:
+ *
+ *	imsg type  (uint32)  = logical message type   (enum fugu_msgtype)
+ *	imsg id    (uint32)  = destination endpoint   (enum fugu_ep)
+ *	payload              = struct fugu_chdr + chunk bytes
+ *
+ * The parent forwards a frame to its destination endpoint with imsg_forward(3)
+ * purely on imsg_get_id(); reassembly happens only at the final endpoint.
+ *
+ * Wire byte order is host order: this is same-host IPC between processes of one
+ * build, the standard imsg(3) convention.
+ */
+
+#ifndef FUGU_IMSGPROTO_H
+#define FUGU_IMSGPROTO_H
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <stdint.h>
+
+#include <imsg.h>
+
+/* Logical message types (the imsg "type" field). */
+enum fugu_msgtype {
+	M_NONE = 0,
+	M_READY,	/* worker  -> parent : startup handshake		*/
+	M_CONFIG,	/* parent  -> worker : resolved non-secret settings	*/
+	M_SECRET,	/* parent  -> net|fetch : one credential (net: uint32	*/
+			/*           provider index + key bytes; fetch: raw)	*/
+	M_SUBMIT,	/* ui      -> net   : conversation projection		*/
+	M_CANCEL,	/* ui      -> net   : abort the in-flight turn		*/
+	M_DELTA,	/* net     -> ui    : streamed assistant text		*/
+	M_TOOL_CALL,	/* net     -> ui    : tool invocation notice		*/
+	M_MESSAGE,	/* net     -> ui    : a complete conversation message	*/
+	M_TOOL_REQUEST,	/* net     -> exec|fetch|ui : run a tool		*/
+	M_TOOL_RESULT,	/* exec|fetch|ui -> net : tool result			*/
+	M_TOOLS_ADD,	/* ui      -> net   : extra tool defs (JSON fragment,	*/
+			/*           comma-prefixed, no enclosing brackets)	*/
+	M_USAGE,	/* net     -> ui    : token accounting			*/
+	M_TURN_DONE,	/* net     -> ui    : assistant turn complete		*/
+	M_SUMMARIZE,	/* ui      -> net   : summarise the conversation (/compact) */
+	M_SUMMARY,	/* net     -> ui    : the conversation summary text	*/
+	M_MODELS,	/* ui <-> net : request (empty) / reply (id list; with	*/
+			/*           >1 provider each row is "name\tmodel")	*/
+	M_SET_MODEL,	/* ui  -> net : switch model ("model" or "name\tmodel")	*/
+	M_ERROR,	/* any     -> ui    : human-readable error		*/
+	M_SHUTDOWN,	/* parent  -> worker : orderly exit			*/
+	M__MAX
+};
+
+/* Endpoint identifiers (the imsg "id" field; the relay's routing key). */
+enum fugu_ep {
+	EP_PARENT = 0,
+	EP_UI,
+	EP_NET,
+	EP_EXEC,
+	EP_FETCH,
+	EP__MAX
+};
+
+/*
+ * Per-chunk sub-header, prepended to each chunk's bytes inside the imsg
+ * payload.  Sixteen bytes; the chunk's data length is the imsg payload length
+ * minus sizeof(struct fugu_chdr).
+ */
+struct fugu_chdr {
+	uint32_t	src;	/* source endpoint (enum fugu_ep)		*/
+	uint32_t	msgid;	/* logical id, unique per source channel		*/
+	uint32_t	total;	/* total logical payload length			*/
+	uint32_t	off;	/* byte offset of this chunk in the payload	*/
+};
+
+/*
+ * Reassembly is bounded two ways against a hostile peer: a partial message's
+ * buffer grows with the bytes actually delivered (never eagerly to the
+ * advertised total), and when all IP_MAXASM slots are busy the oldest partial
+ * is evicted, so a flood of never-completed messages cannot wedge the channel.
+ */
+#define IP_MAXASM	8			/* concurrent reassemblies	*/
+#define IP_MAXMSG	(16U * 1024 * 1024)	/* logical message size cap	*/
+#define IP_MAXCHUNK \
+	(MAX_IMSGSIZE - IMSG_HEADER_SIZE - sizeof(struct fugu_chdr))
+
+/* In-progress reassembly of one logical message. */
+struct ip_reasm {
+	int		inuse;
+	uint32_t	src;
+	uint32_t	msgid;
+	uint32_t	type;
+	uint32_t	dst;
+	uint32_t	total;	/* advertised final length			*/
+	uint32_t	have;	/* bytes received so far (== next expected off)	*/
+	uint32_t	cap;	/* allocated size of data (grows toward total)	*/
+	uint64_t	seq;	/* allocation order, for oldest-first eviction	*/
+	uint8_t	       *data;
+};
+
+/* One imsg channel plus its reassembly state. */
+struct imsgproto {
+	struct imsgbuf	ibuf;
+	uint32_t	next_msgid;
+	uint64_t	asm_seq;	/* monotonic stamp for reasm eviction	*/
+	struct ip_reasm	reasm[IP_MAXASM];
+};
+
+/* A fully reassembled logical message handed to the caller. */
+struct fugu_msg {
+	uint32_t	type;	/* enum fugu_msgtype				*/
+	uint32_t	dst;	/* enum fugu_ep destination			*/
+	uint32_t	src;	/* enum fugu_ep source				*/
+	uint8_t	       *data;	/* heap, freed by ip_msg_free(); NULL if len 0	*/
+	size_t		len;
+};
+
+int	ip_init(struct imsgproto *, int);
+void	ip_clear(struct imsgproto *);
+int	ip_fd(struct imsgproto *);
+
+int	ip_compose(struct imsgproto *, uint32_t, uint32_t, uint32_t,
+	    const void *, size_t);
+int	ip_flush(struct imsgproto *);
+int	ip_write(struct imsgproto *);
+
+int	ip_read(struct imsgproto *);
+int	ip_get(struct imsgproto *, struct fugu_msg *);
+void	ip_msg_free(struct fugu_msg *);
+
+/*
+ * Relay support: pull the next raw frame without reassembling it (1/0/-1, like
+ * ip_get).  The caller routes on imsg_get_id(), ip_forward()s it to the
+ * destination channel, and imsg_free()s it.  This is how the parent relays
+ * worker<->worker traffic without ever buffering a whole logical message.
+ */
+int	ip_frame_get(struct imsgproto *, struct imsg *);
+int	ip_forward(struct imsgproto *, struct imsg *);
+
+#endif /* FUGU_IMSGPROTO_H */
blob - /dev/null
blob + 8ac14c1bdec9d1600ae5217550902eecce0f56e1 (mode 644)
--- /dev/null
+++ src/common/jsmn.h
@@ -0,0 +1,471 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2010 Serge Zaitsev
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+#ifndef JSMN_H
+#define JSMN_H
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifdef JSMN_STATIC
+#define JSMN_API static
+#else
+#define JSMN_API extern
+#endif
+
+/**
+ * JSON type identifier. Basic types are:
+ * 	o Object
+ * 	o Array
+ * 	o String
+ * 	o Other primitive: number, boolean (true/false) or null
+ */
+typedef enum {
+  JSMN_UNDEFINED = 0,
+  JSMN_OBJECT = 1 << 0,
+  JSMN_ARRAY = 1 << 1,
+  JSMN_STRING = 1 << 2,
+  JSMN_PRIMITIVE = 1 << 3
+} jsmntype_t;
+
+enum jsmnerr {
+  /* Not enough tokens were provided */
+  JSMN_ERROR_NOMEM = -1,
+  /* Invalid character inside JSON string */
+  JSMN_ERROR_INVAL = -2,
+  /* The string is not a full JSON packet, more bytes expected */
+  JSMN_ERROR_PART = -3
+};
+
+/**
+ * JSON token description.
+ * type		type (object, array, string etc.)
+ * start	start position in JSON data string
+ * end		end position in JSON data string
+ */
+typedef struct jsmntok {
+  jsmntype_t type;
+  int start;
+  int end;
+  int size;
+#ifdef JSMN_PARENT_LINKS
+  int parent;
+#endif
+} jsmntok_t;
+
+/**
+ * JSON parser. Contains an array of token blocks available. Also stores
+ * the string being parsed now and current position in that string.
+ */
+typedef struct jsmn_parser {
+  unsigned int pos;     /* offset in the JSON string */
+  unsigned int toknext; /* next token to allocate */
+  int toksuper;         /* superior token node, e.g. parent object or array */
+} jsmn_parser;
+
+/**
+ * Create JSON parser over an array of tokens
+ */
+JSMN_API void jsmn_init(jsmn_parser *parser);
+
+/**
+ * Run JSON parser. It parses a JSON data string into and array of tokens, each
+ * describing
+ * a single JSON object.
+ */
+JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
+                        jsmntok_t *tokens, const unsigned int num_tokens);
+
+#ifndef JSMN_HEADER
+/**
+ * Allocates a fresh unused token from the token pool.
+ */
+static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens,
+                                   const size_t num_tokens) {
+  jsmntok_t *tok;
+  if (parser->toknext >= num_tokens) {
+    return NULL;
+  }
+  tok = &tokens[parser->toknext++];
+  tok->start = tok->end = -1;
+  tok->size = 0;
+#ifdef JSMN_PARENT_LINKS
+  tok->parent = -1;
+#endif
+  return tok;
+}
+
+/**
+ * Fills token type and boundaries.
+ */
+static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type,
+                            const int start, const int end) {
+  token->type = type;
+  token->start = start;
+  token->end = end;
+  token->size = 0;
+}
+
+/**
+ * Fills next available token with JSON primitive.
+ */
+static int jsmn_parse_primitive(jsmn_parser *parser, const char *js,
+                                const size_t len, jsmntok_t *tokens,
+                                const size_t num_tokens) {
+  jsmntok_t *token;
+  int start;
+
+  start = parser->pos;
+
+  for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
+    switch (js[parser->pos]) {
+#ifndef JSMN_STRICT
+    /* In strict mode primitive must be followed by "," or "}" or "]" */
+    case ':':
+#endif
+    case '\t':
+    case '\r':
+    case '\n':
+    case ' ':
+    case ',':
+    case ']':
+    case '}':
+      goto found;
+    default:
+                   /* to quiet a warning from gcc*/
+      break;
+    }
+    if (js[parser->pos] < 32 || js[parser->pos] >= 127) {
+      parser->pos = start;
+      return JSMN_ERROR_INVAL;
+    }
+  }
+#ifdef JSMN_STRICT
+  /* In strict mode primitive must be followed by a comma/object/array */
+  parser->pos = start;
+  return JSMN_ERROR_PART;
+#endif
+
+found:
+  if (tokens == NULL) {
+    parser->pos--;
+    return 0;
+  }
+  token = jsmn_alloc_token(parser, tokens, num_tokens);
+  if (token == NULL) {
+    parser->pos = start;
+    return JSMN_ERROR_NOMEM;
+  }
+  jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos);
+#ifdef JSMN_PARENT_LINKS
+  token->parent = parser->toksuper;
+#endif
+  parser->pos--;
+  return 0;
+}
+
+/**
+ * Fills next token with JSON string.
+ */
+static int jsmn_parse_string(jsmn_parser *parser, const char *js,
+                             const size_t len, jsmntok_t *tokens,
+                             const size_t num_tokens) {
+  jsmntok_t *token;
+
+  int start = parser->pos;
+  
+  /* Skip starting quote */
+  parser->pos++;
+  
+  for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
+    char c = js[parser->pos];
+
+    /* Quote: end of string */
+    if (c == '\"') {
+      if (tokens == NULL) {
+        return 0;
+      }
+      token = jsmn_alloc_token(parser, tokens, num_tokens);
+      if (token == NULL) {
+        parser->pos = start;
+        return JSMN_ERROR_NOMEM;
+      }
+      jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos);
+#ifdef JSMN_PARENT_LINKS
+      token->parent = parser->toksuper;
+#endif
+      return 0;
+    }
+
+    /* Backslash: Quoted symbol expected */
+    if (c == '\\' && parser->pos + 1 < len) {
+      int i;
+      parser->pos++;
+      switch (js[parser->pos]) {
+      /* Allowed escaped symbols */
+      case '\"':
+      case '/':
+      case '\\':
+      case 'b':
+      case 'f':
+      case 'r':
+      case 'n':
+      case 't':
+        break;
+      /* Allows escaped symbol \uXXXX */
+      case 'u':
+        parser->pos++;
+        for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0';
+             i++) {
+          /* If it isn't a hex character we have an error */
+          if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) ||   /* 0-9 */
+                (js[parser->pos] >= 65 && js[parser->pos] <= 70) ||   /* A-F */
+                (js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */
+            parser->pos = start;
+            return JSMN_ERROR_INVAL;
+          }
+          parser->pos++;
+        }
+        parser->pos--;
+        break;
+      /* Unexpected symbol */
+      default:
+        parser->pos = start;
+        return JSMN_ERROR_INVAL;
+      }
+    }
+  }
+  parser->pos = start;
+  return JSMN_ERROR_PART;
+}
+
+/**
+ * Parse JSON string and fill tokens.
+ */
+JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
+                        jsmntok_t *tokens, const unsigned int num_tokens) {
+  int r;
+  int i;
+  jsmntok_t *token;
+  int count = parser->toknext;
+
+  for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
+    char c;
+    jsmntype_t type;
+
+    c = js[parser->pos];
+    switch (c) {
+    case '{':
+    case '[':
+      count++;
+      if (tokens == NULL) {
+        break;
+      }
+      token = jsmn_alloc_token(parser, tokens, num_tokens);
+      if (token == NULL) {
+        return JSMN_ERROR_NOMEM;
+      }
+      if (parser->toksuper != -1) {
+        jsmntok_t *t = &tokens[parser->toksuper];
+#ifdef JSMN_STRICT
+        /* In strict mode an object or array can't become a key */
+        if (t->type == JSMN_OBJECT) {
+          return JSMN_ERROR_INVAL;
+        }
+#endif
+        t->size++;
+#ifdef JSMN_PARENT_LINKS
+        token->parent = parser->toksuper;
+#endif
+      }
+      token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY);
+      token->start = parser->pos;
+      parser->toksuper = parser->toknext - 1;
+      break;
+    case '}':
+    case ']':
+      if (tokens == NULL) {
+        break;
+      }
+      type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY);
+#ifdef JSMN_PARENT_LINKS
+      if (parser->toknext < 1) {
+        return JSMN_ERROR_INVAL;
+      }
+      token = &tokens[parser->toknext - 1];
+      for (;;) {
+        if (token->start != -1 && token->end == -1) {
+          if (token->type != type) {
+            return JSMN_ERROR_INVAL;
+          }
+          token->end = parser->pos + 1;
+          parser->toksuper = token->parent;
+          break;
+        }
+        if (token->parent == -1) {
+          if (token->type != type || parser->toksuper == -1) {
+            return JSMN_ERROR_INVAL;
+          }
+          break;
+        }
+        token = &tokens[token->parent];
+      }
+#else
+      for (i = parser->toknext - 1; i >= 0; i--) {
+        token = &tokens[i];
+        if (token->start != -1 && token->end == -1) {
+          if (token->type != type) {
+            return JSMN_ERROR_INVAL;
+          }
+          parser->toksuper = -1;
+          token->end = parser->pos + 1;
+          break;
+        }
+      }
+      /* Error if unmatched closing bracket */
+      if (i == -1) {
+        return JSMN_ERROR_INVAL;
+      }
+      for (; i >= 0; i--) {
+        token = &tokens[i];
+        if (token->start != -1 && token->end == -1) {
+          parser->toksuper = i;
+          break;
+        }
+      }
+#endif
+      break;
+    case '\"':
+      r = jsmn_parse_string(parser, js, len, tokens, num_tokens);
+      if (r < 0) {
+        return r;
+      }
+      count++;
+      if (parser->toksuper != -1 && tokens != NULL) {
+        tokens[parser->toksuper].size++;
+      }
+      break;
+    case '\t':
+    case '\r':
+    case '\n':
+    case ' ':
+      break;
+    case ':':
+      parser->toksuper = parser->toknext - 1;
+      break;
+    case ',':
+      if (tokens != NULL && parser->toksuper != -1 &&
+          tokens[parser->toksuper].type != JSMN_ARRAY &&
+          tokens[parser->toksuper].type != JSMN_OBJECT) {
+#ifdef JSMN_PARENT_LINKS
+        parser->toksuper = tokens[parser->toksuper].parent;
+#else
+        for (i = parser->toknext - 1; i >= 0; i--) {
+          if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) {
+            if (tokens[i].start != -1 && tokens[i].end == -1) {
+              parser->toksuper = i;
+              break;
+            }
+          }
+        }
+#endif
+      }
+      break;
+#ifdef JSMN_STRICT
+    /* In strict mode primitives are: numbers and booleans */
+    case '-':
+    case '0':
+    case '1':
+    case '2':
+    case '3':
+    case '4':
+    case '5':
+    case '6':
+    case '7':
+    case '8':
+    case '9':
+    case 't':
+    case 'f':
+    case 'n':
+      /* And they must not be keys of the object */
+      if (tokens != NULL && parser->toksuper != -1) {
+        const jsmntok_t *t = &tokens[parser->toksuper];
+        if (t->type == JSMN_OBJECT ||
+            (t->type == JSMN_STRING && t->size != 0)) {
+          return JSMN_ERROR_INVAL;
+        }
+      }
+#else
+    /* In non-strict mode every unquoted value is a primitive */
+    default:
+#endif
+      r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens);
+      if (r < 0) {
+        return r;
+      }
+      count++;
+      if (parser->toksuper != -1 && tokens != NULL) {
+        tokens[parser->toksuper].size++;
+      }
+      break;
+
+#ifdef JSMN_STRICT
+    /* Unexpected char in strict mode */
+    default:
+      return JSMN_ERROR_INVAL;
+#endif
+    }
+  }
+
+  if (tokens != NULL) {
+    for (i = parser->toknext - 1; i >= 0; i--) {
+      /* Unmatched opened object or array */
+      if (tokens[i].start != -1 && tokens[i].end == -1) {
+        return JSMN_ERROR_PART;
+      }
+    }
+  }
+
+  return count;
+}
+
+/**
+ * Creates a new parser based over a given buffer with an array of tokens
+ * available.
+ */
+JSMN_API void jsmn_init(jsmn_parser *parser) {
+  parser->pos = 0;
+  parser->toknext = 0;
+  parser->toksuper = -1;
+}
+
+#endif /* JSMN_HEADER */
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* JSMN_H */
blob - /dev/null
blob + ae60981674ceae8be3865d47707ac23b8d49b2c2 (mode 644)
--- /dev/null
+++ src/common/json.c
@@ -0,0 +1,567 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "log.h"
+#include "util.h"
+
+#define JSMN_STRICT
+#include "jsmn.h"
+
+#include "json.h"
+
+/* ===== Emitter ===== */
+
+void
+json_writer_init(struct json_writer *w, struct buf *b)
+{
+	w->b = b;
+	w->depth = 0;
+	w->type[0] = 't';
+	w->count[0] = 0;
+	w->after_key = 0;
+}
+
+/*
+ * Emit a separator before the next value: nothing if it directly follows a
+ * key, a comma if the current container already holds a value, and bump the
+ * element count. Aborts on a bare value inside an object (a key must precede).
+ */
+static void
+json_pre(struct json_writer *w)
+{
+	if (w->after_key) {
+		w->after_key = 0;
+		return;
+	}
+	if (w->type[w->depth] == 'o')
+		fatalx("json: value without key in object");
+	if (w->count[w->depth] > 0)
+		buf_putc(w->b, ',');
+	w->count[w->depth]++;
+}
+
+static void
+json_push(struct json_writer *w, char type, int open)
+{
+	json_pre(w);
+	buf_putc(w->b, open);
+	if (w->depth >= JSON_MAXDEPTH)
+		fatalx("json: nesting too deep");
+	w->depth++;
+	w->type[w->depth] = type;
+	w->count[w->depth] = 0;
+}
+
+static void
+json_pop(struct json_writer *w, char type, int close)
+{
+	if (w->depth <= 0 || w->type[w->depth] != type)
+		fatalx("json: mismatched container end");
+	buf_putc(w->b, close);
+	w->depth--;
+}
+
+void
+json_object_start(struct json_writer *w)
+{
+	json_push(w, 'o', '{');
+}
+
+void
+json_object_end(struct json_writer *w)
+{
+	json_pop(w, 'o', '}');
+}
+
+void
+json_array_start(struct json_writer *w)
+{
+	json_push(w, 'a', '[');
+}
+
+void
+json_array_end(struct json_writer *w)
+{
+	json_pop(w, 'a', ']');
+}
+
+static void
+json_emit_escaped(struct buf *b, const char *s, size_t len)
+{
+	size_t		i;
+	unsigned char	c;
+
+	buf_putc(b, '"');
+	for (i = 0; i < len; i++) {
+		c = (unsigned char)s[i];
+		switch (c) {
+		case '"':
+			buf_puts_cstr(b, "\\\"");
+			break;
+		case '\\':
+			buf_puts_cstr(b, "\\\\");
+			break;
+		case '\b':
+			buf_puts_cstr(b, "\\b");
+			break;
+		case '\f':
+			buf_puts_cstr(b, "\\f");
+			break;
+		case '\n':
+			buf_puts_cstr(b, "\\n");
+			break;
+		case '\r':
+			buf_puts_cstr(b, "\\r");
+			break;
+		case '\t':
+			buf_puts_cstr(b, "\\t");
+			break;
+		default:
+			if (c < 0x20)
+				buf_printf(b, "\\u%04x", c);
+			else
+				buf_putc(b, c);	/* UTF-8 passes through */
+		}
+	}
+	buf_putc(b, '"');
+}
+
+void
+json_key(struct json_writer *w, const char *k)
+{
+	if (w->type[w->depth] != 'o')
+		fatalx("json: key outside object");
+	if (w->after_key)
+		fatalx("json: key after key");
+	if (w->count[w->depth] > 0)
+		buf_putc(w->b, ',');
+	w->count[w->depth]++;
+	json_emit_escaped(w->b, k, strlen(k));
+	buf_putc(w->b, ':');
+	w->after_key = 1;
+}
+
+void
+json_string(struct json_writer *w, const char *s)
+{
+	json_pre(w);
+	json_emit_escaped(w->b, s, strlen(s));
+}
+
+void
+json_string_n(struct json_writer *w, const char *s, size_t n)
+{
+	json_pre(w);
+	json_emit_escaped(w->b, s, n);
+}
+
+void
+json_int(struct json_writer *w, long long n)
+{
+	json_pre(w);
+	buf_printf(w->b, "%lld", n);
+}
+
+void
+json_bool(struct json_writer *w, int v)
+{
+	json_pre(w);
+	buf_puts_cstr(w->b, v ? "true" : "false");
+}
+
+void
+json_null(struct json_writer *w)
+{
+	json_pre(w);
+	buf_puts_cstr(w->b, "null");
+}
+
+void
+json_raw(struct json_writer *w, const char *r)
+{
+	json_pre(w);
+	buf_puts_cstr(w->b, r);
+}
+
+void
+json_raw_n(struct json_writer *w, const char *r, size_t n)
+{
+	json_pre(w);
+	buf_append(w->b, r, n);
+}
+
+void
+json_kv_string(struct json_writer *w, const char *k, const char *v)
+{
+	json_key(w, k);
+	json_string(w, v);
+}
+
+void
+json_kv_int(struct json_writer *w, const char *k, long long v)
+{
+	json_key(w, k);
+	json_int(w, v);
+}
+
+void
+json_kv_bool(struct json_writer *w, const char *k, int v)
+{
+	json_key(w, k);
+	json_bool(w, v);
+}
+
+/* ===== Parser ===== */
+
+/*
+ * Cap on JSON nesting depth.  jsmn imposes no depth limit and json_skip() (and
+ * thus json_obj_get()) recurse once per level, so a hostile deeply-nested
+ * document could overflow the stack.  Reject anything deeper than this before
+ * it reaches the recursive walkers.  Generous vs any real API payload.
+ */
+#define JSON_MAXPARSE_DEPTH	256
+
+/*
+ * Bound nesting depth with an iterative pre-order walk over the flat token
+ * stream (the explicit stack is itself bounded, so this cannot recurse).
+ * Returns 1 if the document is within the depth cap, 0 if too deep.
+ */
+static int
+json_depth_ok(const jsmntok_t *tok, int ntok)
+{
+	int	need[JSON_MAXPARSE_DEPTH + 1];	/* child tokens still due per level */
+	int	depth = 0, i;
+
+	if (ntok <= 0)
+		return (1);
+	need[0] = ntok;
+	for (i = 0; i < ntok; i++) {
+		int	children;
+
+		while (depth > 0 && need[depth] == 0)
+			depth--;
+		if (need[depth] > 0)
+			need[depth]--;
+		if (tok[i].type == JSMN_OBJECT)
+			children = tok[i].size * 2;	/* key + value per member */
+		else if (tok[i].type == JSMN_ARRAY)
+			children = tok[i].size;
+		else
+			continue;
+		if (children > 0) {
+			if (depth + 1 > JSON_MAXPARSE_DEPTH)
+				return (0);
+			need[++depth] = children;
+		}
+	}
+	return (1);
+}
+
+int
+json_parse(struct json_doc *d, const char *js, size_t len)
+{
+	jsmn_parser	p;
+	size_t		cap = 64;
+	int		r;
+
+	d->src = js;
+	d->tok = NULL;
+	d->ntok = 0;
+
+	for (;;) {
+		d->tok = xreallocarray(d->tok, cap, sizeof(jsmntok_t));
+		jsmn_init(&p);
+		r = jsmn_parse(&p, js, len, d->tok, cap);
+		if (r >= 0) {
+			if (!json_depth_ok(d->tok, r)) {
+				free(d->tok);
+				d->tok = NULL;
+				return (-1);	/* nested too deep: treat as malformed */
+			}
+			d->ntok = r;
+			return (0);
+		}
+		if (r == JSMN_ERROR_NOMEM) {
+			if (cap > (size_t)INT_MAX / 2)
+				fatalx("json_parse: token count overflow");
+			cap *= 2;
+			continue;
+		}
+		/* JSMN_ERROR_INVAL or JSMN_ERROR_PART: malformed input. */
+		free(d->tok);
+		d->tok = NULL;
+		return (-1);
+	}
+}
+
+void
+json_doc_free(struct json_doc *d)
+{
+	free(d->tok);
+	d->tok = NULL;
+	d->ntok = 0;
+	d->src = NULL;
+}
+
+int
+json_tok_type(const struct json_doc *d, int i)
+{
+	return ((int)d->tok[i].type);
+}
+
+int
+json_tok_size(const struct json_doc *d, int i)
+{
+	return (d->tok[i].size);
+}
+
+int
+json_tok_streq(const struct json_doc *d, int i, const char *s)
+{
+	const jsmntok_t	*t = &d->tok[i];
+	size_t		 n = (size_t)(t->end - t->start);
+
+	return (strlen(s) == n && memcmp(d->src + t->start, s, n) == 0);
+}
+
+int
+json_skip(const struct json_doc *d, int i)
+{
+	const jsmntok_t	*t = &d->tok[i];
+	int		 j, k;
+
+	switch (t->type) {
+	case JSMN_OBJECT:
+		j = i + 1;
+		for (k = 0; k < t->size; k++) {
+			j = json_skip(d, j);	/* key */
+			j = json_skip(d, j);	/* value */
+		}
+		return (j);
+	case JSMN_ARRAY:
+		j = i + 1;
+		for (k = 0; k < t->size; k++)
+			j = json_skip(d, j);
+		return (j);
+	default:
+		return (i + 1);
+	}
+}
+
+const char *
+json_tok_raw(const struct json_doc *d, int i, size_t *len)
+{
+	const jsmntok_t	*t = &d->tok[i];
+
+	*len = (size_t)(t->end - t->start);
+	return (d->src + t->start);
+}
+
+int
+json_obj_get(const struct json_doc *d, int obj, const char *key)
+{
+	int	j, k, val;
+
+	if (d->tok[obj].type != JSMN_OBJECT)
+		return (-1);
+	j = obj + 1;
+	for (k = 0; k < d->tok[obj].size; k++) {
+		val = j + 1;
+		if (d->tok[j].type == JSMN_STRING && json_tok_streq(d, j, key))
+			return (val);
+		j = json_skip(d, val);
+	}
+	return (-1);
+}
+
+/*
+ * Look up a JSON_STRING field by key and decode it to a freshly allocated
+ * heap string, also reporting the true byte length through *lenp.  This is
+ * the length-aware twin of obj_getstr: callers that splice the decoded
+ * bytes back into an output buffer or wire format MUST use the reported
+ * length, not strlen, because legitimate "\0" escapes decode to real NUL
+ * bytes that strlen would silently truncate.  Returns NULL (with *lenp
+ * untouched-as-zero) when `obj` is negative, the key is absent, or the
+ * value is not a string; in those cases the function is a clean no-op.
+ * `lenp` may itself be NULL when only the C-string view is needed.
+ * Free the returned pointer with free(3).
+ */
+char *
+obj_getstr_n(const struct json_doc *d, int obj, const char *key, size_t *lenp)
+{
+	int	t;
+
+	if (lenp != NULL)
+		*lenp = 0;
+	if (obj < 0)
+		return (NULL);
+	if ((t = json_obj_get(d, obj, key)) < 0 ||
+	    json_tok_type(d, t) != JSON_STRING)
+		return (NULL);
+	return (json_tok_strdup_n(d, t, lenp));
+}
+
+static void
+utf8_encode(struct buf *b, unsigned int cp)
+{
+	if (cp < 0x80)
+		buf_putc(b, (int)cp);
+	else if (cp < 0x800) {
+		buf_putc(b, (int)(0xc0 | (cp >> 6)));
+		buf_putc(b, (int)(0x80 | (cp & 0x3f)));
+	} else if (cp < 0x10000) {
+		buf_putc(b, (int)(0xe0 | (cp >> 12)));
+		buf_putc(b, (int)(0x80 | ((cp >> 6) & 0x3f)));
+		buf_putc(b, (int)(0x80 | (cp & 0x3f)));
+	} else {
+		buf_putc(b, (int)(0xf0 | (cp >> 18)));
+		buf_putc(b, (int)(0x80 | ((cp >> 12) & 0x3f)));
+		buf_putc(b, (int)(0x80 | ((cp >> 6) & 0x3f)));
+		buf_putc(b, (int)(0x80 | (cp & 0x3f)));
+	}
+}
+
+static int
+hex4(const char *s, unsigned int *out)
+{
+	unsigned int	v = 0;
+	int		i, c;
+
+	for (i = 0; i < 4; i++) {
+		c = (unsigned char)s[i];
+		v <<= 4;
+		if (c >= '0' && c <= '9')
+			v |= (unsigned int)(c - '0');
+		else if (c >= 'a' && c <= 'f')
+			v |= (unsigned int)(c - 'a' + 10);
+		else if (c >= 'A' && c <= 'F')
+			v |= (unsigned int)(c - 'A' + 10);
+		else
+			return (-1);
+	}
+	*out = v;
+	return (0);
+}
+
+/*
+ * Decode token i's JSON string into a freshly allocated heap buffer.  The
+ * decoded bytes are written into out and a trailing NUL is appended so callers
+ * may treat the result as a C string when they know it contains no embedded
+ * NULs.  Returns out->len at the point the NUL was written so that the
+ * decoded byte count (excluding the terminator) can be reported precisely.
+ */
+static size_t
+json_tok_emit_decoded(const struct json_doc *d, int i, struct buf *out)
+{
+	const jsmntok_t	*t = &d->tok[i];
+	const char	*s = d->src + t->start;
+	size_t		 n = (size_t)(t->end - t->start);
+	size_t		 p = 0, decoded_len;
+	unsigned int	 cp, lo;
+
+	buf_init(out);
+	while (p < n) {
+		if (s[p] != '\\') {
+			buf_putc(out, s[p++]);
+			continue;
+		}
+		if (++p >= n)
+			break;
+		switch (s[p]) {
+		case '"':
+			buf_putc(out, '"');
+			p++;
+			break;
+		case '\\':
+			buf_putc(out, '\\');
+			p++;
+			break;
+		case '/':
+			buf_putc(out, '/');
+			p++;
+			break;
+		case 'b':
+			buf_putc(out, '\b');
+			p++;
+			break;
+		case 'f':
+			buf_putc(out, '\f');
+			p++;
+			break;
+		case 'n':
+			buf_putc(out, '\n');
+			p++;
+			break;
+		case 'r':
+			buf_putc(out, '\r');
+			p++;
+			break;
+		case 't':
+			buf_putc(out, '\t');
+			p++;
+			break;
+		case 'u':
+			p++;
+			if (p + 4 > n || hex4(s + p, &cp) == -1) {
+				buf_puts_cstr(out, "\\u");
+				break;
+			}
+			p += 4;
+			if (cp >= 0xd800 && cp <= 0xdbff && p + 6 <= n &&
+			    s[p] == '\\' && s[p + 1] == 'u' &&
+			    hex4(s + p + 2, &lo) == 0 &&
+			    lo >= 0xdc00 && lo <= 0xdfff) {
+				cp = 0x10000 + ((cp - 0xd800) << 10) +
+				    (lo - 0xdc00);
+				p += 6;
+			}
+			utf8_encode(out, cp);
+			break;
+		default:
+			buf_putc(out, '\\');
+			buf_putc(out, s[p]);
+			p++;
+		}
+	}
+	decoded_len = out->len;
+	buf_putc(out, '\0');
+	return (decoded_len);
+}
+
+char *
+json_tok_strdup_n(const struct json_doc *d, int i, size_t *lenp)
+{
+	struct buf	out;
+	size_t		decoded_len;
+
+	decoded_len = json_tok_emit_decoded(d, i, &out);
+	if (lenp != NULL)
+		*lenp = decoded_len;
+	return (out.data);
+}
+
+char *
+json_tok_strdup(const struct json_doc *d, int i)
+{
+	return (json_tok_strdup_n(d, i, NULL));
+}
blob - /dev/null
blob + a49f17cc3c78ef1854281f3b5652ed3a9aba611f (mode 644)
--- /dev/null
+++ src/common/json.h
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_JSON_H
+#define FUGU_JSON_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+/*
+ * JSON output (emitter) and input (a thin wrapper over the vendored jsmn
+ * tokenizer). The emitter writes into a struct buf and owns all escaping and
+ * separator placement; the parser exposes token indices into a flat array and
+ * never leaks jsmn types into the rest of the program.
+ */
+
+/* ===== Emitter ===== */
+
+#define JSON_MAXDEPTH	32
+
+struct json_writer {
+	struct buf	*b;
+	int		 depth;
+	char		 type[JSON_MAXDEPTH + 1];	/* 't'op, 'o'bj, 'a'rr */
+	int		 count[JSON_MAXDEPTH + 1];	/* members/elements so far */
+	int		 after_key;			/* a key awaits its value */
+};
+
+void	 json_writer_init(struct json_writer *, struct buf *);
+void	 json_object_start(struct json_writer *);
+void	 json_object_end(struct json_writer *);
+void	 json_array_start(struct json_writer *);
+void	 json_array_end(struct json_writer *);
+void	 json_key(struct json_writer *, const char *);
+void	 json_string(struct json_writer *, const char *);
+void	 json_string_n(struct json_writer *, const char *, size_t);
+void	 json_int(struct json_writer *, long long);
+void	 json_bool(struct json_writer *, int);
+void	 json_null(struct json_writer *);
+void	 json_raw(struct json_writer *, const char *);	/* pre-formatted JSON */
+void	 json_raw_n(struct json_writer *, const char *, size_t);
+void	 json_kv_string(struct json_writer *, const char *, const char *);
+void	 json_kv_int(struct json_writer *, const char *, long long);
+void	 json_kv_bool(struct json_writer *, const char *, int);
+
+/* ===== Parser ===== */
+
+/* Mirrors jsmntype_t values so callers never need jsmn.h. */
+enum json_type {
+	JSON_UNDEFINED	= 0,
+	JSON_OBJECT	= 1 << 0,
+	JSON_ARRAY	= 1 << 1,
+	JSON_STRING	= 1 << 2,
+	JSON_PRIMITIVE	= 1 << 3
+};
+
+struct jsmntok;		/* opaque here; fully defined in json.c via jsmn.h */
+
+struct json_doc {
+	const char	*src;	/* borrowed; must outlive the doc */
+	struct jsmntok	*tok;
+	int		 ntok;
+};
+
+int	 json_parse(struct json_doc *, const char *, size_t);	/* 0 ok, -1 bad */
+void	 json_doc_free(struct json_doc *);
+
+int	 json_tok_type(const struct json_doc *, int);	/* enum json_type */
+int	 json_tok_size(const struct json_doc *, int);
+int	 json_tok_streq(const struct json_doc *, int, const char *);
+/*
+ * Unescaped C string; caller free()s.  STRLEN-TRUNCATING: if the decoded
+ * value carries an embedded NUL (a legitimate JSON "\0" escape), the
+ * returned pointer reads as truncated under strlen.  This is fine for
+ * dispatch and equality (strcmp on small enums), but anything that flows
+ * the bytes to wire or storage MUST use json_tok_strdup_n() below.
+ */
+char	*json_tok_strdup(const struct json_doc *, int);
+
+/*
+ * Like json_tok_strdup() but also reports the decoded byte length through
+ * *lenp.  The returned heap string holds the JSON value's escape-decoded
+ * contents (e.g. "\0" becomes a real NUL byte) and is NUL-terminated, but
+ * since the contents may themselves contain embedded NULs, callers that need
+ * the true length MUST use *lenp instead of strlen() to avoid silent
+ * truncation.  *lenp does not include the trailing NUL terminator.  lenp may
+ * be NULL if the caller does not need the length.  Free the returned pointer
+ * with free().
+ */
+char	*json_tok_strdup_n(const struct json_doc *, int, size_t *lenp);
+
+int	 json_obj_get(const struct json_doc *, int, const char *);
+/*
+ * Look up a JSON_STRING field by key and decode it to a heap string.
+ * Reports the true byte length through *lenp so wire/storage writers do not
+ * have to fall back to strlen (which would truncate on legitimate embedded
+ * NULs from "\0" escapes).  Returns NULL on miss / non-string / obj < 0.
+ * Free the returned pointer; lenp may be NULL.
+ */
+char	*obj_getstr_n(const struct json_doc *, int obj, const char *key,
+	    size_t *lenp);
+int	 json_skip(const struct json_doc *, int);
+
+/*
+ * Borrow a token's raw source bytes (no copy, no unescaping): for an object or
+ * array this is the whole `{...}` / `[...]` subtree, letting it be spliced into
+ * another document with json_raw_n().  The bytes live in the doc's src.
+ */
+const char *json_tok_raw(const struct json_doc *, int, size_t *len);
+
+#endif /* FUGU_JSON_H */
blob - /dev/null
blob + 76d85c723957a4d44519d9c609c19ec5f9e0474d (mode 644)
--- /dev/null
+++ src/common/log.c
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Derived from OpenBSD usr.sbin/ntpd/log.c (rev 1.19): a dest-bitmask logger
+ * that can route to stderr and/or syslog, with a multi-level verbose gate on
+ * log_debug().
+ */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdarg.h>
+#include <string.h>
+#include <syslog.h>
+#include <errno.h>
+#include <time.h>
+
+#include "log.h"
+
+static int		 dest;
+static int		 verbose;
+const char		*log_procname;
+
+void
+log_init(int n_dest, int n_verbose, int facility)
+{
+	extern char	*__progname;
+
+	dest = n_dest;
+	verbose = n_verbose;
+	log_procinit(__progname);
+
+	if (dest & LOG_TO_SYSLOG)
+		openlog(__progname, LOG_PID | LOG_NDELAY, facility);
+
+	tzset();
+}
+
+void
+log_procinit(const char *procname)
+{
+	if (procname != NULL)
+		log_procname = procname;
+}
+
+void
+log_setverbose(int v)
+{
+	verbose = v;
+}
+
+int
+log_getverbose(void)
+{
+	return (verbose);
+}
+
+void
+logit(int pri, const char *fmt, ...)
+{
+	va_list	ap;
+
+	va_start(ap, fmt);
+	vlog(pri, fmt, ap);
+	va_end(ap);
+}
+
+void
+vlog(int pri, const char *fmt, va_list ap)
+{
+	char	*nfmt;
+	int	 saved_errno = errno;
+	va_list	 ap2;
+
+	va_copy(ap2, ap);
+	if (dest & LOG_TO_STDERR) {
+		/* best effort in out of mem situations */
+		if (asprintf(&nfmt, "%s\n", fmt) == -1) {
+			vfprintf(stderr, fmt, ap);
+			fprintf(stderr, "\n");
+		} else {
+			vfprintf(stderr, nfmt, ap);
+			free(nfmt);
+		}
+		fflush(stderr);
+	}
+	if (dest & LOG_TO_SYSLOG)
+		vsyslog(pri, fmt, ap2);
+	va_end(ap2);
+
+	errno = saved_errno;
+}
+
+void
+log_warn(const char *emsg, ...)
+{
+	char		*nfmt;
+	va_list		 ap;
+	int		 saved_errno = errno;
+
+	/* best effort to even work in out of memory situations */
+	if (emsg == NULL)
+		logit(LOG_ERR, "%s", strerror(saved_errno));
+	else {
+		va_start(ap, emsg);
+
+		if (asprintf(&nfmt, "%s: %s", emsg,
+		    strerror(saved_errno)) == -1) {
+			/* we tried it... */
+			vlog(LOG_ERR, emsg, ap);
+			logit(LOG_ERR, "%s", strerror(saved_errno));
+		} else {
+			vlog(LOG_ERR, nfmt, ap);
+			free(nfmt);
+		}
+		va_end(ap);
+	}
+
+	errno = saved_errno;
+}
+
+void
+log_warnx(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, emsg);
+	vlog(LOG_ERR, emsg, ap);
+	va_end(ap);
+}
+
+void
+log_info(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, emsg);
+	vlog(LOG_INFO, emsg, ap);
+	va_end(ap);
+}
+
+void
+log_debug(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	if (verbose > 1) {
+		va_start(ap, emsg);
+		vlog(LOG_DEBUG, emsg, ap);
+		va_end(ap);
+	}
+}
+
+static void	vfatalc(int code, const char *emsg, va_list ap)
+		    __attribute__((__format__ (printf, 2, 0)));
+
+static void
+vfatalc(int code, const char *emsg, va_list ap)
+{
+	static char	s[BUFSIZ];
+	const char	*sep;
+
+	if (emsg != NULL) {
+		(void)vsnprintf(s, sizeof(s), emsg, ap);
+		sep = ": ";
+	} else {
+		s[0] = '\0';
+		sep = "";
+	}
+	if (code)
+		logit(LOG_CRIT, "%s: %s%s%s",
+		    log_procname, s, sep, strerror(code));
+	else
+		logit(LOG_CRIT, "%s%s%s", log_procname, sep, s);
+}
+
+void
+fatal(const char *emsg, ...)
+{
+	va_list	ap;
+
+	va_start(ap, emsg);
+	vfatalc(errno, emsg, ap);
+	va_end(ap);
+	exit(1);
+}
+
+void
+fatalx(const char *emsg, ...)
+{
+	va_list	ap;
+
+	va_start(ap, emsg);
+	vfatalc(0, emsg, ap);
+	va_end(ap);
+	exit(1);
+}
blob - /dev/null
blob + 2870d149cb34ef79a82ecd595f820b0b54547baf (mode 644)
--- /dev/null
+++ src/common/log.h
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2003, 2004 Henning Brauer <henning@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_LOG_H
+#define FUGU_LOG_H
+
+#include <sys/cdefs.h>
+#include <stdarg.h>
+
+#define LOG_TO_STDERR	(1<<0)
+#define LOG_TO_SYSLOG	(1<<1)
+
+void	 log_init(int, int, int);
+void	 log_procinit(const char *);
+void	 log_setverbose(int);
+int	 log_getverbose(void);
+void	 log_warn(const char *, ...)
+	    __attribute__((__format__ (printf, 1, 2)));
+void	 log_warnx(const char *, ...)
+	    __attribute__((__format__ (printf, 1, 2)));
+void	 log_info(const char *, ...)
+	    __attribute__((__format__ (printf, 1, 2)));
+void	 log_debug(const char *, ...)
+	    __attribute__((__format__ (printf, 1, 2)));
+void	 logit(int, const char *, ...)
+	    __attribute__((__format__ (printf, 2, 3)));
+void	 vlog(int, const char *, va_list)
+	    __attribute__((__format__ (printf, 2, 0)));
+__dead void fatal(const char *, ...)
+	    __attribute__((__format__ (printf, 1, 2)));
+__dead void fatalx(const char *, ...)
+	    __attribute__((__format__ (printf, 1, 2)));
+
+#endif /* FUGU_LOG_H */
blob - /dev/null
blob + 349315a4dccd169a2a47d4c9f6f6ac40e12fda4b (mode 644)
--- /dev/null
+++ src/common/mdrender.c
@@ -0,0 +1,243 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Markdown -> styled wide text.  Two passes: a line-oriented block pass
+ * (fenced code, headings, bullet lists, and dropping GFM table delimiter rows)
+ * that delegates each line's content to a recursive inline pass
+ * (bold/italic/inline-code/escapes).  The structure
+ * mirrors the streaming model of the caller: it re-renders the whole buffer
+ * each frame, so an unterminated marker or open code fence simply resolves once
+ * the closing bytes arrive.
+ */
+
+#include <wchar.h>
+
+#include "mdrender.h"
+#include "util.h"
+
+#define MD_BULLET	0x2022		/* Unicode bullet (U+2022) */
+#define MD_MAXDEPTH	16		/* cap inline emphasis nesting */
+
+struct out {
+	wchar_t	*wc;
+	int	*st;
+	size_t	 n, cap;
+};
+
+static void
+emit(struct out *o, wchar_t c, int style)
+{
+	if (o->n == o->cap) {
+		o->cap = o->cap ? o->cap * 2 : 128;
+		o->wc = xreallocarray(o->wc, o->cap, sizeof(*o->wc));
+		o->st = xreallocarray(o->st, o->cap, sizeof(*o->st));
+	}
+	o->wc[o->n] = c;
+	o->st[o->n] = style;
+	o->n++;
+}
+
+/* Characters that a backslash may escape into a literal. */
+static int
+is_md_punct(wchar_t c)
+{
+	return (c == L'*' || c == L'_' || c == L'`' || c == L'#' ||
+	    c == L'\\' || c == L'-' || c == L'+' || c == L'[' || c == L']' ||
+	    c == L'(' || c == L')' || c == L'.' || c == L'!' || c == L'>' ||
+	    c == L'|');
+}
+
+/*
+ * Inline pass over one line [w, w+n): escapes, `inline code`, and * / _ / ** /
+ * __ emphasis.  Recurses (depth-capped) so emphasis can nest; an unmatched
+ * marker is emitted literally rather than swallowing the rest of the line.
+ */
+static void
+inline_md(struct out *o, const wchar_t *w, size_t n, int base, int depth)
+{
+	size_t	i = 0;
+
+	while (i < n) {
+		wchar_t	c = w[i];
+
+		if (c == L'\\' && i + 1 < n && is_md_punct(w[i + 1])) {
+			emit(o, w[i + 1], base);	/* \* -> literal * */
+			i += 2;
+			continue;
+		}
+		if (c == L'`') {			/* `inline code` */
+			size_t	j = i + 1, k;
+
+			while (j < n && w[j] != L'`')
+				j++;
+			if (j < n) {			/* closed on this line */
+				for (k = i + 1; k < j; k++)
+					emit(o, w[k], base | MD_CODE);
+				i = j + 1;
+				continue;
+			}
+		}
+		if ((c == L'*' || c == L'_') && depth < MD_MAXDEPTH) {
+			int	dbl = (i + 1 < n && w[i + 1] == c);
+			size_t	mlen = dbl ? 2 : 1;
+			size_t	j = i + mlen, close = 0;
+			int	found = 0;
+
+			while (j < n) {			/* find a closing marker */
+				if (w[j] == c &&
+				    (!dbl || (j + 1 < n && w[j + 1] == c))) {
+					close = j;
+					found = 1;
+					break;
+				}
+				j++;
+			}
+			if (found && close > i + mlen) {	/* non-empty span */
+				inline_md(o, w + i + mlen, close - (i + mlen),
+				    base | (dbl ? MD_BOLD : MD_ITALIC),
+				    depth + 1);
+				i = close + mlen;
+				continue;
+			}
+		}
+		emit(o, c, base);
+		i++;
+	}
+}
+
+static int
+is_fence(const wchar_t *w, size_t p, size_t le)
+{
+	return (le - p >= 3 &&
+	    ((w[p] == L'`' && w[p + 1] == L'`' && w[p + 2] == L'`') ||
+	     (w[p] == L'~' && w[p + 1] == L'~' && w[p + 2] == L'~')));
+}
+
+/*
+ * A GFM table delimiter row -- the "|---|:-:|" line under a header.  We don't
+ * lay tables out (see the renderer's block-pass notes); we just recognise this
+ * row, which is otherwise visual noise, so md_render() can drop it.  It is a run
+ * of only '|', '-', ':' and blanks, with at least one of each structural mark.
+ */
+static int
+is_table_sep(const wchar_t *w, size_t p, size_t le)
+{
+	int	pipe = 0, dash = 0;
+
+	for (; p < le; p++) {
+		switch (w[p]) {
+		case L'|':
+			pipe = 1;
+			break;
+		case L'-':
+			dash = 1;
+			break;
+		case L':':
+		case L' ':
+		case L'\t':
+			break;
+		default:
+			return (0);	/* any other glyph: not a delimiter */
+		}
+	}
+	return (pipe && dash);
+}
+
+size_t
+md_render(const wchar_t *in, size_t n, int ascii, wchar_t **out, int **style)
+{
+	struct out	o = { NULL, NULL, 0, 0 };
+	size_t		i = 0;
+	int		fence = 0;
+
+	while (i < n) {
+		size_t	ls = i, le, p, k;
+		int	had_nl, h;
+
+		while (i < n && in[i] != L'\n')
+			i++;
+		le = i;				/* [ls, le) is the line body */
+		had_nl = (i < n);		/* it ended with a newline */
+		if (i < n)
+			i++;			/* consume the newline */
+
+		p = ls;
+		while (p < le && (in[p] == L' ' || in[p] == L'\t'))
+			p++;
+
+		/* A ``` / ~~~ fence line toggles code mode and is itself dropped. */
+		if (is_fence(in, p, le)) {
+			fence = !fence;
+			continue;
+		}
+		if (fence) {			/* verbatim, dimmed, no inline parse */
+			for (k = ls; k < le; k++)
+				emit(&o, in[k], MD_CODEBLOCK);
+			if (had_nl)
+				emit(&o, L'\n', 0);
+			continue;
+		}
+
+		/* Drop a GFM table delimiter row; header/body rows render as-is. */
+		if (is_table_sep(in, p, le))
+			continue;
+
+		/* Heading: 1-6 '#' then a space; render the rest bold. */
+		h = 0;
+		p = ls;
+		while (p < le && in[p] == L'#') {
+			p++;
+			h++;
+		}
+		if (h >= 1 && h <= 6 && p < le && in[p] == L' ') {
+			while (p < le && in[p] == L' ')
+				p++;
+			inline_md(&o, in + p, le - p, MD_HEADING | MD_BOLD, 0);
+			if (had_nl)
+				emit(&o, L'\n', 0);
+			continue;
+		}
+
+		/* Bullet: indent, then - / * / + then a space -> bullet glyph. */
+		p = ls;
+		while (p < le && (in[p] == L' ' || in[p] == L'\t'))
+			p++;
+		if (p < le && (in[p] == L'-' || in[p] == L'*' || in[p] == L'+') &&
+		    p + 1 < le && in[p + 1] == L' ') {
+			for (k = ls; k < p; k++)	/* preserve indent */
+				emit(&o, L' ', 0);
+			emit(&o, ascii ? L'-' : MD_BULLET, 0);
+			emit(&o, L' ', 0);
+			p += 2;
+			while (p < le && in[p] == L' ')
+				p++;
+			inline_md(&o, in + p, le - p, 0, 0);
+			if (had_nl)
+				emit(&o, L'\n', 0);
+			continue;
+		}
+
+		/* Ordinary paragraph / numbered-list line. */
+		inline_md(&o, in + ls, le - ls, 0, 0);
+		if (had_nl)
+			emit(&o, L'\n', 0);
+	}
+
+	*out = o.wc;
+	*style = o.st;
+	return (o.n);
+}
blob - /dev/null
blob + 2a7f05ff94798aa4b93e9f4f56d0e1a232caff89 (mode 644)
--- /dev/null
+++ src/common/mdrender.h
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_MDRENDER_H
+#define FUGU_MDRENDER_H
+
+#include <stddef.h>
+#include <wchar.h>
+
+/*
+ * A small markdown-to-styled-text renderer for display.  It is deliberately
+ * UI-agnostic: it emits per-character *style flags* (below), not terminal
+ * attributes, so the front end maps them to whatever it can show (curses
+ * attributes, colour, ...).  Scope is the "common set" of what an assistant
+ * actually emits: headings, bold/italic/inline-code, fenced code blocks, and
+ * bullet lists.  Markdown markers are removed; newlines are preserved so the
+ * caller can wrap each logical line.
+ */
+
+#define MD_BOLD		0x01	/* **bold** / __bold__ and headings */
+#define MD_ITALIC	0x02	/* *italic* / _italic_ */
+#define MD_CODE		0x04	/* `inline code` */
+#define MD_CODEBLOCK	0x08	/* inside a ``` fenced block */
+#define MD_HEADING	0x10	/* a # heading line (implies bold styling) */
+
+/*
+ * Render decoded wide text into parallel arrays of output wide characters and
+ * per-character MD_* style flags.  Markdown markers are dropped and list
+ * bullets substituted (ascii != 0 selects an ASCII bullet glyph instead of a
+ * Unicode one).  On return *out and *style point to malloc'd arrays the caller
+ * must free(); the return value is their length (0, with both set to NULL, for
+ * empty input).  Allocation failure is fatal (xreallocarray).
+ */
+size_t	md_render(const wchar_t *in, size_t n, int ascii, wchar_t **out,
+	    int **style);
+
+#endif /* FUGU_MDRENDER_H */
blob - /dev/null
blob + 7445e12597b3682fe6b314ab3af7c0b62b421264 (mode 644)
--- /dev/null
+++ src/common/openai.c
@@ -0,0 +1,681 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <limits.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+#include "json.h"
+#include "openai.h"
+#include "provider.h"
+#include "provider_ops.h"
+#include "util.h"
+
+/* Defensive cap on simultaneous tool calls reassembled from a stream. */
+#define OPENAI_MAX_TOOLCALLS	64
+
+/* ===== request builder ===== */
+
+static void	emit_messages(struct json_writer *, const char *system,
+		    const char *messages_json);
+static void	emit_assistant(struct json_writer *, struct json_doc *,
+		    int content);
+static void	emit_user_blocks(struct json_writer *, struct json_doc *,
+		    int content);
+static void	emit_tools(struct json_writer *, const char *tools_json);
+
+static char	   *obj_getstr(struct json_doc *, int, const char *);
+static long long    obj_getint(struct json_doc *, int, const char *, long long);
+
+void
+openai_build_request(struct buf *out, const char *model, int max_tokens,
+    const char *system, const char *messages_json, const char *tools_json)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "model", model);
+	json_kv_int(&w, "max_tokens", max_tokens);
+	json_kv_bool(&w, "stream", 1);
+	/* Ask servers that support it to report usage in the final chunk. */
+	json_key(&w, "stream_options");
+	json_object_start(&w);
+	json_kv_bool(&w, "include_usage", 1);
+	json_object_end(&w);
+
+	json_key(&w, "messages");
+	emit_messages(&w, system, messages_json);
+
+	emit_tools(&w, tools_json);
+	json_object_end(&w);
+}
+
+/* Translate the canonical Anthropic messages array into OpenAI messages. */
+static void
+emit_messages(struct json_writer *w, const char *system,
+    const char *messages_json)
+{
+	struct json_doc	d;
+	int		n, i, e;
+
+	json_array_start(w);
+	if (system != NULL) {
+		json_object_start(w);
+		json_kv_string(w, "role", "system");
+		json_kv_string(w, "content", system);
+		json_object_end(w);
+	}
+
+	if (json_parse(&d, messages_json, strlen(messages_json)) == -1 ||
+	    d.ntok < 1 || json_tok_type(&d, 0) != JSON_ARRAY) {
+		json_doc_free(&d);
+		json_array_end(w);
+		return;
+	}
+
+	n = json_tok_size(&d, 0);
+	e = 1;
+	for (i = 0; i < n; i++) {
+		int	role_t = json_obj_get(&d, e, "role");
+		int	content_t = json_obj_get(&d, e, "content");
+
+		if (content_t >= 0 && json_tok_type(&d, content_t) == JSON_ARRAY) {
+			if (role_t >= 0 && json_tok_streq(&d, role_t, "assistant"))
+				emit_assistant(w, &d, content_t);
+			else
+				emit_user_blocks(w, &d, content_t);
+		} else {
+			/* String (or absent) content passes through verbatim. */
+			char	*role = role_t >= 0 ?
+			    json_tok_strdup(&d, role_t) : NULL;
+			char	*content = content_t >= 0 ?
+			    json_tok_strdup(&d, content_t) : NULL;
+
+			json_object_start(w);
+			json_kv_string(w, "role", role != NULL ? role : "user");
+			json_kv_string(w, "content",
+			    content != NULL ? content : "");
+			json_object_end(w);
+			free(role);
+			free(content);
+		}
+		e = json_skip(&d, e);
+	}
+
+	json_doc_free(&d);
+	json_array_end(w);
+}
+
+/*
+ * An Anthropic assistant turn (content blocks) becomes one OpenAI assistant
+ * message: text blocks concatenate into "content", and each tool_use block
+ * becomes a tool_calls[] entry whose function.arguments is the input object
+ * serialised as a JSON string (OpenAI requires a string there).
+ */
+static void
+emit_assistant(struct json_writer *w, struct json_doc *d, int content)
+{
+	struct buf	text;
+	int		n, i, b, ntool = 0;
+
+	buf_init(&text);
+	n = json_tok_size(d, content);
+	b = content + 1;
+	for (i = 0; i < n; i++) {
+		int	type_t = json_obj_get(d, b, "type");
+
+		if (type_t >= 0 && json_tok_streq(d, type_t, "text")) {
+			int	txt = json_obj_get(d, b, "text");
+			char	*s = txt >= 0 ? json_tok_strdup(d, txt) : NULL;
+
+			if (s != NULL) {
+				buf_puts_cstr(&text, s);
+				free(s);
+			}
+		} else if (type_t >= 0 && json_tok_streq(d, type_t, "tool_use"))
+			ntool++;
+		b = json_skip(d, b);
+	}
+
+	json_object_start(w);
+	json_kv_string(w, "role", "assistant");
+	json_key(w, "content");
+	json_string_n(w, text.data, text.len);
+
+	if (ntool > 0) {
+		json_key(w, "tool_calls");
+		json_array_start(w);
+		b = content + 1;
+		for (i = 0; i < n; i++) {
+			int	type_t = json_obj_get(d, b, "type");
+
+			if (type_t >= 0 && json_tok_streq(d, type_t, "tool_use")) {
+				int	id_t = json_obj_get(d, b, "id");
+				int	name_t = json_obj_get(d, b, "name");
+				int	in_t = json_obj_get(d, b, "input");
+				char	*id = id_t >= 0 ?
+				    json_tok_strdup(d, id_t) : NULL;
+				char	*nm = name_t >= 0 ?
+				    json_tok_strdup(d, name_t) : NULL;
+
+				json_object_start(w);
+				json_kv_string(w, "id", id != NULL ? id : "");
+				json_kv_string(w, "type", "function");
+				json_key(w, "function");
+				json_object_start(w);
+				json_kv_string(w, "name", nm != NULL ? nm : "");
+				json_key(w, "arguments");
+				if (in_t >= 0) {
+					size_t		 rlen;
+					const char	*raw =
+					    json_tok_raw(d, in_t, &rlen);
+
+					json_string_n(w, raw, rlen);
+				} else
+					json_string(w, "{}");
+				json_object_end(w);	/* function */
+				json_object_end(w);	/* tool_calls[] entry */
+				free(id);
+				free(nm);
+			}
+			b = json_skip(d, b);
+		}
+		json_array_end(w);
+	}
+
+	json_object_end(w);
+	buf_free(&text);
+}
+
+/*
+ * An Anthropic user turn carrying tool_result blocks expands to one OpenAI
+ * role:"tool" message per result; any text blocks collapse to a trailing user
+ * message (rare -- fugu's tool-result turns are pure).
+ */
+static void
+emit_user_blocks(struct json_writer *w, struct json_doc *d, int content)
+{
+	struct buf	text;
+	int		n, i, b, have_text = 0;
+
+	buf_init(&text);
+	n = json_tok_size(d, content);
+	b = content + 1;
+	for (i = 0; i < n; i++) {
+		int	type_t = json_obj_get(d, b, "type");
+
+		if (type_t >= 0 && json_tok_streq(d, type_t, "tool_result")) {
+			int	id_t = json_obj_get(d, b, "tool_use_id");
+			int	c_t = json_obj_get(d, b, "content");
+			char	*id = id_t >= 0 ?
+			    json_tok_strdup(d, id_t) : NULL;
+			char	*c = c_t >= 0 ? json_tok_strdup(d, c_t) : NULL;
+
+			json_object_start(w);
+			json_kv_string(w, "role", "tool");
+			json_kv_string(w, "tool_call_id", id != NULL ? id : "");
+			json_kv_string(w, "content", c != NULL ? c : "");
+			json_object_end(w);
+			free(id);
+			free(c);
+		} else if (type_t >= 0 && json_tok_streq(d, type_t, "text")) {
+			int	txt = json_obj_get(d, b, "text");
+			char	*s = txt >= 0 ? json_tok_strdup(d, txt) : NULL;
+
+			if (s != NULL) {
+				buf_puts_cstr(&text, s);
+				have_text = 1;
+				free(s);
+			}
+		}
+		b = json_skip(d, b);
+	}
+
+	if (have_text) {
+		json_object_start(w);
+		json_kv_string(w, "role", "user");
+		json_key(w, "content");
+		json_string_n(w, text.data, text.len);
+		json_object_end(w);
+	}
+	buf_free(&text);
+}
+
+/* Rewrap the Anthropic tool list as OpenAI functions; omit if empty. */
+static void
+emit_tools(struct json_writer *w, const char *tools_json)
+{
+	struct json_doc	d;
+	int		n, i, e;
+
+	if (tools_json == NULL)
+		return;
+	if (json_parse(&d, tools_json, strlen(tools_json)) == -1 ||
+	    d.ntok < 1 || json_tok_type(&d, 0) != JSON_ARRAY) {
+		json_doc_free(&d);
+		return;
+	}
+	n = json_tok_size(&d, 0);
+	if (n == 0) {
+		json_doc_free(&d);
+		return;
+	}
+
+	json_key(w, "tools");
+	json_array_start(w);
+	e = 1;
+	for (i = 0; i < n; i++) {
+		char	*name = obj_getstr(&d, e, "name");
+		char	*desc = obj_getstr(&d, e, "description");
+		int	 schema_t = json_obj_get(&d, e, "input_schema");
+
+		json_object_start(w);
+		json_kv_string(w, "type", "function");
+		json_key(w, "function");
+		json_object_start(w);
+		json_kv_string(w, "name", name != NULL ? name : "");
+		if (desc != NULL)
+			json_kv_string(w, "description", desc);
+		json_key(w, "parameters");
+		if (schema_t >= 0) {
+			size_t		 rlen;
+			const char	*raw = json_tok_raw(&d, schema_t, &rlen);
+
+			json_raw_n(w, raw, rlen);
+		} else
+			json_raw(w, "{\"type\":\"object\"}");
+		json_object_end(w);	/* function */
+		json_object_end(w);	/* tools[] entry */
+		free(name);
+		free(desc);
+		e = json_skip(&d, e);
+	}
+	json_array_end(w);
+	json_doc_free(&d);
+}
+
+/* ===== streaming response decoder ===== */
+
+static void	accumulate_toolcalls(struct openai_stream *, struct json_doc *,
+		    int arr);
+static void	flush_toolcalls(struct openai_stream *);
+static struct openai_toolcall *toolslot(struct openai_stream *, int);
+static const char *map_stop(const char *);
+
+void
+openai_stream_init(struct openai_stream *s, const struct llm_cbs *cbs, void *arg)
+{
+	memset(s, 0, sizeof(*s));
+	s->cbs = *cbs;
+	s->arg = arg;
+}
+
+void
+openai_stream_clear(struct openai_stream *s)
+{
+	int	i;
+
+	for (i = 0; i < s->ntools; i++) {
+		buf_free(&s->tools[i].id);
+		buf_free(&s->tools[i].name);
+		buf_free(&s->tools[i].args);
+	}
+	free(s->tools);
+	memset(s, 0, sizeof(*s));
+}
+
+void
+openai_stream_event(struct openai_stream *s, const char *event,
+    const char *data, size_t datalen)
+{
+	struct json_doc	d;
+	int		err, usage, choices, c0, delta, t;
+
+	(void)event;	/* OpenAI sends bare data lines (event defaults to "message") */
+	if (s->error)
+		return;
+
+	/* The stream terminates with a literal "[DONE]" sentinel, not JSON. */
+	if (datalen == 6 && memcmp(data, "[DONE]", 6) == 0) {
+		flush_toolcalls(s);	/* in case a server omits the finish_reason chunk */
+		if (s->cbs.on_done != NULL)
+			s->cbs.on_done(s->arg);
+		return;
+	}
+
+	if (json_parse(&d, data, datalen) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		s->error = 1;
+		if (s->cbs.on_error != NULL)
+			s->cbs.on_error("parse_error", "invalid event JSON",
+			    s->arg);
+		return;
+	}
+
+	/* Mid-stream error envelope: {"error":{"message":..,"type":..}}. */
+	if ((err = json_obj_get(&d, 0, "error")) >= 0 &&
+	    json_tok_type(&d, err) == JSON_OBJECT) {
+		char	*type = obj_getstr(&d, err, "type");
+		char	*msg = obj_getstr(&d, err, "message");
+
+		s->error = 1;
+		if (s->cbs.on_error != NULL)
+			s->cbs.on_error(type != NULL ? type : "error",
+			    msg != NULL ? msg : "", s->arg);
+		free(type);
+		free(msg);
+		json_doc_free(&d);
+		return;
+	}
+
+	/* Usage block: present in the final chunk when include_usage is honored. */
+	if ((usage = json_obj_get(&d, 0, "usage")) >= 0 &&
+	    json_tok_type(&d, usage) == JSON_OBJECT) {
+		s->input_tokens = obj_getint(&d, usage, "prompt_tokens",
+		    s->input_tokens);
+		s->output_tokens = obj_getint(&d, usage, "completion_tokens",
+		    s->output_tokens);
+		if (s->cbs.on_usage != NULL)
+			s->cbs.on_usage(s->input_tokens, s->output_tokens,
+			    s->arg);
+	}
+
+	choices = json_obj_get(&d, 0, "choices");
+	if (choices < 0 || json_tok_type(&d, choices) != JSON_ARRAY ||
+	    json_tok_size(&d, choices) < 1) {
+		json_doc_free(&d);
+		return;
+	}
+	c0 = choices + 1;		/* first (and only) choice */
+
+	if ((delta = json_obj_get(&d, c0, "delta")) >= 0 &&
+	    json_tok_type(&d, delta) == JSON_OBJECT) {
+		if ((t = json_obj_get(&d, delta, "content")) >= 0 &&
+		    json_tok_type(&d, t) == JSON_STRING) {
+			size_t	 fraglen;
+			char	*frag = json_tok_strdup_n(&d, t, &fraglen);
+
+			if (frag != NULL) {
+				if (s->cbs.on_text != NULL)
+					s->cbs.on_text(frag, fraglen, s->arg);
+				free(frag);
+			}
+		}
+		if ((t = json_obj_get(&d, delta, "tool_calls")) >= 0 &&
+		    json_tok_type(&d, t) == JSON_ARRAY)
+			accumulate_toolcalls(s, &d, t);
+	}
+
+	/* finish_reason closes the assistant message: flush tools, then stop. */
+	if ((t = json_obj_get(&d, c0, "finish_reason")) >= 0 &&
+	    json_tok_type(&d, t) == JSON_STRING) {
+		char	*fr = json_tok_strdup(&d, t);
+
+		flush_toolcalls(s);
+		if (s->cbs.on_stop != NULL)
+			s->cbs.on_stop(map_stop(fr), s->arg);
+		free(fr);
+	}
+
+	json_doc_free(&d);
+}
+
+/* Merge one streamed tool_calls array into the per-index reassembly slots. */
+static void
+accumulate_toolcalls(struct openai_stream *s, struct json_doc *d, int arr)
+{
+	int	n = json_tok_size(d, arr);
+	int	e = arr + 1, i;
+
+	for (i = 0; i < n; i++) {
+		struct openai_toolcall	*tc;
+		const char		*errstr;
+		int			 idx_t = json_obj_get(d, e, "index");
+		int			 id_t = json_obj_get(d, e, "id");
+		int			 fn_t = json_obj_get(d, e, "function");
+		int			 idx = 0;
+
+		if (idx_t >= 0) {
+			char	*v = json_tok_strdup(d, idx_t);
+
+			if (v != NULL) {
+				idx = (int)strtonum(v, 0,
+				    OPENAI_MAX_TOOLCALLS - 1, &errstr);
+				if (errstr != NULL)
+					idx = -1;
+				free(v);
+			}
+		}
+		if ((tc = toolslot(s, idx)) == NULL) {
+			e = json_skip(d, e);
+			continue;
+		}
+		tc->seen = 1;
+		/*
+		 * id/name/arguments are only consumed when the delta actually
+		 * carries a JSON string: continuation deltas commonly omit them,
+		 * and an emulating server may send an explicit null -- which
+		 * json_tok_strdup would otherwise copy verbatim as "null",
+		 * clobbering the real value captured by an earlier delta.
+		 */
+		if (id_t >= 0 && json_tok_type(d, id_t) == JSON_STRING) {
+			size_t	 vlen;
+			char	*v = json_tok_strdup_n(d, id_t, &vlen);
+
+			if (v != NULL && vlen > 0) {
+				buf_reset(&tc->id);
+				buf_append(&tc->id, v, vlen);
+			}
+			free(v);
+		}
+		if (fn_t >= 0 && json_tok_type(d, fn_t) == JSON_OBJECT) {
+			int	name_t = json_obj_get(d, fn_t, "name");
+			int	args_t = json_obj_get(d, fn_t, "arguments");
+
+			if (name_t >= 0 && json_tok_type(d, name_t) == JSON_STRING) {
+				size_t	 vlen;
+				char	*v = json_tok_strdup_n(d, name_t, &vlen);
+
+				if (v != NULL && vlen > 0) {
+					buf_reset(&tc->name);
+					buf_append(&tc->name, v, vlen);
+				}
+				free(v);
+			}
+			if (args_t >= 0 &&
+			    json_tok_type(d, args_t) == JSON_STRING) {
+				size_t	 vlen;
+				char	*v = json_tok_strdup_n(d, args_t, &vlen);
+
+				if (v != NULL) {
+					buf_append(&tc->args, v, vlen);
+					free(v);
+				}
+			}
+		}
+		e = json_skip(d, e);
+	}
+}
+
+/* Deliver every reassembled tool call exactly once, in index order. */
+static void
+flush_toolcalls(struct openai_stream *s)
+{
+	int	i;
+
+	if (s->flushed)
+		return;
+	s->flushed = 1;
+	for (i = 0; i < s->ntools; i++) {
+		struct openai_toolcall	*tc = &s->tools[i];
+		const char		*args;
+		size_t			 alen;
+
+		if (!tc->seen)
+			continue;
+		buf_terminate(&tc->id);	/* NUL-terminate, uncounted */
+		buf_terminate(&tc->name);
+		if (tc->args.len > 0) {
+			buf_terminate(&tc->args);
+			args = tc->args.data;
+			alen = tc->args.len;
+		} else {
+			args = "{}";
+			alen = 2;
+		}
+		if (s->cbs.on_tool_call != NULL)
+			s->cbs.on_tool_call(tc->id.data, tc->name.data, args,
+			    alen, s->arg);
+	}
+}
+
+/* Find (growing as needed) the reassembly slot for a tool-call index. */
+static struct openai_toolcall *
+toolslot(struct openai_stream *s, int idx)
+{
+	int	old, i;
+
+	if (idx < 0 || idx >= OPENAI_MAX_TOOLCALLS)
+		return (NULL);
+	if (idx >= s->ntools) {
+		old = s->ntools;
+		s->tools = xreallocarray(s->tools, (size_t)idx + 1,
+		    sizeof(*s->tools));
+		for (i = old; i <= idx; i++) {
+			buf_init(&s->tools[i].id);
+			buf_init(&s->tools[i].name);
+			buf_init(&s->tools[i].args);
+			s->tools[i].seen = 0;
+		}
+		s->ntools = idx + 1;
+	}
+	return (&s->tools[idx]);
+}
+
+/* Map an OpenAI finish_reason onto fugu's Anthropic-flavored stop_reason. */
+static const char *
+map_stop(const char *fr)
+{
+	if (fr == NULL)
+		return ("end_turn");
+	if (strcmp(fr, "tool_calls") == 0)
+		return ("tool_use");
+	if (strcmp(fr, "length") == 0)
+		return ("max_tokens");
+	if (strcmp(fr, "stop") == 0)
+		return ("end_turn");
+	return (fr);		/* content_filter and friends pass through */
+}
+
+/* Duplicate an object member as a C string (string or primitive); NULL if absent. */
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+	int	t = json_obj_get(d, obj, key);
+
+	if (t < 0)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
+
+static long long
+obj_getint(struct json_doc *d, int obj, const char *key, long long dflt)
+{
+	const char	*errstr;
+	char		*s;
+	long long	 v;
+	int		 t = json_obj_get(d, obj, key);
+
+	if (t < 0 || (s = json_tok_strdup(d, t)) == NULL)
+		return (dflt);
+	v = strtonum(s, LLONG_MIN, LLONG_MAX, &errstr);
+	free(s);
+	return (errstr != NULL ? dflt : v);
+}
+
+/* ===== vtable adapters ===== */
+
+/*
+ * Default request path for the chat/completions endpoint.  net_run's models-
+ * endpoint derivation swaps "/chat/completions" out of this when synthesising
+ * the models list URL.
+ */
+#define OPENAI_PATH		"/v1/chat/completions"
+
+/*
+ * Build a streaming chat/completions request: body via openai_build_request,
+ * plus the OpenAI-style header set (Authorization: Bearer, content-type,
+ * accept).  authval holds the bearer string so the caller can buf_freezero it
+ * after http_build_request has captured it; the authorization header points
+ * into authval->data.
+ */
+static void
+openai_prep_request(struct buf *body, struct http_header *h, int hmax,
+    int *nh, struct buf *authval, const char **path, const char *key,
+    const char *model, int max_tokens, const char *system, const char *conv,
+    const char *tools)
+{
+	if (hmax < 3)
+		return;			/* caller bug; net worker sizes h[4] */
+	openai_build_request(body, model, max_tokens, system, conv, tools);
+	buf_printf(authval, "Bearer %s", key);
+	buf_terminate(authval);	/* NUL-terminate, uncounted */
+	h[0].name = "authorization";	h[0].value = authval->data;
+	h[1].name = "content-type";	h[1].value = "application/json";
+	h[2].name = "accept";		h[2].value = "text/event-stream";
+	*nh = 3;
+	*path = OPENAI_PATH;
+}
+
+static void
+openai_stream_init_op(union llm_stream_storage *s,
+    const struct llm_cbs *cbs, void *arg)
+{
+	openai_stream_init(&s->o, cbs, arg);
+}
+
+static void
+openai_stream_event_op(union llm_stream_storage *s, const char *event,
+    const char *data, size_t len)
+{
+	openai_stream_event(&s->o, event, data, len);
+}
+
+static void
+openai_stream_clear_op(union llm_stream_storage *s)
+{
+	openai_stream_clear(&s->o);
+}
+
+static const char *
+openai_models_path_op(void)
+{
+	return (OPENAI_PATH);
+}
+
+const struct llm_provider_ops openai_ops = {
+	.prep_request	= openai_prep_request,
+	.stream_init	= openai_stream_init_op,
+	.stream_event	= openai_stream_event_op,
+	.stream_clear	= openai_stream_clear_op,
+	.models_path	= openai_models_path_op,
+};
blob - /dev/null
blob + cfccc69630162f34bb4ecc4bb02b0c08cf84d412 (mode 644)
--- /dev/null
+++ src/common/openai.h
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * openai: the provider layer for the OpenAI chat/completions API (and the many
+ * vendors and local servers that emulate it).  Like anthropic.c it is two pure,
+ * socket-free halves -- openai_build_request() serialises a streaming request
+ * body, and struct openai_stream interprets the streamed response.
+ *
+ * fugu keeps its conversation in the canonical Anthropic shape, so the builder
+ * TRANSLATES on the way out: the Anthropic messages array (string or content-
+ * block form) collapses to OpenAI roles (assistant tool_use -> tool_calls, a
+ * user turn of tool_result blocks -> role:"tool" messages), the system prompt
+ * folds into a leading system message, and the tool list is rewrapped as
+ * {"type":"function","function":{...}}.  The decoder fires the same llm_cbs as
+ * anthropic, so the net worker is identical for either provider.
+ */
+
+#ifndef FUGU_OPENAI_H
+#define FUGU_OPENAI_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+#include "provider.h"
+
+/*
+ * Serialise a streaming chat/completions request body into out.  system is
+ * emitted as a leading {"role":"system"} message when non-NULL.  messages_json
+ * and tools_json are the canonical Anthropic-shaped arrays; tools_json may be
+ * NULL or "[]" to send no tools.
+ */
+void	openai_build_request(struct buf *out, const char *model,
+	    int max_tokens, const char *system, const char *messages_json,
+	    const char *tools_json);
+
+/* One in-flight tool call, reassembled from streamed deltas by index. */
+struct openai_toolcall {
+	struct buf	id;
+	struct buf	name;
+	struct buf	args;	/* accumulated arguments JSON text */
+	int		seen;
+};
+
+struct openai_stream {
+	struct llm_cbs		 cbs;
+	void			*arg;
+	struct openai_toolcall	*tools;		/* indexed by choices[].delta index */
+	int			 ntools;
+	long long		 input_tokens;
+	long long		 output_tokens;
+	int			 flushed;	/* tool calls already delivered	*/
+	int			 error;		/* a fatal stream error was seen */
+};
+
+void	openai_stream_init(struct openai_stream *, const struct llm_cbs *,
+	    void *);
+void	openai_stream_clear(struct openai_stream *);
+
+/*
+ * Feed one SSE event (the event type is ignored; OpenAI sends bare data lines
+ * and a final "[DONE]" sentinel).  Shaped to be called from an sse_event_cb
+ * trampoline.
+ */
+void	openai_stream_event(struct openai_stream *, const char *event,
+	    const char *data, size_t datalen);
+
+/*
+ * Vtable instance -- see provider.h.  Defined in openai.c; resolved onto a
+ * struct net_provider at config-apply time so the net worker can dispatch
+ * prep_request / stream_init / stream_event / stream_clear / models_path
+ * without branching on enum llm_provider.  Forward-declared here because
+ * provider.h pulls this header in before completing struct llm_provider_ops.
+ */
+struct llm_provider_ops;
+extern const struct llm_provider_ops openai_ops;
+
+#endif /* FUGU_OPENAI_H */
blob - /dev/null
blob + 037d5ea15dbab6cbaeee723951ad8ff8f1e3c64b (mode 644)
--- /dev/null
+++ src/common/parse.y
@@ -0,0 +1,719 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+%{
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <ctype.h>
+#include <limits.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "conf.h"
+#include "log.h"
+#include "util.h"
+
+static struct file {
+	struct file		*prev;
+	FILE			*stream;
+	char			*name;
+	size_t			 ungetpos;
+	size_t			 ungetsize;
+	u_char			*ungetbuf;
+	int			 eof_reached;
+	int			 secret;	/* StrictModes-check this file */
+	int			 lineno;
+	int			 errors;
+} *file, *topfile;
+struct file	*pushfile(const char *, int);
+int		 popfile(void);
+int		 yyparse(void);
+int		 yylex(void);
+int		 yyerror(const char *, ...)
+    __attribute__((__format__ (printf, 1, 2)))
+    __attribute__((__nonnull__ (1)));
+int		 kw_cmp(const void *, const void *);
+int		 lookup(char *);
+int		 igetc(void);
+int		 lgetc(int);
+void		 lungetc(int);
+int		 findeol(void);
+
+TAILQ_HEAD(symhead, sym)	 symhead = TAILQ_HEAD_INITIALIZER(symhead);
+struct sym {
+	TAILQ_ENTRY(sym)	 entry;
+	int			 persist;
+	char			*nam;
+	char			*val;
+};
+int		 symset(const char *, const char *, int);
+char		*symget(const char *);
+
+static struct conf		*conf;
+static struct conf_settings	*cur;	/* settings being filled (global or block) */
+
+typedef struct {
+	union {
+		long long	 number;
+		char		*string;
+	} v;
+	int lineno;
+} YYSTYPE;
+
+%}
+
+%token	MODEL SYSTEM MAX_TOKENS CONTEXT_LIMIT ALLOW_SUBPROCESS_NET ALLOW_WRITE
+%token	WEB_SEARCH SEARCH_PROVIDER ANTHROPIC_KEY KAGI_TOKEN ASCII
+%token	ANTHROPIC_KEY_FILE KAGI_TOKEN_FILE
+%token	PROVIDER TYPE API_KEY API_KEY_FILE API_HOST API_PORT API_PATH
+%token	HTTP_ALLOW HTTP_BLOCK
+%token	MATCH USER GROUP INCLUDE YES NO
+%token	ERROR
+%token	<v.string>	STRING
+%token	<v.number>	NUMBER
+%type	<v.number>	yesno matchtype
+
+%%
+
+grammar		: /* empty */
+		| grammar '\n'
+		| grammar include '\n'
+		| grammar varset '\n'
+		| grammar setting '\n'
+		| grammar match '\n'
+		| grammar providerblock '\n'
+		| grammar error '\n'		{ file->errors++; }
+		;
+
+include		: INCLUDE STRING		{
+			struct file	*nfile;
+
+			if ((nfile = pushfile($2, file->secret)) == NULL) {
+				yyerror("failed to include file %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+			file = nfile;
+			lungetc('\n');
+		}
+		;
+
+varset		: STRING '=' STRING		{
+			char	*s;
+
+			for (s = $1; *s != '\0'; s++) {
+				if (isspace((unsigned char)*s)) {
+					yyerror("macro name may not contain "
+					    "whitespace");
+					free($1);
+					free($3);
+					YYERROR;
+				}
+			}
+			symset($1, $3, 0);
+			free($1);
+			free($3);
+		}
+		;
+
+setting		: MODEL STRING			{
+			conf_set_string(cur, CS_MODEL, &cur->model, $2);
+			free($2);
+		}
+		| SYSTEM STRING			{
+			conf_set_string(cur, CS_SYSTEM, &cur->system, $2);
+			free($2);
+		}
+		| MAX_TOKENS NUMBER		{
+			cur->max_tokens = $2;
+			conf_set_flag(cur, CS_MAX_TOKENS);
+		}
+		| CONTEXT_LIMIT NUMBER		{
+			cur->context_limit = $2;
+			conf_set_flag(cur, CS_CONTEXT_LIMIT);
+		}
+		| ALLOW_SUBPROCESS_NET yesno	{
+			cur->allow_subprocess_net = $2;
+			conf_set_flag(cur, CS_ALLOW_SUBPROC_NET);
+		}
+		| ALLOW_WRITE yesno		{
+			cur->allow_write = $2;
+			conf_set_flag(cur, CS_ALLOW_WRITE);
+		}
+		| WEB_SEARCH yesno		{
+			cur->web_search = $2;
+			conf_set_flag(cur, CS_WEB_SEARCH);
+		}
+		| ASCII yesno			{
+			cur->ascii = $2;
+			conf_set_flag(cur, CS_ASCII);
+		}
+		| SEARCH_PROVIDER STRING	{
+			conf_set_string(cur, CS_SEARCH_PROVIDER,
+			    &cur->search_provider, $2);
+			free($2);
+		}
+		| ANTHROPIC_KEY STRING		{
+			conf_set_string(cur, CS_ANTHROPIC_KEY,
+			    &cur->anthropic_key, $2);
+			free($2);
+		}
+		| KAGI_TOKEN STRING		{
+			conf_set_string(cur, CS_KAGI_TOKEN,
+			    &cur->kagi_token, $2);
+			free($2);
+		}
+		| ANTHROPIC_KEY_FILE STRING	{
+			conf_set_string(cur, CS_ANTHROPIC_KEY_FILE,
+			    &cur->anthropic_key_file, $2);
+			free($2);
+		}
+		| KAGI_TOKEN_FILE STRING	{
+			conf_set_string(cur, CS_KAGI_TOKEN_FILE,
+			    &cur->kagi_token_file, $2);
+			free($2);
+		}
+		| PROVIDER STRING		{
+			conf_set_string(cur, CS_PROVIDER, &cur->provider, $2);
+			free($2);
+		}
+		| TYPE STRING			{
+			conf_set_string(cur, CS_PROVIDER, &cur->provider, $2);
+			free($2);
+		}
+		| API_KEY STRING		{
+			conf_set_string(cur, CS_API_KEY, &cur->api_key, $2);
+			free($2);
+		}
+		| API_KEY_FILE STRING		{
+			conf_set_string(cur, CS_API_KEY_FILE,
+			    &cur->api_key_file, $2);
+			free($2);
+		}
+		| API_HOST STRING		{
+			conf_set_string(cur, CS_API_HOST, &cur->api_host, $2);
+			free($2);
+		}
+		| API_PORT STRING		{
+			conf_set_string(cur, CS_API_PORT, &cur->api_port, $2);
+			free($2);
+		}
+		| API_PATH STRING		{
+			conf_set_string(cur, CS_API_PATH, &cur->api_path, $2);
+			free($2);
+		}
+		| HTTP_ALLOW STRING		{
+			conf_set_string(cur, CS_HTTP_ALLOW, &cur->http_allow, $2);
+			free($2);
+		}
+		| HTTP_BLOCK STRING		{
+			conf_set_string(cur, CS_HTTP_BLOCK, &cur->http_block, $2);
+			free($2);
+		}
+		;
+
+yesno		: YES				{ $$ = 1; }
+		| NO				{ $$ = 0; }
+		;
+
+matchtype	: USER				{ $$ = MATCH_USER; }
+		| GROUP				{ $$ = MATCH_GROUP; }
+		;
+
+match		: MATCH matchtype STRING '{'	{
+			cur = &conf_match_new(conf,
+			    (enum match_kind)$2, $3)->settings;
+			free($3);
+		} optnl matchopts_l '}'		{
+			cur = &conf->global;
+		}
+		;
+
+matchopts_l	: /* empty */
+		| matchopts_l setting optnl
+		;
+
+/*
+ * A named provider block: `provider "name" { type ...; api_key ...; ... }`.
+ * Distinct from the flat `provider "type"` setting above -- the trailing '{'
+ * is what tells them apart (LALR resolves on that one-token lookahead).
+ */
+providerblock	: PROVIDER STRING '{'		{
+			cur = &conf_provider_new(conf, $2)->settings;
+			free($2);
+		} optnl matchopts_l '}'		{
+			cur = &conf->global;
+		}
+		;
+
+optnl		: /* empty */
+		| optnl '\n'
+		;
+
+%%
+
+int
+yyerror(const char *fmt, ...)
+{
+	va_list		 ap;
+	char		*msg;
+
+	file->errors++;
+	va_start(ap, fmt);
+	if (vasprintf(&msg, fmt, ap) == -1)
+		fatalx("yyerror vasprintf");
+	va_end(ap);
+	log_warnx("%s:%d: %s", file->name, yylval.lineno, msg);
+	free(msg);
+	return (0);
+}
+
+struct keywords {
+	const char	*k_name;
+	int		 k_val;
+};
+
+int
+kw_cmp(const void *k, const void *e)
+{
+	return (strcmp(k, ((const struct keywords *)e)->k_name));
+}
+
+int
+lookup(char *s)
+{
+	/* this has to be sorted always */
+	static const struct keywords keywords[] = {
+		{ "allow_subprocess_net",	ALLOW_SUBPROCESS_NET },
+		{ "allow_write",		ALLOW_WRITE },
+		{ "anthropic_key",		ANTHROPIC_KEY },
+		{ "anthropic_key_file",		ANTHROPIC_KEY_FILE },
+		{ "api_host",			API_HOST },
+		{ "api_key",			API_KEY },
+		{ "api_key_file",		API_KEY_FILE },
+		{ "api_path",			API_PATH },
+		{ "api_port",			API_PORT },
+		{ "ascii",			ASCII },
+		{ "context_limit",		CONTEXT_LIMIT },
+		{ "group",			GROUP },
+		{ "http_allow",			HTTP_ALLOW },
+		{ "http_block",			HTTP_BLOCK },
+		{ "include",			INCLUDE },
+		{ "kagi_token",			KAGI_TOKEN },
+		{ "kagi_token_file",		KAGI_TOKEN_FILE },
+		{ "match",			MATCH },
+		{ "max_tokens",			MAX_TOKENS },
+		{ "model",			MODEL },
+		{ "no",				NO },
+		{ "provider",			PROVIDER },
+		{ "search_provider",		SEARCH_PROVIDER },
+		{ "system",			SYSTEM },
+		{ "type",			TYPE },
+		{ "user",			USER },
+		{ "web_search",			WEB_SEARCH },
+		{ "yes",			YES }
+	};
+	const struct keywords	*p;
+
+	p = bsearch(s, keywords, sizeof(keywords) / sizeof(keywords[0]),
+	    sizeof(keywords[0]), kw_cmp);
+
+	if (p)
+		return (p->k_val);
+	else
+		return (STRING);
+}
+
+/*
+ * Macro expansion is done by pushing the macro's value back onto the input,
+ * bracketed by START_EXPAND/DONE_EXPAND sentinels that igetc() turns into the
+ * `expanding` state.  This mirrors the current base lexer template
+ * (usr.sbin/httpd/parse.y) and supports nested macros via a growable per-file
+ * unget buffer, replacing the old fixed 128-byte pushback array.
+ */
+#define START_EXPAND	1
+#define DONE_EXPAND	2
+
+static int	expanding;
+
+int
+igetc(void)
+{
+	int	c;
+
+	while (1) {
+		if (file->ungetpos > 0)
+			c = file->ungetbuf[--file->ungetpos];
+		else
+			c = getc(file->stream);
+
+		if (c == START_EXPAND)
+			expanding = 1;
+		else if (c == DONE_EXPAND)
+			expanding = 0;
+		else
+			break;
+	}
+	return (c);
+}
+
+int
+lgetc(int quotec)
+{
+	int		c, next;
+
+	if (quotec) {
+		if ((c = igetc()) == EOF) {
+			yyerror("reached end of file while parsing "
+			    "quoted string");
+			if (file == topfile || popfile() == EOF)
+				return (EOF);
+			return (quotec);
+		}
+		return (c);
+	}
+
+	while ((c = igetc()) == '\\') {
+		next = igetc();
+		if (next != '\n') {
+			c = next;
+			break;
+		}
+		yylval.lineno = file->lineno;
+		file->lineno++;
+	}
+
+	if (c == EOF) {
+		/*
+		 * Fake EOL when hitting EOF for the first time, so the line
+		 * count is right if the last line lacks a trailing newline.
+		 */
+		if (file->eof_reached == 0) {
+			file->eof_reached = 1;
+			return ('\n');
+		}
+		while (c == EOF) {
+			if (file == topfile || popfile() == EOF)
+				return (EOF);
+			c = igetc();
+		}
+	}
+	return (c);
+}
+
+void
+lungetc(int c)
+{
+	if (c == EOF)
+		return;
+
+	if (file->ungetpos >= file->ungetsize) {
+		file->ungetbuf = xreallocarray(file->ungetbuf,
+		    file->ungetsize, 2);
+		file->ungetsize *= 2;
+	}
+	file->ungetbuf[file->ungetpos++] = c;
+}
+
+int
+findeol(void)
+{
+	int	c;
+
+	/* skip to either EOF or the first real EOL */
+	while (1) {
+		c = lgetc(0);
+		if (c == '\n') {
+			file->lineno++;
+			break;
+		}
+		if (c == EOF)
+			break;
+	}
+	return (ERROR);
+}
+
+int
+yylex(void)
+{
+	char	 buf[8096];
+	char	*p, *val;
+	int	 quotec, next, c;
+	int	 token;
+
+top:
+	p = buf;
+	while ((c = lgetc(0)) == ' ' || c == '\t')
+		; /* nothing */
+
+	yylval.lineno = file->lineno;
+	if (c == '#')
+		while ((c = lgetc(0)) != '\n' && c != EOF)
+			; /* nothing */
+	if (c == '$' && !expanding) {
+		while (1) {
+			if ((c = lgetc(0)) == EOF)
+				return (0);
+			if (p + 1 >= buf + sizeof(buf) - 1) {
+				yyerror("string too long");
+				return (findeol());
+			}
+			if (isalnum(c) || c == '_') {
+				*p++ = c;
+				continue;
+			}
+			*p = '\0';
+			lungetc(c);
+			break;
+		}
+		val = symget(buf);
+		if (val == NULL) {
+			yyerror("macro '%s' not defined", buf);
+			return (findeol());
+		}
+		lungetc(DONE_EXPAND);
+		for (size_t i = strlen(val); i > 0; i--)
+			lungetc((unsigned char)val[i - 1]);
+		lungetc(START_EXPAND);
+		goto top;
+	}
+
+	switch (c) {
+	case '\'':
+	case '"':
+		quotec = c;
+		while (1) {
+			if ((c = lgetc(quotec)) == EOF)
+				return (0);
+			if (c == '\n') {
+				file->lineno++;
+				continue;
+			} else if (c == '\\') {
+				if ((next = lgetc(quotec)) == EOF)
+					return (0);
+				if (next == quotec || next == ' ' ||
+				    next == '\t')
+					c = next;
+				else if (next == '\n') {
+					file->lineno++;
+					continue;
+				} else
+					lungetc(next);
+			} else if (c == quotec) {
+				*p = '\0';
+				break;
+			} else if (c == '\0') {
+				yyerror("syntax error");
+				return (findeol());
+			}
+			if (p + 1 >= buf + sizeof(buf) - 1) {
+				yyerror("string too long");
+				return (findeol());
+			}
+			*p++ = c;
+		}
+		yylval.v.string = strdup(buf);
+		if (yylval.v.string == NULL)
+			fatal("yylex: strdup");
+		return (STRING);
+	}
+
+#define allowed_to_end_number(x) \
+	(isspace(x) || x == ')' || x ==',' || x == '/' || x == '}' || x == '=')
+
+	if (c == '-' || isdigit(c)) {
+		do {
+			*p++ = c;
+			if ((size_t)(p-buf) >= sizeof(buf)) {
+				yyerror("string too long");
+				return (findeol());
+			}
+		} while ((c = lgetc(0)) != EOF && isdigit(c));
+		lungetc(c);
+		if (p == buf + 1 && buf[0] == '-')
+			goto nodigits;
+		if (c == EOF || allowed_to_end_number(c)) {
+			const char *errstr = NULL;
+
+			*p = '\0';
+			yylval.v.number = strtonum(buf, LLONG_MIN,
+			    LLONG_MAX, &errstr);
+			if (errstr) {
+				yyerror("\"%s\" invalid number: %s",
+				    buf, errstr);
+				return (findeol());
+			}
+			return (NUMBER);
+		} else {
+nodigits:
+			while (p > buf + 1)
+				lungetc((unsigned char)*--p);
+			c = (unsigned char)*--p;
+			if (c == '-')
+				return (c);
+		}
+	}
+
+#define allowed_in_string(x) \
+	(isalnum(x) || (ispunct(x) && x != '(' && x != ')' && \
+	x != '{' && x != '}' && x != '<' && x != '>' && \
+	x != '!' && x != '=' && x != '/' && x != '#' && \
+	x != ','))
+
+	if (isalnum(c) || c == ':' || c == '_' || c == '*') {
+		do {
+			*p++ = c;
+			if ((size_t)(p-buf) >= sizeof(buf)) {
+				yyerror("string too long");
+				return (findeol());
+			}
+		} while ((c = lgetc(0)) != EOF && (allowed_in_string(c)));
+		lungetc(c);
+		*p = '\0';
+		if ((token = lookup(buf)) == STRING)
+			if ((yylval.v.string = strdup(buf)) == NULL)
+				fatal("yylex: strdup");
+		return (token);
+	}
+	if (c == '\n') {
+		yylval.lineno = file->lineno;
+		file->lineno++;
+	}
+	if (c == EOF)
+		return (0);
+	return (c);
+}
+
+struct file *
+pushfile(const char *name, int secret)
+{
+	struct file	*nfile;
+
+	nfile = xcalloc(1, sizeof(*nfile));
+	nfile->name = xstrdup(name);
+	if ((nfile->stream = fopen(nfile->name, "r")) == NULL) {
+		log_warn("%s", nfile->name);
+		free(nfile->name);
+		free(nfile);
+		return (NULL);
+	}
+	if (secret &&
+	    conf_check_secrecy(fileno(nfile->stream), nfile->name,
+	    getuid()) == -1) {
+		fclose(nfile->stream);
+		free(nfile->name);
+		free(nfile);
+		return (NULL);
+	}
+	/*
+	 * Top-level file starts at line 1; an included file starts at 0 because
+	 * the include rule injects a '\n' (lungetc) that yylex counts as the
+	 * first line.  `file` is the current top-of-stack (NULL for the top file).
+	 */
+	nfile->lineno = (file == NULL) ? 1 : 0;
+	nfile->secret = secret;
+	nfile->ungetsize = 16;
+	nfile->ungetbuf = xmalloc(nfile->ungetsize);
+	nfile->prev = file;
+	return (nfile);
+}
+
+int
+popfile(void)
+{
+	struct file	*prev;
+
+	prev = file->prev;
+	if (prev != NULL)
+		prev->errors += file->errors;
+
+	fclose(file->stream);
+	free(file->name);
+	free(file->ungetbuf);
+	free(file);
+	file = prev;
+	return (file ? 0 : EOF);
+}
+
+int
+symset(const char *nam, const char *val, int persist)
+{
+	struct sym	*sym;
+
+	TAILQ_FOREACH(sym, &symhead, entry) {
+		if (strcmp(nam, sym->nam) == 0)
+			break;
+	}
+	if (sym != NULL) {
+		if (sym->persist)
+			return (0);
+		free(sym->nam);
+		free(sym->val);
+		TAILQ_REMOVE(&symhead, sym, entry);
+		free(sym);
+	}
+	sym = xcalloc(1, sizeof(*sym));
+	sym->nam = xstrdup(nam);
+	sym->val = xstrdup(val);
+	sym->persist = persist;
+	TAILQ_INSERT_TAIL(&symhead, sym, entry);
+	return (0);
+}
+
+char *
+symget(const char *nam)
+{
+	struct sym	*sym;
+
+	TAILQ_FOREACH(sym, &symhead, entry)
+		if (strcmp(nam, sym->nam) == 0)
+			return (sym->val);
+	return (NULL);
+}
+
+int
+conf_parse_file(struct conf *xconf, const char *filename, int secret)
+{
+	struct sym	*sym;
+	int		 errors;
+
+	conf = xconf;
+	cur = &conf->global;
+	file = NULL;		/* empty include stack: the top file starts at line 1 */
+
+	if ((file = pushfile(filename, secret)) == NULL)
+		return (-1);
+	topfile = file;
+
+	yyparse();
+	errors = file->errors;
+	popfile();
+
+	/* Drop all macros so a later parse starts clean. */
+	while ((sym = TAILQ_FIRST(&symhead)) != NULL) {
+		TAILQ_REMOVE(&symhead, sym, entry);
+		free(sym->nam);
+		free(sym->val);
+		free(sym);
+	}
+
+	return (errors ? -1 : 0);
+}
blob - /dev/null
blob + 1e19e4b5d8dff756c399b3f6b3c81491309c4e69 (mode 644)
--- /dev/null
+++ src/common/provider.h
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_PROVIDER_H
+#define FUGU_PROVIDER_H
+
+#include <sys/types.h>
+
+/*
+ * The provider abstraction.  fugu reaches a model endpoint through one of two
+ * wire protocols: the Anthropic Messages API, or the OpenAI chat/completions
+ * API that most other vendors and local servers (OpenRouter, Groq, Together,
+ * DeepSeek, Ollama, llama.cpp, ...) emulate.  Each provider module is two pure
+ * halves -- a request builder and a streaming-response decoder.  The decoder
+ * turns that vendor's event vocabulary into this single set of semantic
+ * callbacks, so the net worker's agent loop, its conversation projection, and
+ * the journal stay provider-agnostic (always in the canonical Anthropic shape).
+ *
+ * The per-provider dispatch table (union llm_stream_storage, struct
+ * llm_provider_ops) lives one layer up in provider_ops.h to avoid an include
+ * cycle with the adapter headers, which need struct llm_cbs by value.
+ */
+
+enum llm_provider {
+	LLM_ANTHROPIC = 0,
+	LLM_OPENAI
+};
+
+/* Semantic callbacks fired while interpreting a streamed response. */
+struct llm_cbs {
+	void (*on_text)(const char *text, size_t len, void *arg);
+	void (*on_tool_call)(const char *id, const char *name,
+	    const char *input_json, size_t len, void *arg);
+	void (*on_usage)(long long input_tokens, long long output_tokens,
+	    void *arg);
+	void (*on_stop)(const char *stop_reason, void *arg);
+	void (*on_error)(const char *type, const char *message, void *arg);
+	void (*on_done)(void *arg);
+};
+
+#endif /* FUGU_PROVIDER_H */
blob - /dev/null
blob + 21d621f09d737dd6031854eff4877558efcfebde (mode 644)
--- /dev/null
+++ src/common/provider_ops.h
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * provider_ops: the provider-dispatch seam.  Sits one layer above provider.h
+ * (which defines the semantic callback surface that every adapter targets) and
+ * pulls in both adapter headers so the per-provider stream structs are complete
+ * types at the point union llm_stream_storage is defined.
+ *
+ * Lives in its own header because adapter headers must include provider.h for
+ * struct llm_cbs; routing the union + ops through that same header would form
+ * an include cycle that the include guards can't unwind.  Callers that only
+ * need the callback surface include provider.h; the net worker, which has to
+ * dispatch concrete adapter ops, includes provider_ops.h.
+ */
+
+#ifndef FUGU_PROVIDER_OPS_H
+#define FUGU_PROVIDER_OPS_H
+
+#include <sys/types.h>
+
+#include "anthropic.h"
+#include "openai.h"
+#include "provider.h"
+
+/*
+ * Stack storage for either provider's streaming decoder; the union member used
+ * is selected by the active provider's ops table.  Lets the net worker keep
+ * one automatic-storage decoder slot per call rather than two named locals.
+ */
+union llm_stream_storage {
+	struct anthropic_stream	a;
+	struct openai_stream	o;
+};
+
+/* Forward decls -- defined in the headers indicated. */
+struct http_header;	/* http.h */
+struct buf;		/* buf.h  */
+
+/*
+ * Per-provider dispatch.  Each adapter exposes a const ops table; the active
+ * provider's ops is resolved at config-apply time and stored on the
+ * struct net_provider (added in a later step).
+ *
+ * prep_request fills body with the streaming request body, populates the
+ *	header array (up to hmax slots, *nh on return), stashes any synthesised
+ *	auth-header value in authval (so the caller can buf_freezero it), and
+ *	sets *path to the default request path for this provider.  The caller
+ *	is free to override *path afterward.  Broken-out scalars (key, model)
+ *	rather than a struct net_provider * keep this header decoupled from
+ *	net_run.h while the seam is moving.
+ * stream_init / stream_event / stream_clear drive the SSE decoder via the
+ *	union; the active union member is implicit in the ops table chosen.
+ * models_path returns the default chat-endpoint path constant for this
+ *	provider; the caller derives the models-list endpoint from it.
+ */
+struct llm_provider_ops {
+	void	(*prep_request)(struct buf *body, struct http_header *h,
+		    int hmax, int *nh, struct buf *authval,
+		    const char **path, const char *key, const char *model,
+		    int max_tokens, const char *system,
+		    const char *conv, const char *tools);
+	void	(*stream_init)(union llm_stream_storage *,
+		    const struct llm_cbs *, void *arg);
+	void	(*stream_event)(union llm_stream_storage *,
+		    const char *event, const char *data, size_t len);
+	void	(*stream_clear)(union llm_stream_storage *);
+	const char *(*models_path)(void);
+};
+
+#endif /* FUGU_PROVIDER_OPS_H */
blob - /dev/null
blob + 5316a9b77149eb24e61e1cf7ab10b614cdbfe811 (mode 644)
--- /dev/null
+++ src/common/sse.c
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <string.h>
+
+#include "buf.h"
+#include "sse.h"
+
+#define SSE_MAXLINE_DEFAULT	(1U << 20)	/* 1 MiB per line */
+#define SSE_MAXTOTAL_DEFAULT	(8U << 20)	/* 8 MiB per event payload */
+
+static int	sse_line(struct sse_parser *, const char *, size_t);
+static void	sse_dispatch(struct sse_parser *);
+
+void
+sse_init(struct sse_parser *p, sse_event_cb cb, void *arg)
+{
+	memset(p, 0, sizeof(*p));
+	buf_init(&p->line);
+	buf_init(&p->event);
+	buf_init(&p->data);
+	p->cb = cb;
+	p->arg = arg;
+	p->maxline = SSE_MAXLINE_DEFAULT;
+	p->maxtotal = SSE_MAXTOTAL_DEFAULT;
+}
+
+void
+sse_clear(struct sse_parser *p)
+{
+	buf_free(&p->line);
+	buf_free(&p->event);
+	buf_free(&p->data);
+	memset(p, 0, sizeof(*p));
+}
+
+/*
+ * Feed bytes.  Returns 0, or -1 if a single line exceeds maxline (a defense
+ * against a peer that streams an unbounded line to exhaust memory).
+ */
+int
+sse_push(struct sse_parser *p, const void *vp, size_t len)
+{
+	const unsigned char	*s = vp;
+	size_t			 i;
+
+	for (i = 0; i < len; i++) {
+		unsigned char	ch = s[i];
+
+		if (ch == '\n') {
+			if (p->after_cr) {	/* the LF of a CRLF: swallow */
+				p->after_cr = 0;
+				continue;
+			}
+			if (sse_line(p, p->line.data, p->line.len) == -1)
+				return (-1);
+			buf_reset(&p->line);
+		} else if (ch == '\r') {
+			p->after_cr = 1;
+			if (sse_line(p, p->line.data, p->line.len) == -1)
+				return (-1);
+			buf_reset(&p->line);
+		} else {
+			p->after_cr = 0;
+			if (p->line.len >= p->maxline)
+				return (-1);
+			buf_putc(&p->line, ch);
+		}
+	}
+	return (0);
+}
+
+/*
+ * Process one complete line (terminator already consumed).  Returns 0, or -1
+ * if the accumulated event payload would exceed maxtotal (a hostile stream that
+ * sends data lines without ever terminating the event).
+ */
+static int
+sse_line(struct sse_parser *p, const char *line, size_t len)
+{
+	const char	*value;
+	size_t		 i, vlen;
+
+	if (len == 0) {			/* blank line dispatches the event */
+		sse_dispatch(p);
+		return (0);
+	}
+	if (line[0] == ':')		/* comment */
+		return (0);
+
+	for (i = 0; i < len && line[i] != ':'; i++)
+		;
+	if (i < len) {
+		value = line + i + 1;
+		vlen = len - (i + 1);
+		if (vlen > 0 && value[0] == ' ') {	/* strip one leading space */
+			value++;
+			vlen--;
+		}
+	} else {			/* a field name with no colon: empty value */
+		value = line + len;
+		vlen = 0;
+	}
+
+	if (i == 5 && memcmp(line, "event", 5) == 0) {
+		buf_reset(&p->event);
+		buf_append(&p->event, value, vlen);
+	} else if (i == 4 && memcmp(line, "data", 4) == 0) {
+		if (p->data.len + vlen + 1 > p->maxtotal)
+			return (-1);	/* event never terminated; give up */
+		buf_append(&p->data, value, vlen);
+		buf_putc(&p->data, '\n');
+		p->have_data = 1;
+	}
+	/* "id", "retry", and unknown fields are accepted and ignored. */
+	return (0);
+}
+
+static void
+sse_dispatch(struct sse_parser *p)
+{
+	const char	*ev;
+
+	/* The accumulated data carries a trailing '\n'; the spec removes it. */
+	if (p->data.len > 0 && p->data.data[p->data.len - 1] == '\n')
+		p->data.len--;
+
+	if (p->have_data) {
+		if (p->event.len > 0) {
+			buf_terminate(&p->event);	/* NUL-terminate, uncounted */
+			ev = p->event.data;
+		} else
+			ev = "message";
+
+		buf_terminate(&p->data);		/* NUL-terminate, uncounted */
+
+		p->cb(ev, p->data.data, p->data.len, p->arg);
+	}
+
+	buf_reset(&p->event);
+	buf_reset(&p->data);
+	p->have_data = 0;
+}
blob - /dev/null
blob + fd9f8b912bf4fdd8b074db7f6e4542d8d6ba199b (mode 644)
--- /dev/null
+++ src/common/sse.h
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * sse: an incremental parser for the Server-Sent Events wire format
+ * (text/event-stream), the framing the Anthropic Messages API streams over.
+ *
+ * Bytes arrive from the network in arbitrary-sized pieces, so this is a push
+ * parser: feed it whatever you read with sse_push(); it buffers partial lines
+ * across calls and invokes the callback once per dispatched event (a blank
+ * line).  It implements the parts of the EventSource spec we need: CR, LF, and
+ * CRLF line endings, ':' comment lines, the one-optional-space-after-colon
+ * rule, multiple "data:" lines joined by '\n', and the default "message" event
+ * type.  "id" and "retry" fields are accepted and ignored.
+ */
+
+#ifndef FUGU_SSE_H
+#define FUGU_SSE_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+/*
+ * Invoked once per event.  event is the NUL-terminated event type ("message"
+ * by default); data is the NUL-terminated payload of datalen bytes (the
+ * trailing newline already removed).  Both pointers are owned by the parser and
+ * valid only for the duration of the call; copy anything you need to keep.
+ */
+typedef void (*sse_event_cb)(const char *event, const char *data,
+    size_t datalen, void *arg);
+
+struct sse_parser {
+	struct buf	line;		/* current line, until a terminator	*/
+	struct buf	event;		/* current "event:" field		*/
+	struct buf	data;		/* accumulated "data:" fields		*/
+	int		have_data;	/* a data field was seen this event	*/
+	int		after_cr;	/* last byte was CR; swallow a LF	*/
+	size_t		maxline;	/* per-line cap (hostile-stream guard)	*/
+	size_t		maxtotal;	/* cap on one event's accumulated data	*/
+	sse_event_cb	cb;
+	void	       *arg;
+};
+
+void	sse_init(struct sse_parser *, sse_event_cb, void *);
+void	sse_clear(struct sse_parser *);
+int	sse_push(struct sse_parser *, const void *, size_t);	/* 0 / -1 */
+
+#endif /* FUGU_SSE_H */
blob - /dev/null
blob + feb0971d4227b19d120af44c78733fd2d680e204 (mode 644)
--- /dev/null
+++ src/common/tlsconn.c
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * A thin blocking libtls client.  See tlsconn.h for the contract; this
+ * file owns the implementation that was previously inlined into the net
+ * and fetch workers.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "tlsconn.h"
+#include "util.h"
+
+/*
+ * Snapshot a libtls error into a freshly allocated string so the caller can
+ * read it after tls_free() has released the context-owned buffer.  tls_error()
+ * can return NULL when no error has been recorded -- substitute a placeholder
+ * so callers can rely on *errstr being non-NULL and freeable on the -1 path.
+ *
+ * Contract for this file: tls_error() is called HERE and nowhere else.  Every
+ * function that surfaces a libtls failure to a caller goes through
+ * tlsconn_dup_error first, so the borrowed libtls error buffer never escapes
+ * the module.  Any new code that captures tls_error() inline must be migrated
+ * to this helper or it will reintroduce the use-after-free class the audit
+ * closed (the borrowed pointer becomes dangling after the next tls_free /
+ * tls_close).
+ */
+static char *
+tlsconn_dup_error(struct tls *ctx)
+{
+	const char	*e = tls_error(ctx);
+
+	return (xstrdup(e != NULL ? e : "(unknown)"));
+}
+
+struct tls_config *
+tlsconn_client_config(const char *cafile)
+{
+	struct tls_config	*cfg;
+	uint8_t			*ca;
+	size_t			 calen;
+
+	if ((cfg = tls_config_new()) == NULL)
+		return (NULL);
+	if (cafile != NULL) {
+		if ((ca = tls_load_file(cafile, &calen, NULL)) == NULL) {
+			tls_config_free(cfg);
+			return (NULL);
+		}
+		/* tls_config_set_ca_mem copies the data, so we free our copy. */
+		if (tls_config_set_ca_mem(cfg, ca, calen) == -1) {
+			free(ca);
+			tls_config_free(cfg);
+			return (NULL);
+		}
+		free(ca);
+	}
+	return (cfg);
+}
+
+int
+tlsconn_connect(struct tlsconn *c, struct tls_config *cfg, const char *host,
+    const char *port, char **errstr)
+{
+	memset(c, 0, sizeof(*c));
+	c->fd = -1;
+	if ((c->ctx = tls_client()) == NULL) {
+		*errstr = xstrdup("tls_client failed");
+		return (-1);
+	}
+	if (tls_configure(c->ctx, cfg) == -1) {
+		*errstr = tlsconn_dup_error(c->ctx);
+		goto fail;
+	}
+	if (tls_connect(c->ctx, host, port) == -1) {
+		*errstr = tlsconn_dup_error(c->ctx);
+		goto fail;
+	}
+	if (tlsconn_do_handshake(c->ctx) == -1) {
+		*errstr = tlsconn_dup_error(c->ctx);
+		goto fail;
+	}
+	return (0);
+fail:
+	/* *errstr was duplicated above, so it survives tls_free(). */
+	tls_free(c->ctx);
+	c->ctx = NULL;
+	return (-1);
+}
+
+int
+tlsconn_attach(struct tlsconn *c, struct tls_config *cfg, int fd,
+    const char *servername, char **errstr)
+{
+	memset(c, 0, sizeof(*c));
+	/*
+	 * c->fd stays -1 until handshake succeeds; if any step below fails the
+	 * caller still owns `fd` and is responsible for close()ing it.  This
+	 * is asymmetric with tlsconn_connect (which dials the socket itself
+	 * and so owns it on failure too), but fetch's dial path needs to keep
+	 * fd lifetime under its own SSRF/timeout-aware error reporting.
+	 */
+	c->fd = -1;
+	if ((c->ctx = tls_client()) == NULL) {
+		*errstr = xstrdup("tls_client failed");
+		return (-1);
+	}
+	if (tls_configure(c->ctx, cfg) == -1) {
+		*errstr = tlsconn_dup_error(c->ctx);
+		goto fail;
+	}
+	if (tls_connect_socket(c->ctx, fd, servername) == -1) {
+		*errstr = tlsconn_dup_error(c->ctx);
+		goto fail;
+	}
+	if (tlsconn_do_handshake(c->ctx) == -1) {
+		*errstr = tlsconn_dup_error(c->ctx);
+		goto fail;
+	}
+	c->fd = fd;			/* handshake done -- we own it now */
+	return (0);
+fail:
+	tls_free(c->ctx);
+	c->ctx = NULL;
+	return (-1);
+}
+
+ssize_t
+tlsconn_read(struct tlsconn *c, void *buf, size_t len)
+{
+	ssize_t	r;
+
+	do {
+		r = tls_read(c->ctx, buf, len);
+	} while (r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT);
+	return (r);
+}
+
+ssize_t
+tlsconn_write(struct tlsconn *c, const void *buf, size_t len)
+{
+	ssize_t	r;
+
+	do {
+		r = tls_write(c->ctx, buf, len);
+	} while (r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT);
+	return (r);
+}
+
+int
+tlsconn_write_all(struct tlsconn *c, const void *buf, size_t len)
+{
+	const uint8_t	*p = buf;
+	size_t		 off = 0;
+	ssize_t		 n;
+
+	while (off < len) {
+		n = tlsconn_write(c, p + off, len - off);
+		if (n == -1)
+			return (-1);
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+void
+tlsconn_close(struct tlsconn *c)
+{
+	if (c->ctx != NULL) {
+		tls_close(c->ctx);
+		tls_free(c->ctx);
+		c->ctx = NULL;
+	}
+	if (c->fd >= 0) {
+		close(c->fd);
+		c->fd = -1;
+	}
+}
+
+int
+tlsconn_do_handshake(struct tls *ctx)
+{
+	int	r;
+
+	do {
+		r = tls_handshake(ctx);
+	} while (r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT);
+	return (r);
+}
blob - /dev/null
blob + 46b96549e0e26dad8a24ef41d081f3479b58e55a (mode 644)
--- /dev/null
+++ src/common/tlsconn.h
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_TLSCONN_H
+#define FUGU_TLSCONN_H
+
+/*
+ * A thin blocking libtls client used by the workers for HTTPS endpoints.
+ * The CA bundle is loaded into memory when the config is built, so the
+ * connect path itself needs no rpath -- the worker can pledge "stdio inet
+ * dns" before connecting.  tls_read/tls_write WANT_POLL* returns are
+ * retried internally, so callers see ordinary blocking read/write
+ * semantics.
+ */
+
+#include <sys/types.h>
+
+#include <tls.h>
+
+struct tlsconn {
+	struct tls	*ctx;
+	int		 fd;	/* owned socket (connect_socket); -1 otherwise */
+};
+
+struct tls_config	*tlsconn_client_config(const char *cafile);
+/*
+ * Open a TLS client connection to host:port.  Returns 0 on success.  On
+ * failure returns -1 and stores a heap-allocated, NUL-terminated error
+ * description in *errstr; the caller must free() it.  The string is owned by
+ * the caller because the underlying libtls context-owned buffer is released
+ * before this function returns -- reading tls_error()'s pointer afterwards
+ * would be a use-after-free.  *errstr is left untouched on success.
+ */
+int	 tlsconn_connect(struct tlsconn *, struct tls_config *,
+	    const char *host, const char *port, char **errstr);
+/*
+ * Attach TLS to an already-connected socket: the fetch worker dials with
+ * its own SSRF guard and wall-clock deadline, then hands the open fd here
+ * for the TLS handshake.  servername drives the SNI extension and
+ * peer-name verification (use the URL host, not the dial-resolved IP).
+ *
+ * Ownership: on SUCCESS the struct tlsconn takes the fd into c->fd, and
+ * tlsconn_close will close it.  On FAILURE the fd is NOT touched -- the
+ * caller's dial-time fd is its responsibility to close.  Same errstr
+ * contract as tlsconn_connect: -1 returns leave *errstr pointing at a
+ * heap-allocated description that the caller free()s.
+ */
+int	 tlsconn_attach(struct tlsconn *, struct tls_config *, int fd,
+	    const char *servername, char **errstr);
+ssize_t	 tlsconn_read(struct tlsconn *, void *, size_t);
+ssize_t	 tlsconn_write(struct tlsconn *, const void *, size_t);
+int	 tlsconn_write_all(struct tlsconn *, const void *, size_t);
+void	 tlsconn_close(struct tlsconn *);
+int	 tlsconn_do_handshake(struct tls *);
+
+#endif /* FUGU_TLSCONN_H */
blob - /dev/null
blob + adc134d939f793d01793c30c6c7a6b52fce0105d (mode 644)
--- /dev/null
+++ src/common/util.c
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "log.h"
+#include "util.h"
+
+void *
+xmalloc(size_t size)
+{
+	void	*p;
+
+	if (size == 0)
+		fatalx("xmalloc: zero size");
+	if ((p = malloc(size)) == NULL)
+		fatal("malloc");
+	return (p);
+}
+
+void *
+xcalloc(size_t nmemb, size_t size)
+{
+	void	*p;
+
+	if (nmemb == 0 || size == 0)
+		fatalx("xcalloc: zero size");
+	if (SIZE_MAX / nmemb < size)
+		fatalx("xcalloc: nmemb * size > SIZE_MAX");
+	if ((p = calloc(nmemb, size)) == NULL)
+		fatal("calloc");
+	return (p);
+}
+
+void *
+xreallocarray(void *o, size_t nmemb, size_t size)
+{
+	void	*p;
+
+	if (nmemb == 0 || size == 0)
+		fatalx("xreallocarray: zero size");
+	if ((p = reallocarray(o, nmemb, size)) == NULL)
+		fatal("reallocarray");
+	return (p);
+}
+
+char *
+xstrdup(const char *s)
+{
+	char	*p;
+
+	if ((p = strdup(s)) == NULL)
+		fatal("strdup");
+	return (p);
+}
+
+void
+drop_privgroup(void)
+{
+	gid_t	gid = getgid();
+
+	/* Set real, effective, and saved gid so the _fugu group cannot return. */
+	if (setresgid(gid, gid, gid) == -1)
+		fatal("setresgid");
+}
blob - /dev/null
blob + 6a1ee03b1ba420a6c190a282679b1a6d1dbdd866 (mode 644)
--- /dev/null
+++ src/common/util.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_UTIL_H
+#define FUGU_UTIL_H
+
+#include <sys/types.h>
+
+/* Allocation wrappers that call fatal() on failure; never return NULL. */
+void	*xmalloc(size_t);
+void	*xcalloc(size_t, size_t);
+void	*xreallocarray(void *, size_t, size_t);
+char	*xstrdup(const char *);
+
+/*
+ * Irreversibly drop to the real group id, shedding any setgid group inherited
+ * at exec.  The parent is installed setgid _fugu solely to read the root-owned
+ * /etc/fugu.conf; once that read is done (and in every worker from the outset)
+ * the privilege must go so neither the process nor anything it exec()s can reach
+ * _fugu-group files.  Calls fatal() if the drop fails.
+ */
+void	 drop_privgroup(void);
+
+#endif /* FUGU_UTIL_H */
blob - /dev/null
blob + 01c0c1f79da1a3aba0949b1fb727a73ba927b80c (mode 644)
--- /dev/null
+++ src/exec/Makefile
@@ -0,0 +1,8 @@
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG=	fugu-exec
+SRCS+=	exec.c exec_run.c tools_file.c tools_shell.c
+BINDIR=	${PREFIX}/libexec/fugu
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + c9a922195661aa8c144de26b7f5ddeb4c6affcca (mode 644)
--- /dev/null
+++ src/exec/exec.c
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-exec: the tool worker (${PREFIX}/libexec/fugu/fugu-exec).
+ *
+ * Runs the model's file tools (read/write/edit) and base-tool wrappers
+ * (shell/grep/find/ls) against the project cwd, inside a unveil(2)-confined
+ * sandbox with NO network for the worker or its subprocesses (pledge "stdio
+ * rpath wpath cpath fattr proc exec", inet-excluding execpromises; -N relaxes
+ * the latter for subprocesses only).  Holds no secret.  The imsg channel
+ * arrives on fd 3; not intended to be run directly.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "log.h"
+#include "util.h"
+
+#define FUGU_EXEC_IMSG_FD	3
+
+int
+main(int argc, char *argv[])
+{
+	struct imsgproto	ip;
+	struct stat		st;
+	extern char		*__progname;
+	int			ch, debug = 0, verbose = 0, subproc_net = 0;
+
+	while ((ch = getopt(argc, argv, "dvN")) != -1) {
+		switch (ch) {
+		case 'd':
+			debug = 1;
+			break;
+		case 'v':
+			verbose++;
+			break;
+		case 'N':			/* allow_subprocess_net (parent) */
+			subproc_net = 1;
+			break;
+		default:
+			break;
+		}
+	}
+
+	log_init(debug ? LOG_TO_STDERR : LOG_TO_SYSLOG, verbose, LOG_DAEMON);
+	signal(SIGPIPE, SIG_IGN);
+	drop_privgroup();		/* never need the parent's setgid _fugu group */
+
+	if (fstat(FUGU_EXEC_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+		fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+		    "directly\n", __progname);
+		return (1);
+	}
+	if (ip_init(&ip, FUGU_EXEC_IMSG_FD) == -1)
+		fatal("ip_init");
+
+	exec_sandbox(subproc_net);
+	return (exec_loop(&ip) == 0 ? 0 : 1);
+}
blob - /dev/null
blob + 9ff8465a06432d13ebfaa06b153ab2c42e254855 (mode 644)
--- /dev/null
+++ src/exec/exec_run.c
@@ -0,0 +1,293 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The exec worker: runs the model's file tools inside a cwd-confined sandbox
+ * with no network.  It receives M_TOOL_REQUEST {id,name,input}, dispatches by
+ * name, and replies M_TOOL_RESULT {id,content,is_error} to net via the parent.
+ */
+
+#include <sys/types.h>
+
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "exec_run.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "tools.h"
+#include "util.h"
+
+struct execstate {
+	struct imsgproto	*ip;
+	int			 allow_write;
+};
+
+static void	handle_tool(struct execstate *, const void *, size_t);
+static void	apply_config(struct execstate *, const void *, size_t);
+static void	emit_result(struct execstate *, const char *, struct buf *, int);
+static char	*obj_getstr(struct json_doc *, int, const char *);
+static void	unveil_or_skip(const char *, const char *);
+
+/* unveil(2) a path, tolerating ENOENT so optional dirs need not exist. */
+static void
+unveil_or_skip(const char *path, const char *perms)
+{
+	if (unveil(path, perms) == -1 && errno != ENOENT)
+		fatal("unveil %s", path);
+}
+
+void
+exec_sandbox(int allow_subprocess_net)
+{
+	/* The project tree: the model's file and shell tools work here. */
+	if (unveil(".", "rwc") == -1)
+		fatal("unveil .");
+
+	/* Base tools (ksh/grep/find/ls) plus the libraries they load. */
+	unveil_or_skip("/bin", "rx");
+	unveil_or_skip("/sbin", "rx");
+	unveil_or_skip("/usr/bin", "rx");
+	unveil_or_skip("/usr/sbin", "rx");
+	unveil_or_skip("/usr/local/bin", "rx");
+	unveil_or_skip("/usr/lib", "rx");
+	unveil_or_skip("/usr/libexec", "rx");
+	unveil_or_skip("/usr/local/lib", "rx");
+	unveil_or_skip("/var/run/ld.so.hints", "r");
+	unveil_or_skip("/dev/null", "rw");
+
+	/*
+	 * Subprocess networking is opt-in.  When enabled, the execpromises below
+	 * grant children "inet dns"; they also need the resolver and trust store
+	 * visible.  The exec worker itself never gets inet.
+	 */
+	if (allow_subprocess_net) {
+		unveil_or_skip("/etc/resolv.conf", "r");
+		unveil_or_skip("/etc/hosts", "r");
+		unveil_or_skip("/etc/services", "r");
+		unveil_or_skip("/etc/ssl", "r");
+	}
+
+	if (unveil(NULL, NULL) == -1)		/* lock the unveil list */
+		fatal("unveil lock");
+
+	/*
+	 * Our own promises stay tight: proc+exec to run the base tools, fattr for
+	 * the atomic writer's fchmod(2), and NO inet.  The execpromises are the
+	 * ceiling every subprocess (and its descendants) runs under: a superset of
+	 * what ksh/ls/find/grep pledge for themselves (they need getpw, tty, and
+	 * flock), but WITHOUT "inet dns" -- so a subprocess that tries to touch the
+	 * network has its own pledge() denied or is killed.  "inet dns" is added to
+	 * the ceiling only when allow_subprocess_net.  The unveil confinement above
+	 * is inherited across execve(2) either way.
+	 */
+	if (pledge("stdio rpath wpath cpath fattr proc exec",
+	    allow_subprocess_net ?
+	    "stdio rpath wpath cpath fattr chown getpw proc exec tty flock "
+	    "inet dns" :
+	    "stdio rpath wpath cpath fattr chown getpw proc exec tty flock")
+	    == -1)
+		fatal("pledge");
+}
+
+int
+exec_loop(struct imsgproto *ip)
+{
+	struct execstate	es;
+	struct fugu_msg		m;
+	int			r;
+
+	memset(&es, 0, sizeof(es));
+	es.ip = ip;
+	es.allow_write = 1;	/* confined to cwd by unveil; config can disable */
+
+	for (;;) {
+		r = ip_get(ip, &m);
+		if (r == -1) {
+			log_warnx("exec: imsg protocol error");
+			break;
+		}
+		if (r == 0) {
+			if (ip_read(ip) <= 0)
+				break;
+			continue;
+		}
+		switch (m.type) {
+		case M_CONFIG:
+			apply_config(&es, m.data, m.len);
+			break;
+		case M_TOOL_REQUEST:
+			handle_tool(&es, m.data, m.len);
+			break;
+		case M_SHUTDOWN:
+			ip_msg_free(&m);
+			goto out;
+		default:
+			break;
+		}
+		ip_msg_free(&m);
+	}
+out:
+	return (0);
+}
+
+static void
+apply_config(struct execstate *es, const void *data, size_t len)
+{
+	struct json_doc	d;
+	int		t;
+
+	if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		return;
+	}
+	if ((t = json_obj_get(&d, 0, "allow_write")) >= 0)
+		es->allow_write = json_tok_streq(&d, t, "true");
+	json_doc_free(&d);
+}
+
+static void
+handle_tool(struct execstate *es, const void *data, size_t len)
+{
+	struct json_doc	d;
+	struct buf	out;
+	char		*id = NULL, *name = NULL, *path = NULL;
+	int		input, is_error = 1;
+
+	buf_init(&out);
+
+	if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		buf_puts_cstr(&out, "malformed tool request");
+		emit_result(es, "", &out, 1);
+		json_doc_free(&d);
+		buf_free(&out);
+		return;
+	}
+
+	id = obj_getstr(&d, 0, "id");
+	name = obj_getstr(&d, 0, "name");
+	input = json_obj_get(&d, 0, "input");
+
+	if (name == NULL) {
+		buf_puts_cstr(&out, "missing tool name");
+	} else if (strcmp(name, "read") == 0) {
+		if ((path = obj_getstr(&d, input, "path")) == NULL)
+			buf_puts_cstr(&out, "read: missing 'path'");
+		else
+			is_error = (tool_read(path, &out) != 0);
+	} else if (strcmp(name, "write") == 0) {
+		char	*content = obj_getstr(&d, input, "content");
+
+		if ((path = obj_getstr(&d, input, "path")) == NULL ||
+		    content == NULL)
+			buf_puts_cstr(&out, "write: missing 'path' or 'content'");
+		else
+			is_error = (tool_write(es->allow_write, path, content,
+			    strlen(content), &out) != 0);
+		free(content);
+	} else if (strcmp(name, "edit") == 0) {
+		char	*olds = obj_getstr(&d, input, "old_string");
+		char	*news = obj_getstr(&d, input, "new_string");
+
+		if ((path = obj_getstr(&d, input, "path")) == NULL ||
+		    olds == NULL || news == NULL)
+			buf_puts_cstr(&out, "edit: missing 'path', 'old_string' or "
+			    "'new_string'");
+		else
+			is_error = (tool_edit(es->allow_write, path, olds, news,
+			    &out) != 0);
+		free(olds);
+		free(news);
+	} else if (strcmp(name, "shell") == 0) {
+		char	*cmd = obj_getstr(&d, input, "command");
+
+		if (cmd == NULL)
+			buf_puts_cstr(&out, "shell: missing 'command'");
+		else
+			is_error = (tool_shell(cmd, &out) != 0);
+		free(cmd);
+	} else if (strcmp(name, "grep") == 0) {
+		char	*pat = obj_getstr(&d, input, "pattern");
+
+		path = obj_getstr(&d, input, "path");
+		if (pat == NULL)
+			buf_puts_cstr(&out, "grep: missing 'pattern'");
+		else
+			is_error = (tool_grep(pat, path, &out) != 0);
+		free(pat);
+	} else if (strcmp(name, "find") == 0) {
+		char	*nm = obj_getstr(&d, input, "name");
+
+		path = obj_getstr(&d, input, "path");
+		is_error = (tool_find(path, nm, &out) != 0);
+		free(nm);
+	} else if (strcmp(name, "ls") == 0) {
+		path = obj_getstr(&d, input, "path");
+		is_error = (tool_ls(path, &out) != 0);
+	} else {
+		buf_printf(&out, "unknown tool: %s", name);
+	}
+
+	emit_result(es, id != NULL ? id : "", &out, is_error);
+	free(id);
+	free(name);
+	free(path);
+	json_doc_free(&d);
+	buf_free(&out);
+}
+
+static void
+emit_result(struct execstate *es, const char *id, struct buf *content,
+    int is_error)
+{
+	struct json_writer	w;
+	struct buf		b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id);
+	json_key(&w, "content");
+	json_string_n(&w, content->len > 0 ? content->data : "", content->len);
+	json_kv_bool(&w, "is_error", is_error);
+	json_object_end(&w);
+
+	if (ip_compose(es->ip, M_TOOL_RESULT, EP_NET, EP_EXEC, b.data,
+	    b.len) == -1 || ip_flush(es->ip) == -1)
+		fatalx("exec: failed to send result");
+	buf_free(&b);
+}
+
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+	int	t;
+
+	if (obj < 0)
+		return (NULL);
+	if ((t = json_obj_get(d, obj, key)) < 0 ||
+	    json_tok_type(d, t) != JSON_STRING)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
blob - /dev/null
blob + e0981fe53d424a14ec69cc03164cf137a9aa9c45 (mode 644)
--- /dev/null
+++ src/exec/exec_run.h
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_EXEC_RUN_H
+#define FUGU_EXEC_RUN_H
+
+#include "imsgproto.h"
+
+/*
+ * Confine the worker to the current working directory and the base tools it
+ * spawns: unveil "." rwc, unveil the system
+ * binary/library dirs rx, then pledge "stdio rpath wpath cpath fattr proc exec"
+ * (no inet) with execpromises that exclude "inet dns" -- so the worker and every
+ * subprocess it runs are network-blind -- unless allow_subprocess_net is set, in
+ * which case subprocesses (only) gain "inet dns".  Call after any chdir(2) and
+ * before serving requests.
+ */
+void	exec_sandbox(int allow_subprocess_net);
+
+/*
+ * Service tool requests on an imsg channel to the parent until M_SHUTDOWN or
+ * the channel closes.  Returns 0 on a clean exit.
+ */
+int	exec_loop(struct imsgproto *);
+
+#endif /* FUGU_EXEC_RUN_H */
blob - /dev/null
blob + 4b57313d38bde2bf897387542c08956fea43ac46 (mode 644)
--- /dev/null
+++ src/exec/tools.h
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * tools: the native file tools run by the exec worker.  Each writes its result
+ * (file content, a confirmation, or an error message) into out and returns 0 on
+ * success or -1 on error.  Path access is confined by the worker's unveil(2);
+ * these functions add no path policy of their own beyond the allow_write gate.
+ */
+
+#ifndef FUGU_TOOLS_H
+#define FUGU_TOOLS_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+#define TOOLS_MAXFILE	(1024 * 1024)		/* cap a read/edit at 1 MiB */
+#define TOOLS_MAXOUTPUT	(256 * 1024)		/* cap captured subprocess output */
+#define TOOLS_EXEC_TIMEOUT	30		/* kill a runaway child after N s */
+
+/* Native file tools (tools_file.c). */
+int	tool_read(const char *path, struct buf *out);
+int	tool_write(int allow_write, const char *path, const char *content,
+	    size_t clen, struct buf *out);
+int	tool_edit(int allow_write, const char *path, const char *old_string,
+	    const char *new_string, struct buf *out);
+
+/*
+ * Subprocess tools (tools_shell.c): each runs a base program with its output
+ * (combined stdout+stderr) captured into out and returns 0, or -1 if the child
+ * could not be spawned or had to be killed for exceeding the time limit.  A
+ * non-zero exit status is reported in out but is not itself an error.  The
+ * children run under the exec worker's execpromises (no network unless
+ * allow_subprocess_net), with cwd confinement inherited from unveil(2).
+ */
+int	tool_shell(const char *command, struct buf *out);
+int	tool_grep(const char *pattern, const char *path, struct buf *out);
+int	tool_find(const char *path, const char *name, struct buf *out);
+int	tool_ls(const char *path, struct buf *out);
+
+#endif /* FUGU_TOOLS_H */
blob - /dev/null
blob + b5ee0970605686bca81afe150c155e4e29f31f2c (mode 644)
--- /dev/null
+++ src/exec/tools_file.c
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "tools.h"
+
+static int	write_all(int, const char *, size_t);
+static int	slurp(const char *, struct buf *);
+static int	write_atomic(const char *, const char *, size_t, struct buf *);
+
+int
+tool_read(const char *path, struct buf *out)
+{
+	if (slurp(path, out) == -1) {
+		int	e = errno;
+
+		buf_reset(out);
+		buf_printf(out, "cannot read %s: %s", path, strerror(e));
+		return (-1);
+	}
+	return (0);
+}
+
+int
+tool_write(int allow_write, const char *path, const char *content, size_t clen,
+    struct buf *out)
+{
+	if (!allow_write) {
+		buf_printf(out, "writing is disabled (allow_write no)");
+		return (-1);
+	}
+	if (write_atomic(path, content, clen, out) == -1)
+		return (-1);
+	buf_printf(out, "wrote %zu bytes to %s", clen, path);
+	return (0);
+}
+
+int
+tool_edit(int allow_write, const char *path, const char *old_string,
+    const char *new_string, struct buf *out)
+{
+	struct buf	content, nc;
+	char		*m;
+	size_t		 oldlen, newlen, off, tail;
+
+	if (!allow_write) {
+		buf_printf(out, "editing is disabled (allow_write no)");
+		return (-1);
+	}
+	if ((oldlen = strlen(old_string)) == 0) {
+		buf_printf(out, "edit: old_string is empty");
+		return (-1);
+	}
+
+	buf_init(&content);
+	if (slurp(path, &content) == -1) {
+		int	e = errno;
+
+		buf_printf(out, "cannot read %s: %s", path, strerror(e));
+		buf_free(&content);
+		return (-1);
+	}
+
+	m = memmem(content.data, content.len, old_string, oldlen);
+	if (m == NULL) {
+		buf_printf(out, "old_string not found in %s", path);
+		buf_free(&content);
+		return (-1);
+	}
+	off = (size_t)(m - content.data);
+	if (memmem(m + 1, content.len - off - 1, old_string, oldlen) != NULL) {
+		buf_printf(out, "old_string is not unique in %s", path);
+		buf_free(&content);
+		return (-1);
+	}
+
+	newlen = strlen(new_string);
+	tail = content.len - off - oldlen;
+	buf_init(&nc);
+	buf_append(&nc, content.data, off);
+	buf_append(&nc, new_string, newlen);
+	buf_append(&nc, content.data + off + oldlen, tail);
+
+	if (write_atomic(path, nc.data, nc.len, out) == -1) {
+		buf_free(&content);
+		buf_free(&nc);
+		return (-1);
+	}
+	buf_printf(out, "edited %s", path);
+	buf_free(&content);
+	buf_free(&nc);
+	return (0);
+}
+
+static int
+write_all(int fd, const char *data, size_t len)
+{
+	size_t	off = 0;
+	ssize_t	n;
+
+	while (off < len) {
+		n = write(fd, data + off, len - off);
+		if (n == -1) {
+			if (errno == EINTR)
+				continue;
+			return (-1);
+		}
+		if (n == 0) {		/* should not happen on a regular file */
+			errno = EPIPE;
+			return (-1);
+		}
+		off += (size_t)n;
+	}
+	return (0);
+}
+
+/* Read an entire file into content.  0 ok; -1 with errno (EISDIR/EFBIG/...). */
+static int
+slurp(const char *path, struct buf *content)
+{
+	struct stat	st;
+	char		b[65536];
+	ssize_t		n;
+	int		fd;
+
+	if ((fd = open(path, O_RDONLY)) == -1)
+		return (-1);
+	if (fstat(fd, &st) == 0 && S_ISDIR(st.st_mode)) {
+		close(fd);
+		errno = EISDIR;
+		return (-1);
+	}
+	for (;;) {
+		n = read(fd, b, sizeof(b));
+		if (n == -1) {
+			if (errno == EINTR)
+				continue;
+			close(fd);
+			return (-1);
+		}
+		if (n == 0)
+			break;
+		if (content->len + (size_t)n > TOOLS_MAXFILE) {
+			close(fd);
+			errno = EFBIG;
+			return (-1);
+		}
+		buf_append(content, b, (size_t)n);
+	}
+	close(fd);
+	return (0);
+}
+
+/*
+ * Write data to path durably and atomically: a sibling temp file, fsync,
+ * rename(2).  On failure, an error message is left in out.
+ */
+static int
+write_atomic(const char *path, const char *data, size_t len, struct buf *out)
+{
+	struct stat	st;
+	char		tmp[PATH_MAX];
+	mode_t		mode = 0644;
+	int		fd;
+
+	if (stat(path, &st) == 0)
+		mode = st.st_mode & 0777;	/* preserve an existing file's mode */
+	if (snprintf(tmp, sizeof(tmp), "%s.fugu.XXXXXX", path) >=
+	    (int)sizeof(tmp)) {
+		buf_printf(out, "path too long: %s", path);
+		return (-1);
+	}
+	if ((fd = mkstemp(tmp)) == -1) {
+		buf_printf(out, "cannot create temp file for %s: %s", path,
+		    strerror(errno));
+		return (-1);
+	}
+	if (write_all(fd, data, len) == -1) {
+		buf_printf(out, "write failed: %s", strerror(errno));
+		close(fd);
+		unlink(tmp);
+		return (-1);
+	}
+	(void)fchmod(fd, mode);
+	if (fsync(fd) == -1) {
+		buf_printf(out, "fsync failed: %s", strerror(errno));
+		close(fd);
+		unlink(tmp);
+		return (-1);
+	}
+	if (close(fd) == -1) {
+		buf_printf(out, "close failed: %s", strerror(errno));
+		unlink(tmp);
+		return (-1);
+	}
+	if (rename(tmp, path) == -1) {
+		buf_printf(out, "rename failed: %s", strerror(errno));
+		unlink(tmp);
+		return (-1);
+	}
+	return (0);
+}
blob - /dev/null
blob + d5de050e1f87ea064ab3a7cbd3a6b3199c0c4637 (mode 644)
--- /dev/null
+++ src/exec/tools_shell.c
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The subprocess tools: shell (/bin/ksh -c), grep, find, and ls, each running a
+ * base program and returning its combined stdout+stderr.  A single helper,
+ * run_capture(), forks the child with stdin from /dev/null and stdout/stderr to
+ * a pipe, closes every other descriptor (notably the imsg channel on fd 3) so a
+ * tool cannot reach the parent, enforces a wall-clock timeout, and caps the
+ * captured output.  Path and network confinement come from the worker's
+ * unveil(2)/pledge(2) sandbox, which the children inherit.
+ */
+
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "tools.h"
+
+static int	run_capture(char *const [], struct buf *);
+
+/*
+ * Run argv to completion, appending its combined output to out.  Returns 0 if
+ * the child ran (whatever its exit status), -1 if it could not be spawned or
+ * was killed for exceeding TOOLS_EXEC_TIMEOUT.
+ */
+static int
+run_capture(char *const argv[], struct buf *out)
+{
+	struct timespec	deadline, now;
+	struct pollfd	pfd;
+	char		b[16384];
+	pid_t		pid;
+	size_t		total = 0;
+	int		p[2], status, timedout = 0, truncated = 0;
+	ssize_t		n;
+
+	if (pipe(p) == -1) {
+		buf_printf(out, "pipe: %s", strerror(errno));
+		return (-1);
+	}
+	if ((pid = fork()) == -1) {
+		buf_printf(out, "fork: %s", strerror(errno));
+		close(p[0]);
+		close(p[1]);
+		return (-1);
+	}
+	if (pid == 0) {
+		int	nfd;
+
+		nfd = open("/dev/null", O_RDWR);
+		if (nfd != -1)
+			(void)dup2(nfd, STDIN_FILENO);
+		(void)dup2(p[1], STDOUT_FILENO);
+		(void)dup2(p[1], STDERR_FILENO);
+		closefrom(STDERR_FILENO + 1);	/* drop imsg fd, pipe, nfd */
+		execv(argv[0], argv);
+		_exit(127);
+	}
+
+	close(p[1]);
+	pfd.fd = p[0];
+	pfd.events = POLLIN;
+	clock_gettime(CLOCK_MONOTONIC, &deadline);
+	deadline.tv_sec += TOOLS_EXEC_TIMEOUT;
+
+	for (;;) {
+		long	ms;
+		int	r;
+
+		clock_gettime(CLOCK_MONOTONIC, &now);
+		ms = (deadline.tv_sec - now.tv_sec) * 1000 +
+		    (deadline.tv_nsec - now.tv_nsec) / 1000000;
+		if (ms <= 0) {
+			kill(pid, SIGKILL);
+			timedout = 1;
+			break;
+		}
+		r = poll(&pfd, 1, ms > 1000 ? 1000 : (int)ms);
+		if (r == -1) {
+			if (errno == EINTR)
+				continue;
+			kill(pid, SIGKILL);	/* poll failed: kill the child so the */
+			timedout = 1;		/* waitpid below cannot block forever */
+			break;
+		}
+		if (r == 0)
+			continue;		/* re-check the deadline */
+		n = read(p[0], b, sizeof(b));
+		if (n <= 0)
+			break;			/* EOF: child closed the pipe */
+		if (total >= TOOLS_MAXOUTPUT) {
+			/* already at the cap and more bytes arrived: real truncation */
+			truncated = 1;
+			kill(pid, SIGKILL);	/* enough; stop the producer */
+			break;
+		} else {
+			size_t	room = TOOLS_MAXOUTPUT - total;
+			size_t	take = (size_t)n > room ? room : (size_t)n;
+
+			buf_append(out, b, take);
+			total += take;
+			if (take < (size_t)n) {
+				/* this read had to be clipped: real truncation */
+				truncated = 1;
+				kill(pid, SIGKILL);
+				break;
+			}
+		}
+	}
+	close(p[0]);
+
+	while (waitpid(pid, &status, 0) == -1 && errno == EINTR)
+		;
+
+	if (timedout) {
+		buf_printf(out, "\n[killed: exceeded %d second time limit]",
+		    TOOLS_EXEC_TIMEOUT);
+		return (-1);
+	}
+	if (truncated)
+		buf_printf(out, "\n[output truncated at %d bytes]",
+		    TOOLS_MAXOUTPUT);
+	else if (WIFSIGNALED(status))
+		buf_printf(out, "\n[terminated by signal %d]",
+		    WTERMSIG(status));
+	else if (WIFEXITED(status) && WEXITSTATUS(status) != 0)
+		buf_printf(out, "\n[exit status %d]", WEXITSTATUS(status));
+	return (0);
+}
+
+int
+tool_shell(const char *command, struct buf *out)
+{
+	char *const	argv[] = {
+		"/bin/ksh", "-c", (char *)(uintptr_t)command, NULL
+	};
+
+	return (run_capture(argv, out));
+}
+
+int
+tool_grep(const char *pattern, const char *path, struct buf *out)
+{
+	char *const	argv[] = {
+		"/usr/bin/grep", "-RnI", "--", (char *)(uintptr_t)pattern,
+		(char *)(uintptr_t)(path != NULL ? path : "."), NULL
+	};
+
+	return (run_capture(argv, out));
+}
+
+int
+tool_find(const char *path, const char *name, struct buf *out)
+{
+	const char	*root = path != NULL ? path : ".";
+
+	if (name != NULL) {
+		char *const	argv[] = {
+			"/usr/bin/find", (char *)(uintptr_t)root, "-name",
+			(char *)(uintptr_t)name, NULL
+		};
+
+		return (run_capture(argv, out));
+	} else {
+		char *const	argv[] = {
+			"/usr/bin/find", (char *)(uintptr_t)root, NULL
+		};
+
+		return (run_capture(argv, out));
+	}
+}
+
+int
+tool_ls(const char *path, struct buf *out)
+{
+	char *const	argv[] = {
+		"/bin/ls", "-la", "--",
+		(char *)(uintptr_t)(path != NULL ? path : "."), NULL
+	};
+
+	return (run_capture(argv, out));
+}
blob - /dev/null
blob + 97e331623c4fc92b0e27604e046d9336b4e30c79 (mode 644)
--- /dev/null
+++ src/fetch/Makefile
@@ -0,0 +1,10 @@
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG=	fugu-fetch
+SRCS+=	fetch.c fetch_run.c kagi.c html2text.c http.c tlsconn.c
+BINDIR=	${PREFIX}/libexec/fugu
+LDADD+=	-ltls
+DPADD+=	${LIBTLS}
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 24fb2ef91fd7d0d261236d6742cdfc51eda6ed5e (mode 644)
--- /dev/null
+++ src/fetch/fetch.c
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-fetch: the web worker (${PREFIX}/libexec/fugu/fugu-fetch).
+ *
+ * Performs web_search (Kagi API) and web_fetch (arbitrary-URL HTTPS GET +
+ * HTML->text) via libtls, parsing untrusted HTML in isolation under pledge
+ * "stdio inet dns".  Holds the Kagi token (from the parent over imsg) but NOT
+ * the Anthropic key, and can never exec.  The imsg channel arrives on fd 3; not
+ * intended to be run directly.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "fetch_run.h"
+#include "imsgproto.h"
+#include "log.h"
+#include "util.h"
+
+#define FUGU_FETCH_IMSG_FD	3
+
+int
+main(int argc, char *argv[])
+{
+	struct imsgproto	ip;
+	struct stat		st;
+	extern char		*__progname;
+	int			ch, debug = 0, verbose = 0;
+
+	while ((ch = getopt(argc, argv, "dv")) != -1) {
+		switch (ch) {
+		case 'd':
+			debug = 1;
+			break;
+		case 'v':
+			verbose++;
+			break;
+		default:
+			break;
+		}
+	}
+
+	log_init(debug ? LOG_TO_STDERR : LOG_TO_SYSLOG, verbose, LOG_DAEMON);
+	signal(SIGPIPE, SIG_IGN);
+	drop_privgroup();		/* never need the parent's setgid _fugu group */
+
+	if (fstat(FUGU_FETCH_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+		fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+		    "directly\n", __progname);
+		return (1);
+	}
+	if (ip_init(&ip, FUGU_FETCH_IMSG_FD) == -1)
+		fatal("ip_init");
+
+	return (fetch_loop(&ip) == 0 ? 0 : 1);
+}
blob - /dev/null
blob + 0b9b7fabd62b9627986ed889b0a6d9d71e25344f (mode 644)
--- /dev/null
+++ src/fetch/fetch_run.c
@@ -0,0 +1,1168 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The fetch worker: runs the brokered web tools in an address space that holds
+ * the Kagi token but never the Anthropic key, can never exec, and parses
+ * hostile web content in isolation.  It receives M_TOOL_REQUEST {id,name,input}
+ * for web_search/web_fetch and replies M_TOOL_RESULT {id,content,is_error}.
+ *
+ * Startup pledge "stdio rpath inet dns" loads the CA bundle once; we then narrow
+ * to "stdio inet dns" and never touch a file again.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <netdb.h>
+#include <poll.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "fetch_run.h"
+#include "html2text.h"
+#include "http.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "kagi.h"
+#include "log.h"
+#include "tlsconn.h"
+#include "util.h"
+
+/*
+ * Inlined from src/common/url.c: parse an absolute http(s) URL into its
+ * connect parts.  Defensive: host restricted to host characters (no Host:
+ * header smuggling), path rejects controls/space (no request-line smuggling),
+ * fragment dropped.
+ */
+struct url {
+	int	https;			/* 1 = https, 0 = http			*/
+	char	host[256];		/* no brackets, even for IPv6		*/
+	char	port[8];		/* numeric, defaulted from the scheme	*/
+	char	path[2048];		/* path + query, always begins with '/'	*/
+};
+
+static int	url_parse(const char *in, struct url *out);
+
+/*
+ * Inlined from src/common/httpacl.c: host allow/block matcher for the model's
+ * http_request tool.  Default-deny: empty/NULL allow denies everything; block
+ * wins.  A pattern is HOSTPAT[/PATHPREFIX]; "." prefix on HOSTPAT makes it a
+ * domain-suffix pattern.  Host compare is case-insensitive; path prefix is not.
+ */
+static int	httpacl_allowed(const char *allow, const char *block,
+		    const char *host, const char *path);
+static int	acl_is_ws(int c);
+static int	acl_host_match(const char *pat, size_t patlen, const char *host);
+static int	acl_path_prefix_match(const char *path, const char *pref,
+		    size_t preflen);
+static int	acl_list_match(const char *list, const char *host,
+		    const char *path);
+
+#define FETCH_DEFAULT_CAFILE	"/etc/ssl/cert.pem"
+#define FETCH_MAXBODY		(2 * 1024 * 1024)	/* raw download cap	*/
+#define FETCH_DISPLAY_MAX	(256 * 1024)		/* non-HTML text cap	*/
+#define FETCH_USER_AGENT	"fugu/0.1"
+#define FETCH_TIMEOUT		30	/* whole-request wall-clock seconds	*/
+
+struct fetchstate {
+	struct imsgproto	*ip;
+	char			*token;		/* Kagi token (a secret)	*/
+	char			*cafile;
+	char			*kagi_host;
+	char			*kagi_port;
+	struct tls_config	*tlscfg;
+	int			 ready;
+	int			 allow_private;	/* web_fetch may reach private IPs */
+	char			*http_allow;	/* http_request: allowed host patterns */
+	char			*http_block;	/* http_request: blocked host patterns */
+};
+
+/* Body sink for http_fetch: append decoded bytes up to a cap. */
+struct fetchsink {
+	struct buf	*body;
+	size_t		 cap;
+	int		 truncated;
+};
+
+static void	apply_config(struct fetchstate *, const void *, size_t);
+static void	ensure_ready(struct fetchstate *);
+static void	handle_tool(struct fetchstate *, const void *, size_t);
+static int	do_web_search(struct fetchstate *, const char *, struct buf *);
+static int	do_web_fetch(struct fetchstate *, const char *, struct buf *);
+static int	do_http_request(struct fetchstate *, struct json_doc *, int,
+		    struct buf *);
+static int	http_fetch(struct fetchstate *, const char *host,
+		    const char *port, const char *path, const char *method,
+		    const void *reqbody, size_t reqbodylen,
+		    const struct http_header *extra, size_t nextra, int guard,
+		    struct http_resp *, struct buf *body, int *truncated,
+		    char **errstr);
+static int	ms_left(const struct timespec *);
+static int	io_wait(int, short, const struct timespec *);
+static int	addr_blocked(const struct sockaddr *);
+static int	dial(const char *, const char *, int, const struct timespec *,
+		    char **);
+static void	fetch_body(const void *, size_t, void *);
+static void	emit_result(struct fetchstate *, const char *, struct buf *,
+		    int);
+static char	*obj_getstr(struct json_doc *, int, const char *);
+/* obj_getstr_n lives in common/json.h now (promoted from this file). */
+static void	state_clear(struct fetchstate *);
+
+int
+fetch_loop(struct imsgproto *ip)
+{
+	struct fetchstate	fs;
+	struct fugu_msg		m;
+	int			r;
+
+	memset(&fs, 0, sizeof(fs));
+	fs.ip = ip;
+	fs.cafile = xstrdup(FETCH_DEFAULT_CAFILE);
+	fs.kagi_host = xstrdup(KAGI_HOST);
+	fs.kagi_port = xstrdup("443");
+
+	if (pledge("stdio rpath inet dns", NULL) == -1)
+		fatal("pledge");
+
+	if (ip_compose(ip, M_READY, EP_PARENT, EP_FETCH, NULL, 0) == -1 ||
+	    ip_flush(ip) == -1)
+		fatalx("fetch: failed to send M_READY");
+
+	for (;;) {
+		r = ip_get(ip, &m);
+		if (r == -1) {
+			log_warnx("fetch: imsg protocol error");
+			break;
+		}
+		if (r == 0) {
+			if (ip_read(ip) <= 0)
+				break;
+			continue;
+		}
+		switch (m.type) {
+		case M_SECRET:
+			if (fs.token != NULL)
+				freezero(fs.token, strlen(fs.token));
+			fs.token = xmalloc(m.len + 1);
+			memcpy(fs.token, m.data, m.len);
+			fs.token[m.len] = '\0';
+			break;
+		case M_CONFIG:
+			apply_config(&fs, m.data, m.len);
+			ensure_ready(&fs);
+			break;
+		case M_TOOL_REQUEST:
+			handle_tool(&fs, m.data, m.len);
+			break;
+		case M_SHUTDOWN:
+			ip_msg_free(&m);
+			goto out;
+		default:
+			break;
+		}
+		ip_msg_free(&m);
+	}
+out:
+	state_clear(&fs);
+	return (0);
+}
+
+static void
+apply_config(struct fetchstate *fs, const void *data, size_t len)
+{
+	struct json_doc	d;
+	char		*s;
+	int		 t;
+
+	if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		log_warnx("fetch: malformed config");
+		return;
+	}
+	if ((s = obj_getstr(&d, 0, "cafile")) != NULL) {
+		free(fs->cafile);
+		fs->cafile = s;
+	}
+	if ((s = obj_getstr(&d, 0, "kagi_host")) != NULL) {
+		free(fs->kagi_host);
+		fs->kagi_host = s;
+	}
+	if ((s = obj_getstr(&d, 0, "kagi_port")) != NULL) {
+		free(fs->kagi_port);
+		fs->kagi_port = s;
+	}
+	if ((t = json_obj_get(&d, 0, "allow_private")) >= 0)
+		fs->allow_private = json_tok_streq(&d, t, "true");
+	if ((s = obj_getstr(&d, 0, "http_allow")) != NULL) {
+		free(fs->http_allow);
+		fs->http_allow = s;
+	}
+	if ((s = obj_getstr(&d, 0, "http_block")) != NULL) {
+		free(fs->http_block);
+		fs->http_block = s;
+	}
+	json_doc_free(&d);
+}
+
+static void
+ensure_ready(struct fetchstate *fs)
+{
+	if (fs->ready)
+		return;
+	fs->tlscfg = tlsconn_client_config(fs->cafile);
+	if (fs->tlscfg == NULL)
+		log_warnx("fetch: could not load CA bundle %s", fs->cafile);
+	if (pledge("stdio inet dns", NULL) == -1)
+		fatal("pledge");
+	fs->ready = 1;
+}
+
+static void
+handle_tool(struct fetchstate *fs, const void *data, size_t len)
+{
+	struct json_doc	d;
+	struct buf	out;
+	char		*id = NULL, *name = NULL;
+	int		input, is_error = 1;
+
+	buf_init(&out);
+	ensure_ready(fs);
+
+	if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		buf_puts_cstr(&out, "malformed tool request");
+		emit_result(fs, "", &out, 1);
+		json_doc_free(&d);
+		buf_free(&out);
+		return;
+	}
+
+	id = obj_getstr(&d, 0, "id");
+	name = obj_getstr(&d, 0, "name");
+	input = json_obj_get(&d, 0, "input");
+
+	if (name == NULL) {
+		buf_puts_cstr(&out, "missing tool name");
+	} else if (strcmp(name, "web_search") == 0) {
+		size_t	 qlen = 0;
+		char	*q = obj_getstr_n(&d, input, "query", &qlen);
+
+		/*
+		 * The query is JSON-serialised straight into the body that is
+		 * POSTed to Kagi; a NUL would silently truncate it via strlen()
+		 * inside the writer, so refuse rather than send the wrong query.
+		 */
+		if (q == NULL)
+			buf_puts_cstr(&out, "web_search: missing 'query'");
+		else if (qlen != strlen(q))
+			buf_puts_cstr(&out, "web_search: 'query' must not contain "
+			    "NUL bytes");
+		else
+			is_error = (do_web_search(fs, q, &out) != 0);
+		free(q);
+	} else if (strcmp(name, "web_fetch") == 0) {
+		size_t	 ulen = 0;
+		char	*u = obj_getstr_n(&d, input, "url", &ulen);
+
+		/* The HTTP request-line cannot carry a NUL byte; reject. */
+		if (u == NULL)
+			buf_puts_cstr(&out, "web_fetch: missing 'url'");
+		else if (ulen != strlen(u))
+			buf_puts_cstr(&out, "web_fetch: 'url' must not contain "
+			    "NUL bytes");
+		else
+			is_error = (do_web_fetch(fs, u, &out) != 0);
+		free(u);
+	} else if (strcmp(name, "http_request") == 0) {
+		is_error = (do_http_request(fs, &d, input, &out) != 0);
+	} else {
+		buf_printf(&out, "unknown tool: %s", name);
+	}
+
+	emit_result(fs, id != NULL ? id : "", &out, is_error);
+	free(id);
+	free(name);
+	json_doc_free(&d);
+	buf_free(&out);
+}
+
+static int
+do_web_search(struct fetchstate *fs, const char *query, struct buf *out)
+{
+	struct http_header	hdr[2];
+	struct http_resp	r;
+	struct buf		reqbody, authval, body;
+	char			*err = NULL;
+	int			rc = -1, truncated = 0;
+
+	if (fs->token == NULL) {
+		buf_puts_cstr(out, "web_search is unavailable: no Kagi token "
+		    "configured (set kagi_token in /etc/fugu.conf)");
+		return (-1);
+	}
+
+	buf_init(&reqbody);
+	buf_init(&authval);
+	buf_init(&body);
+	kagi_search_body(&reqbody, query);	/* {"query":...,"limit":N} */
+	buf_printf(&authval, "Bearer %s", fs->token);	/* v1 auth scheme */
+	buf_terminate(&authval);			/* NUL-terminate, uncounted */
+	hdr[0].name = "authorization";
+	hdr[0].value = authval.data;
+	hdr[1].name = "content-type";
+	hdr[1].value = "application/json";
+
+	/* guard=0: the Kagi endpoint is the trusted, configured host.  v1 = POST. */
+	if (http_fetch(fs, fs->kagi_host, fs->kagi_port, KAGI_SEARCH_PATH, "POST",
+	    reqbody.data, reqbody.len, hdr, 2, 0, &r, &body, &truncated,
+	    &err) == -1) {
+		buf_printf(out, "web_search: %s", err != NULL ? err :
+		    "request failed");
+		free(err);
+		err = NULL;
+	} else if (r.status != 200) {
+		char	*msg = body.len > 0 ?
+			    kagi_error_msg(body.data, body.len) : NULL;
+
+		/* Surface Kagi's own reason (e.g. "Unauthorized") when present. */
+		if (msg != NULL)
+			buf_printf(out, "web_search: Kagi returned HTTP %d: %.*s",
+			    r.status, 256, msg);
+		else
+			buf_printf(out, "web_search: Kagi returned HTTP %d",
+			    r.status);
+		free(msg);
+	} else {
+		rc = kagi_format_results(body.data != NULL ? body.data : "",
+		    body.len, out);
+	}
+
+	http_resp_clear(&r);
+	freezero(authval.data, authval.len);	/* held the token */
+	buf_free(&reqbody);
+	buf_free(&body);
+	return (rc);
+}
+
+static int
+do_web_fetch(struct fetchstate *fs, const char *urlstr, struct buf *out)
+{
+	struct url		u;
+	struct http_resp	r;
+	struct buf		body;
+	char			*err = NULL, ct[160];
+	int			rc = 0, truncated = 0, is_html, is_text;
+	size_t			cl;
+
+	if (url_parse(urlstr, &u) == -1) {
+		buf_puts_cstr(out, "web_fetch: invalid or unsupported URL");
+		return (-1);
+	}
+	if (!u.https) {
+		buf_puts_cstr(out, "web_fetch: only https URLs are supported");
+		return (-1);
+	}
+
+	buf_init(&body);
+	/* guard the attacker-influenced destination unless explicitly allowed. */
+	if (http_fetch(fs, u.host, u.port, u.path, "GET", NULL, 0, NULL, 0,
+	    fs->allow_private ? 0 : 1, &r, &body, &truncated, &err) == -1) {
+		buf_printf(out, "web_fetch: %s: %s", u.host, err != NULL ?
+		    err : "request failed");
+		free(err);
+		err = NULL;
+		http_resp_clear(&r);
+		buf_free(&body);
+		return (-1);
+	}
+
+	/* fugu does not follow redirects (each hop would need re-guarding). */
+	if (r.status >= 300 && r.status < 400) {
+		buf_printf(out, "web_fetch: %s returned HTTP %d (a redirect). "
+		    "fugu does not follow redirects; fetch the target URL "
+		    "directly.", u.host, r.status);
+		http_resp_clear(&r);
+		buf_free(&body);
+		return (0);
+	}
+
+	ct[0] = '\0';
+	if (r.ctype.len > 0 && r.ctype.data != NULL) {
+		cl = r.ctype.len < sizeof(ct) - 1 ? r.ctype.len : sizeof(ct) - 1;
+		memcpy(ct, r.ctype.data, cl);
+		ct[cl] = '\0';
+	}
+	is_html = (strcasestr(ct, "html") != NULL);
+	is_text = (strncasecmp(ct, "text/", 5) == 0 || ct[0] == '\0');
+
+	if (r.status != 200)
+		buf_printf(out, "(HTTP %d from %s)\n\n", r.status, u.host);
+
+	if (is_html || ct[0] == '\0') {
+		html2text(body.data != NULL ? body.data : "", body.len, out);
+	} else if (is_text) {
+		size_t	n = body.len < FETCH_DISPLAY_MAX ? body.len :
+		    FETCH_DISPLAY_MAX;
+
+		if (body.data != NULL)
+			buf_append(out, body.data, n);
+		if (n < body.len)
+			truncated = 1;
+	} else {
+		buf_printf(out, "[%zu bytes of non-text content (%s)]",
+		    body.len, ct[0] != '\0' ? ct : "unknown type");
+	}
+
+	if (truncated)
+		buf_printf(out, "\n\n[content truncated]");
+
+	http_resp_clear(&r);
+	buf_free(&body);
+	return (rc);
+}
+
+/* Canonical (uppercase) form of a supported HTTP method, or NULL if unknown. */
+static const char *
+method_canon(const char *m)
+{
+	static const char *const ok[] = { "GET", "POST", "PUT", "PATCH",
+	    "DELETE", "HEAD", "OPTIONS" };
+	size_t	i;
+
+	for (i = 0; i < sizeof(ok) / sizeof(ok[0]); i++)
+		if (strcasecmp(m, ok[i]) == 0)
+			return (ok[i]);
+	return (NULL);
+}
+
+/*
+ * http_request: a general HTTPS client the model drives.  The operator's
+ * allow/block lists (httpacl) decide which hosts are reachable; the SSRF guard
+ * stays on (private/loopback/metadata refused) unless allow_private.  The model
+ * supplies the method, headers and body; fugu injects none of its own
+ * credentials, so the model can only reach what the operator already trusts.
+ */
+static int
+do_http_request(struct fetchstate *fs, struct json_doc *d, int input,
+    struct buf *out)
+{
+#define HTTP_MAX_HDRS	24
+	struct url		 u;
+	struct http_resp	 r;
+	struct buf		 body;
+	struct http_header	 extra[HTTP_MAX_HDRS];
+	char			*hname[HTTP_MAX_HDRS], *hval[HTTP_MAX_HDRS];
+	const char		*method;
+	char			*err = NULL, *urlstr, *rawmethod, *reqbody;
+	size_t			 nextra = 0, i, blen, urllen, methlen;
+	int			 hdrs, rc = -1, truncated = 0, bad_hdr = 0;
+
+	urlstr = obj_getstr_n(d, input, "url", &urllen);
+	if (urlstr == NULL) {
+		buf_puts_cstr(out, "http_request: missing 'url'");
+		return (-1);
+	}
+	/*
+	 * The HTTP request line cannot carry a NUL byte; reject rather than
+	 * silently truncating at the first NUL inside the URL.
+	 */
+	if (urllen != strlen(urlstr)) {
+		buf_puts_cstr(out, "http_request: 'url' must not contain NUL bytes");
+		free(urlstr);
+		return (-1);
+	}
+	if (url_parse(urlstr, &u) == -1 || !u.https) {
+		buf_puts_cstr(out, "http_request: 'url' must be an absolute https URL");
+		free(urlstr);
+		return (-1);
+	}
+	free(urlstr);
+
+	/* The operator's allowlist (minus the blocklist) decides reachability. */
+	if (!httpacl_allowed(fs->http_allow, fs->http_block, u.host, u.path)) {
+		buf_printf(out, "http_request: host \"%s\" is not allowed by the "
+		    "configured http_allow/http_block lists", u.host);
+		return (-1);
+	}
+
+	rawmethod = obj_getstr_n(d, input, "method", &methlen);
+	/* HTTP method is a protocol literal -- NULs are not allowed. */
+	if (rawmethod != NULL && methlen != strlen(rawmethod)) {
+		buf_puts_cstr(out, "http_request: 'method' must not contain NUL bytes");
+		free(rawmethod);
+		return (-1);
+	}
+	method = (rawmethod != NULL) ? method_canon(rawmethod) : "GET";
+	if (method == NULL) {
+		buf_printf(out, "http_request: unsupported method \"%s\"",
+		    rawmethod);
+		free(rawmethod);
+		return (-1);
+	}
+
+	/*
+	 * Body length comes from the decoded byte count -- NOT strlen() --
+	 * because a model-supplied body can legitimately contain embedded NUL
+	 * bytes (binary, protobuf, etc.).  strlen() would silently truncate.
+	 */
+	reqbody = obj_getstr_n(d, input, "body", &blen);
+
+	/* Turn the model's headers object into the extra-header array. */
+	hdrs = json_obj_get(d, input, "headers");
+	if (hdrs >= 0 && json_tok_type(d, hdrs) == JSON_OBJECT) {
+		int	n = json_tok_size(d, hdrs), k = hdrs + 1, j;
+
+		for (j = 0; j < n && nextra < HTTP_MAX_HDRS; j++) {
+			int	v = k + 1;
+			char	*nm, *vl;
+			size_t	 nmlen = 0, vllen = 0;
+
+			if (json_tok_type(d, v) != JSON_STRING) {
+				k = json_skip(d, v);	/* non-string value: skip */
+				continue;
+			}
+			nm = json_tok_strdup_n(d, k, &nmlen);
+			vl = json_tok_strdup_n(d, v, &vllen);
+			/*
+			 * HTTP header field-names and field-values are written
+			 * as C strings into the request, and the protocol does
+			 * not permit a NUL byte in either.  Reject the whole
+			 * request rather than silently truncating.
+			 */
+			if (nm != NULL && vl != NULL &&
+			    (nmlen != strlen(nm) || vllen != strlen(vl))) {
+				free(nm);
+				free(vl);
+				bad_hdr = 1;
+				break;
+			}
+			/* host + content-length are owned by the transport. */
+			if (nm != NULL && vl != NULL &&
+			    strcasecmp(nm, "host") != 0 &&
+			    strcasecmp(nm, "content-length") != 0) {
+				hname[nextra] = nm;
+				hval[nextra] = vl;
+				extra[nextra].name = nm;
+				extra[nextra].value = vl;
+				nextra++;
+			} else {
+				free(nm);
+				free(vl);
+			}
+			k = json_skip(d, v);
+		}
+	}
+
+	if (bad_hdr) {
+		buf_puts_cstr(out, "http_request: header names and values must not "
+		    "contain NUL bytes");
+		for (i = 0; i < nextra; i++) {
+			free(hname[i]);
+			free(hval[i]);
+		}
+		free(rawmethod);
+		free(reqbody);
+		return (-1);
+	}
+
+	buf_init(&body);
+	if (http_fetch(fs, u.host, u.port, u.path, method,
+	    blen > 0 ? reqbody : NULL, blen, extra, nextra,
+	    fs->allow_private ? 0 : 1, &r, &body, &truncated, &err) == -1) {
+		buf_printf(out, "http_request: %s: %s", u.host,
+		    err != NULL ? err : "request failed");
+		free(err);
+		err = NULL;
+	} else {
+		size_t	n = body.len < FETCH_DISPLAY_MAX ? body.len :
+			    FETCH_DISPLAY_MAX;
+
+		/* Status line then the raw body (non-2xx is a result, not error). */
+		buf_printf(out, "HTTP %d\n\n", r.status);
+		if (body.data != NULL && n > 0)
+			buf_append(out, body.data, n);
+		if (truncated || n < body.len)
+			buf_puts_cstr(out, "\n\n[response truncated]");
+		rc = 0;
+	}
+
+	http_resp_clear(&r);
+	buf_free(&body);
+	for (i = 0; i < nextra; i++) {
+		free(hname[i]);
+		free(hval[i]);
+	}
+	free(rawmethod);
+	free(reqbody);
+	return (rc);
+#undef HTTP_MAX_HDRS
+}
+
+/* Milliseconds until the deadline, clamped to [0, FETCH_TIMEOUT*1000]. */
+static int
+ms_left(const struct timespec *dl)
+{
+	struct timespec	now;
+	long		ms;
+
+	clock_gettime(CLOCK_MONOTONIC, &now);
+	ms = (dl->tv_sec - now.tv_sec) * 1000 +
+	    (dl->tv_nsec - now.tv_nsec) / 1000000;
+	if (ms < 0)
+		return (0);
+	if (ms > 1000 * FETCH_TIMEOUT)
+		return (1000 * FETCH_TIMEOUT);
+	return ((int)ms);
+}
+
+/* poll fd for events until the deadline: 1 ready, 0 timeout, -1 error. */
+static int
+io_wait(int fd, short events, const struct timespec *dl)
+{
+	struct pollfd	p;
+	int		ms;
+
+	if ((ms = ms_left(dl)) <= 0)
+		return (0);
+	p.fd = fd;
+	p.events = events;
+	p.revents = 0;
+	return (poll(&p, 1, ms));
+}
+
+/*
+ * SSRF guard: is this resolved address loopback/private/link-local/metadata?
+ * web_fetch destinations are attacker-influenced, so by default we refuse to
+ * connect to anything that is not a public address.
+ */
+static int
+addr_blocked(const struct sockaddr *sa)
+{
+	if (sa->sa_family == AF_INET) {
+		uint32_t	a = ntohl(((const struct sockaddr_in *)sa)->
+				    sin_addr.s_addr);
+		uint8_t		o1 = (a >> 24) & 0xff, o2 = (a >> 16) & 0xff;
+
+		if (o1 == 0 || o1 == 127)		/* this-net, loopback	*/
+			return (1);
+		if (o1 == 10)				/* RFC1918		*/
+			return (1);
+		if (o1 == 172 && o2 >= 16 && o2 <= 31)	/* RFC1918		*/
+			return (1);
+		if (o1 == 192 && o2 == 168)		/* RFC1918		*/
+			return (1);
+		if (o1 == 169 && o2 == 254)		/* link-local + metadata*/
+			return (1);
+		if (o1 == 100 && o2 >= 64 && o2 <= 127)	/* CGNAT		*/
+			return (1);
+		if (o1 >= 224)				/* multicast/reserved	*/
+			return (1);
+		return (0);
+	}
+	if (sa->sa_family == AF_INET6) {
+		const struct in6_addr	*a6 =
+		    &((const struct sockaddr_in6 *)sa)->sin6_addr;
+
+		if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_LINKLOCAL(a6) ||
+		    IN6_IS_ADDR_MULTICAST(a6) || IN6_IS_ADDR_UNSPECIFIED(a6))
+			return (1);
+		if ((a6->s6_addr[0] & 0xfe) == 0xfc)	/* fc00::/7 ULA		*/
+			return (1);
+		if (IN6_IS_ADDR_V4MAPPED(a6)) {		/* ::ffff:v4 -> check v4*/
+			struct sockaddr_in	s4;
+
+			memset(&s4, 0, sizeof(s4));
+			s4.sin_family = AF_INET;
+			memcpy(&s4.sin_addr, &a6->s6_addr[12], 4);
+			return (addr_blocked((struct sockaddr *)&s4));
+		}
+		return (0);
+	}
+	return (1);				/* unknown family: refuse */
+}
+
+/*
+ * Resolve host:port and connect a non-blocking socket within the deadline.
+ * When guard is set, reject blocked addresses on the RESOLVED address (so
+ * decimal/hex host encodings and DNS rebinding cannot slip past).  Returns
+ * the connected fd, or -1 with *errstr set to a heap-allocated, NUL-
+ * terminated description that the caller free()s -- same ownership shape
+ * as tlsconn_connect's *errstr so http_fetch can treat both kinds of
+ * failure uniformly.
+ */
+static int
+dial(const char *host, const char *port, int guard, const struct timespec *dl,
+    char **errstr)
+{
+	struct addrinfo	 hints, *res, *ai;
+	int		 fd = -1, blocked = 0;
+
+	memset(&hints, 0, sizeof(hints));
+	hints.ai_family = AF_UNSPEC;
+	hints.ai_socktype = SOCK_STREAM;
+	if (getaddrinfo(host, port, &hints, &res) != 0) {
+		*errstr = xstrdup("host resolution failed");
+		return (-1);
+	}
+	for (ai = res; ai != NULL; ai = ai->ai_next) {
+		int		fl, err = 0;
+		socklen_t	sl = sizeof(err);
+
+		if (guard && addr_blocked(ai->ai_addr)) {
+			blocked = 1;
+			continue;
+		}
+		if ((fd = socket(ai->ai_family, ai->ai_socktype,
+		    ai->ai_protocol)) == -1)
+			continue;
+		if ((fl = fcntl(fd, F_GETFL)) != -1)
+			(void)fcntl(fd, F_SETFL, fl | O_NONBLOCK);
+		if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
+			break;			/* connected immediately */
+		if (errno == EINPROGRESS && io_wait(fd, POLLOUT, dl) == 1 &&
+		    getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &sl) == 0 &&
+		    err == 0)
+			break;			/* connected */
+		close(fd);
+		fd = -1;
+	}
+	freeaddrinfo(res);
+	if (fd == -1)
+		*errstr = xstrdup(blocked ?
+		    "destination address is not allowed" :
+		    "could not connect (timeout or refused)");
+	return (fd);
+}
+
+/*
+ * One HTTPS request (GET, or POST when reqbody is non-NULL) with a whole-request
+ * wall-clock deadline (connect + handshake + write + read) and an SSRF guard.  r
+ * is always initialised on return (clear it).  0 on a received response
+ * (r.status/r.ctype/body filled), -1 on a transport failure with *errstr set.
+ */
+static int
+http_fetch(struct fetchstate *fs, const char *host, const char *port,
+    const char *path, const char *method, const void *reqbody, size_t reqbodylen,
+    const struct http_header *extra, size_t nextra, int guard,
+    struct http_resp *r, struct buf *body, int *truncated, char **errstr)
+{
+	struct tlsconn		 c;
+	struct http_header	 h[32];		/* user-agent + accept + extras */
+	struct fetchsink	 sink;
+	struct buf		 req;
+	struct timespec		 dl;
+	char			*tlserr = NULL;
+	char			 hosthdr[280], rbuf[8192];
+	ssize_t			 n;
+	size_t			 nh = 0, i;
+	int			 fd = -1, done = 0;
+
+	memset(&c, 0, sizeof(c));
+	c.fd = -1;
+	sink.body = body;
+	sink.cap = FETCH_MAXBODY;
+	sink.truncated = 0;
+	http_resp_init(r, fetch_body, &sink);
+
+	if (fs->tlscfg == NULL) {
+		*errstr = xstrdup("TLS not configured");
+		return (-1);
+	}
+
+	clock_gettime(CLOCK_MONOTONIC, &dl);
+	dl.tv_sec += FETCH_TIMEOUT;
+
+	if ((fd = dial(host, port, guard, &dl, errstr)) == -1)
+		return (-1);
+
+	/* Host header: bracket IPv6 literals, add a non-default port. */
+	if (strchr(host, ':') != NULL) {
+		if (strcmp(port, "443") == 0)
+			snprintf(hosthdr, sizeof(hosthdr), "[%s]", host);
+		else
+			snprintf(hosthdr, sizeof(hosthdr), "[%s]:%s", host,
+			    port);
+	} else if (strcmp(port, "443") == 0)
+		(void)strlcpy(hosthdr, host, sizeof(hosthdr));
+	else
+		snprintf(hosthdr, sizeof(hosthdr), "%s:%s", host, port);
+
+	h[nh].name = "user-agent";
+	h[nh].value = FETCH_USER_AGENT;
+	nh++;
+	h[nh].name = "accept";
+	h[nh].value = "*/*";
+	nh++;
+	for (i = 0; i < nextra && nh < sizeof(h) / sizeof(h[0]); i++)
+		h[nh++] = extra[i];
+
+	buf_init(&req);
+	http_build_request(&req, method, hosthdr, path, h, nh, reqbody, reqbodylen);
+
+	/*
+	 * Attach TLS to the dialed fd via the shared tlsconn module.  On
+	 * success c takes ownership of fd (tlsconn_close closes it); on
+	 * failure we still own fd and the close()-on-out path runs.  tlserr is
+	 * a heap-owned libtls reason -- splice it into *errstr so the user
+	 * sees the real cause (expired cert, name mismatch, etc.) instead of
+	 * the historical canned literal "TLS handshake failed".
+	 */
+	if (tlsconn_attach(&c, fs->tlscfg, fd, host, &tlserr) == -1) {
+		struct buf	msg;
+
+		buf_init(&msg);
+		buf_printf(&msg, "TLS handshake failed: %s",
+		    tlserr != NULL ? tlserr : "(no reason)");
+		buf_terminate(&msg);
+		*errstr = xstrdup(buf_cstr(&msg));
+		buf_free(&msg);
+		free(tlserr);
+		/* fd not transferred -- the out: label closes it. */
+		goto out;
+	}
+	fd = -1;		/* c.fd owns the socket now; out: must not close */
+
+	if (tlsconn_write_all(&c, req.data, req.len) == -1) {
+		*errstr = xstrdup("request write failed");
+		goto out;
+	}
+
+	for (;;) {				/* read the response */
+		n = tlsconn_read(&c, rbuf, sizeof(rbuf));
+		if (n == 0) {
+			http_resp_eof(r);
+			done = 1;
+			break;
+		}
+		if (n < 0) {
+			*errstr = xstrdup("connection read failed");
+			goto out;
+		}
+		if (http_resp_push(r, rbuf, (size_t)n) == -1) {
+			*errstr = xstrdup("malformed HTTP response");
+			goto out;
+		}
+		if (sink.truncated || http_resp_done(r)) {	/* cap reached */
+			done = 1;
+			break;
+		}
+	}
+out:
+	*truncated = sink.truncated;
+	tlsconn_close(&c);		/* idempotent; no-op if attach failed */
+	if (fd != -1)			/* attach failed -- caller's fd never moved */
+		close(fd);
+	buf_free(&req);
+	return (done ? 0 : -1);
+}
+
+static void
+fetch_body(const void *data, size_t len, void *arg)
+{
+	struct fetchsink	*s = arg;
+	size_t			 room, take;
+
+	if (s->body->len >= s->cap) {
+		s->truncated = 1;
+		return;
+	}
+	room = s->cap - s->body->len;
+	take = len > room ? room : len;
+	buf_append(s->body, data, take);
+	if (take < len)
+		s->truncated = 1;
+}
+
+static void
+emit_result(struct fetchstate *fs, const char *id, struct buf *content,
+    int is_error)
+{
+	struct json_writer	w;
+	struct buf		b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id);
+	json_key(&w, "content");
+	json_string_n(&w, content->len > 0 ? content->data : "", content->len);
+	json_kv_bool(&w, "is_error", is_error);
+	json_object_end(&w);
+
+	if (ip_compose(fs->ip, M_TOOL_RESULT, EP_NET, EP_FETCH, b.data,
+	    b.len) == -1 || ip_flush(fs->ip) == -1)
+		fatalx("fetch: failed to send result");
+	buf_free(&b);
+}
+
+static char *
+obj_getstr(struct json_doc *d, int obj, const char *key)
+{
+	int	t;
+
+	if (obj < 0)
+		return (NULL);
+	if ((t = json_obj_get(d, obj, key)) < 0 ||
+	    json_tok_type(d, t) != JSON_STRING)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
+
+static void
+state_clear(struct fetchstate *fs)
+{
+	if (fs->token != NULL)
+		freezero(fs->token, strlen(fs->token));
+	free(fs->cafile);
+	free(fs->kagi_host);
+	free(fs->kagi_port);
+	free(fs->http_allow);
+	free(fs->http_block);
+	if (fs->tlscfg != NULL)
+		tls_config_free(fs->tlscfg);
+	memset(fs, 0, sizeof(*fs));
+}
+
+/* ------------------------------------------------------------------------ */
+/* Inlined url module (was src/common/url.c).                               */
+/* ------------------------------------------------------------------------ */
+
+static int
+url_parse(const char *in, struct url *u)
+{
+	const char	*p, *aend, *a, *at, *colon, *rb, *hp;
+	const char	*errstr;
+	char		*h;
+	size_t		 alen, hlen, plen, i;
+
+	memset(u, 0, sizeof(*u));
+	if (strncasecmp(in, "https://", 8) == 0) {
+		u->https = 1;
+		p = in + 8;
+	} else if (strncasecmp(in, "http://", 7) == 0) {
+		u->https = 0;
+		p = in + 7;
+	} else
+		return (-1);
+
+	alen = strcspn(p, "/?#");	/* authority ends at path/query/frag */
+	aend = p + alen;
+
+	/* Path + query (root-synthesised when absent); the fragment is dropped. */
+	if (*aend == '/') {
+		if (strlcpy(u->path, aend, sizeof(u->path)) >= sizeof(u->path))
+			return (-1);
+	} else {
+		u->path[0] = '/';
+		u->path[1] = '\0';
+		if (*aend == '?' &&
+		    strlcat(u->path, aend, sizeof(u->path)) >= sizeof(u->path))
+			return (-1);
+	}
+	if ((h = strchr(u->path, '#')) != NULL)
+		*h = '\0';
+
+	/* Authority: optional userinfo@, host (maybe [IPv6]), optional :port. */
+	a = p;
+	if ((at = memchr(p, '@', alen)) != NULL)
+		a = at + 1;
+
+	if (*a == '[') {
+		if ((rb = memchr(a, ']', (size_t)(aend - a))) == NULL)
+			return (-1);
+		hp = a + 1;
+		hlen = (size_t)(rb - (a + 1));
+		a = rb + 1;
+	} else {
+		colon = memchr(a, ':', (size_t)(aend - a));
+		hp = a;
+		hlen = (size_t)((colon != NULL ? colon : aend) - a);
+		a = (colon != NULL ? colon : aend);
+	}
+	if (hlen == 0 || hlen >= sizeof(u->host))
+		return (-1);
+	memcpy(u->host, hp, hlen);
+	u->host[hlen] = '\0';
+
+	if (a < aend && *a == ':') {
+		a++;
+		plen = (size_t)(aend - a);
+		if (plen == 0 || plen >= sizeof(u->port))
+			return (-1);
+		memcpy(u->port, a, plen);
+		u->port[plen] = '\0';
+		(void)strtonum(u->port, 1, 65535, &errstr);
+		if (errstr != NULL)
+			return (-1);
+	} else if (a < aend)
+		return (-1);		/* trailing junk after a literal host */
+	else
+		(void)strlcpy(u->port, u->https ? "443" : "80",
+		    sizeof(u->port));
+
+	/* Host may only contain host characters (no header smuggling). */
+	for (i = 0; u->host[i] != '\0'; i++) {
+		unsigned char hc = (unsigned char)u->host[i];
+
+		if (!(isalnum(hc) || hc == '.' || hc == '-' || hc == ':'))
+			return (-1);
+	}
+	/* Path may not contain a space or any control byte (no request smuggling). */
+	for (i = 0; u->path[i] != '\0'; i++) {
+		unsigned char pc = (unsigned char)u->path[i];
+
+		if (pc <= 0x20 || pc == 0x7f)
+			return (-1);
+	}
+	return (0);
+}
+
+/* ------------------------------------------------------------------------ */
+/* Inlined httpacl module (was src/common/httpacl.c).                      */
+/* ------------------------------------------------------------------------ */
+
+static int
+acl_is_ws(int c)
+{
+	return (c == ' ' || c == '\t' || c == '\n' || c == '\r');
+}
+
+/*
+ * Case-insensitive match of host against the host part of one pattern token.
+ * A leading '.' makes it a domain suffix: ".example.com" matches both the bare
+ * "example.com" and any "*.example.com".
+ */
+static int
+acl_host_match(const char *pat, size_t patlen, const char *host)
+{
+	size_t	hlen = strlen(host);
+
+	if (patlen == 0)
+		return (0);
+	if (pat[0] == '.') {
+		const char	*bare = pat + 1;
+		size_t		 barelen = patlen - 1;
+
+		if (barelen == 0)
+			return (0);
+		/* exact match against the bare domain "example.com" */
+		if (hlen == barelen && strncasecmp(host, bare, barelen) == 0)
+			return (1);
+		/* suffix match: host ends with ".example.com" */
+		if (hlen > patlen &&
+		    strncasecmp(host + (hlen - patlen), pat, patlen) == 0)
+			return (1);
+		return (0);
+	}
+	return (hlen == patlen && strncasecmp(host, pat, patlen) == 0);
+}
+
+/*
+ * Path-prefix match on a path-segment boundary: pref matches path iff path
+ * starts with pref AND the next byte is end-of-string or a '/'.  Without the
+ * boundary check, pattern "v1" would also match "v1admin/secrets", letting a
+ * sibling path slip past the ACL.
+ */
+static int
+acl_path_prefix_match(const char *path, const char *pref, size_t preflen)
+{
+	if (strncmp(path, pref, preflen) != 0)
+		return (0);
+	return (path[preflen] == '\0' || path[preflen] == '/' ||
+	    path[preflen] == '?' || path[preflen] == '#');
+}
+
+/* Does host/path match any whitespace-separated token in list? */
+static int
+acl_list_match(const char *list, const char *host, const char *path)
+{
+	const char	*p = list;
+
+	if (list == NULL)
+		return (0);
+	while (*p != '\0') {
+		const char	*tok, *slash;
+		size_t		 toklen, hostlen;
+
+		while (acl_is_ws((unsigned char)*p))
+			p++;
+		if (*p == '\0')
+			break;
+		tok = p;
+		while (*p != '\0' && !acl_is_ws((unsigned char)*p))
+			p++;
+		toklen = (size_t)(p - tok);
+
+		/* split host[/pathprefix] on the first '/' */
+		slash = memchr(tok, '/', toklen);
+		hostlen = (slash != NULL) ? (size_t)(slash - tok) : toklen;
+
+		if (!acl_host_match(tok, hostlen, host))
+			continue;
+		if (slash == NULL)
+			return (1);		/* host-only pattern: any path */
+
+		/*
+		 * Path-prefix match.  Request paths begin with '/'; compare the
+		 * pattern's prefix against the path with that leading '/' dropped
+		 * so "host/v1" matches the path "/v1/...".  Anchored to a segment
+		 * boundary so "host/v1" does not match "/v1admin/secrets".
+		 */
+		{
+			const char	*pref = slash + 1;
+			size_t		 preflen = toklen - hostlen - 1;
+			const char	*rp;
+
+			if (path == NULL)
+				continue;
+			rp = (path[0] == '/') ? path + 1 : path;
+			if (acl_path_prefix_match(rp, pref, preflen))
+				return (1);
+		}
+	}
+	return (0);
+}
+
+static int
+httpacl_allowed(const char *allow, const char *block, const char *host,
+    const char *path)
+{
+	if (host == NULL || *host == '\0')
+		return (0);
+	if (!acl_list_match(allow, host, path))
+		return (0);			/* default-deny */
+	if (acl_list_match(block, host, path))
+		return (0);			/* block wins */
+	return (1);
+}
+
blob - /dev/null
blob + 9f7f981556fd4e75401496035c9765f2cd3e869f (mode 644)
--- /dev/null
+++ src/fetch/fetch_run.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_FETCH_RUN_H
+#define FUGU_FETCH_RUN_H
+
+#include "imsgproto.h"
+
+/*
+ * Service web_search/web_fetch tool requests on the imsg channel to the parent.
+ * Holds only the Kagi token; pledges "stdio inet dns" once the CA bundle is
+ * loaded.  Returns 0 on a clean exit.
+ */
+int	fetch_loop(struct imsgproto *);
+
+#endif /* FUGU_FETCH_RUN_H */
blob - /dev/null
blob + 25bb5bd212a7627c35d0541f0386d45c2698d799 (mode 644)
--- /dev/null
+++ src/fetch/kagi.c
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdlib.h>
+
+#include "buf.h"
+#include "json.h"
+#include "kagi.h"
+
+#define KAGI_TITLE_MAX		256	/* cap each field so one hostile	*/
+#define KAGI_URL_MAX		1024	/* result cannot flood the model	*/
+#define KAGI_SNIPPET_MAX	2048
+
+static char	*field(struct json_doc *, int, const char *);
+
+void
+kagi_search_body(struct buf *body, const char *query)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, body);
+	json_object_start(&w);
+	json_kv_string(&w, "query", query);	/* JSON-escaped by the writer */
+	json_kv_int(&w, "limit", KAGI_MAX_RESULTS);
+	json_object_end(&w);
+}
+
+int
+kagi_format_results(const char *json, size_t len, struct buf *out)
+{
+	struct json_doc	d;
+	int		data, search, err, n, e, i, count = 0;
+
+	if (json_parse(&d, json, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		buf_puts_cstr(out, "web_search: malformed response from Kagi");
+		json_doc_free(&d);
+		return (-1);
+	}
+
+	/* v1 error envelope: {"errors":[{"code":..,"message":".."}], ...}. */
+	if ((err = json_obj_get(&d, 0, "errors")) >= 0) {
+		char	*msg = NULL;
+
+		if (json_tok_type(&d, err) == JSON_ARRAY &&
+		    json_tok_size(&d, err) > 0)
+			msg = field(&d, err + 1, "message");
+		buf_printf(out, "web_search: Kagi API error%s%s",
+		    msg != NULL ? ": " : "", msg != NULL ? msg : "");
+		free(msg);
+		json_doc_free(&d);
+		return (-1);
+	}
+
+	/* v1 success: {"data":{"search":[{title,url,snippet}],"related_search":..}} */
+	data = json_obj_get(&d, 0, "data");
+	if (data < 0 || json_tok_type(&d, data) != JSON_OBJECT) {
+		buf_puts_cstr(out, "web_search: unexpected response shape from Kagi");
+		json_doc_free(&d);
+		return (-1);
+	}
+	search = json_obj_get(&d, data, "search");
+	if (search < 0 || json_tok_type(&d, search) != JSON_ARRAY) {
+		buf_puts_cstr(out, "No results.");	/* answer-only or empty result set */
+		json_doc_free(&d);
+		return (0);
+	}
+
+	n = json_tok_size(&d, search);
+	e = search + 1;
+	for (i = 0; i < n; i++) {
+		if (json_tok_type(&d, e) == JSON_OBJECT &&
+		    count < KAGI_MAX_RESULTS) {
+			char	*title = field(&d, e, "title");
+			char	*u = field(&d, e, "url");
+			char	*snip = field(&d, e, "snippet");
+
+			count++;
+			buf_printf(out, "%d. %.*s\n", count, KAGI_TITLE_MAX,
+			    title != NULL ? title : "(untitled)");
+			if (u != NULL)
+				buf_printf(out, "   %.*s\n", KAGI_URL_MAX, u);
+			if (snip != NULL)
+				buf_printf(out, "   %.*s\n", KAGI_SNIPPET_MAX,
+				    snip);
+			buf_putc(out, '\n');
+			free(title);
+			free(u);
+			free(snip);
+		}
+		e = json_skip(&d, e);
+	}
+
+	if (count == 0)
+		buf_puts_cstr(out, "No results.");
+	json_doc_free(&d);
+	return (0);
+}
+
+char *
+kagi_error_msg(const char *json, size_t len)
+{
+	struct json_doc	d;
+	char		*msg = NULL;
+	int		 err;
+
+	if (json_parse(&d, json, len) == 0 && d.ntok >= 1 &&
+	    json_tok_type(&d, 0) == JSON_OBJECT &&
+	    (err = json_obj_get(&d, 0, "errors")) >= 0 &&
+	    json_tok_type(&d, err) == JSON_ARRAY &&
+	    json_tok_size(&d, err) > 0)
+		msg = field(&d, err + 1, "message");
+	json_doc_free(&d);
+	return (msg);
+}
+
+static char *
+field(struct json_doc *d, int obj, const char *key)
+{
+	int	t;
+
+	if (obj < 0)
+		return (NULL);
+	if ((t = json_obj_get(d, obj, key)) < 0 ||
+	    json_tok_type(d, t) != JSON_STRING)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
blob - /dev/null
blob + f142d86ba582980ad31d6403a68493dd988400bb (mode 644)
--- /dev/null
+++ src/fetch/kagi.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * kagi: build a Kagi Search API request and render its JSON response into the
+ * text the model sees.  The HTTP transport and the Authorization header live in
+ * the fetch worker; this file is the pure request-path/response-shape logic.
+ */
+
+#ifndef FUGU_KAGI_H
+#define FUGU_KAGI_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+#define KAGI_HOST		"kagi.com"
+#define KAGI_SEARCH_PATH	"/api/v1/search"	/* POST, JSON body */
+#define KAGI_MAX_RESULTS	10
+
+/* Build the v1 POST request body: {"query":"<query>","limit":N}. */
+void	kagi_search_body(struct buf *body, const char *query);
+
+/*
+ * Render a Kagi v1 search response (the JSON body) into out as a numbered list
+ * of title/url/snippet drawn from data.search[].  0 on success (out holds the
+ * listing or "No results."), -1 if the body is not a well-formed success
+ * envelope (out then holds a short error note).
+ */
+int	kagi_format_results(const char *json, size_t len, struct buf *out);
+
+/*
+ * Extract the message from a Kagi v1 error envelope ({"errors":[{"message":
+ * ".."}]}), used to explain a non-200 reply.  Returns a malloc'd string the
+ * caller frees, or NULL if the body is not such an envelope.
+ */
+char	*kagi_error_msg(const char *json, size_t len);
+
+#endif /* FUGU_KAGI_H */
blob - /dev/null
blob + a9244b9eb7581aa6b0c04bf8efe44fa891551287 (mode 644)
--- /dev/null
+++ src/net/Makefile
@@ -0,0 +1,10 @@
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG=	fugu-net
+SRCS+=	net.c net_run.c net_api.c http.c sse.c anthropic.c openai.c tlsconn.c
+BINDIR=	${PREFIX}/libexec/fugu
+LDADD+=	-ltls
+DPADD+=	${LIBTLS}
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 7d6415b7f9ab4469c5db41c2df01d52e451cf5f2 (mode 644)
--- /dev/null
+++ src/net/net.c
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-net: the network worker (${PREFIX}/libexec/fugu/fugu-net).
+ *
+ * Holds the Anthropic API key (received from the parent over imsg, never read
+ * from a file) and speaks HTTPS to api.anthropic.com via libtls, driving the
+ * streaming agent loop under pledge "stdio inet dns". No exec, no curses.
+ *
+ * The parent passes the imsg channel on fd FUGU_NET_IMSG_FD; the worker is not
+ * meant to be run directly.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "imsgproto.h"
+#include "log.h"
+#include "net_run.h"
+#include "util.h"
+
+#define FUGU_NET_IMSG_FD	3
+
+int
+main(int argc, char *argv[])
+{
+	struct imsgproto	ip;
+	struct stat		st;
+	extern char		*__progname;
+	int			ch, debug = 0, verbose = 0;
+
+	while ((ch = getopt(argc, argv, "dv")) != -1) {
+		switch (ch) {
+		case 'd':
+			debug = 1;
+			break;
+		case 'v':
+			verbose++;
+			break;
+		default:
+			break;
+		}
+	}
+
+	log_init(debug ? LOG_TO_STDERR : LOG_TO_SYSLOG, verbose, LOG_DAEMON);
+	signal(SIGPIPE, SIG_IGN);	/* a peer hangup must not kill us */
+	drop_privgroup();		/* never need the parent's setgid _fugu group */
+
+	if (fstat(FUGU_NET_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+		fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+		    "directly\n", __progname);
+		return (1);
+	}
+	if (ip_init(&ip, FUGU_NET_IMSG_FD) == -1)
+		fatal("ip_init");
+
+	return (net_loop(&ip) == 0 ? 0 : 1);
+}
blob - /dev/null
blob + ed088fe4c3c9b3c8e1102e282467048ebd5d5b1c (mode 644)
--- /dev/null
+++ src/net/net_api.c
@@ -0,0 +1,531 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The transport seam: one streamed Messages call (net_api_call) and the models
+ * endpoint (net_fetch_models) per provider.  Both terminate at a struct buf the
+ * caller owns -- no side-channel through netstate -- so the agent loop stays
+ * unaware of HTTP, TLS, SSE, or the provider's wire format.  The provider's
+ * ops table fans out request building and stream decoding; the semantic
+ * callbacks below relay text/tool_use/usage/stop to the UI and back into the
+ * turncollect the caller passed in.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "http.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "net_api.h"
+#include "net_run.h"
+#include "provider.h"
+#include "provider_ops.h"
+#include "sse.h"
+#include "tlsconn.h"
+#include "util.h"
+
+#define NET_API_VERSION		"2023-06-01"
+#define MODELS_MAXBODY		(1U << 20)	/* untrusted models body cap */
+
+/* Body sink: a 200 response feeds the SSE parser, otherwise capture the error. */
+struct bodysink {
+	struct http_resp	*r;
+	struct sse_parser	*sse;
+	struct buf		 err;
+};
+
+/* Route each SSE event to the active provider's stream decoder. */
+struct streamctx {
+	const struct net_provider	*prov;
+	union llm_stream_storage	*ctx;	/* the provider's stream object */
+};
+
+/*
+ * Bounded sink for the untrusted (non-streamed) models response body: append
+ * until the cap, then flag truncation -- a hostile or runaway server must not
+ * be able to grow the worker's heap without limit.  Mirrors the fetch worker's
+ * FETCH_MAXBODY guard.
+ */
+struct models_sink {
+	struct buf	*buf;
+	size_t		 cap;
+	int		 truncated;
+};
+
+/*
+ * Per-call context passed to every semantic callback.  Bundles the netstate
+ * (for emitting deltas, errors, and reading the summarizing flag) with the
+ * turncollect to populate -- one allocation per call, on the agent loop's
+ * stack, threaded as the callback arg via stream_init.
+ */
+struct callctx {
+	struct netstate		*ns;
+	struct turncollect	*tc;
+};
+
+static void	net_body(const void *, size_t, void *);
+static void	sse_to_provider(const char *, const char *, size_t, void *);
+
+static void	on_text(const char *, size_t, void *);
+static void	on_tool_call(const char *, const char *, const char *, size_t,
+		    void *);
+static void	on_usage(long long, long long, void *);
+static void	on_stop(const char *, void *);
+static void	on_err(const char *, const char *, void *);
+static void	on_done(void *);
+
+static void	models_body(const void *, size_t, void *);
+static void	net_models_path(struct net_provider *, struct buf *);
+
+static const struct llm_cbs NET_CBS = {
+	on_text, on_tool_call, on_usage, on_stop, on_err, on_done
+};
+
+void
+turncollect_init(struct turncollect *tc)
+{
+	memset(tc, 0, sizeof(*tc));
+	buf_init(&tc->text);
+}
+
+void
+turncollect_free(struct turncollect *tc)
+{
+	size_t	i;
+
+	buf_free(&tc->text);
+	for (i = 0; i < tc->ntools; i++) {
+		free(tc->tools[i].id);
+		free(tc->tools[i].name);
+		buf_free(&tc->tools[i].input);
+	}
+	free(tc->tools);
+	free(tc->stop_reason);
+	memset(tc, 0, sizeof(*tc));
+}
+
+/* One streamed Messages call.  Results land in *out; out->error on parse fault. */
+int
+net_api_call(struct netstate *ns, struct turncollect *out, struct buf *conv,
+    const char *system, const char *tools)
+{
+	struct http_header		 h[4];
+	struct tlsconn			 c;
+	struct http_resp		 r;
+	struct sse_parser		 p;
+	union llm_stream_storage	 stream;
+	struct streamctx		 sc;
+	struct bodysink			 bs;
+	struct callctx			 cc;
+	struct buf			 body, req, authval;
+	struct net_provider		*prov = net_active(ns);
+	const char			*path = NULL;
+	char				*err = NULL;
+	char				 rbuf[4096];
+	int				 nh = 0, rc = -1;
+	ssize_t				 n;
+
+	if (prov == NULL) {		/* net_run_turn guarantees this; be safe */
+		net_emit_error(ns, "auth", "no provider");
+		return (-1);
+	}
+
+	buf_terminate(conv);		/* NUL-terminate for the raw splice */
+
+	buf_init(&body);
+	buf_init(&req);
+	buf_init(&authval);
+
+	/*
+	 * Both halves -- request body and, below, response decoding -- are the
+	 * only provider-specific parts; everything around them is shared.
+	 */
+	prov->ops->prep_request(&body, h, 4, &nh, &authval, &path, prov->key,
+	    prov->model, ns->max_tokens, system, conv->data, tools);
+	if (prov->path != NULL)		/* explicit override wins over the default */
+		path = prov->path;
+	http_build_request(&req, "POST", prov->host, path, h, nh,
+	    body.data, body.len);
+	buf_freezero(&authval);		/* held the bearer token; scrub it */
+
+	if (tlsconn_connect(&c, ns->tlscfg, prov->host, prov->port, &err) == -1) {
+		net_emit_error(ns, "connect", err);
+		free(err);		/* tlsconn_connect hands ownership over */
+		buf_free(&body);
+		buf_freezero(&req);	/* embeds the auth header */
+		return (-1);
+	}
+	if (tlsconn_write_all(&c, req.data, req.len) == -1) {
+		net_emit_error(ns, "write", "request failed");
+		goto done;
+	}
+
+	cc.ns = ns;
+	cc.tc = out;
+	prov->ops->stream_init(&stream, &NET_CBS, &cc);
+	sc.prov = prov;
+	sc.ctx = &stream;
+	sse_init(&p, sse_to_provider, &sc);
+	bs.r = &r;
+	bs.sse = &p;
+	buf_init(&bs.err);
+	http_resp_init(&r, net_body, &bs);
+
+	for (;;) {
+		n = tlsconn_read(&c, rbuf, sizeof(rbuf));
+		if (n == 0) {
+			http_resp_eof(&r);
+			break;
+		}
+		if (n == -1) {
+			net_emit_error(ns, "read", "connection failed");
+			out->error = 1;
+			break;
+		}
+		if (http_resp_push(&r, rbuf, (size_t)n) == -1) {
+			net_emit_error(ns, "http", "malformed response");
+			out->error = 1;
+			break;
+		}
+		if (http_resp_done(&r))
+			break;
+	}
+	if (r.status != 0 && r.status != 200 && !out->error) {
+		struct buf	e;
+		int		elen = bs.err.len > 600 ? 600 : (int)bs.err.len;
+
+		buf_init(&e);
+		buf_printf(&e, "HTTP %d: %.*s", r.status, elen, bs.err.data);
+		net_emit(ns, M_ERROR, EP_UI, e.data, e.len);
+		buf_free(&e);
+		out->error = 1;
+	}
+
+	buf_free(&bs.err);
+	prov->ops->stream_clear(&stream);
+	sse_clear(&p);
+	http_resp_clear(&r);
+	rc = 0;
+done:
+	tlsconn_close(&c);
+	buf_free(&body);
+	buf_freezero(&req);	/* embeds the auth header */
+	return (rc);
+}
+
+/*
+ * GET the provider's models endpoint, collect the body, and parse the ids into
+ * out.  0 on success; -1 (with an M_ERROR already emitted) on failure.
+ */
+int
+net_fetch_models(struct netstate *ns, struct net_provider *p, struct buf *out)
+{
+	struct http_header	h[3];
+	struct tlsconn		c;
+	struct http_resp	r;
+	struct buf		path, authval, req, body;
+	struct models_sink	sink;
+	char			*err = NULL;
+	char			rbuf[4096];
+	ssize_t			n;
+	int			nh, rc = -1;
+
+	buf_init(&path);
+	buf_init(&authval);
+	buf_init(&req);
+	buf_init(&body);
+	net_models_path(p, &path);
+
+	if (p->type == LLM_OPENAI) {
+		buf_printf(&authval, "Bearer %s", p->key);
+		buf_terminate(&authval);
+		h[0].name = "authorization";	h[0].value = authval.data;
+		h[1].name = "accept";		h[1].value = "application/json";
+		nh = 2;
+	} else {
+		h[0].name = "x-api-key";	  h[0].value = p->key;
+		h[1].name = "anthropic-version";  h[1].value = NET_API_VERSION;
+		h[2].name = "accept";		  h[2].value = "application/json";
+		nh = 3;
+	}
+	http_build_request(&req, "GET", p->host, path.data, h, nh, NULL, 0);
+	buf_freezero(&authval);		/* held the bearer token */
+
+	if (tlsconn_connect(&c, ns->tlscfg, p->host, p->port, &err) == -1) {
+		net_emit_error(ns, "connect", err);
+		free(err);		/* tlsconn_connect hands ownership over */
+		goto out;
+	}
+	if (tlsconn_write_all(&c, req.data, req.len) == -1) {
+		net_emit_error(ns, "write", "request failed");
+		goto close;
+	}
+
+	sink.buf = &body;
+	sink.cap = MODELS_MAXBODY;
+	sink.truncated = 0;
+	http_resp_init(&r, models_body, &sink);
+	for (;;) {
+		n = tlsconn_read(&c, rbuf, sizeof(rbuf));
+		if (n == 0) {
+			http_resp_eof(&r);
+			break;
+		}
+		if (n == -1) {
+			net_emit_error(ns, "read", "connection failed");
+			http_resp_clear(&r);
+			goto close;
+		}
+		if (http_resp_push(&r, rbuf, (size_t)n) == -1) {
+			net_emit_error(ns, "http", "malformed response");
+			http_resp_clear(&r);
+			goto close;
+		}
+		if (http_resp_done(&r) || sink.truncated)
+			break;		/* over the cap: stop reading this server */
+	}
+
+	if (r.status != 200) {
+		struct buf	e;
+		int		elen = body.len > 400 ? 400 : (int)body.len;
+
+		buf_init(&e);
+		buf_printf(&e, "models: HTTP %d: %.*s", r.status, elen,
+		    body.data != NULL ? body.data : "");
+		net_emit(ns, M_ERROR, EP_UI, e.data, e.len);
+		buf_free(&e);
+		http_resp_clear(&r);
+		goto close;
+	}
+	if (sink.truncated) {
+		net_emit_error(ns, "models", "response too large");
+		http_resp_clear(&r);
+		goto close;
+	}
+
+	net_parse_models(body.data, body.len, out);
+	http_resp_clear(&r);
+	rc = 0;
+close:
+	tlsconn_close(&c);
+out:
+	buf_free(&path);
+	buf_freezero(&req);	/* embeds the auth header; scrub it */
+	buf_free(&body);
+	return (rc);
+}
+
+/*
+ * The models endpoint sits beside the chat endpoint: derive it by swapping the
+ * chat path's final segment(s) for "models" (".../chat/completions" or
+ * ".../messages" -> ".../models").  Falls back to "/v1/models".
+ */
+static void
+net_models_path(struct net_provider *p, struct buf *out)
+{
+	const char	*chat = p->path != NULL ? p->path :
+	    p->ops->models_path();
+	const char	*suffix = strstr(chat, "chat/completions") != NULL ?
+	    "chat/completions" : "messages";
+	const char	*at = strstr(chat, suffix);
+
+	if (at != NULL) {
+		buf_append(out, chat, (size_t)(at - chat));
+		buf_puts_cstr(out, "models");
+	} else
+		buf_puts_cstr(out, "/v1/models");
+	buf_terminate(out);		/* NUL-terminate for http_build_request */
+}
+
+/*
+ * Parse a models-list response (both providers return {"data":[{"id":...}]})
+ * into out as newline-separated ids.  Exposed for testing.
+ */
+void
+net_parse_models(const char *json, size_t len, struct buf *out)
+{
+	struct json_doc	d;
+	int		data, n, e, i;
+
+	if (json_parse(&d, json, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		return;
+	}
+	data = json_obj_get(&d, 0, "data");
+	if (data < 0 || json_tok_type(&d, data) != JSON_ARRAY) {
+		json_doc_free(&d);
+		return;
+	}
+	n = json_tok_size(&d, data);
+	e = data + 1;
+	for (i = 0; i < n; i++) {
+		if (json_tok_type(&d, e) == JSON_OBJECT) {
+			int	id = json_obj_get(&d, e, "id");
+
+			if (id >= 0 && json_tok_type(&d, id) == JSON_STRING) {
+				char	*s = json_tok_strdup(&d, id);
+
+				if (s != NULL) {
+					if (out->len > 0)
+						buf_putc(out, '\n');
+					buf_puts_cstr(out, s);
+					free(s);
+				}
+			}
+		}
+		e = json_skip(&d, e);
+	}
+	json_doc_free(&d);
+}
+
+/* ---- response pipeline trampolines ---- */
+
+static void
+net_body(const void *data, size_t len, void *arg)
+{
+	struct bodysink	*bs = arg;
+
+	if (bs->r->status == 200)
+		(void)sse_push(bs->sse, data, len);
+	else
+		buf_append(&bs->err, data, len);
+}
+
+static void
+sse_to_provider(const char *event, const char *data, size_t datalen, void *arg)
+{
+	struct streamctx	*sc = arg;
+
+	sc->prov->ops->stream_event(sc->ctx, event, data, datalen);
+}
+
+static void
+models_body(const void *data, size_t len, void *arg)
+{
+	struct models_sink	*s = arg;
+
+	if (s->truncated)
+		return;
+	if (s->buf->len + len > s->cap) {
+		len = s->buf->len < s->cap ? s->cap - s->buf->len : 0;
+		s->truncated = 1;
+	}
+	if (len > 0)
+		buf_append(s->buf, data, len);
+}
+
+/* ---- semantic stream callbacks: relay to the UI and collect for the loop --- */
+
+static void
+on_text(const char *text, size_t len, void *arg)
+{
+	struct callctx	*cc = arg;
+
+	if (!cc->ns->summarizing)	/* a /compact reply is not transcript text */
+		net_emit(cc->ns, M_DELTA, EP_UI, text, len);
+	buf_append(&cc->tc->text, text, len);
+}
+
+static void
+on_tool_call(const char *id, const char *name, const char *input_json,
+    size_t len, void *arg)
+{
+	struct callctx		*cc = arg;
+	struct turncollect	*tc = cc->tc;
+	struct toolcall		*t;
+	struct json_writer	 w;
+	struct buf		 b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id);
+	json_kv_string(&w, "name", name);
+	json_key(&w, "input");
+	json_raw_n(&w, input_json, len);
+	json_object_end(&w);
+	net_emit(cc->ns, M_TOOL_CALL, EP_UI, b.data, b.len);
+	buf_free(&b);
+
+	tc->tools = xreallocarray(tc->tools, tc->ntools + 1,
+	    sizeof(*tc->tools));
+	t = &tc->tools[tc->ntools];
+	t->id = xstrdup(id);
+	t->name = xstrdup(name);
+	buf_init(&t->input);
+	/*
+	 * NUL-terminate the stored input so later json_raw() readers can use
+	 * it as a C string.  buf_puts_cstr() copies strlen() bytes without writing
+	 * a trailing NUL, leaving t->input.data[t->input.len] uninitialised.
+	 * Without this, a parallel batch of tool calls bleeds garbage from
+	 * adjacent heap into the assistant message's "input" field and
+	 * downstream M_TOOL_REQUEST payloads -- exec then rejects every call
+	 * as "malformed tool request".
+	 */
+	buf_append(&t->input, input_json, len);
+	buf_terminate(&t->input);
+	tc->ntools++;
+}
+
+static void
+on_usage(long long in, long long out, void *arg)
+{
+	struct callctx		*cc = arg;
+	struct json_writer	 w;
+	struct buf		 b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_int(&w, "input_tokens", in);
+	json_kv_int(&w, "output_tokens", out);
+	json_object_end(&w);
+	net_emit(cc->ns, M_USAGE, EP_UI, b.data, b.len);
+	buf_free(&b);
+
+	cc->tc->usage_in = in;
+	cc->tc->usage_out = out;
+}
+
+static void
+on_stop(const char *stop_reason, void *arg)
+{
+	struct callctx	*cc = arg;
+
+	free(cc->tc->stop_reason);
+	cc->tc->stop_reason = xstrdup(stop_reason);
+}
+
+static void
+on_err(const char *type, const char *message, void *arg)
+{
+	struct callctx	*cc = arg;
+
+	net_emit_error(cc->ns, type, message);
+	cc->tc->error = 1;
+}
+
+static void
+on_done(void *arg)
+{
+	(void)arg;	/* the agent loop decides when the turn is complete */
+}
blob - /dev/null
blob + 20b4a015802d8832e4d9bf57a202b79fa08d7442 (mode 644)
--- /dev/null
+++ src/net/net_api.h
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_NET_API_H
+#define FUGU_NET_API_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+#include "net_run.h"
+
+/*
+ * One tool_use block collected from a streamed assistant response.  id and name
+ * are NUL-terminated strdup'd copies; input holds the raw JSON object as bytes.
+ */
+struct toolcall {
+	char		*id;
+	char		*name;
+	struct buf	 input;
+};
+
+/*
+ * Everything one streamed API response yields: assistant text, any tool_use
+ * blocks, usage counters, stop reason, and an error flag set when a fatal
+ * problem was already emitted to the UI.  Allocated on the agent loop's stack
+ * and passed by pointer; turncollect_init / turncollect_free bracket its life.
+ */
+struct turncollect {
+	struct buf	 text;
+	struct toolcall	*tools;
+	size_t		 ntools;
+	long long	 usage_in;
+	long long	 usage_out;
+	char		*stop_reason;
+	int		 error;
+};
+
+void	turncollect_init(struct turncollect *);
+void	turncollect_free(struct turncollect *);
+
+/*
+ * One streamed API call against the active provider.  Builds the request from
+ * conv/system/tools, decodes the SSE stream into out via the provider's ops,
+ * and emits live deltas to the UI.  Returns 0 on transport success (including
+ * mid-stream parse failures, which set out->error) and -1 on connect/write/
+ * HTTP failure (an M_ERROR has already been emitted).
+ */
+int	net_api_call(struct netstate *, struct turncollect *out,
+	    struct buf *conv, const char *system, const char *tools);
+
+/*
+ * GET the provider's models endpoint, collect the body, and parse the ids into
+ * out as newline-separated strings.  0 on success; -1 (with an M_ERROR already
+ * emitted) on failure.
+ */
+int	net_fetch_models(struct netstate *, struct net_provider *,
+	    struct buf *out);
+
+/*
+ * Parse a provider models-list response ({"data":[{"id":...}]}) into out as
+ * newline-separated model ids.  Exposed for testing.
+ */
+void	net_parse_models(const char *json, size_t len, struct buf *out);
+
+#endif /* FUGU_NET_API_H */
blob - /dev/null
blob + ece65f6bc5280702212c708a5874131b26fac8d3 (mode 644)
--- /dev/null
+++ src/net/net_run.c
@@ -0,0 +1,931 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The net worker's heart: an imsg loop holding the Anthropic key.  For each
+ * submitted conversation it runs the agent loop -- call the Messages API
+ * (net_api.c), stream the reply to the UI, and while the model asks for tools,
+ * route each call to exec/fetch (via the parent), append the assistant tool_use
+ * and the user tool_result to the conversation, and call again -- until the
+ * model stops.
+ *
+ * Startup pledge: the caller has us at "stdio rpath inet dns" to read the CA
+ * bundle once; we then narrow to "stdio inet dns" and never touch a file again.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <tls.h>
+
+#include "anthropic.h"
+#include "buf.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "net_api.h"
+#include "net_run.h"
+#include "openai.h"
+#include "provider.h"
+#include "provider_ops.h"
+#include "tlsconn.h"
+#include "util.h"
+
+#define NET_DEFAULT_MODEL	"claude-sonnet-4-6"
+#define NET_DEFAULT_HOST	"api.anthropic.com"
+#define NET_OPENAI_HOST		"api.openai.com"
+#define NET_DEFAULT_PORT	"443"
+#define NET_DEFAULT_CAFILE	"/etc/ssl/cert.pem"
+#define NET_MAX_ITERS		50	/* tool-use rounds per turn */
+
+/*
+ * The tools the model may call; implementations live in the exec/fetch workers.
+ * The base (file/shell) tools are always offered; tools_schema() appends the web
+ * tools when web_search is enabled and the http_request tool when an http_allow
+ * list is configured.  No fragment is a valid array on its own -- the closing
+ * ']' is added at assembly time.
+ */
+static const char TOOLS_BASE[] =
+"[{\"name\":\"read\",\"description\":\"Read a file's contents. Use an absolute "
+"or project-relative path. You must read a file before you can edit it. To "
+"list a directory, use ls, not read.\",\"input_schema\":{\"type\":\"object\","
+"\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the "
+"file.\"}},\"required\":[\"path\"]}},"
+"{\"name\":\"write\",\"description\":\"Create a new file or completely "
+"overwrite an existing one. To change part of an existing file use edit "
+"instead, which only replaces the matched text. Prefer editing an existing "
+"file over creating a new one; do not create documentation unless asked.\","
+"\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":"
+"\"string\"},\"content\":{\"type\":\"string\"}},\"required\":[\"path\","
+"\"content\"]}},"
+"{\"name\":\"edit\",\"description\":\"Replace one exact occurrence of old_string "
+"with new_string in a file. You must read the file first. old_string must "
+"match the file byte-for-byte, including indentation, and must be unique -- "
+"include just enough surrounding lines to make it so, or the edit fails. Keep "
+"old_string minimal. Use write to create a new file.\",\"input_schema\":{"
+"\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},"
+"\"old_string\":{\"type\":\"string\"},\"new_string\":{\"type\":\"string\"}},"
+"\"required\":[\"path\",\"old_string\",\"new_string\"]}},"
+"{\"name\":\"shell\",\"description\":\"Run a command with /bin/ksh -c in the "
+"project directory and return its combined stdout and stderr. There is NO "
+"network access -- installs, fetches, and pushes fail; use web_fetch or "
+"web_search for the network. Prefer the dedicated tools (read, grep, find, ls) "
+"over their shell equivalents. Be careful with destructive commands.\","
+"\"input_schema\":{\"type\":\"object\",\"properties\":{"
+"\"command\":{\"type\":\"string\",\"description\":\"The shell command to "
+"run.\"}},\"required\":[\"command\"]}},"
+"{\"name\":\"grep\",\"description\":\"Search file contents for a regular "
+"expression (grep -RnI), returning matching lines with file and line numbers. "
+"Use this to find where something is defined or used. To find files by name, "
+"use find instead.\",\"input_schema\":{\"type\":\"object\",\"properties\":{"
+"\"pattern\":{\"type\":\"string\",\"description\":\"The regular expression to "
+"search for.\"},\"path\":{\"type\":\"string\",\"description\":\"File or "
+"directory to search; defaults to the current directory.\"}},\"required\":["
+"\"pattern\"]}},"
+"{\"name\":\"find\",\"description\":\"List files under a directory, optionally "
+"filtered by a -name glob. Use to locate files by name; to search inside "
+"files, use grep.\",\"input_schema\":{\"type\":\"object\","
+"\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Directory to "
+"search; defaults to the current directory.\"},\"name\":{\"type\":\"string\","
+"\"description\":\"Optional -name glob pattern, e.g. '*.c'.\"}}}},"
+"{\"name\":\"ls\",\"description\":\"List directory contents (ls -la), including "
+"permissions and sizes. read cannot list directories.\","
+"\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":"
+"\"string\",\"description\":\"File or directory to list; defaults to the "
+"current directory.\"}}}}";
+
+/* Appended to TOOLS_BASE when web_search is enabled; note the leading comma. */
+static const char TOOLS_WEB[] =
+",{\"name\":\"web_search\",\"description\":\"Search the web via Kagi and return "
+"result titles, URLs, and snippets. Use for current information or docs beyond "
+"your knowledge; follow a result with web_fetch to read it.\",\"input_schema\":"
+"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\","
+"\"description\":\"The search query.\"}},\"required\":[\"query\"]}},"
+"{\"name\":\"web_fetch\",\"description\":\"Fetch an http(s) URL and return its "
+"readable text (HTML converted to plain text). Read-only; cannot reach "
+"authenticated or private pages.\",\"input_schema\":{\"type\":"
+"\"object\",\"properties\":{\"url\":{\"type\":\"string\",\"description\":\"The "
+"absolute http or https URL to fetch.\"}},\"required\":[\"url\"]}}";
+
+/*
+ * Appended when http_request is enabled (an http_allow list is configured).
+ * A general HTTPS client the model drives: arbitrary method, model-supplied
+ * headers, and a body.  The fetch worker enforces the host allow/block lists,
+ * so the description tells the model the reachable set is restricted.
+ */
+static const char TOOLS_HTTP[] =
+",{\"name\":\"http_request\",\"description\":\"Make an HTTPS request to an "
+"allowed host (the operator restricts which hosts are reachable; you will get "
+"an error otherwise). Use for JSON/REST APIs. Returns the status line and "
+"response body.\",\"input_schema\":{\"type\":\"object\",\"properties\":{"
+"\"url\":{\"type\":\"string\",\"description\":\"Absolute https URL.\"},"
+"\"method\":{\"type\":\"string\",\"description\":\"HTTP method; defaults to "
+"GET.\"},\"headers\":{\"type\":\"object\",\"description\":\"Optional request "
+"headers as a name/value object.\"},\"body\":{\"type\":\"string\","
+"\"description\":\"Optional request body (e.g. a JSON string).\"}},"
+"\"required\":[\"url\"]}}";
+
+/* Assemble the tool list: base tools always, web/http tools when enabled. */
+void
+tools_schema(struct buf *b, int web_search, int http_request, const char *extra)
+{
+	buf_puts_cstr(b, TOOLS_BASE);
+	if (web_search)
+		buf_puts_cstr(b, TOOLS_WEB);
+	if (http_request)
+		buf_puts_cstr(b, TOOLS_HTTP);
+	if (extra != NULL && *extra != '\0')
+		buf_puts_cstr(b, extra);	/* leading comma is part of the fragment */
+	buf_putc(b, ']');
+	buf_putc(b, '\0');		/* a C-string for the request builder */
+}
+
+/*
+ * Default system prompt, sent on every normal turn unless the config supplies
+ * its own `system`.  Kept short and provider-neutral: identity, the terse-tone
+ * doctrine, scope/style defaults that suit a minimal agent, tool guidance, and
+ * a one-line refusal posture.
+ */
+static const char NET_DEFAULT_SYSTEM[] =
+"You are fugu, a terminal coding assistant. You work on a Unix system, "
+"following OpenBSD conventions where they apply, in any language or stack.\n"
+"\n"
+"Tone. Be terse. No preamble or postamble -- do not restate the question or "
+"close with \"let me know if...\". Lead with the answer or outcome; add "
+"reasoning only when it changes what the reader does next. Be short by saying "
+"less, not by writing in fragments or arrow-chains -- use complete sentences. "
+"A simple question gets a one-line answer, not headings. Your output is "
+"rendered as Markdown in a terminal; reference code as file:line. No emojis "
+"unless asked.\n"
+"\n"
+"Doing tasks. Do what is asked -- no extra features, refactors, or "
+"abstractions. Match the surrounding code's style, naming, and idioms, and "
+"check a library is already used before reaching for it. Do not add comments "
+"unless the reason behind the code is non-obvious; never narrate what the code "
+"does. Delete dead code rather than leaving compatibility shims or "
+"commented-out blocks. Do not create documentation unless asked.\n"
+"\n"
+"Tools. Prefer the dedicated tools (read, grep, find, ls) over the shell, and "
+"run independent calls together. Read a file before editing it. Looking is "
+"free -- read and search freely -- but confirm before destructive or "
+"hard-to-reverse changes. Report results honestly: if a build or test fails, "
+"say so with the output.\n"
+"\n"
+"Security. Help with defensive security, authorized testing, and analysis. "
+"Refuse to build malware, mass-targeting, or destructive tooling.";
+
+/* System prompt for /compact: one tool-less call that condenses the history. */
+static const char SUMMARY_SYSTEM[] =
+"You are condensing a software-engineering chat session so it can continue with "
+"a smaller context window. Write a concise but complete summary that preserves "
+"the user's goals and constraints, the key decisions and their rationale, the "
+"current state of the work, the important file paths and identifiers, and any "
+"open questions or next steps. Address it to yourself as notes for continuing "
+"the conversation. Output only the summary, with no preamble or questions.";
+
+/* Empty tool list: the summarisation call must not invoke any tools. */
+static const char SUMMARY_TOOLS[] = "[]";
+
+static void	net_apply_config(struct netstate *, const void *, size_t);
+static void	net_ensure_ready(struct netstate *);
+static void	net_run_turn(struct netstate *, const char *, size_t);
+static void	net_summarize(struct netstate *, const char *, size_t);
+static void	net_list_models(struct netstate *);
+static void	net_set_model(struct netstate *, const void *, size_t);
+static void	net_providers_free(struct netstate *);
+static void	net_apply_providers(struct netstate *, struct json_doc *, int);
+static void	net_state_clear(struct netstate *);
+static char	*obj_str(struct json_doc *, int obj, const char *key);
+static char	*cfg_str(struct json_doc *, const char *);
+
+static void	net_models_append(struct buf *, const char *, const struct buf *,
+		    int tag);
+
+static void	conv_append(struct buf *, const struct buf *);
+static void	build_assistant_msg(struct buf *, const struct turncollect *);
+static void	build_tool_results(struct netstate *, const struct turncollect *,
+		    struct buf *);
+static int	send_tool_and_wait(struct netstate *, const struct toolcall *,
+		    struct buf *, int *);
+static uint32_t	tool_dst(const char *);
+
+int
+net_loop(struct imsgproto *ip)
+{
+	struct netstate	ns;
+	struct fugu_msg	m;
+	int		r, n;
+
+	memset(&ns, 0, sizeof(ns));
+	ns.ip = ip;
+	ns.max_tokens = 4096;
+	ns.web_search = 1;		/* offered unless the config disables it */
+	ns.cafile = xstrdup(NET_DEFAULT_CAFILE);
+	/*
+	 * Start with a single anthropic "default" provider so the worker is
+	 * well-formed before any M_CONFIG; the config replaces this array.
+	 */
+	ns.providers = xreallocarray(NULL, 1, sizeof(*ns.providers));
+	memset(&ns.providers[0], 0, sizeof(ns.providers[0]));
+	ns.providers[0].name = xstrdup("default");
+	ns.providers[0].type = LLM_ANTHROPIC;
+	ns.providers[0].ops = &anthropic_ops;
+	ns.providers[0].model = xstrdup(NET_DEFAULT_MODEL);
+	ns.providers[0].host = xstrdup(NET_DEFAULT_HOST);
+	ns.providers[0].port = xstrdup(NET_DEFAULT_PORT);
+	ns.nprov = 1;
+	ns.active = 0;
+
+	if (pledge("stdio rpath inet dns", NULL) == -1)
+		fatal("pledge");
+
+	net_emit(&ns, M_READY, EP_PARENT, NULL, 0);
+
+	for (;;) {
+		r = ip_get(ip, &m);
+		if (r == -1) {
+			log_warnx("net: imsg protocol error");
+			break;
+		}
+		if (r == 0) {
+			if ((n = ip_read(ip)) <= 0)
+				break;
+			continue;
+		}
+
+		switch (m.type) {
+		case M_SECRET: {
+			/* Payload: uint32 provider index (host order) + key bytes. */
+			uint32_t	idx;
+			size_t		klen;
+
+			if (m.len < sizeof(idx))
+				break;
+			memcpy(&idx, m.data, sizeof(idx));
+			if (idx >= ns.nprov)
+				break;		/* config not yet applied / bad idx */
+			klen = m.len - sizeof(idx);
+			if (ns.providers[idx].key != NULL)
+				freezero(ns.providers[idx].key,
+				    strlen(ns.providers[idx].key));
+			ns.providers[idx].key = xmalloc(klen + 1);
+			memcpy(ns.providers[idx].key,
+			    (char *)m.data + sizeof(idx), klen);
+			ns.providers[idx].key[klen] = '\0';
+			break;
+		}
+		case M_CONFIG:
+			net_apply_config(&ns, (char *)m.data, m.len);
+			net_ensure_ready(&ns);
+			break;
+		case M_SUBMIT:
+			net_run_turn(&ns, (char *)m.data, m.len);
+			break;
+		case M_SUMMARIZE:
+			net_summarize(&ns, (char *)m.data, m.len);
+			break;
+		case M_MODELS:
+			net_list_models(&ns);
+			break;
+		case M_SET_MODEL:
+			net_set_model(&ns, m.data, m.len);
+			break;
+		case M_TOOLS_ADD:
+			/*
+			 * Register an additional tool fragment (currently sent
+			 * once by ui at startup with the `skill` tool def).  The
+			 * payload is a JSON fragment that splices into the tools
+			 * array, comma-prefixed and bracket-less.  Replaces any
+			 * prior fragment -- ui owns the registry, not us.
+			 */
+			free(ns.extra_tools);
+			if (m.len > 0) {
+				ns.extra_tools = xmalloc(m.len + 1);
+				memcpy(ns.extra_tools, m.data, m.len);
+				ns.extra_tools[m.len] = '\0';
+			} else {
+				ns.extra_tools = NULL;
+			}
+			break;
+		case M_SHUTDOWN:
+			ip_msg_free(&m);
+			goto out;
+		default:
+			break;
+		}
+		ip_msg_free(&m);
+		if (ns.aborted)
+			break;
+	}
+out:
+	net_state_clear(&ns);
+	return (0);
+}
+
+/*
+ * The agent loop: call the API, and while the model requests tools, run them
+ * and call again, accumulating the whole exchange in conv.
+ */
+static void
+net_run_turn(struct netstate *ns, const char *messages, size_t mlen)
+{
+	struct buf		 conv, tools;
+	struct net_provider	*p = net_active(ns);
+	const char		*system;
+	int			 iter;
+
+	net_ensure_ready(ns);
+	if (ns->tlscfg == NULL) {
+		net_emit_error(ns, "tls", "no CA configured");
+		return;
+	}
+	if (p == NULL || p->key == NULL) {
+		net_emit_error(ns, "auth", "no API key");
+		return;
+	}
+
+	/* The config's system prompt overrides fugu's built-in default. */
+	system = ns->system != NULL ? ns->system : NET_DEFAULT_SYSTEM;
+
+	buf_init(&conv);
+	buf_append(&conv, messages, mlen);
+	buf_init(&tools);
+	tools_schema(&tools, ns->web_search, ns->http_request, ns->extra_tools);
+
+	for (iter = 0; iter < NET_MAX_ITERS; iter++) {
+		struct turncollect	tc;
+		struct buf		am, um;
+
+		turncollect_init(&tc);
+
+		(void)net_api_call(ns, &tc, &conv, system, tools.data);
+
+		if (tc.error) {
+			turncollect_free(&tc);
+			break;
+		}
+		/*
+		 * Record the assistant message (text + any tool_use): send it to
+		 * the UI for its conversation + journal, and add it to the running
+		 * projection.
+		 */
+		buf_init(&am);
+		build_assistant_msg(&am, &tc);
+		net_emit(ns, M_MESSAGE, EP_UI, am.data, am.len);
+		conv_append(&conv, &am);
+		buf_free(&am);
+
+		if (tc.ntools == 0) {		/* model is done */
+			const char	*sr = tc.stop_reason != NULL ?
+			    tc.stop_reason : "end_turn";
+
+			net_emit(ns, M_TURN_DONE, EP_UI, sr, strlen(sr));
+			turncollect_free(&tc);
+			break;
+		}
+
+		/* Run the tools and record the tool_result message likewise. */
+		buf_init(&um);
+		build_tool_results(ns, &tc, &um);
+		net_emit(ns, M_MESSAGE, EP_UI, um.data, um.len);
+		conv_append(&conv, &um);
+		buf_free(&um);
+
+		turncollect_free(&tc);
+		if (ns->aborted)
+			break;
+	}
+
+	if (iter >= NET_MAX_ITERS) {
+		net_emit_error(ns, "loop", "tool iteration limit reached");
+		net_emit(ns, M_TURN_DONE, EP_UI, "max_iterations",
+		    strlen("max_iterations"));
+	}
+	buf_free(&conv);
+	buf_free(&tools);
+}
+
+/*
+ * /compact: one tool-less Messages call that summarises the conversation.  The
+ * UI sends the same projection it would M_SUBMIT; we append a final user turn
+ * asking for the summary, run a single call with the summary system prompt and
+ * no tools (deltas hushed), and return the collected text as M_SUMMARY.  On any
+ * error net_api_call has already emitted M_ERROR and the UI keeps its context.
+ */
+static void
+net_summarize(struct netstate *ns, const char *messages, size_t mlen)
+{
+	struct turncollect	tc;
+	struct json_writer	w;
+	struct buf		conv, instr;
+	struct net_provider	*p = net_active(ns);
+
+	net_ensure_ready(ns);
+	if (ns->tlscfg == NULL) {
+		net_emit_error(ns, "tls", "no CA configured");
+		return;
+	}
+	if (p == NULL || p->key == NULL) {
+		net_emit_error(ns, "auth", "no API key");
+		return;
+	}
+
+	buf_init(&conv);
+	buf_append(&conv, messages, mlen);
+
+	/* Append "summarise it" as a closing user turn so the model replies. */
+	buf_init(&instr);
+	json_writer_init(&w, &instr);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "user");
+	json_kv_string(&w, "content", "Summarize our conversation so far, "
+	    "following your instructions.");
+	json_object_end(&w);
+	conv_append(&conv, &instr);
+	buf_free(&instr);
+
+	turncollect_init(&tc);
+	ns->summarizing = 1;
+	(void)net_api_call(ns, &tc, &conv, SUMMARY_SYSTEM, SUMMARY_TOOLS);
+	ns->summarizing = 0;
+
+	if (!tc.error)
+		net_emit(ns, M_SUMMARY, EP_UI, tc.text.data, tc.text.len);
+
+	turncollect_free(&tc);
+	buf_free(&conv);
+}
+
+/*
+ * /model: fetch every configured provider's model ids and reply to the UI with
+ * a newline-separated list (M_MODELS).  With more than one provider each id is
+ * tagged "name\tmodel" so the picker (and net_set_model) can route the choice;
+ * with one provider the ids are bare, exactly as before.  Always replies -- an
+ * empty list on total failure -- so the UI can clear its loading state.
+ */
+static void
+net_list_models(struct netstate *ns)
+{
+	struct buf	out;
+	size_t		i;
+	int		tag, anykey = 0;
+
+	buf_init(&out);
+	net_ensure_ready(ns);
+	tag = ns->nprov > 1;
+	if (ns->tlscfg == NULL)
+		net_emit_error(ns, "tls", "no CA configured");
+	else {
+		for (i = 0; i < ns->nprov; i++) {
+			struct net_provider	*p = &ns->providers[i];
+			struct buf		 one;
+
+			if (p->key == NULL)
+				continue;	/* a keyless provider lists nothing */
+			anykey = 1;
+			buf_init(&one);
+			if (net_fetch_models(ns, p, &one) == 0)
+				net_models_append(&out, p->name, &one, tag);
+			buf_free(&one);
+		}
+		if (!anykey)
+			net_emit_error(ns, "auth", "no API key");
+	}
+
+	net_emit(ns, M_MODELS, EP_UI, out.data, out.len);
+	buf_free(&out);
+}
+
+/*
+ * Append one provider's models (newline-separated ids in `ids`) to `out`, each
+ * prefixed with "name\t" when tag is set, keeping out newline-separated.
+ */
+static void
+net_models_append(struct buf *out, const char *name, const struct buf *ids,
+    int tag)
+{
+	const char	*d = ids->data;
+	size_t		 start, i;
+
+	for (start = 0, i = 0; i <= ids->len; i++) {
+		if (i == ids->len || d[i] == '\n') {
+			if (i > start) {
+				if (out->len > 0)
+					buf_putc(out, '\n');
+				if (tag) {
+					buf_puts_cstr(out, name);
+					buf_putc(out, '\t');
+				}
+				buf_append(out, d + start, i - start);
+			}
+			start = i + 1;
+		}
+	}
+}
+
+/*
+ * Switch the provider/model used for subsequent turns.  The payload is either a
+ * bare model id (set the active provider's model) or "name\tmodel" (switch the
+ * active provider to the named one, then set its model) -- the form the picker
+ * sends when several providers are configured.
+ */
+static void
+net_set_model(struct netstate *ns, const void *data, size_t len)
+{
+	const char		*d = data, *tab;
+	struct net_provider	*p;
+	size_t			 modoff = 0, modlen, i;
+
+	if (len == 0 || ns->nprov == 0)
+		return;
+	if ((tab = memchr(d, '\t', len)) != NULL) {
+		size_t	namelen = (size_t)(tab - d);
+
+		for (i = 0; i < ns->nprov; i++)
+			if (strlen(ns->providers[i].name) == namelen &&
+			    memcmp(ns->providers[i].name, d, namelen) == 0) {
+				ns->active = i;
+				break;
+			}
+		modoff = namelen + 1;
+	}
+	if (modoff > len)
+		return;
+	modlen = len - modoff;
+	if (modlen == 0)
+		return;
+	p = &ns->providers[ns->active];
+	free(p->model);
+	p->model = xmalloc(modlen + 1);
+	memcpy(p->model, d + modoff, modlen);
+	p->model[modlen] = '\0';
+}
+
+/* Append one message object to a JSON messages array that ends in ']'. */
+static void
+conv_append(struct buf *conv, const struct buf *msg)
+{
+	conv->len--;			/* drop the trailing ']' */
+	buf_putc(conv, ',');
+	buf_append(conv, msg->data, msg->len);
+	buf_putc(conv, ']');
+}
+
+static void
+build_assistant_msg(struct buf *out, const struct turncollect *tc)
+{
+	struct json_writer	w;
+	size_t			i;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "assistant");
+	json_key(&w, "content");
+	json_array_start(&w);
+	if (tc->text.len > 0) {
+		json_object_start(&w);
+		json_kv_string(&w, "type", "text");
+		json_key(&w, "text");
+		json_string_n(&w, tc->text.data, tc->text.len);
+		json_object_end(&w);
+	}
+	for (i = 0; i < tc->ntools; i++) {
+		json_object_start(&w);
+		json_kv_string(&w, "type", "tool_use");
+		json_kv_string(&w, "id", tc->tools[i].id);
+		json_kv_string(&w, "name", tc->tools[i].name);
+		json_key(&w, "input");
+		json_raw_n(&w, tc->tools[i].input.data,
+		    tc->tools[i].input.len);
+		json_object_end(&w);
+	}
+	json_array_end(&w);
+	json_object_end(&w);
+}
+
+static void
+build_tool_results(struct netstate *ns, const struct turncollect *tc,
+    struct buf *out)
+{
+	struct json_writer	w;
+	size_t			i;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "user");
+	json_key(&w, "content");
+	json_array_start(&w);
+	for (i = 0; i < tc->ntools; i++) {
+		struct buf	content;
+		int		is_error = 0;
+
+		buf_init(&content);
+		if (send_tool_and_wait(ns, &tc->tools[i], &content,
+		    &is_error) == -1) {
+			ns->aborted = 1;
+			buf_free(&content);
+			break;
+		}
+		json_object_start(&w);
+		json_kv_string(&w, "type", "tool_result");
+		json_kv_string(&w, "tool_use_id", tc->tools[i].id);
+		json_key(&w, "content");
+		json_string_n(&w, content.len > 0 ? content.data : "",
+		    content.len);
+		json_kv_bool(&w, "is_error", is_error);
+		json_object_end(&w);
+		buf_free(&content);
+	}
+	json_array_end(&w);
+	json_object_end(&w);
+}
+
+/* Send a tool request to its worker and block for the result.  0 / -1. */
+static int
+send_tool_and_wait(struct netstate *ns, const struct toolcall *tool,
+    struct buf *content, int *is_error)
+{
+	struct json_writer	w;
+	struct buf		req;
+	struct fugu_msg		m;
+	int			r;
+
+	buf_init(&req);
+	json_writer_init(&w, &req);
+	json_object_start(&w);
+	json_kv_string(&w, "id", tool->id);
+	json_kv_string(&w, "name", tool->name);
+	json_key(&w, "input");
+	json_raw_n(&w, tool->input.data, tool->input.len);
+	json_object_end(&w);
+
+	r = (ip_compose(ns->ip, M_TOOL_REQUEST, tool_dst(tool->name), EP_NET,
+	    req.data, req.len) != -1 && ip_flush(ns->ip) != -1) ? 0 : -1;
+	buf_free(&req);
+	if (r == -1)
+		return (-1);
+
+	for (;;) {
+		r = ip_get(ns->ip, &m);
+		if (r == -1)
+			return (-1);
+		if (r == 0) {
+			if (ip_read(ns->ip) <= 0)
+				return (-1);
+			continue;
+		}
+		if (m.type == M_TOOL_RESULT) {
+			struct json_doc	d;
+			int		t;
+
+			if (json_parse(&d, m.data, m.len) == 0 && d.ntok >= 1) {
+				if ((t = json_obj_get(&d, 0, "content")) >= 0) {
+					size_t	 clen = 0;
+					char	*c = json_tok_strdup_n(&d, t, &clen);
+
+					if (c != NULL) {
+						buf_append(content, c, clen);
+						free(c);
+					}
+				}
+				t = json_obj_get(&d, 0, "is_error");
+				*is_error = (t >= 0 &&
+				    json_tok_streq(&d, t, "true"));
+			}
+			json_doc_free(&d);
+			ip_msg_free(&m);
+			return (0);
+		}
+		if (m.type == M_SHUTDOWN) {
+			ip_msg_free(&m);
+			return (-1);
+		}
+		ip_msg_free(&m);	/* ignore anything else mid-turn */
+	}
+}
+
+static uint32_t
+tool_dst(const char *name)
+{
+	if (strcmp(name, "web_search") == 0 || strcmp(name, "web_fetch") == 0 ||
+	    strcmp(name, "http_request") == 0)
+		return (EP_FETCH);
+	if (strcmp(name, "skill") == 0)
+		return (EP_UI);		/* skills are serviced by fugu-ui */
+	return (EP_EXEC);
+}
+
+void
+net_emit(struct netstate *ns, uint32_t type, uint32_t dst, const void *data,
+    size_t len)
+{
+	if (ip_compose(ns->ip, type, dst, EP_NET, data, len) == -1 ||
+	    ip_flush(ns->ip) == -1)
+		fatalx("net: failed to send imsg");
+}
+
+void
+net_emit_error(struct netstate *ns, const char *what, const char *detail)
+{
+	struct buf	b;
+
+	buf_init(&b);
+	buf_printf(&b, "%s: %s", what, detail != NULL ? detail : "error");
+	net_emit(ns, M_ERROR, EP_UI, b.data, b.len);
+	buf_free(&b);
+}
+
+static void
+net_apply_config(struct netstate *ns, const void *data, size_t len)
+{
+	struct json_doc	 d;
+	const char	*errstr;
+	char		*s;
+	int		 t;
+
+	if (json_parse(&d, data, len) == -1 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		log_warnx("net: malformed config");
+		return;
+	}
+	if ((t = json_obj_get(&d, 0, "max_tokens")) >= 0) {
+		char		*v = json_tok_strdup(&d, t);
+		long long	 mx;
+
+		if (v != NULL) {
+			mx = strtonum(v, 1, 1000000, &errstr);
+			if (errstr == NULL)
+				ns->max_tokens = (int)mx;
+			free(v);
+		}
+	}
+	if ((t = json_obj_get(&d, 0, "web_search")) >= 0)
+		ns->web_search = json_tok_streq(&d, t, "true");
+	if ((t = json_obj_get(&d, 0, "http_request")) >= 0)
+		ns->http_request = json_tok_streq(&d, t, "true");
+	if ((s = cfg_str(&d, "system")) != NULL) {
+		free(ns->system);
+		ns->system = s;
+	}
+	if ((s = cfg_str(&d, "cafile")) != NULL) {
+		free(ns->cafile);
+		ns->cafile = s;
+	}
+	/* The provider array (and "active" index) replaces the worker's set. */
+	if ((t = json_obj_get(&d, 0, "providers")) >= 0 &&
+	    json_tok_type(&d, t) == JSON_ARRAY) {
+		long long	active = 0;
+		int		a;
+
+		net_apply_providers(ns, &d, t);
+		if ((a = json_obj_get(&d, 0, "active")) >= 0) {
+			char	*v = json_tok_strdup(&d, a);
+
+			if (v != NULL) {
+				active = strtonum(v, 0, 1000000, &errstr);
+				if (errstr != NULL)
+					active = 0;
+				free(v);
+			}
+		}
+		ns->active = ((size_t)active < ns->nprov) ? (size_t)active : 0;
+	}
+	json_doc_free(&d);
+}
+
+/*
+ * Rebuild ns->providers from a config "providers" array.  Each element supplies
+ * name/type and optional model/host/port/path; missing fields fall back to the
+ * type's defaults.  Keys are not here -- they arrive separately as M_SECRET.
+ */
+static void
+net_apply_providers(struct netstate *ns, struct json_doc *d, int arr)
+{
+	struct net_provider	*np;
+	int			 n = json_tok_size(d, arr), e = arr + 1, i;
+
+	if (n <= 0)
+		return;			/* keep the existing set rather than empty */
+	net_providers_free(ns);
+	np = xreallocarray(NULL, (size_t)n, sizeof(*np));
+	for (i = 0; i < n; i++) {
+		if (json_tok_type(d, e) == JSON_OBJECT) {
+			struct net_provider	*p = &np[ns->nprov];
+			char			*ty;
+
+			memset(p, 0, sizeof(*p));
+			if ((p->name = obj_str(d, e, "name")) == NULL)
+				p->name = xstrdup("default");
+			ty = obj_str(d, e, "type");
+			p->type = (ty != NULL && strcmp(ty, "openai") == 0) ?
+			    LLM_OPENAI : LLM_ANTHROPIC;
+			free(ty);
+			p->ops = (p->type == LLM_OPENAI) ?
+			    &openai_ops : &anthropic_ops;
+			if ((p->model = obj_str(d, e, "model")) == NULL)
+				p->model = xstrdup(NET_DEFAULT_MODEL);
+			if ((p->host = obj_str(d, e, "host")) == NULL)
+				p->host = xstrdup(p->type == LLM_OPENAI ?
+				    NET_OPENAI_HOST : NET_DEFAULT_HOST);
+			if ((p->port = obj_str(d, e, "port")) == NULL)
+				p->port = xstrdup(NET_DEFAULT_PORT);
+			p->path = obj_str(d, e, "path");	/* NULL = default */
+			ns->nprov++;
+		}
+		e = json_skip(d, e);
+	}
+	ns->providers = np;
+	ns->active = 0;
+}
+
+struct net_provider *
+net_active(struct netstate *ns)
+{
+	if (ns->nprov == 0)
+		return (NULL);
+	if (ns->active >= ns->nprov)
+		ns->active = 0;
+	return (&ns->providers[ns->active]);
+}
+
+static void
+net_providers_free(struct netstate *ns)
+{
+	size_t	i;
+
+	for (i = 0; i < ns->nprov; i++) {
+		struct net_provider	*p = &ns->providers[i];
+
+		if (p->key != NULL)
+			freezero(p->key, strlen(p->key));
+		free(p->name);
+		free(p->model);
+		free(p->host);
+		free(p->port);
+		free(p->path);
+	}
+	free(ns->providers);
+	ns->providers = NULL;
+	ns->nprov = 0;
+	ns->active = 0;
+}
+
+static void
+net_ensure_ready(struct netstate *ns)
+{
+	if (ns->ready)
+		return;
+	ns->tlscfg = tlsconn_client_config(ns->cafile);
+	if (ns->tlscfg == NULL)
+		log_warnx("net: could not load CA bundle %s", ns->cafile);
+	if (pledge("stdio inet dns", NULL) == -1)
+		fatal("pledge");
+	ns->ready = 1;
+}
+
+static void
+net_state_clear(struct netstate *ns)
+{
+	net_providers_free(ns);
+	free(ns->system);
+	free(ns->extra_tools);
+	free(ns->cafile);
+	if (ns->tlscfg != NULL)
+		tls_config_free(ns->tlscfg);
+	memset(ns, 0, sizeof(*ns));
+}
+
+/* A string-valued child `key` of object token `obj`, strdup'd, or NULL. */
+static char *
+obj_str(struct json_doc *d, int obj, const char *key)
+{
+	int	t = json_obj_get(d, obj, key);
+
+	if (t < 0 || json_tok_type(d, t) != JSON_STRING)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
+
+static char *
+cfg_str(struct json_doc *d, const char *key)
+{
+	return (obj_str(d, 0, key));
+}
blob - /dev/null
blob + 6ae66cfbc835d27a17c1520fb0e4f13ebf9da3db (mode 644)
--- /dev/null
+++ src/net/net_run.h
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_NET_RUN_H
+#define FUGU_NET_RUN_H
+
+#include <sys/types.h>
+
+#include <stdint.h>
+
+#include <tls.h>
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "provider.h"
+
+struct llm_provider_ops;	/* provider_ops.h */
+
+/*
+ * One configured model provider.  fugu can hold several at once (an Anthropic
+ * key and an OpenAI key, say); ns->active selects the one driving turns, and
+ * /model switches it.  Each carries its own endpoint and credential; the key
+ * is the only secret and is freezero()d on replacement or teardown.
+ */
+struct net_provider {
+	char			*name;	/* config name, e.g. "default"	*/
+	enum llm_provider	 type;	/* wire protocol: anthropic|openai */
+	char			*key;	/* API credential (secret) or NULL */
+	char			*model;
+	char			*host;
+	char			*port;
+	char			*path;	/* request-path override, else default */
+	const struct llm_provider_ops	*ops;	/* resolved at config time */
+};
+
+/*
+ * The net worker's state across an imsg session: the imsg channel, the set of
+ * configured providers (and which one is active), the agent's tunables, the
+ * TLS client config, and a few transient flags the inner loop watches.  The
+ * agent-loop modules read it directly; the transport seam (net_api.c) reads
+ * tlscfg / active and writes nothing.
+ */
+struct netstate {
+	struct imsgproto	*ip;
+	struct net_provider	*providers;	/* one or more configured */
+	size_t			 nprov;
+	size_t			 active;	/* index of the driving provider */
+	int			 max_tokens;
+	int			 web_search;	/* offer web_search/web_fetch */
+	int			 http_request;	/* offer http_request tool */
+	char			*extra_tools;	/* JSON fragment from M_TOOLS_ADD */
+	char			*system;
+	char			*cafile;
+	struct tls_config	*tlscfg;
+	int			 ready;
+	int			 aborted;	/* shutdown seen mid-turn */
+	int			 summarizing;	/* /compact call: hush deltas */
+};
+
+/*
+ * Run the net worker loop on an already-connected imsg channel to the parent.
+ * Pledges down to "stdio inet dns" once the CA bundle is loaded, then services
+ * M_SECRET / M_CONFIG / M_SUBMIT until M_SHUTDOWN or the channel closes.
+ * Returns 0 on a clean exit.
+ */
+int	net_loop(struct imsgproto *);
+
+/*
+ * Build the Messages API tool list into b (NUL-terminated): the base file/shell
+ * tools always, plus web_search/web_fetch when web_search is nonzero, and the
+ * general http_request tool when http_request is nonzero, and any extra tool
+ * fragments registered via M_TOOLS_ADD (comma-prefixed, no enclosing brackets)
+ * when `extra` is non-NULL.  Exposed for testing.
+ */
+void	tools_schema(struct buf *b, int web_search, int http_request,
+	    const char *extra);
+
+/* The active provider, or NULL when none is configured. */
+struct net_provider	*net_active(struct netstate *);
+
+/* Send one imsg from the net worker; fatal on send failure. */
+void	net_emit(struct netstate *, uint32_t type, uint32_t dst,
+	    const void *data, size_t len);
+
+/* Emit an M_ERROR to the UI of the form "what: detail". */
+void	net_emit_error(struct netstate *, const char *what, const char *detail);
+
+#endif /* FUGU_NET_RUN_H */
blob - /dev/null
blob + 4a96aa04bb1671f88a9d92818faf60d401eedfe6 (mode 644)
--- /dev/null
+++ src/parent/Makefile
@@ -0,0 +1,13 @@
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG=	fugu
+SRCS+=	parent.c conf.c confload.c parse.y
+BINDIR=	${PREFIX}/bin
+CPPFLAGS+=	-DFUGU_LIBEXEC=\"${PREFIX}/libexec/fugu\"
+
+# The user-facing program carries both manuals; sources live in ../../man.
+MAN=	fugu.1 fugu.conf.5
+MANDIR=	${PREFIX}/man/man
+.PATH:	${.CURDIR}/../../man
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + b3a268bbd778f327dfe05f3dd4dd3520f7ce3e2a (mode 644)
--- /dev/null
+++ src/parent/parent.c
@@ -0,0 +1,1003 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu: the parent / coordinator process (installed as ${PREFIX}/bin/fugu).
+ *
+ *   - reads /etc/fugu.conf (running setgid _fugu, the only privilege fugu holds)
+ *     and resolves the effective config for the invoking uid (conf_load);
+ *   - fork+execs the ui and net helpers, each over an imsg(3) socketpair handed
+ *     to the child on fd 3;
+ *   - delegates each provider's key to net (M_SECRET) and the non-secret
+ *     settings (M_CONFIG), then freezero()s its copy;
+ *   - drops the setgid _fugu group once the secrets are delegated;
+ *   - relays every worker<->worker imsg by destination id, never reassembling a
+ *     payload, and never giving one worker a channel to another;
+ *   - pledges "stdio proc" once the helpers are spawned.
+ *
+ * Helper paths default to ${FUGU_LIBEXEC} and may be overridden per worker via
+ * the environment (used by the test harness).
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "conf.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "util.h"
+
+#ifndef FUGU_LIBEXEC
+#define FUGU_LIBEXEC	"/usr/local/libexec/fugu"
+#endif
+
+#define IMSG_CHILD_FD	3
+
+enum { W_UI, W_NET, W_EXEC, W_FETCH, NWORKERS };
+
+struct wdesc {
+	const char	*name;
+	uint32_t	 ep;
+	const char	*env_bin;
+	const char	*def_bin;
+};
+
+static const struct wdesc WDESC[NWORKERS] = {
+	{ "ui",    EP_UI,    "FUGU_UI_BIN",    FUGU_LIBEXEC "/fugu-ui"    },
+	{ "net",   EP_NET,   "FUGU_NET_BIN",   FUGU_LIBEXEC "/fugu-net"   },
+	{ "exec",  EP_EXEC,  "FUGU_EXEC_BIN",  FUGU_LIBEXEC "/fugu-exec"  },
+	{ "fetch", EP_FETCH, "FUGU_FETCH_BIN", FUGU_LIBEXEC "/fugu-fetch" },
+};
+
+struct worker {
+	pid_t		 pid;
+	struct imsgproto	 ip;
+	int		 active;
+};
+
+static struct worker	workers[NWORKERS];
+
+/* One row of `fugu -l` output (session id, mtime, message count, preview). */
+struct sessrow {
+	char	id[64];
+	time_t	mtime;
+	int	nmsg;
+	char	preview[56];
+};
+
+static __dead void	usage(void);
+static void		create_session_dir(void);
+static int		sessions_path(char *, size_t);
+static int		session_exists(const char *);
+static int		session_select(const struct dirent *);
+static int		resolve_last(char *, size_t);
+static void		session_preview(const char *, size_t, char *, size_t);
+static void		scan_session(const char *, struct sessrow *);
+static int		sessrow_cmp(const void *, const void *);
+static int		list_sessions(void);
+static int		run(struct conf_settings *, struct conf_providers *,
+			    const char *, int);
+static int		spawn(int, char *const[], int);
+static const char	*trusted_env(const char *);
+static void		delegate_net(struct conf_settings *,
+			    struct conf_providers *);
+static void		delegate_exec(struct conf_settings *);
+static void		delegate_fetch(struct conf_settings *);
+static int		 secret_load(int, const char *, int, const char *,
+			    char **, size_t *);
+static char		*resolve_secret(int, const char *, int, const char *);
+static struct worker	*worker_by_ep(uint32_t);
+static int		relay_loop(void);
+static int		relay_drain(struct worker *);
+static void		shutdown_workers(void);
+
+static void
+usage(void)
+{
+	extern char	*__progname;
+
+	fprintf(stderr, "usage: %s [-cdlnv] [-r id]\n", __progname);
+	exit(1);
+}
+
+int
+main(int argc, char *argv[])
+{
+	struct conf_settings	eff;
+	struct conf_providers	provs;
+	const char		*rid = NULL, *resume_id = NULL;
+	char			 last[64];
+	int			 ch, debug = 0, verbose = 0, confcheck = 0;
+	int			 lflag = 0, cflag = 0, rc;
+
+	while ((ch = getopt(argc, argv, "cdlnr:v")) != -1) {
+		switch (ch) {
+		case 'c':			/* resume the most recent session */
+			cflag = 1;
+			break;
+		case 'd':
+			debug = 1;
+			break;
+		case 'l':			/* list sessions and exit */
+			lflag = 1;
+			break;
+		case 'n':
+			confcheck = 1;
+			break;
+		case 'r':			/* resume the named session */
+			rid = optarg;
+			break;
+		case 'v':
+			verbose++;
+			break;
+		default:
+			usage();
+		}
+	}
+	argc -= optind;
+	argv += optind;
+	if (argc > 0 || (cflag && rid != NULL))
+		usage();
+
+	/*
+	 * Startup runs on the terminal, before the ui takes the screen, so log
+	 * to stderr: config-parse and other startup errors must be visible to
+	 * whoever ran fugu, not buried in syslog.  run() switches to syslog once
+	 * the ui owns the terminal (unless -d keeps everything on stderr).
+	 */
+	log_init(LOG_TO_STDERR, verbose, LOG_USER);
+
+	memset(&eff, 0, sizeof(eff));
+	TAILQ_INIT(&provs);
+	if (conf_load(&eff, &provs) == -1)
+		return (1);
+
+	if (confcheck) {
+		conf_settings_print(&eff, stdout);
+		conf_providers_print(&provs, stdout);
+		conf_settings_clear(&eff);
+		conf_providers_clear(&provs);
+		return (0);
+	}
+	if (lflag) {
+		rc = list_sessions();
+		conf_settings_clear(&eff);
+		conf_providers_clear(&provs);
+		return (rc);
+	}
+
+	if (rid != NULL) {
+		if (session_exists(rid) == -1) {
+			log_warnx("no such session: %s", rid);
+			conf_settings_clear(&eff);
+			conf_providers_clear(&provs);
+			return (1);
+		}
+		resume_id = rid;
+	} else if (cflag) {
+		if (resolve_last(last, sizeof(last)) == -1) {
+			log_warnx("no sessions to resume");
+			conf_settings_clear(&eff);
+			conf_providers_clear(&provs);
+			return (1);
+		}
+		resume_id = last;
+	}
+
+	create_session_dir();
+	signal(SIGPIPE, SIG_IGN);
+	rc = run(&eff, &provs, resume_id, debug);
+	conf_settings_clear(&eff);
+	conf_providers_clear(&provs);
+	return (rc);
+}
+
+/* Ensure ~/.fugu/sessions exists so the ui can write its journal (best-effort). */
+static void
+create_session_dir(void)
+{
+	const char	*home;
+	char		 path[PATH_MAX];
+
+	if ((home = getenv("HOME")) == NULL || *home == '\0')
+		return;
+	if (snprintf(path, sizeof(path), "%s/.fugu", home) < (int)sizeof(path))
+		(void)mkdir(path, 0700);
+	if (snprintf(path, sizeof(path), "%s/.fugu/sessions", home) <
+	    (int)sizeof(path))
+		(void)mkdir(path, 0700);
+}
+
+/* Build "$HOME/.fugu/sessions" into buf. -1 if HOME is unset or it won't fit. */
+static int
+sessions_path(char *buf, size_t bufsz)
+{
+	const char	*home;
+
+	if ((home = getenv("HOME")) == NULL || *home == '\0')
+		return (-1);
+	if (snprintf(buf, bufsz, "%s/.fugu/sessions", home) >= (int)bufsz)
+		return (-1);
+	return (0);
+}
+
+/* 0 if session <id> names a readable journal file, -1 otherwise. */
+static int
+session_exists(const char *id)
+{
+	char		dir[PATH_MAX], path[PATH_MAX];
+	struct stat	st;
+
+	if (strchr(id, '/') != NULL || strcmp(id, ".") == 0 ||
+	    strcmp(id, "..") == 0)
+		return (-1);
+	if (sessions_path(dir, sizeof(dir)) == -1)
+		return (-1);
+	if (snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id) >=
+	    (int)sizeof(path))
+		return (-1);
+	if (stat(path, &st) == -1 || !S_ISREG(st.st_mode))
+		return (-1);
+	return (0);
+}
+
+/* scandir(3) filter: accept "<id>.jsonl" session journals. */
+static int
+session_select(const struct dirent *e)
+{
+	size_t	nlen = strlen(e->d_name);
+
+	return (nlen > 6 && strcmp(e->d_name + nlen - 6, ".jsonl") == 0);
+}
+
+/* Find the most recently modified session id (by mtime). 0 ok, -1 if none. */
+static int
+resolve_last(char *out, size_t outsz)
+{
+	char		 dir[PATH_MAX], path[PATH_MAX];
+	struct dirent	**names;
+	struct stat	 st;
+	time_t		 best = 0;
+	size_t		 nlen;
+	int		 n, i, found = 0;
+
+	if (sessions_path(dir, sizeof(dir)) == -1)
+		return (-1);
+	if ((n = scandir(dir, &names, session_select, NULL)) == -1)
+		return (-1);
+	for (i = 0; i < n; i++) {
+		nlen = strlen(names[i]->d_name);
+		if (nlen - 6 < outsz &&
+		    snprintf(path, sizeof(path), "%s/%s", dir, names[i]->d_name) <
+		    (int)sizeof(path) &&
+		    stat(path, &st) != -1 && S_ISREG(st.st_mode) &&
+		    (!found || st.st_mtime > best)) {
+			memcpy(out, names[i]->d_name, nlen - 6); /* strip ".jsonl" */
+			out[nlen - 6] = '\0';
+			best = st.st_mtime;
+			found = 1;
+		}
+		free(names[i]);
+	}
+	free(names);
+	return (found ? 0 : -1);
+}
+
+/* Extract a one-line, control-free preview from a stored message object. */
+static void
+session_preview(const char *json, size_t len, char *out, size_t outsz)
+{
+	struct json_doc	d;
+	char		*s = NULL;
+	size_t		 i, o = 0;
+	int		 ct;
+
+	out[0] = '\0';
+	if (json_parse(&d, json, len) != 0 || d.ntok < 1) {
+		json_doc_free(&d);
+		return;
+	}
+	if ((ct = json_obj_get(&d, 0, "content")) >= 0 &&
+	    json_tok_type(&d, ct) == JSON_STRING)
+		s = json_tok_strdup(&d, ct);
+	json_doc_free(&d);
+	if (s == NULL)
+		return;
+
+	for (i = 0; s[i] != '\0' && o + 1 < outsz; i++) {
+		unsigned char	c = (unsigned char)s[i];
+
+		if (c < 0x20 || c == 0x7f)
+			c = ' ';		/* fold control chars to a space */
+		if (c == ' ' && (o == 0 || out[o - 1] == ' '))
+			continue;		/* collapse runs of whitespace */
+		out[o++] = (char)c;
+	}
+	while (o > 0 && out[o - 1] == ' ')
+		o--;
+	out[o] = '\0';
+	free(s);
+}
+
+/* Count the messages in a session file and read a preview from the first. */
+static void
+scan_session(const char *path, struct sessrow *r)
+{
+	FILE	*fp;
+	char	*line = NULL;
+	size_t	 cap = 0;
+	ssize_t	 n;
+	int	 first = 1;
+
+	r->nmsg = 0;
+	r->preview[0] = '\0';
+	if ((fp = fopen(path, "r")) == NULL)
+		return;
+	while ((n = getline(&line, &cap, fp)) != -1) {
+		while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+			n--;
+		if (n == 0)
+			continue;
+		r->nmsg++;
+		if (first) {
+			first = 0;
+			session_preview(line, (size_t)n, r->preview,
+			    sizeof(r->preview));
+		}
+	}
+	free(line);
+	fclose(fp);
+}
+
+/* qsort comparator: most recently modified session first. */
+static int
+sessrow_cmp(const void *a, const void *b)
+{
+	const struct sessrow	*x = a, *y = b;
+
+	if (x->mtime < y->mtime)
+		return (1);
+	if (x->mtime > y->mtime)
+		return (-1);
+	return (0);
+}
+
+/* `fugu -l`: print the saved sessions, newest first.  Returns an exit code. */
+static int
+list_sessions(void)
+{
+	char		 dir[PATH_MAX], path[PATH_MAX], when[32];
+	struct dirent	**names;
+	struct stat	 st;
+	struct tm	 tm;
+	struct sessrow	*rows = NULL;
+	size_t		 nrows = 0, i, nlen;
+	int		 n, j;
+
+	if (sessions_path(dir, sizeof(dir)) == -1) {
+		log_warnx("HOME is not set");
+		return (1);
+	}
+	if ((n = scandir(dir, &names, session_select, NULL)) == -1) {
+		printf("no saved sessions\n");
+		return (0);
+	}
+	for (j = 0; j < n; j++) {
+		struct sessrow	r;
+
+		nlen = strlen(names[j]->d_name);
+		if (nlen - 6 < sizeof(r.id) &&
+		    snprintf(path, sizeof(path), "%s/%s", dir, names[j]->d_name) <
+		    (int)sizeof(path) &&
+		    stat(path, &st) != -1 && S_ISREG(st.st_mode)) {
+			memset(&r, 0, sizeof(r));
+			memcpy(r.id, names[j]->d_name, nlen - 6);
+			r.id[nlen - 6] = '\0';
+			r.mtime = st.st_mtime;
+			scan_session(path, &r);
+			rows = xreallocarray(rows, nrows + 1, sizeof(*rows));
+			rows[nrows++] = r;
+		}
+		free(names[j]);
+	}
+	free(names);
+
+	if (nrows == 0) {
+		printf("no saved sessions\n");
+		free(rows);
+		return (0);
+	}
+	qsort(rows, nrows, sizeof(*rows), sessrow_cmp);
+	printf("%-22s  %-16s  %4s  %s\n", "SESSION", "MODIFIED", "MSGS",
+	    "FIRST MESSAGE");
+	for (i = 0; i < nrows; i++) {
+		when[0] = '\0';
+		if (localtime_r(&rows[i].mtime, &tm) != NULL)
+			strftime(when, sizeof(when), "%Y-%m-%d %H:%M", &tm);
+		printf("%-22s  %-16s  %4d  %s\n", rows[i].id, when,
+		    rows[i].nmsg, rows[i].preview);
+	}
+	free(rows);
+	return (0);
+}
+
+static int
+run(struct conf_settings *eff, struct conf_providers *provs,
+    const char *resume_id, int debug)
+{
+	int	subproc_net, ascii, i;
+
+	subproc_net = (eff->set & CS_ALLOW_SUBPROC_NET) &&
+	    eff->allow_subprocess_net;
+	ascii = (eff->set & CS_ASCII) && eff->ascii;
+
+	for (i = 0; i < NWORKERS; i++) {
+		/*
+		 * Some startup policy travels as argv flags rather than over the
+		 * imsg config channel: exec's subprocess-network (fixed before the
+		 * worker pledges), and the ui's ascii display + resume session id.
+		 */
+		char	*extra[4];
+		int	 nextra = 0;
+
+		if (i == W_EXEC && subproc_net)
+			extra[nextra++] = "-N";
+		else if (i == W_UI) {
+			if (ascii)
+				extra[nextra++] = "-a";
+			if (resume_id != NULL) {
+				extra[nextra++] = "-R";
+				extra[nextra++] = (char *)(uintptr_t)resume_id;
+			}
+		}
+
+		if (spawn(i, extra, nextra) == -1) {
+			log_warnx("failed to spawn %s", WDESC[i].name);
+			shutdown_workers();
+			return (1);
+		}
+	}
+
+	/* Any key file is read here, before we drop rpath. */
+	delegate_net(eff, provs);
+	delegate_exec(eff);
+	delegate_fetch(eff);
+
+	/*
+	 * The secrets are read and delegated; shed the setgid _fugu group (the
+	 * workers already dropped their inherited copy at startup) so nothing
+	 * downstream can reach /etc/fugu.conf or other _fugu-readable files.
+	 */
+	drop_privgroup();
+
+	if (pledge("stdio proc", NULL) == -1)
+		fatal("pledge");
+
+	/*
+	 * The ui now owns the terminal; route the parent's own diagnostics to
+	 * syslog so they don't paint over the curses display.  -d keeps them on
+	 * stderr for debugging (the screen will be garbled, but that is the
+	 * debugging tradeoff).
+	 */
+	if (!debug)
+		log_init(LOG_TO_SYSLOG, log_getverbose(), LOG_USER);
+
+	relay_loop();
+	shutdown_workers();
+	return (0);
+}
+
+/*
+ * getenv() that an installed (set-group-id) fugu ignores: when issetugid() is
+ * true the environment is untrusted, so a privileged process must not let it
+ * steer worker binary paths, endpoints, or the CA bundle.  Honoured only in a
+ * non-setgid binary -- development and the regress harness.
+ */
+static const char *
+trusted_env(const char *name)
+{
+	return (issetugid() ? NULL : getenv(name));
+}
+
+/* Fork+exec worker i (with any extra argv words), on fd 3. */
+static int
+spawn(int i, char *const extra[], int nextra)
+{
+	const char	*bin;
+	pid_t		 pid;
+	int		 sv[2];
+
+	bin = trusted_env(WDESC[i].env_bin);
+	if (bin == NULL || *bin == '\0')
+		bin = WDESC[i].def_bin;
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1) {
+		log_warn("socketpair");
+		return (-1);
+	}
+	if ((pid = fork()) == -1) {
+		log_warn("fork");
+		close(sv[0]);
+		close(sv[1]);
+		return (-1);
+	}
+	if (pid == 0) {
+		char	*av[8];		/* bin + at most a few flags + NULL */
+		int	 n = 0, k;
+
+		av[n++] = (char *)(uintptr_t)bin;
+		for (k = 0; k < nextra && n < 7; k++)
+			av[n++] = extra[k];
+		av[n] = NULL;
+
+		if (dup2(sv[1], IMSG_CHILD_FD) == -1)
+			_exit(127);
+		/*
+		 * Only the ui owns the terminal; the other workers have no use
+		 * for stdin/stdout, so point them at /dev/null (stderr is kept
+		 * for debug logging).  They communicate solely over fd 3.
+		 */
+		if (i != W_UI) {
+			int	nfd = open("/dev/null", O_RDWR);
+
+			if (nfd != -1) {
+				(void)dup2(nfd, STDIN_FILENO);
+				(void)dup2(nfd, STDOUT_FILENO);
+				if (nfd > IMSG_CHILD_FD)
+					close(nfd);
+			}
+		}
+		closefrom(IMSG_CHILD_FD + 1);	/* drop every other channel */
+		execv(bin, av);
+		_exit(127);
+	}
+
+	close(sv[1]);
+	if (ip_init(&workers[i].ip, sv[0]) == -1) {
+		log_warn("ip_init");
+		return (-1);
+	}
+	workers[i].pid = pid;
+	workers[i].active = 1;
+	return (0);
+}
+
+/*
+ * One model provider to delegate.  Its non-secret fields come from a
+ * conf_settings (the flat "default" provider, or a named provider block); host
+ * and port are resolved separately so an env override can pin the default's
+ * endpoint for tests.  The secret is resolved at send time from `s` so it never
+ * lingers in this descriptor.
+ */
+struct netprov_desc {
+	const char			*name;
+	const struct conf_settings	*s;
+	const char			*host;	/* or NULL for the type default */
+	const char			*port;	/* or NULL for 443 */
+};
+
+/*
+ * Resolve one descriptor's API key with its true byte length: generic
+ * api_key first, then legacy.  Returns 0 on success (*out / *outlen filled),
+ * -1 if no key is configured.
+ */
+static int
+netprov_key(const struct conf_settings *s, char **out, size_t *outlen)
+{
+	if (secret_load(s->set & CS_API_KEY, s->api_key,
+	    s->set & CS_API_KEY_FILE, s->api_key_file, out, outlen) == 0)
+		return (0);
+	return (secret_load(s->set & CS_ANTHROPIC_KEY, s->anthropic_key,
+	    s->set & CS_ANTHROPIC_KEY_FILE, s->anthropic_key_file,
+	    out, outlen));
+}
+
+static void
+delegate_net(struct conf_settings *eff, struct conf_providers *provs)
+{
+	struct worker		*net = &workers[W_NET];
+	struct json_writer	 w;
+	struct buf		 cfg;
+	struct conf_provider	*cp;
+	struct netprov_desc	*descs;
+	const char		*cafile, *envhost, *envport;
+	size_t			 ndesc = 0, cap, i;
+	int			 include_default;
+
+	/*
+	 * The flat top-level/match keys define an implicit "default" provider --
+	 * included whenever any model-API key or endpoint was set there, and also
+	 * when no named providers exist (so there is always at least one provider,
+	 * which yields the friendly "no api_key" error if it too is keyless).
+	 */
+	include_default = (eff->set & (CS_API_KEY | CS_API_KEY_FILE |
+	    CS_ANTHROPIC_KEY | CS_ANTHROPIC_KEY_FILE | CS_PROVIDER | CS_API_HOST |
+	    CS_API_PATH)) != 0 || TAILQ_EMPTY(provs);
+
+	cap = 1;
+	TAILQ_FOREACH(cp, provs, entry)
+		cap++;
+	descs = xreallocarray(NULL, cap, sizeof(*descs));
+
+	envhost = trusted_env("FUGU_NET_HOST");
+	envport = trusted_env("FUGU_NET_PORT");
+	if (include_default) {
+		descs[ndesc].name = "default";
+		descs[ndesc].s = eff;
+		descs[ndesc].host = envhost != NULL ? envhost :
+		    (eff->set & CS_API_HOST ? eff->api_host : NULL);
+		descs[ndesc].port = envport != NULL ? envport :
+		    (eff->set & CS_API_PORT ? eff->api_port : NULL);
+		ndesc++;
+	}
+	TAILQ_FOREACH(cp, provs, entry) {
+		descs[ndesc].name = cp->name;
+		descs[ndesc].s = &cp->settings;
+		descs[ndesc].host = cp->settings.set & CS_API_HOST ?
+		    cp->settings.api_host : NULL;
+		descs[ndesc].port = cp->settings.set & CS_API_PORT ?
+		    cp->settings.api_port : NULL;
+		ndesc++;
+	}
+
+	/* M_CONFIG: the non-secret provider array plus shared turn settings. */
+	buf_init(&cfg);
+	json_writer_init(&w, &cfg);
+	json_object_start(&w);
+	if (eff->set & CS_SYSTEM)
+		json_kv_string(&w, "system", eff->system);
+	if (eff->set & CS_MAX_TOKENS)
+		json_kv_int(&w, "max_tokens", eff->max_tokens);
+	/* Web tools are offered unless explicitly disabled. */
+	json_kv_bool(&w, "web_search",
+	    (eff->set & CS_WEB_SEARCH) ? eff->web_search : 1);
+	/* The general http_request tool is offered only when an allowlist exists. */
+	json_kv_bool(&w, "http_request", (eff->set & CS_HTTP_ALLOW) &&
+	    eff->http_allow != NULL && eff->http_allow[0] != '\0');
+	if ((cafile = trusted_env("FUGU_NET_CAFILE")) != NULL)
+		json_kv_string(&w, "cafile", cafile);
+	json_kv_int(&w, "active", 0);	/* the default/first provider starts active */
+	json_key(&w, "providers");
+	json_array_start(&w);
+	for (i = 0; i < ndesc; i++) {
+		const struct conf_settings	*s = descs[i].s;
+
+		json_object_start(&w);
+		json_kv_string(&w, "name", descs[i].name);
+		/* Wire protocol: "openai" selects chat/completions, else anthropic. */
+		json_kv_string(&w, "type",
+		    (s->set & CS_PROVIDER) ? s->provider : "anthropic");
+		if (s->set & CS_MODEL)
+			json_kv_string(&w, "model", s->model);
+		if (descs[i].host != NULL)
+			json_kv_string(&w, "host", descs[i].host);
+		if (descs[i].port != NULL)
+			json_kv_string(&w, "port", descs[i].port);
+		if (s->set & CS_API_PATH)
+			json_kv_string(&w, "path", s->api_path);
+		json_object_end(&w);
+	}
+	json_array_end(&w);
+	json_object_end(&w);
+
+	if (ip_compose(&net->ip, M_CONFIG, EP_NET, EP_PARENT, cfg.data,
+	    cfg.len) == -1 || ip_flush(&net->ip) == -1)
+		log_warnx("net: config delegation failed");
+	buf_free(&cfg);
+
+	/*
+	 * One M_SECRET per provider that resolves a key.  The payload is the
+	 * provider's index (uint32, host order) followed by the key bytes, so the
+	 * key still travels only in M_SECRET -- never in argv, env, or M_CONFIG.
+	 */
+	for (i = 0; i < ndesc; i++) {
+		struct buf	 sec;
+		uint32_t	 idx = (uint32_t)i;
+		char		*key;
+		size_t		 keylen;
+
+		if (netprov_key(descs[i].s, &key, &keylen) == -1) {
+			if (ndesc == 1)
+				log_warnx("no api_key/anthropic_key configured; "
+				    "net cannot authenticate");
+			continue;
+		}
+		buf_init(&sec);
+		buf_append(&sec, &idx, sizeof(idx));
+		buf_append(&sec, key, keylen);
+		if (ip_compose(&net->ip, M_SECRET, EP_NET, EP_PARENT, sec.data,
+		    sec.len) == -1 || ip_flush(&net->ip) == -1)
+			log_warnx("net: secret delegation failed");
+		buf_freezero(&sec);		/* held the key bytes */
+		freezero(key, keylen);
+	}
+	free(descs);
+}
+
+static void
+delegate_exec(struct conf_settings *eff)
+{
+	struct worker		*exec = &workers[W_EXEC];
+	struct json_writer	 w;
+	struct buf		 cfg;
+	int			 allow;
+
+	allow = (eff->set & CS_ALLOW_WRITE) ? eff->allow_write : 1;
+
+	buf_init(&cfg);
+	json_writer_init(&w, &cfg);
+	json_object_start(&w);
+	json_kv_bool(&w, "allow_write", allow);
+	json_object_end(&w);
+	if (ip_compose(&exec->ip, M_CONFIG, EP_EXEC, EP_PARENT, cfg.data,
+	    cfg.len) == -1 || ip_flush(&exec->ip) == -1)
+		log_warnx("exec: config delegation failed");
+	buf_free(&cfg);
+}
+
+static void
+delegate_fetch(struct conf_settings *eff)
+{
+	struct worker		*fetch = &workers[W_FETCH];
+	struct json_writer	 w;
+	struct buf		 cfg;
+	const char		*host, *port, *cafile;
+	char			*token;
+
+	buf_init(&cfg);
+	json_writer_init(&w, &cfg);
+	json_object_start(&w);
+	/* Endpoint override (test/advanced); production uses kagi.com:443. */
+	if ((host = trusted_env("FUGU_FETCH_KAGI_HOST")) != NULL)
+		json_kv_string(&w, "kagi_host", host);
+	if ((port = trusted_env("FUGU_FETCH_KAGI_PORT")) != NULL)
+		json_kv_string(&w, "kagi_port", port);
+	if ((cafile = trusted_env("FUGU_FETCH_CAFILE")) != NULL)
+		json_kv_string(&w, "cafile", cafile);
+	/* Opt out of the web_fetch SSRF guard (e.g. to reach an intranet). */
+	if (trusted_env("FUGU_FETCH_ALLOW_PRIVATE") != NULL)
+		json_kv_bool(&w, "allow_private", 1);
+	/* Host allow/block lists gating the model's http_request tool. */
+	if (eff->set & CS_HTTP_ALLOW)
+		json_kv_string(&w, "http_allow", eff->http_allow);
+	if (eff->set & CS_HTTP_BLOCK)
+		json_kv_string(&w, "http_block", eff->http_block);
+	json_object_end(&w);
+
+	if (ip_compose(&fetch->ip, M_CONFIG, EP_FETCH, EP_PARENT, cfg.data,
+	    cfg.len) == -1 || ip_flush(&fetch->ip) == -1)
+		log_warnx("fetch: config delegation failed");
+	buf_free(&cfg);
+
+	/* The Kagi token is optional: without it, web_fetch still works. */
+	{
+		size_t	tokenlen;
+
+		if (secret_load(eff->set & CS_KAGI_TOKEN, eff->kagi_token,
+		    eff->set & CS_KAGI_TOKEN_FILE, eff->kagi_token_file,
+		    &token, &tokenlen) == 0) {
+			if (ip_compose(&fetch->ip, M_SECRET, EP_FETCH,
+			    EP_PARENT, token, tokenlen) == -1 ||
+			    ip_flush(&fetch->ip) == -1)
+				log_warnx("fetch: secret delegation failed");
+			freezero(token, tokenlen);
+		}
+	}
+}
+
+/*
+ * Length-aware secret loader.  Produces (*out, *outlen) on success and 0
+ * return; on failure or empty secret returns -1 with *out unchanged.  The
+ * length is the true byte count, NOT strlen(*out) -- so a file-loaded secret
+ * that legitimately contains embedded NULs survives the round trip into the
+ * M_SECRET payload and freezero() clears every byte, not just up to the
+ * first NUL.  Inline secrets (config STRING tokens) cannot contain NULs by
+ * construction of the conf parser, but reporting their length too keeps the
+ * single API.
+ */
+static int
+secret_load(int have_inline, const char *inline_val, int have_file,
+    const char *file_path, char **out, size_t *outlen)
+{
+	FILE	*fp;
+	char	*line = NULL;
+	size_t	 cap = 0;
+	ssize_t	 n;
+
+	if (have_inline) {
+		size_t	len;
+
+		if (inline_val == NULL || *inline_val == '\0') {
+			log_warnx("empty inline secret");
+			return (-1);
+		}
+		len = strlen(inline_val);
+		*out = xstrdup(inline_val);
+		*outlen = len;
+		return (0);
+	}
+	if (!have_file)
+		return (-1);
+
+	if ((fp = fopen(file_path, "r")) == NULL) {
+		log_warn("open %s", file_path);
+		return (-1);
+	}
+	/* A key file holds a secret: enforce StrictModes on the open fd. */
+	if (conf_check_secrecy(fileno(fp), file_path, getuid()) == -1) {
+		fclose(fp);
+		return (-1);
+	}
+	n = getline(&line, &cap, fp);
+	fclose(fp);
+	if (n <= 0) {
+		free(line);
+		log_warnx("empty key file: %s", file_path);
+		return (-1);
+	}
+	while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+		line[--n] = '\0';
+	if (n == 0) {
+		free(line);
+		log_warnx("empty key file: %s", file_path);
+		return (-1);
+	}
+	*out = line;
+	*outlen = (size_t)n;
+	return (0);
+}
+
+/*
+ * Back-compat wrapper that throws away the length.  Use only when the caller
+ * is about to feed the bytes into a strlen-safe sink (e.g. a log message).
+ * Secret-bearing paths must call secret_load() directly so freezero() and
+ * the imsg payload see the true byte count.
+ */
+static char *
+resolve_secret(int have_inline, const char *inline_val, int have_file,
+    const char *file_path)
+{
+	char	*out;
+	size_t	 len;
+
+	(void)len;
+	if (secret_load(have_inline, inline_val, have_file, file_path,
+	    &out, &len) == -1)
+		return (NULL);
+	return (out);
+}
+
+static struct worker *
+worker_by_ep(uint32_t ep)
+{
+	int	i;
+
+	for (i = 0; i < NWORKERS; i++) {
+		if (WDESC[i].ep == ep && workers[i].active)
+			return (&workers[i]);
+	}
+	return (NULL);
+}
+
+static int
+relay_loop(void)
+{
+	struct pollfd	pfd[NWORKERS];
+	int		i, n;
+
+	for (;;) {
+		n = 0;
+		for (i = 0; i < NWORKERS; i++) {
+			if (!workers[i].active)
+				continue;
+			pfd[i].fd = ip_fd(&workers[i].ip);
+			pfd[i].events = POLLIN;
+			pfd[i].revents = 0;
+			n++;
+		}
+		if (n == 0)
+			return (0);
+
+		if (poll(pfd, NWORKERS, -1) == -1) {
+			if (errno == EINTR)
+				continue;
+			log_warn("poll");
+			return (-1);
+		}
+		for (i = 0; i < NWORKERS; i++) {
+			if (!workers[i].active)
+				continue;
+			if (pfd[i].revents & (POLLHUP | POLLERR | POLLNVAL)) {
+				log_info("%s exited", WDESC[i].name);
+				return (0);
+			}
+			if (pfd[i].revents & POLLIN) {
+				if (relay_drain(&workers[i]) == -1)
+					return (0);
+			}
+		}
+	}
+}
+
+static int
+relay_drain(struct worker *from)
+{
+	struct worker	*to;
+	struct imsg	 imsg;
+	uint32_t	 dst;
+	int		 n;
+
+	if (ip_read(&from->ip) <= 0)
+		return (-1);		/* worker closed the channel */
+
+	for (;;) {
+		n = ip_frame_get(&from->ip, &imsg);
+		if (n == -1)
+			return (-1);
+		if (n == 0)
+			return (0);
+
+		dst = imsg_get_id(&imsg);
+		if (dst == EP_PARENT) {
+			/* worker -> parent control frame (e.g. M_READY) */
+			imsg_free(&imsg);
+			continue;
+		}
+		if ((to = worker_by_ep(dst)) != NULL) {
+			if (ip_forward(&to->ip, &imsg) == -1 ||
+			    ip_flush(&to->ip) == -1)
+				log_warnx("relay to %u failed", dst);
+		} else
+			log_warnx("relay: no worker for dst %u", dst);
+		imsg_free(&imsg);
+	}
+}
+
+static void
+shutdown_workers(void)
+{
+	int	i, status;
+
+	for (i = 0; i < NWORKERS; i++) {
+		if (!workers[i].active)
+			continue;
+		close(ip_fd(&workers[i].ip));	/* EOF -> worker exits */
+		ip_clear(&workers[i].ip);
+		workers[i].active = 0;
+	}
+	for (i = 0; i < NWORKERS; i++) {
+		if (workers[i].pid > 0)
+			waitpid(workers[i].pid, &status, 0);
+	}
+}
blob - /dev/null
blob + 11caed2326b502325808956e45d0ebc55fc99aee (mode 644)
--- /dev/null
+++ src/ui/Makefile
@@ -0,0 +1,10 @@
+.include "${.CURDIR}/../Makefile.inc"
+
+PROG=	fugu-ui
+SRCS+=	ui.c ui_curses.c journal.c convo.c skills.c mdrender.c fuzzy.c
+BINDIR=	${PREFIX}/libexec/fugu
+LDADD+=	-lcurses
+DPADD+=	${LIBCURSES}
+MAN=
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + c947869bc06b803685cc68581be9566285cf557b (mode 644)
--- /dev/null
+++ src/ui/convo.c
@@ -0,0 +1,453 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The conversation model shared by both front-ends: an ordered list of complete
+ * message objects (raw JSON), the projection rebuilt from it for each M_SUBMIT,
+ * and the resume path that replays a session journal back into a fresh convo and
+ * (via a callback) into whatever the front-end uses to display a transcript.
+ *
+ * Keeping this out of ui.c (which owns main()) lets it be unit-tested directly.
+ */
+
+#include <sys/types.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "journal.h"
+#include "json.h"
+#include "ui.h"
+#include "util.h"
+
+/* Append a {"role":<role>,"content":<text>} message built from plain text. */
+static void
+convo_add_role(struct convo *cv, const char *role, const char *text)
+{
+	struct json_writer	w;
+	struct buf		b;
+
+	buf_init(&b);
+	json_writer_init(&w, &b);
+	json_object_start(&w);
+	json_kv_string(&w, "role", role);
+	json_kv_string(&w, "content", text);
+	json_object_end(&w);
+	buf_terminate(&b);
+
+	cv->msg = xreallocarray(cv->msg, cv->n + 1, sizeof(*cv->msg));
+	cv->msg[cv->n++] = xstrdup(buf_cstr(&b));
+	buf_free(&b);
+}
+
+void
+convo_add_user(struct convo *cv, const char *text)
+{
+	convo_add_role(cv, "user", text);
+}
+
+void
+convo_clear(struct convo *cv)
+{
+	convo_truncate(cv, 0);
+}
+
+/*
+ * Replace the active context with a /compact summary.  The summary is stored as
+ * a user/assistant pair so the next real user turn still alternates correctly --
+ * a lone synthetic message would leave two same-role turns adjacent, which the
+ * Messages API rejects.
+ */
+void
+convo_set_summary(struct convo *cv, const char *summary)
+{
+	convo_truncate(cv, 0);
+	convo_add_role(cv, "user", "Here is a summary of our conversation so "
+	    "far; continue from where we left off.");
+	convo_add_role(cv, "assistant", summary);
+}
+
+/* Build the journal marker recording a /clear (context reset). */
+void
+convo_marker_clear(struct buf *out)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "fugu", "clear");
+	json_object_end(&w);
+}
+
+/* Build the journal marker recording a /compact, carrying the summary text. */
+void
+convo_marker_compact(struct buf *out, const char *summary)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "fugu", "compact");
+	json_kv_string(&w, "summary", summary);
+	json_object_end(&w);
+}
+
+/*
+ * Build the journal marker emitted when a turn fails mid-flight (HTTP error,
+ * channel drop, malformed stream).  On resume it triggers a truncation back to
+ * the previous transactional boundary -- the most recent skill marker, plain
+ * user-typed message, /clear, /compact, or the start of the journal -- so the
+ * orphan records the failed turn wrote (synth pair, partial assistant blocks)
+ * never replay into the rebuilt convo.
+ */
+void
+convo_marker_turn_failed(struct buf *out)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "fugu", "turn_failed");
+	json_object_end(&w);
+}
+
+/*
+ * Append a turn_failed marker to the journal if (and only if) the failed turn
+ * wrote convo records past `mark`.  Both UIs call this from every path that
+ * abandons an in-flight turn (M_ERROR, channel break, send failure): a single
+ * choke point keeps the protocol coherent and prevents two foot-guns:
+ *
+ *   1. Emitting when nothing was added (e.g. /compact failed before any state
+ *      change).  On resume the marker would roll back PAST the last real user
+ *      turn -- the resumed convo silently loses earlier history.
+ *   2. Forgetting to emit on a quiet exit path.  Orphan user/assistant records
+ *      then replay into the rebuilt convo with no marker to discard them.
+ */
+void
+convo_journal_turn_failed(struct journal *j, const struct convo *cv,
+    size_t mark)
+{
+	struct buf	mk;
+
+	if (cv->n <= mark)
+		return;
+	buf_init(&mk);
+	convo_marker_turn_failed(&mk);
+	journal_append(j, mk.data, mk.len);
+	buf_free(&mk);
+}
+
+void
+convo_add_raw(struct convo *cv, const char *json, size_t len)
+{
+	char	*s = xmalloc(len + 1);
+
+	memcpy(s, json, len);
+	s[len] = '\0';
+	cv->msg = xreallocarray(cv->msg, cv->n + 1, sizeof(*cv->msg));
+	cv->msg[cv->n++] = s;
+}
+
+void
+convo_truncate(struct convo *cv, size_t n)
+{
+	while (cv->n > n)
+		free(cv->msg[--cv->n]);
+}
+
+void
+convo_free(struct convo *cv)
+{
+	convo_truncate(cv, 0);
+	free(cv->msg);
+	cv->msg = NULL;
+}
+
+void
+convo_build(const struct convo *cv, struct buf *out)
+{
+	size_t	i;
+
+	buf_putc(out, '[');
+	for (i = 0; i < cv->n; i++) {
+		if (i > 0)
+			buf_putc(out, ',');
+		buf_puts_cstr(out, cv->msg[i]);
+	}
+	buf_putc(out, ']');
+}
+
+/* Hand one tool_use block to the front-end formatted like a live M_TOOL_CALL. */
+static void
+replay_tool_use(const struct json_doc *d, int obj, convo_part_cb cb, void *ctx)
+{
+	struct buf	tb;
+	char		*name = NULL, *input = NULL;
+	int		 nt, it;
+
+	if ((nt = json_obj_get(d, obj, "name")) >= 0)
+		name = json_tok_strdup(d, nt);
+	if ((it = json_obj_get(d, obj, "input")) >= 0)
+		input = json_tok_strdup(d, it);
+
+	buf_init(&tb);
+	buf_printf(&tb, "%s %s", name != NULL ? name : "(tool)",
+	    input != NULL ? input : "");
+	cb(ctx, CP_TOOL, tb.data, tb.len);
+	buf_free(&tb);
+	free(name);
+	free(input);
+}
+
+void
+convo_replay(const char *json, size_t len, convo_part_cb cb, void *ctx)
+{
+	struct json_doc	d;
+	int		role_t, content_t, is_user, n, j, k;
+
+	if (json_parse(&d, json, len) != 0 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		return;
+	}
+	role_t = json_obj_get(&d, 0, "role");
+	content_t = json_obj_get(&d, 0, "content");
+	if (role_t < 0 || content_t < 0) {
+		json_doc_free(&d);
+		return;
+	}
+	is_user = json_tok_streq(&d, role_t, "user");
+
+	/* A plain string is a typed user message (or a bare assistant reply). */
+	if (json_tok_type(&d, content_t) == JSON_STRING) {
+		char	*s = json_tok_strdup(&d, content_t);
+
+		cb(ctx, is_user ? CP_USER : CP_ASSIST, s, strlen(s));
+		free(s);
+		json_doc_free(&d);
+		return;
+	}
+
+	/*
+	 * Otherwise content is an array of blocks.  Show what the live transcript
+	 * shows: assistant text and tool_use calls.  tool_result blocks (the user
+	 * role's array form) carry skill bodies emitted by synth pairs -- the
+	 * substantive part of a slash-skill invocation -- so they are drawn as
+	 * CP_TOOL, the same channel as the matching tool_use.
+	 */
+	if (json_tok_type(&d, content_t) != JSON_ARRAY) {
+		json_doc_free(&d);
+		return;
+	}
+	n = json_tok_size(&d, content_t);
+	j = content_t + 1;
+	for (k = 0; k < n; k++) {
+		int	type_t;
+
+		if (json_tok_type(&d, j) == JSON_OBJECT &&
+		    (type_t = json_obj_get(&d, j, "type")) >= 0) {
+			if (json_tok_streq(&d, type_t, "text")) {
+				int	txt = json_obj_get(&d, j, "text");
+
+				if (txt >= 0) {
+					char	*s = json_tok_strdup(&d, txt);
+
+					cb(ctx, CP_ASSIST, s, strlen(s));
+					free(s);
+				}
+			} else if (json_tok_streq(&d, type_t, "tool_use"))
+				replay_tool_use(&d, j, cb, ctx);
+			else if (json_tok_streq(&d, type_t, "tool_result")) {
+				int	ct = json_obj_get(&d, j, "content");
+
+				if (ct >= 0 &&
+				    json_tok_type(&d, ct) == JSON_STRING) {
+					char	*s = json_tok_strdup(&d, ct);
+
+					cb(ctx, CP_TOOL, s, strlen(s));
+					free(s);
+				}
+			}
+		}
+		j = json_skip(&d, j);
+	}
+	json_doc_free(&d);
+}
+
+struct resume_ctx {
+	struct convo	*cv;
+	convo_part_cb	 cb;
+	void		*cbctx;
+	size_t		 boundary;	/* convo length at last txn boundary */
+};
+
+/*
+ * A journal line is a conversation message or a marker.  Markers travel a
+ * single text out-param: for RK_COMPACT it's the stored summary, for RK_SKILL
+ * it's the slash-name of the invoked skill.
+ */
+enum rec_kind {
+	RK_MESSAGE,
+	RK_CLEAR,
+	RK_COMPACT,
+	RK_SKILL,
+	RK_TURN_FAILED,
+	RK_SKIP,
+};
+
+static enum rec_kind
+record_kind(const char *json, size_t len, char **text)
+{
+	struct json_doc	d;
+	enum rec_kind	k = RK_MESSAGE;
+	int		ft;
+
+	*text = NULL;
+	if (json_parse(&d, json, len) != 0 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		return (RK_SKIP);
+	}
+	if ((ft = json_obj_get(&d, 0, "fugu")) >= 0) {
+		if (json_tok_streq(&d, ft, "clear"))
+			k = RK_CLEAR;
+		else if (json_tok_streq(&d, ft, "compact")) {
+			int	st = json_obj_get(&d, 0, "summary");
+
+			k = RK_COMPACT;
+			if (st >= 0)
+				*text = json_tok_strdup(&d, st);
+		} else if (json_tok_streq(&d, ft, "skill")) {
+			int	nt = json_obj_get(&d, 0, "name");
+
+			k = RK_SKILL;
+			if (nt >= 0)
+				*text = json_tok_strdup(&d, nt);
+		} else if (json_tok_streq(&d, ft, "turn_failed"))
+			k = RK_TURN_FAILED;
+		else
+			k = RK_SKIP;
+	}
+	json_doc_free(&d);
+	return (k);
+}
+
+/*
+ * Is `json` a plain typed user message (role=user, content=string)?  Used to
+ * spot the start of a new transactional boundary on replay -- the rebuilt
+ * convo's `boundary` advances here, and `turn_failed` rolls back to it.  A
+ * user-role tool_result block (content=array) is a continuation inside a turn
+ * and is intentionally NOT a boundary.
+ */
+static int
+is_user_typed_message(const char *json, size_t len)
+{
+	struct json_doc	d;
+	int		role_t, content_t, ok = 0;
+
+	if (json_parse(&d, json, len) != 0 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT) {
+		json_doc_free(&d);
+		return (0);
+	}
+	role_t = json_obj_get(&d, 0, "role");
+	content_t = json_obj_get(&d, 0, "content");
+	ok = role_t >= 0 && content_t >= 0 &&
+	    json_tok_streq(&d, role_t, "user") &&
+	    json_tok_type(&d, content_t) == JSON_STRING;
+	json_doc_free(&d);
+	return (ok);
+}
+
+/*
+ * Replay one journal line: a message is kept and drawn; a /clear resets the
+ * active context (the prior messages stay on screen as scrollback, with a
+ * divider); a /compact replaces the context with its stored summary.  A
+ * turn_failed marker rolls the convo back to the last transactional boundary
+ * (a skill marker, a typed user message, a /clear or /compact, or the start of
+ * the journal) so a turn that failed mid-flight does not leave orphan records
+ * in the replayed convo.  This reconstructs exactly the active context that
+ * was in effect when resuming.
+ */
+static void
+resume_line(void *ctx, const char *line, size_t len)
+{
+	struct resume_ctx	*rc = ctx;
+	char			*text = NULL;
+
+	switch (record_kind(line, len, &text)) {
+	case RK_CLEAR:
+		convo_truncate(rc->cv, 0);
+		rc->boundary = 0;
+		if (rc->cb != NULL)
+			rc->cb(rc->cbctx, CP_INFO, "context cleared",
+			    strlen("context cleared"));
+		break;
+	case RK_COMPACT:
+		convo_set_summary(rc->cv, text != NULL ? text : "");
+		rc->boundary = rc->cv->n;
+		if (rc->cb != NULL) {
+			rc->cb(rc->cbctx, CP_INFO,
+			    "earlier conversation compacted",
+			    strlen("earlier conversation compacted"));
+			if (text != NULL)
+				rc->cb(rc->cbctx, CP_ASSIST, text,
+				    strlen(text));
+		}
+		break;
+	case RK_SKILL:
+		/*
+		 * A skill marker opens a new transaction: the synth trio that
+		 * follows is what turn_failed undoes.  Pin the boundary here so
+		 * the truncation drops the trio and any partial assistant
+		 * blocks streamed before the failure.
+		 */
+		rc->boundary = rc->cv->n;
+		if (rc->cb != NULL && text != NULL)
+			rc->cb(rc->cbctx, CP_INFO, text, strlen(text));
+		break;
+	case RK_TURN_FAILED:
+		convo_truncate(rc->cv, rc->boundary);
+		if (rc->cb != NULL)
+			rc->cb(rc->cbctx, CP_INFO,
+			    "previous failed turn discarded",
+			    strlen("previous failed turn discarded"));
+		break;
+	case RK_MESSAGE:
+		if (is_user_typed_message(line, len))
+			rc->boundary = rc->cv->n;
+		convo_add_raw(rc->cv, line, len);
+		if (rc->cb != NULL)
+			convo_replay(line, len, rc->cb, rc->cbctx);
+		break;
+	default:			/* RK_SKIP: unrecognised line */
+		break;
+	}
+	free(text);
+}
+
+int
+convo_resume(struct convo *cv, const char *id, convo_part_cb cb, void *ctx)
+{
+	struct resume_ctx	rc;
+
+	rc.cv = cv;
+	rc.cb = cb;
+	rc.cbctx = ctx;
+	rc.boundary = 0;
+	return (journal_foreach(id, resume_line, &rc));
+}
blob - /dev/null
blob + 6f86803733749d77cd79cd6eee565b373f76d17a (mode 644)
--- /dev/null
+++ src/ui/journal.c
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "journal.h"
+
+/* Build "$HOME/.fugu/sessions" into buf. -1 if HOME is unset or it won't fit. */
+static int
+sessions_dir(char *buf, size_t bufsz)
+{
+	const char	*home;
+
+	if ((home = getenv("HOME")) == NULL || *home == '\0')
+		return (-1);
+	if (snprintf(buf, bufsz, "%s/.fugu/sessions", home) >= (int)bufsz)
+		return (-1);
+	return (0);
+}
+
+/* A resumed id is interpolated into a path; accept only a safe basename. */
+static int
+valid_id(const char *id)
+{
+	return (id != NULL && *id != '\0' && strchr(id, '/') == NULL &&
+	    strcmp(id, ".") != 0 && strcmp(id, "..") != 0);
+}
+
+static int
+write_all(int fd, const char *p, size_t n)
+{
+	size_t	off = 0;
+	ssize_t	w;
+
+	while (off < n) {
+		w = write(fd, p + off, n - off);
+		if (w == -1) {
+			if (errno == EINTR)
+				continue;
+			return (-1);
+		}
+		if (w == 0) {		/* should not happen on a regular file */
+			errno = EPIPE;
+			return (-1);
+		}
+		off += (size_t)w;
+	}
+	return (0);
+}
+
+void
+journal_unveil(void)
+{
+	const char	*home;
+	char		 dir[PATH_MAX];
+
+	if ((home = getenv("HOME")) != NULL && *home != '\0' &&
+	    snprintf(dir, sizeof(dir), "%s/.fugu/sessions", home) <
+	    (int)sizeof(dir))
+		(void)unveil(dir, "rwc");	/* missing dir -> journaling off */
+}
+
+void
+journal_open(struct journal *j, const char *id)
+{
+	char	dir[PATH_MAX];
+
+	j->fd = -1;
+	j->id[0] = '\0';
+
+	if (sessions_dir(dir, sizeof(dir)) == -1)
+		return;
+
+	if (id != NULL && *id != '\0') {
+		/* Resuming: reuse the existing file so the record stays continuous. */
+		if (!valid_id(id) ||
+		    strlcpy(j->id, id, sizeof(j->id)) >= sizeof(j->id)) {
+			j->id[0] = '\0';
+			return;
+		}
+	} else {
+		/* Epoch + pid: unique, sortable, and free of any timezone files. */
+		(void)snprintf(j->id, sizeof(j->id), "%lld-%ld",
+		    (long long)time(NULL), (long)getpid());
+	}
+
+	if (snprintf(j->path, sizeof(j->path), "%s/%s.jsonl", dir, j->id) >=
+	    (int)sizeof(j->path)) {
+		j->id[0] = '\0';
+		return;
+	}
+	j->fd = open(j->path, O_WRONLY | O_APPEND | O_CREAT, 0600);
+	/* On failure fd stays -1 and journaling is simply disabled. */
+}
+
+int
+journal_foreach(const char *id, void (*cb)(void *, const char *, size_t),
+    void *ctx)
+{
+	char	 dir[PATH_MAX], path[PATH_MAX];
+	FILE	*fp;
+	char	*line = NULL;
+	size_t	 cap = 0;
+	ssize_t	 n;
+
+	if (!valid_id(id) || sessions_dir(dir, sizeof(dir)) == -1)
+		return (-1);
+	if (snprintf(path, sizeof(path), "%s/%s.jsonl", dir, id) >=
+	    (int)sizeof(path))
+		return (-1);
+	if ((fp = fopen(path, "r")) == NULL)
+		return (-1);
+
+	while ((n = getline(&line, &cap, fp)) != -1) {
+		while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r'))
+			line[--n] = '\0';
+		if (n > 0)
+			cb(ctx, line, (size_t)n);
+	}
+	free(line);
+	fclose(fp);
+	return (0);
+}
+
+void
+journal_append(struct journal *j, const char *json, size_t len)
+{
+	if (j->fd < 0)
+		return;
+	if (write_all(j->fd, json, len) == -1 ||
+	    write_all(j->fd, "\n", 1) == -1) {
+		close(j->fd);
+		j->fd = -1;		/* stop after the first write error */
+	}
+}
+
+void
+journal_close(struct journal *j)
+{
+	if (j->fd >= 0) {
+		close(j->fd);
+		j->fd = -1;
+	}
+}
blob - /dev/null
blob + 8a28ae0cd532daa7c43a296bbaca4b7f1516ad20 (mode 644)
--- /dev/null
+++ src/ui/journal.h
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * journal: the append-only per-session record at ~/.fugu/sessions/<id>.jsonl.
+ * One JSON message object per line (the ksh-history / syslog idiom: written as
+ * it happens, never rewritten).  Everything here is best-effort: if the file
+ * cannot be created or a write fails, journaling silently disables itself so a
+ * session is never interrupted by a logging problem.  The fd is opened while the
+ * UI still has wpath/cpath and stays writable after the pledge narrows (writes
+ * to an already-open fd need only "stdio").
+ */
+
+#ifndef FUGU_JOURNAL_H
+#define FUGU_JOURNAL_H
+
+#include <sys/types.h>
+
+#include <limits.h>
+
+struct journal {
+	int	fd;			/* append fd, or -1 when disabled		*/
+	char	id[64];			/* session id (also the file's basename)	*/
+	char	path[PATH_MAX];
+};
+
+/* unveil(2) the sessions directory rwc (best-effort); call before locking. */
+void	journal_unveil(void);
+
+/*
+ * Open (creating) ~/.fugu/sessions/<id>.jsonl for appending; id==NULL mints a
+ * fresh one.  Never fails loudly: on any problem j->fd is left -1.
+ */
+void	journal_open(struct journal *, const char *id);
+
+/* Append one JSON record as a line.  No-op when disabled. */
+void	journal_append(struct journal *, const char *json, size_t len);
+
+void	journal_close(struct journal *);
+
+/*
+ * Read an existing session's journal, oldest line first, invoking cb for each
+ * non-empty line (trailing newline stripped).  Used to resume a session.
+ * Returns 0 on success, -1 if the id is invalid or the file cannot be opened.
+ */
+int	journal_foreach(const char *id,
+	    void (*cb)(void *, const char *line, size_t len), void *ctx);
+
+#endif /* FUGU_JOURNAL_H */
blob - /dev/null
blob + 737fa8856f9f264c1a3d1ec129ca11520f96c027 (mode 644)
--- /dev/null
+++ src/ui/skills.c
@@ -0,0 +1,627 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * skills loader.  Discovers ~/.fugu/skills/<name>/SKILL.md files at startup,
+ * parses an optional YAML-ish header (only `name:` and `description:` are
+ * read), and keeps the body in memory for invocation.  All file I/O is done up
+ * front so the runtime never needs rpath -- once skills_load() returns,
+ * fugu-ui can pledge straight down to "stdio" (or "stdio tty") without
+ * reopening anything.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "json.h"
+#include "log.h"
+#include "skills.h"
+#include "util.h"
+
+#define SKILLS_MAX	256		/* sanity cap on registry size */
+#define SKILL_MAX_BYTES	(256 * 1024)	/* per-file cap */
+
+/* Build "$HOME/.fugu/skills" into buf. -1 if HOME is unset or it won't fit. */
+static int
+skills_dir(char *buf, size_t bufsz)
+{
+	const char	*home;
+
+	if ((home = getenv("HOME")) == NULL || *home == '\0')
+		return (-1);
+	if (snprintf(buf, bufsz, "%s/.fugu/skills", home) >= (int)bufsz)
+		return (-1);
+	return (0);
+}
+
+void
+skills_unveil(void)
+{
+	char	dir[PATH_MAX];
+
+	if (skills_dir(dir, sizeof(dir)) == 0)
+		(void)unveil(dir, "r");		/* missing dir -> skills off */
+}
+
+/* Validate a directory entry name as a slash-command suffix. */
+static int
+valid_skill_name(const char *s)
+{
+	const char	*p;
+
+	if (s == NULL || *s == '\0' || *s == '.')
+		return (0);
+	for (p = s; *p != '\0'; p++) {
+		if (!isalnum((unsigned char)*p) && *p != '-' && *p != '_')
+			return (0);
+	}
+	return (1);
+}
+
+/* Slurp the whole file at path, NUL-terminated, capped at SKILL_MAX_BYTES. */
+static char *
+slurp(const char *path, size_t *outlen)
+{
+	struct stat	st;
+	int		fd;
+	ssize_t		r;
+	size_t		off, sz;
+	char		*buf;
+
+	if ((fd = open(path, O_RDONLY | O_CLOEXEC)) == -1)
+		return (NULL);
+	if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) || st.st_size <= 0 ||
+	    (size_t)st.st_size > SKILL_MAX_BYTES) {
+		close(fd);
+		return (NULL);
+	}
+	sz = (size_t)st.st_size;
+	buf = xmalloc(sz + 1);
+	off = 0;
+	while (off < sz) {
+		r = read(fd, buf + off, sz - off);
+		if (r == -1) {
+			if (errno == EINTR)
+				continue;
+			free(buf);
+			close(fd);
+			return (NULL);
+		}
+		if (r == 0)
+			break;
+		off += (size_t)r;
+	}
+	close(fd);
+	buf[off] = '\0';
+	if (outlen != NULL)
+		*outlen = off;
+	return (buf);
+}
+
+/* Trim ASCII whitespace from both ends in place. */
+static void
+trim(char *p)
+{
+	char	*q;
+	size_t	 n, lead;
+
+	for (q = p; *q != '\0' && isspace((unsigned char)*q); q++)
+		;
+	lead = (size_t)(q - p);
+	n = strlen(q);
+	if (lead > 0)
+		memmove(p, q, n + 1);
+	while (n > 0 && isspace((unsigned char)p[n - 1]))
+		p[--n] = '\0';
+}
+
+/*
+ * If text begins with "---" followed by a newline, scan for a matching closing
+ * fence; for each "key: value" line in between, copy the value into *name,
+ * *description, or *no_invoke (the last is set non-zero when the header carries
+ * `disable-model-invocation: true`).  *body_off is set to the offset where the
+ * body starts (past the closing fence, or 0 if no header is present).  Returns
+ * 0 on success (header parsed or no header), -1 if a fence opened but never
+ * closed -- the caller should treat that as a malformed file.
+ */
+static int
+parse_header(char *text, size_t len, char **name, char **description,
+    int *no_invoke, size_t *body_off)
+{
+	char	*p, *end, *fence_end, *line, *nl, *colon, *key, *val;
+
+	*name = NULL;
+	*description = NULL;
+	*no_invoke = 0;
+	*body_off = 0;
+
+	if (len < 3 || strncmp(text, "---", 3) != 0)
+		return (0);
+	p = text + 3;
+	if (*p != '\n' && *p != '\r' && *p != '\0')
+		return (0);		/* "---foo" -- not a header fence */
+	while (*p == '\r')
+		p++;
+	if (*p == '\n')
+		p++;
+
+	end = text + len;
+	fence_end = NULL;
+	for (line = p; line < end; line = nl + 1) {
+		nl = memchr(line, '\n', (size_t)(end - line));
+		if (nl == NULL)
+			nl = end;
+		if ((size_t)(nl - line) >= 3 && strncmp(line, "---", 3) == 0) {
+			fence_end = nl < end ? nl + 1 : end;
+			break;
+		}
+		if (nl == end)
+			break;
+	}
+	if (fence_end == NULL)
+		return (-1);		/* unterminated header */
+	*body_off = (size_t)(fence_end - text);
+
+	/* Parse key:value lines between the two fences. */
+	for (line = p; line < end; line = nl + 1) {
+		nl = memchr(line, '\n', (size_t)(end - line));
+		if (nl == NULL)
+			nl = end;
+		if ((size_t)(nl - line) >= 3 && strncmp(line, "---", 3) == 0)
+			break;
+		if (nl < end)
+			*nl = '\0';	/* clobber: text is owned, not reread */
+		colon = strchr(line, ':');
+		if (colon != NULL) {
+			*colon = '\0';
+			key = line;
+			val = colon + 1;
+			trim(key);
+			trim(val);
+			if (*val != '\0') {
+				if (*name == NULL && strcmp(key, "name") == 0)
+					*name = xstrdup(val);
+				else if (*description == NULL &&
+				    strcmp(key, "description") == 0)
+					*description = xstrdup(val);
+				else if (strcmp(key,
+				    "disable-model-invocation") == 0 &&
+				    (strcmp(val, "true") == 0 ||
+				    strcmp(val, "yes") == 0 ||
+				    strcmp(val, "1") == 0))
+					*no_invoke = 1;
+			}
+		}
+		if (nl == end)
+			break;
+	}
+	return (0);
+}
+
+/*
+ * Read one SKILL.md and append to sk.  dirname is used as a fallback name if
+ * the header omits one.  Silently returns on any malformed/unreadable file.
+ */
+static void
+load_one(struct skills *sk, const char *dirname, const char *path)
+{
+	struct skill	 s;
+	char		*text, *hdr_name, *hdr_desc, *body;
+	size_t		 len, body_off;
+	int		 no_invoke = 0;
+
+	if ((text = slurp(path, &len)) == NULL)
+		return;
+
+	/*
+	 * Reject any file containing an internal NUL byte BEFORE we parse.  Both
+	 * parse_header (xstrdup on the header `name:` / `description:` values)
+	 * and the body copy below would strlen-truncate at the first NUL,
+	 * silently dropping content downstream.  Refuse the whole file here.
+	 */
+	if (memchr(text, '\0', len) != NULL) {
+		log_warnx("skill %s: file contains NUL byte, skipped", dirname);
+		free(text);
+		return;
+	}
+
+	if (parse_header(text, len, &hdr_name, &hdr_desc, &no_invoke,
+	    &body_off) == -1) {
+		free(text);
+		return;			/* malformed frontmatter */
+	}
+
+	/* Body: everything after the closing fence; must be non-empty. */
+	body = xstrdup(text + body_off);
+	free(text);
+	trim(body);
+	if (*body == '\0') {
+		free(hdr_name);
+		free(hdr_desc);
+		free(body);
+		return;
+	}
+
+	/* Build the slash-form name: header `name:` wins over the dirname. */
+	s.description = hdr_desc;
+	s.body = body;
+	s.no_model_invoke = no_invoke;
+	if (hdr_name != NULL && valid_skill_name(hdr_name)) {
+		size_t	nlen = strlen(hdr_name) + 2;
+
+		s.name = xmalloc(nlen);
+		(void)snprintf(s.name, nlen, "/%s", hdr_name);
+		free(hdr_name);
+	} else {
+		size_t	nlen = strlen(dirname) + 2;
+
+		s.name = xmalloc(nlen);
+		(void)snprintf(s.name, nlen, "/%s", dirname);
+		free(hdr_name);
+	}
+
+	sk->items = xreallocarray(sk->items, sk->n + 1, sizeof(*sk->items));
+	sk->items[sk->n++] = s;
+}
+
+static int
+cmp_skill(const void *a, const void *b)
+{
+	const struct skill	*sa = a, *sb = b;
+
+	return (strcmp(sa->name, sb->name));
+}
+
+void
+skills_load(struct skills *sk)
+{
+	DIR		*d;
+	struct dirent	*de;
+	char		 root[PATH_MAX], path[PATH_MAX];
+
+	if (skills_dir(root, sizeof(root)) == -1)
+		return;
+	if ((d = opendir(root)) == NULL)
+		return;
+	while ((de = readdir(d)) != NULL && sk->n < SKILLS_MAX) {
+		if (!valid_skill_name(de->d_name))
+			continue;
+		if (snprintf(path, sizeof(path), "%s/%s/SKILL.md", root,
+		    de->d_name) >= (int)sizeof(path))
+			continue;
+		load_one(sk, de->d_name, path);
+	}
+	closedir(d);
+
+	if (sk->n > 1)
+		qsort(sk->items, sk->n, sizeof(*sk->items), cmp_skill);
+}
+
+void
+skills_free(struct skills *sk)
+{
+	size_t	i;
+
+	for (i = 0; i < sk->n; i++) {
+		free(sk->items[i].name);
+		free(sk->items[i].description);
+		free(sk->items[i].body);
+	}
+	free(sk->items);
+	sk->items = NULL;
+	sk->n = 0;
+}
+
+const struct skill *
+skills_match(const struct skills *sk, const char *line, const char **trailing)
+{
+	const char	*p, *space;
+	size_t		 i, namelen;
+
+	if (trailing != NULL)
+		*trailing = NULL;
+	if (sk == NULL || sk->n == 0 || line == NULL || line[0] != '/')
+		return (NULL);
+
+	/* Find end of the first whitespace-delimited token. */
+	for (p = line; *p != '\0' && !isspace((unsigned char)*p); p++)
+		;
+	namelen = (size_t)(p - line);
+	space = p;
+
+	for (i = 0; i < sk->n; i++) {
+		const struct skill	*s = &sk->items[i];
+
+		if (strlen(s->name) == namelen &&
+		    strncmp(s->name, line, namelen) == 0) {
+			if (trailing != NULL) {
+				while (*space != '\0' &&
+				    isspace((unsigned char)*space))
+					space++;
+				if (*space != '\0')
+					*trailing = space;
+			}
+			return (s);
+		}
+	}
+	return (NULL);
+}
+
+int
+skills_complete(const struct skills *sk, const char *prefix,
+    const char **matches, int max)
+{
+	size_t	plen = strlen(prefix), i;
+	int	n = 0;
+
+	if (sk == NULL)
+		return (0);
+	for (i = 0; i < sk->n && n < max; i++)
+		if (strncmp(sk->items[i].name, prefix, plen) == 0)
+			matches[n++] = sk->items[i].name;
+	return (n);
+}
+
+/*
+ * Journal record for a skill invocation.  Mirrors the /clear and /compact
+ * markers in convo.c: a {"fugu":"skill","name":"<slash-name>"} line written
+ * just before the user message carrying the body, so resume can replay
+ * faithfully (the user message itself reconstitutes the conversation state;
+ * the marker is informational, for transcripts and future tooling).
+ */
+void
+skills_marker(struct buf *out, const char *slash_name)
+{
+	struct json_writer	w;
+
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "fugu", "skill");
+	json_kv_string(&w, "name", slash_name);
+	json_object_end(&w);
+}
+
+/*
+ * Emit ", { ... }" -- one Messages-API tool definition naming the `skill` tool
+ * with the bare names of every model-invocable skill in the enum, and a
+ * description that lists each skill's name and one-line summary so the model
+ * has a per-skill matching signal.  Net concatenates this onto TOOLS_BASE
+ * before closing the array, so the leading comma is part of our payload.
+ */
+void
+skills_tool_fragment(const struct skills *sk, struct buf *out)
+{
+	struct json_writer	w;
+	struct buf		desc;
+	size_t			i, count = 0;
+
+	if (sk == NULL || sk->n == 0)
+		return;
+	for (i = 0; i < sk->n; i++)
+		if (!sk->items[i].no_model_invoke)
+			count++;
+	if (count == 0)
+		return;
+
+	buf_init(&desc);
+	buf_puts_cstr(&desc, "Invoke an installed skill -- a procedural template "
+	    "the user has placed in ~/.fugu/skills/.  The skill body is "
+	    "returned as the tool result; follow its instructions and drive "
+	    "the resulting workflow.\n\nAvailable skills:\n");
+	for (i = 0; i < sk->n; i++) {
+		const struct skill	*s = &sk->items[i];
+
+		if (s->no_model_invoke)
+			continue;
+		buf_printf(&desc, "- %s: %s\n", s->name + 1,
+		    s->description != NULL ? s->description : "(no description)");
+	}
+	buf_terminate(&desc);
+
+	buf_putc(out, ',');
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "name", "skill");
+	json_kv_string(&w, "description", desc.data);
+	json_key(&w, "input_schema");
+	json_object_start(&w);
+	json_kv_string(&w, "type", "object");
+	json_key(&w, "properties");
+	json_object_start(&w);
+	json_key(&w, "name");
+	json_object_start(&w);
+	json_kv_string(&w, "type", "string");
+	json_kv_string(&w, "description", "Bare name of the skill (no leading "
+	    "slash).");
+	json_key(&w, "enum");
+	json_array_start(&w);
+	for (i = 0; i < sk->n; i++)
+		if (!sk->items[i].no_model_invoke)
+			json_string(&w, sk->items[i].name + 1);
+	json_array_end(&w);
+	json_object_end(&w);
+	json_object_end(&w);
+	json_key(&w, "required");
+	json_array_start(&w);
+	json_string(&w, "name");
+	json_array_end(&w);
+	json_object_end(&w);
+	json_object_end(&w);
+
+	buf_free(&desc);
+}
+
+/*
+ * Build a tool_use_id of the form "fsk_<sec>_<n>" -- unique within a session
+ * (time_t for the day-of, counter for ties) and within an unbroken time/pid
+ * stretch.  The provider only cares that the id is non-empty and that the
+ * matching tool_use/tool_result pair share it.
+ */
+static void
+gen_tool_id(char *out, size_t sz)
+{
+	static unsigned int	counter;
+
+	(void)snprintf(out, sz, "fsk_%lld_%u", (long long)time(NULL),
+	    counter++);
+}
+
+void
+skills_synth_invocation(const struct skill *s, const char *typed_line,
+    const char *trailing, struct buf *user_pre, struct buf *assist_msg,
+    struct buf *user_msg, char *id_out, size_t id_sz)
+{
+	struct json_writer	w;
+	struct buf		body;
+	const char		*bare;
+
+	gen_tool_id(id_out, id_sz);
+	bare = s->name[0] == '/' ? s->name + 1 : s->name;
+
+	/*
+	 * Leading user message carrying the literal typed slash command -- the
+	 * Messages array MUST begin with a user message, even on the first
+	 * turn or right after /compact (last message is an assistant summary).
+	 */
+	json_writer_init(&w, user_pre);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "user");
+	json_kv_string(&w, "content", typed_line != NULL ? typed_line : "");
+	json_object_end(&w);
+	buf_terminate(user_pre);
+
+	/* Assistant message: a single tool_use block addressed to `skill`. */
+	json_writer_init(&w, assist_msg);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "assistant");
+	json_key(&w, "content");
+	json_array_start(&w);
+	json_object_start(&w);
+	json_kv_string(&w, "type", "tool_use");
+	json_kv_string(&w, "id", id_out);
+	json_kv_string(&w, "name", "skill");
+	json_key(&w, "input");
+	json_object_start(&w);
+	json_kv_string(&w, "name", bare);
+	json_object_end(&w);
+	json_object_end(&w);
+	json_array_end(&w);
+	json_object_end(&w);
+	buf_terminate(assist_msg);
+
+	/* Body, plus any trailing argument as a follow-on paragraph. */
+	buf_init(&body);
+	buf_puts_cstr(&body, s->body);
+	if (trailing != NULL && *trailing != '\0') {
+		buf_puts_cstr(&body, "\n\n");
+		buf_puts_cstr(&body, trailing);
+	}
+	buf_terminate(&body);
+
+	/* User message: the matching tool_result block carrying the body. */
+	json_writer_init(&w, user_msg);
+	json_object_start(&w);
+	json_kv_string(&w, "role", "user");
+	json_key(&w, "content");
+	json_array_start(&w);
+	json_object_start(&w);
+	json_kv_string(&w, "type", "tool_result");
+	json_kv_string(&w, "tool_use_id", id_out);
+	json_kv_string(&w, "content", body.data);
+	json_object_end(&w);
+	json_array_end(&w);
+	json_object_end(&w);
+	buf_terminate(user_msg);
+
+	buf_free(&body);
+}
+
+/* Extract a string-typed object member as an owned string. */
+static char *
+obj_getstr(const struct json_doc *d, int obj, const char *key)
+{
+	int	t = json_obj_get(d, obj, key);
+
+	if (t < 0 || json_tok_type(d, t) != JSON_STRING)
+		return (NULL);
+	return (json_tok_strdup(d, t));
+}
+
+void
+skills_handle_tool_request(const struct skills *sk, const void *data,
+    size_t len, struct buf *out)
+{
+	struct json_doc		d;
+	struct json_writer	w;
+	const struct skill	*hit = NULL;
+	char			*id = NULL, *bare = NULL;
+	int			 input, is_error = 1;
+	size_t			 i;
+	const char		*content = "skill not found";
+
+	if (json_parse(&d, data, len) != 0 || d.ntok < 1 ||
+	    json_tok_type(&d, 0) != JSON_OBJECT)
+		goto reply;
+	id = obj_getstr(&d, 0, "id");
+	input = json_obj_get(&d, 0, "input");
+	if (input >= 0)
+		bare = obj_getstr(&d, input, "name");
+	if (bare != NULL && sk != NULL) {
+		for (i = 0; i < sk->n; i++) {
+			const struct skill	*s = &sk->items[i];
+
+			if (strcmp(s->name + 1, bare) != 0)
+				continue;
+			/*
+			 * A skill flagged disable-model-invocation is reachable
+			 * only via the user's slash command; treat a model name
+			 * match as a miss so the reply is "skill not found".
+			 */
+			if (s->no_model_invoke)
+				continue;
+			hit = s;
+			break;
+		}
+	}
+	if (hit != NULL) {
+		content = hit->body;
+		is_error = 0;
+	}
+
+reply:
+	json_writer_init(&w, out);
+	json_object_start(&w);
+	json_kv_string(&w, "id", id != NULL ? id : "");
+	json_kv_string(&w, "content", content);
+	json_kv_bool(&w, "is_error", is_error);
+	json_object_end(&w);
+
+	free(id);
+	free(bare);
+	json_doc_free(&d);
+}
blob - /dev/null
blob + 4746be5dbb6f7df9459c41bb08205ec0c0972bc5 (mode 644)
--- /dev/null
+++ src/ui/skills.h
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * skills: markdown prompt templates the user invokes by slash-name.  Each lives
+ * at ~/.fugu/skills/<name>/SKILL.md with an optional YAML-ish header
+ *
+ *	---
+ *	name: <name>
+ *	description: <one line>
+ *	---
+ *	<body the model sees>
+ *
+ * On invocation the body (plus any trailing argument the user typed after the
+ * name) is appended to the conversation as a user message and submitted -- no
+ * change to the request shape that fugu-net sends to the provider.  Loading is
+ * best-effort like the session journal: anything malformed or unreadable is
+ * silently skipped.
+ */
+
+#ifndef FUGU_SKILLS_H
+#define FUGU_SKILLS_H
+
+#include <sys/types.h>
+
+struct skill {
+	char	*name;		/* slash-form, e.g. "/triage" */
+	char	*description;	/* one-line summary, may be NULL */
+	char	*body;		/* prompt body sent as the user message */
+	int	 no_model_invoke;/* header `disable-model-invocation: true` */
+};
+
+struct skills {
+	struct skill	*items;
+	size_t		 n;
+};
+
+/*
+ * unveil(2) the skills directory readable (best-effort); call before locking,
+ * alongside journal_unveil().
+ */
+void	skills_unveil(void);
+
+/*
+ * Scan ~/.fugu/skills/ and populate sk (zeroed by the caller).  Items sorted
+ * alphabetically.  Anything malformed -- missing SKILL.md, empty body, oversize
+ * file, header without a closing fence -- is silently skipped.
+ */
+void	skills_load(struct skills *sk);
+
+void	skills_free(struct skills *sk);
+
+/*
+ * If `line` begins with a slash-name that matches a loaded skill, return it.
+ * On match, *trailing (if non-NULL) is set to the start of any non-whitespace
+ * text after the name, or NULL if the user typed only the name.  Returns NULL
+ * on no match.
+ */
+const struct skill *skills_match(const struct skills *sk, const char *line,
+	    const char **trailing);
+
+/*
+ * Append skill slash-names having `prefix` to matches[] (up to max appended).
+ * Returns the count appended.  Borrowed names -- the caller must not free them.
+ */
+int	skills_complete(const struct skills *sk, const char *prefix,
+	    const char **matches, int max);
+
+/* Build the journal marker recording a /<skill-name> invocation. */
+struct buf;
+void	skills_marker(struct buf *out, const char *slash_name);
+
+/*
+ * Emit the comma-prefixed JSON fragment that registers the model-invocable
+ * `skill` tool with net (which appends it to its TOOLS_BASE before sending the
+ * Messages API request).  Empty buffer when sk has no model-invocable skills.
+ * The fragment has no enclosing brackets; net inserts it before its closing ].
+ */
+void	skills_tool_fragment(const struct skills *sk, struct buf *out);
+
+/*
+ * Synthesise a user-driven skill invocation as a user/assistant/user triple the
+ * model will see as having already happened:
+ *	{"role":"user","content":"<typed_line>"}
+ *	{"role":"assistant","content":[{"type":"tool_use","id":<id>,
+ *	    "name":"skill","input":{"name":"<bare-name>"}}]}
+ *	{"role":"user","content":[{"type":"tool_result","tool_use_id":<id>,
+ *	    "content":"<body[\n\ntrailing]>"}]}
+ * The leading user message carries the user's literal typed slash command (not
+ * the body) so the resulting messages array always begins with a user message
+ * -- required by Anthropic Messages and strict OpenAI servers, even on the
+ * first turn of a fresh session or right after /compact.  All three messages
+ * are written into the caller's bufs (NUL-terminated like all convo entries).
+ * *id_out (caller-allocated, at least 32 bytes) gets the generated tool_use_id
+ * so the caller can keep them paired in the journal.
+ */
+void	skills_synth_invocation(const struct skill *sk, const char *typed_line,
+	    const char *trailing, struct buf *user_pre,
+	    struct buf *assist_msg, struct buf *user_msg, char *id_out,
+	    size_t id_sz);
+
+/*
+ * Service a model-issued tool_use("skill") request.  data/len is the
+ * M_TOOL_REQUEST payload ({"id","name":"skill","input":{"name":"<bare>"}});
+ * out is filled with the M_TOOL_RESULT payload
+ * ({"id","content":"<body>","is_error":bool}).  Always produces a syntactically
+ * valid result -- on lookup failure, an is_error result naming the bad skill.
+ */
+void	skills_handle_tool_request(const struct skills *sk, const void *data,
+	    size_t len, struct buf *out);
+
+#endif /* FUGU_SKILLS_H */
blob - /dev/null
blob + 35ee38a1b98b93491e4e2d2b1993086ff5f70af6 (mode 644)
--- /dev/null
+++ src/ui/ui.c
@@ -0,0 +1,499 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * fugu-ui: the user-interface worker (${PREFIX}/libexec/fugu/fugu-ui).
+ *
+ * On a real terminal it runs the curses front-end (ui_curses.c); otherwise (a
+ * pipe, the regress harness, a non-terminal stdout) it falls back to the
+ * line-mode reader here.  Both share the conversation model and the session
+ * journal, and speak the same imsg protocol to net via the parent; the imsg
+ * channel arrives on fd 3.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "journal.h"
+#include "log.h"
+#include "skills.h"
+#include "ui.h"
+#include "util.h"
+
+#define FUGU_UI_IMSG_FD	3
+
+/* Print one replayed transcript part when resuming a session in line mode. */
+static void
+line_replay(void *ctx, enum convo_part part, const char *text, size_t len)
+{
+	const char	*pfx;
+
+	(void)ctx;
+	if (part == CP_INFO) {
+		printf("-- %.*s --\n", (int)len, text);
+		return;
+	}
+	switch (part) {
+	case CP_USER:
+		pfx = "you>  ";
+		break;
+	case CP_TOOL:
+		pfx = "tool: ";
+		break;
+	default:
+		pfx = "fugu> ";
+		break;
+	}
+	printf("%s%.*s\n", pfx, (int)len, text);
+}
+
+/*
+ * The slash commands, in completion order.  Single source of truth for both
+ * ui_classify() (exact dispatch) and ui_complete() (Tab completion); /exit and
+ * /quit are aliases for the same action.  New commands -- including any later
+ * contributed dynamically by skills -- need only be added here.
+ */
+static const struct ui_command {
+	const char	*name;
+	enum ui_cmd	 cmd;
+} UI_COMMANDS[] = {
+	{ "/clear",   UC_CLEAR },
+	{ "/compact", UC_COMPACT },
+	{ "/exit",    UC_QUIT },
+	{ "/model",   UC_MODELS },
+	{ "/quit",    UC_QUIT },
+};
+#define NUI_COMMANDS	(sizeof(UI_COMMANDS) / sizeof(UI_COMMANDS[0]))
+
+enum ui_cmd
+ui_classify(const char *line)
+{
+	size_t	i;
+
+	for (i = 0; i < NUI_COMMANDS; i++)
+		if (strcmp(line, UI_COMMANDS[i].name) == 0)
+			return (UI_COMMANDS[i].cmd);
+	return (UC_NONE);
+}
+
+/*
+ * Fill matches[] (up to max entries) with the command names having `prefix` as
+ * a prefix -- built-ins in their static order first, then loaded skill names
+ * alphabetically.  Returns the count.  Names are borrowed (static storage or
+ * owned by the skills registry) -- the caller must not free them.
+ */
+int
+ui_complete(const struct skills *sk, const char *prefix, const char **matches,
+    int max)
+{
+	size_t	plen = strlen(prefix);
+	size_t	i;
+	int	n = 0;
+
+	for (i = 0; i < NUI_COMMANDS && n < max; i++)
+		if (strncmp(UI_COMMANDS[i].name, prefix, plen) == 0)
+			matches[n++] = UI_COMMANDS[i].name;
+	if (n < max)
+		n += skills_complete(sk, prefix, matches + n, max - n);
+	return (n);
+}
+
+/*
+ * Service a model-invoked `skill` tool call: net forwards us the M_TOOL_REQUEST
+ * (its tool_dst routes "skill" to EP_UI); we look the skill up in sk and reply
+ * with M_TOOL_RESULT.  A request for an unknown skill returns an is_error
+ * result so the model sees the failure rather than the channel hanging.
+ * Returns -1 if the imsg send failed -- net is then blocked waiting on a result
+ * that will never arrive, so the caller treats it as a channel break.
+ */
+static int
+line_service_tool(struct imsgproto *ip, const struct skills *sk,
+    const void *data, size_t len)
+{
+	struct buf	out;
+	int		rc = 0;
+
+	buf_init(&out);
+	skills_handle_tool_request(sk, data, len, &out);
+	if (ip_compose(ip, M_TOOL_RESULT, EP_NET, EP_UI, out.data,
+	    out.len) == -1 || ip_flush(ip) == -1) {
+		log_warnx("tool result send failed");
+		rc = -1;
+	}
+	buf_free(&out);
+	return (rc);
+}
+
+/* Stream one reply to stdout.  0 normal, 1 the turn errored, -1 channel closed. */
+static int
+line_collect(struct imsgproto *ip, struct convo *cv, struct journal *j,
+    const struct skills *sk)
+{
+	struct fugu_msg	m;
+	int		r;
+
+	for (;;) {
+		r = ip_get(ip, &m);
+		if (r == -1)
+			return (-1);
+		if (r == 0) {
+			if (ip_read(ip) <= 0)
+				return (-1);
+			continue;
+		}
+		switch (m.type) {
+		case M_DELTA:
+			fwrite(m.data, 1, m.len, stdout);
+			fflush(stdout);
+			break;
+		case M_MESSAGE:
+			convo_add_raw(cv, (char *)m.data, m.len);
+			journal_append(j, (char *)m.data, m.len);
+			break;
+		case M_TOOL_CALL:
+			fprintf(stderr, "\n[tool] %.*s\n", (int)m.len,
+			    (char *)m.data);
+			break;
+		case M_TOOL_REQUEST:
+			if (line_service_tool(ip, sk, m.data, m.len) == -1) {
+				ip_msg_free(&m);
+				return (-1);
+			}
+			break;
+		case M_TURN_DONE:
+			fputc('\n', stdout);
+			fflush(stdout);
+			ip_msg_free(&m);
+			return (0);
+		case M_ERROR:
+			fprintf(stderr, "\n[error] %.*s\n", (int)m.len,
+			    (char *)m.data);
+			ip_msg_free(&m);
+			return (1);
+		default:
+			break;
+		}
+		ip_msg_free(&m);
+	}
+}
+
+/* Wait for a /compact summary.  0 ok (out filled), 1 errored, -1 channel closed. */
+static int
+line_compact(struct imsgproto *ip, struct buf *out)
+{
+	struct fugu_msg	m;
+	int		r;
+
+	for (;;) {
+		r = ip_get(ip, &m);
+		if (r == -1)
+			return (-1);
+		if (r == 0) {
+			if (ip_read(ip) <= 0)
+				return (-1);
+			continue;
+		}
+		switch (m.type) {
+		case M_SUMMARY:
+			buf_append(out, m.data, m.len);
+			ip_msg_free(&m);
+			return (0);
+		case M_ERROR:
+			fprintf(stderr, "\n[error] %.*s\n", (int)m.len,
+			    (char *)m.data);
+			ip_msg_free(&m);
+			return (1);
+		default:
+			break;			/* ignore stray usage/etc. */
+		}
+		ip_msg_free(&m);
+	}
+}
+
+int
+ui_line_run(struct imsgproto *ip, struct convo *cv, struct journal *j,
+    const struct skills *sk, const char *resume_id)
+{
+	struct buf	msgs;
+	char		*line = NULL;
+	size_t		 lcap = 0, mark;
+	ssize_t		 ll;
+	int		 r;
+
+	journal_unveil();
+	if (unveil(NULL, NULL) == -1)
+		fatal("unveil lock");
+	if (pledge("stdio rpath wpath cpath", NULL) == -1)
+		fatal("pledge");
+	if (resume_id != NULL) {
+		printf("-- resumed session %s --\n", resume_id);
+		if (convo_resume(cv, resume_id, line_replay, NULL) == -1)
+			fprintf(stderr, "fugu: cannot read session %s\n",
+			    resume_id);
+		journal_open(j, resume_id);
+	} else
+		journal_open(j, NULL);
+	if (pledge("stdio", NULL) == -1)	/* the journal fd stays writable */
+		fatal("pledge");
+
+	for (;;) {
+		enum ui_cmd	cmd;
+
+		fputs("> ", stdout);
+		fflush(stdout);
+
+		if ((ll = getline(&line, &lcap, stdin)) == -1)
+			break;
+		while (ll > 0 && (line[ll - 1] == '\n' || line[ll - 1] == '\r'))
+			line[--ll] = '\0';
+		if (ll == 0)
+			continue;
+
+		cmd = ui_classify(line);
+		if (cmd == UC_QUIT)
+			break;
+		if (cmd == UC_MODELS) {
+			fputs("-- /model is only available in the interactive "
+			    "UI --\n", stdout);
+			continue;
+		}
+		if (cmd == UC_CLEAR) {
+			struct buf	mk;
+
+			buf_init(&mk);
+			convo_marker_clear(&mk);
+			journal_append(j, mk.data, mk.len);
+			buf_free(&mk);
+			convo_clear(cv);
+			fputs("-- context cleared --\n", stdout);
+			continue;
+		}
+		if (cmd == UC_COMPACT) {
+			struct buf	sum;
+
+			if (cv->n == 0) {
+				fputs("-- nothing to compact --\n", stdout);
+				continue;
+			}
+			buf_init(&msgs);
+			convo_build(cv, &msgs);
+			if (ip_compose(ip, M_SUMMARIZE, EP_NET, EP_UI, msgs.data,
+			    msgs.len) == -1 || ip_flush(ip) == -1) {
+				buf_free(&msgs);
+				break;
+			}
+			buf_free(&msgs);
+
+			buf_init(&sum);
+			r = line_compact(ip, &sum);
+			if (r == 0 && sum.len > 0) {
+				struct buf	mk;
+
+				buf_terminate(&sum);
+				buf_init(&mk);
+				convo_marker_compact(&mk, sum.data);
+				journal_append(j, mk.data, mk.len);
+				buf_free(&mk);
+				convo_set_summary(cv, sum.data);
+				printf("-- compacted --\n%s\n", sum.data);
+			}
+			buf_free(&sum);
+			if (r == -1)
+				break;
+			continue;
+		}
+
+		/*
+		 * A leading slash that isn't a built-in: look it up in the
+		 * skills registry.  On a hit, synthesise an assistant tool_use
+		 * + user tool_result pair so the model receives the body as
+		 * authoritative retrieved instructions (the strongest framing
+		 * for follow-through).  Journal records: marker, then both
+		 * synthesised messages.  On a miss, fall through to the normal
+		 * user-message path.
+		 */
+		{
+			const struct skill	*match = NULL;
+			const char		*trailing = NULL;
+
+			if (line[0] == '/' &&
+			    (match = skills_match(sk, line, &trailing)) != NULL) {
+				struct buf	mk, up, am, um;
+				char		id[40];
+
+				buf_init(&mk);
+				skills_marker(&mk, match->name);
+				journal_append(j, mk.data, mk.len);
+				buf_free(&mk);
+
+				buf_init(&up);
+				buf_init(&am);
+				buf_init(&um);
+				skills_synth_invocation(match, line, trailing,
+				    &up, &am, &um, id, sizeof(id));
+
+				printf("-- %s --\n", match->name);
+
+				mark = cv->n;
+				convo_add_raw(cv, up.data, up.len);
+				convo_add_raw(cv, am.data, am.len);
+				convo_add_raw(cv, um.data, um.len);
+				journal_append(j, up.data, up.len);
+				journal_append(j, am.data, am.len);
+				journal_append(j, um.data, um.len);
+				buf_free(&up);
+				buf_free(&am);
+				buf_free(&um);
+			} else {
+				mark = cv->n;
+				convo_add_user(cv, line);
+				journal_append(j, cv->msg[cv->n - 1],
+				    strlen(cv->msg[cv->n - 1]));
+			}
+		}
+
+		buf_init(&msgs);
+		convo_build(cv, &msgs);
+		if (ip_compose(ip, M_SUBMIT, EP_NET, EP_UI, msgs.data,
+		    msgs.len) == -1 || ip_flush(ip) == -1) {
+			/*
+			 * Submit went out the wire only partially (or not at
+			 * all), but cv + journal already have the user message
+			 * (or skill synth trio) for this turn.  Mark first
+			 * (the helper guards on cv->n > mark), THEN truncate.
+			 */
+			convo_journal_turn_failed(j, cv, mark);
+			convo_truncate(cv, mark);
+			buf_free(&msgs);
+			break;
+		}
+		buf_free(&msgs);
+
+		r = line_collect(ip, cv, j, sk);
+		if (r == -1) {
+			/*
+			 * Channel break mid-turn (tool-reply send failed, peer
+			 * closed, read error).  The user message and any
+			 * M_MESSAGE blocks already in cv + journal would
+			 * replay as orphans on resume -- mark the turn failed.
+			 */
+			convo_journal_turn_failed(j, cv, mark);
+			convo_truncate(cv, mark);
+			break;
+		}
+		if (r == 1) {			/* failed turn: journal then undo */
+			convo_journal_turn_failed(j, cv, mark);
+			convo_truncate(cv, mark);
+		}
+	}
+
+	free(line);
+	return (0);
+}
+
+int
+main(int argc, char *argv[])
+{
+	struct imsgproto	ip;
+	struct convo		cv;
+	struct journal		j;
+	struct skills		sk;
+	struct stat		st;
+	extern char		*__progname;
+	const char		*resume_id = NULL;
+	int			ch, rc, tty, ascii = 0;
+
+	log_init(LOG_TO_SYSLOG, 0, LOG_USER);
+	signal(SIGPIPE, SIG_IGN);
+	drop_privgroup();		/* never need the parent's setgid _fugu group */
+
+	while ((ch = getopt(argc, argv, "aR:")) != -1) {
+		switch (ch) {
+		case 'a':			/* ASCII-only display (parent) */
+			ascii = 1;
+			break;
+		case 'R':			/* resume session id (parent) */
+			resume_id = optarg;
+			break;
+		default:
+			break;
+		}
+	}
+
+	if (fstat(FUGU_UI_IMSG_FD, &st) == -1 || !S_ISSOCK(st.st_mode)) {
+		fprintf(stderr, "%s: internal fugu helper, not meant to be run "
+		    "directly\n", __progname);
+		return (1);
+	}
+	if (ip_init(&ip, FUGU_UI_IMSG_FD) == -1)
+		fatal("ip_init");
+
+	memset(&cv, 0, sizeof(cv));
+	memset(&sk, 0, sizeof(sk));
+	j.fd = -1;
+	tty = isatty(STDIN_FILENO) && isatty(STDOUT_FILENO) &&
+	    getenv("TERM") != NULL;
+
+	/*
+	 * Load skills (~/.fugu/skills/) once, before either front-end pledges
+	 * or unveils.  Slurping bodies into memory now means the runtime needs
+	 * neither rpath nor a peek at the skills directory.
+	 */
+	skills_load(&sk);
+
+	if (ip_compose(&ip, M_READY, EP_PARENT, EP_UI, NULL, 0) == -1 ||
+	    ip_flush(&ip) == -1)
+		fatalx("ui: failed to announce readiness");
+
+	/*
+	 * Register the `skill` tool with net so the model can invoke it.  The
+	 * fragment is empty when no skills are model-invocable, and net treats
+	 * an empty M_TOOLS_ADD as a no-op.  Routing of incoming tool_use("skill")
+	 * back to ui is handled by net's tool_dst() lookup.
+	 */
+	{
+		struct buf	tools;
+
+		buf_init(&tools);
+		skills_tool_fragment(&sk, &tools);
+		if (tools.len > 0 &&
+		    (ip_compose(&ip, M_TOOLS_ADD, EP_NET, EP_UI, tools.data,
+		    tools.len) == -1 || ip_flush(&ip) == -1))
+			fatalx("ui: failed to register skill tool");
+		buf_free(&tools);
+	}
+
+	/* Each front-end opens the journal and narrows its own pledge. */
+	if (tty)
+		rc = ui_curses_run(&ip, &cv, &j, &sk, ascii, resume_id);
+	else
+		rc = ui_line_run(&ip, &cv, &j, &sk, resume_id);
+
+	journal_close(&j);
+	convo_free(&cv);
+	skills_free(&sk);
+	ip_clear(&ip);
+	return (rc);
+}
blob - /dev/null
blob + 1cfbd046e7102c8c6ce46b67c966f0858ec2a7d1 (mode 644)
--- /dev/null
+++ src/ui/ui.h
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef FUGU_UI_H
+#define FUGU_UI_H
+
+#include "buf.h"
+#include "imsgproto.h"
+#include "journal.h"
+#include "skills.h"
+
+/*
+ * The conversation the UI maintains and re-sends as the M_SUBMIT projection,
+ * and persists to the journal.  Each entry is one complete message object as
+ * raw JSON ({"role":...,"content":...}): the user's typed messages are built
+ * here, the assistant and tool_result messages arrive from net (M_MESSAGE) with
+ * their full content (text + tool_use / tool_result blocks), so the history is
+ * faithful across turns.  Shared by both front-ends (ui.c, ui_curses.c).
+ */
+struct convo {
+	char	**msg;		/* each a NUL-terminated raw JSON message object */
+	size_t	  n;
+};
+
+/* Append a user message built from plain text. */
+void	convo_add_user(struct convo *, const char *text);
+/* Append a raw message object (copied) as received from net. */
+void	convo_add_raw(struct convo *, const char *json, size_t len);
+/* Drop messages down to n (undo a failed turn). */
+void	convo_truncate(struct convo *, size_t n);
+void	convo_free(struct convo *);
+void	convo_build(const struct convo *, struct buf *out);	/* messages[] */
+
+/* /clear: reset the active context to empty (the journal is untouched). */
+void	convo_clear(struct convo *);
+/* /compact: replace the active context with a summary (user/assistant pair). */
+void	convo_set_summary(struct convo *, const char *summary);
+/* Build the journal records marking a /clear and a /compact (with summary). */
+void	convo_marker_clear(struct buf *out);
+void	convo_marker_compact(struct buf *out, const char *summary);
+/* Mark a turn that failed mid-flight; on resume drops the orphan records. */
+void	convo_marker_turn_failed(struct buf *out);
+/*
+ * Append a turn_failed marker iff the failed turn wrote records past `mark`.
+ * The single choke point both UIs use: avoids over-emitting (no records to
+ * undo -> would roll back past a prior good turn) and under-emitting (orphan
+ * records replay on resume with nothing to discard them).
+ */
+void	convo_journal_turn_failed(struct journal *,
+		    const struct convo *, size_t mark);
+
+/*
+ * Resume / transcript replay.  A stored message decomposes into renderable
+ * parts; each front-end maps these onto its own display (curses blocks, or
+ * line-mode prefixes).  CP_INFO is a divider (e.g. a replayed /clear or
+ * /compact marker); tool_result blocks are kept in the conversation but not
+ * replayed (they were never drawn live either).
+ */
+enum convo_part { CP_USER, CP_ASSIST, CP_TOOL, CP_INFO };
+typedef void (*convo_part_cb)(void *ctx, enum convo_part, const char *text,
+	    size_t len);
+
+/* Slash commands recognised by both front-ends. */
+enum ui_cmd { UC_NONE, UC_CLEAR, UC_COMPACT, UC_QUIT, UC_MODELS };
+enum ui_cmd	ui_classify(const char *line);
+
+/*
+ * Tab completion: fill matches[] (up to max) with the command names having
+ * `prefix` (a leading "/...") as a prefix, built-ins first then loaded skills,
+ * alphabetical within each; return the count.  `sk` may be NULL.  Borrowed
+ * static / loaded names -- do not free.
+ */
+int	ui_complete(const struct skills *sk, const char *prefix,
+		    const char **matches, int max);
+
+/* Decompose one raw message object into display parts via cb. */
+void	convo_replay(const char *json, size_t len, convo_part_cb, void *ctx);
+/*
+ * Load session <id>'s journal into cv (convo_add_raw per line) and replay each
+ * message through cb for display.  0 on success, -1 if the session is unreadable.
+ */
+int	convo_resume(struct convo *, const char *id, convo_part_cb, void *ctx);
+
+/* Line-mode front-end, used when stdio is not an interactive terminal. */
+int	ui_line_run(struct imsgproto *, struct convo *, struct journal *,
+	    const struct skills *sk, const char *resume_id);
+
+/*
+ * Curses front-end, used on a real terminal.  Narrows pledge to "stdio tty".
+ * ascii: render the transcript/input transliterated to ASCII (no UTF-8 glyphs).
+ * sk: loaded skills registry for slash dispatch and Tab completion (may be NULL).
+ * resume_id: a session to reopen and replay, or NULL to start fresh.
+ */
+int	ui_curses_run(struct imsgproto *, struct convo *, struct journal *,
+	    const struct skills *sk, int ascii, const char *resume_id);
+
+#endif /* FUGU_UI_H */
blob - /dev/null
blob + ccc6855e24cd1e4c165a3a60f9f14ea3c044e266 (mode 644)
--- /dev/null
+++ src/ui/ui_curses.c
@@ -0,0 +1,1938 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * The curses front-end: a scrolling transcript above a one-line input editor,
+ * with a status bar between them.  A poll(2) loop multiplexes the terminal (fd
+ * 0) and the imsg channel to net (fd 3): keystrokes edit/submit the input, and
+ * streamed M_DELTA/M_TOOL_CALL/M_USAGE/M_TURN_DONE/M_ERROR messages update the
+ * transcript live.
+ *
+ * Text is handled as wide characters: block text (UTF-8 bytes from net) is
+ * decoded with mbrtowc(3) for rendering, wrapped by wcwidth(3) display columns,
+ * and drawn with the wide curses API, while the input editor stores wchar_t and
+ * is read with get_wch(3) -- so multi-byte UTF-8 renders as one glyph instead of
+ * a row of mojibake.  The input is a single logical line.  pledge is narrowed to
+ * "stdio tty" once curses has read the terminfo entry.
+ */
+
+#define NCURSES_WIDECHAR 1	/* expose get_wch/addnwstr without _XOPEN_SOURCE */
+
+#include <sys/types.h>
+#include <sys/ioctl.h>
+
+#include <errno.h>
+#include <langinfo.h>
+#include <limits.h>
+#include <locale.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include <curses.h>
+
+#include "buf.h"
+#include "fuzzy.h"
+#include "imsgproto.h"
+#include "json.h"
+#include "log.h"
+#include "mdrender.h"
+#include "ui.h"
+#include "util.h"
+
+enum bkind { BK_INFO, BK_USER, BK_ASSIST, BK_TOOL, BK_ERROR };
+
+/* The input editor is modal: it starts in insert and Esc switches to normal. */
+enum imode { IM_INSERT, IM_NORMAL };
+
+struct block {
+	enum bkind	 kind;
+	struct buf	 text;		/* UTF-8 bytes			*/
+};
+
+struct tui {
+	struct imsgproto	*ip;
+	struct convo		*cv;
+	struct journal		*j;
+	const struct skills	*sk;
+	size_t			 cv_mark;	/* convo length at turn start	*/
+	struct block		*blocks;
+	size_t			 nblocks;
+	wchar_t			*in;		/* input line, wide chars	*/
+	size_t			 ilen, icap, icur;
+	int			 imode;		/* IM_INSERT or IM_NORMAL	*/
+	int			 pend;		/* pending operator (0 or L'd')	*/
+	int			 comping;	/* mid Tab-completion cycle	*/
+	int			 compidx;	/* next match in the cycle	*/
+	char			 compbase[64];	/* the "/..." prefix being completed */
+	/* model picker (/model): a modal fuzzy-search overlay */
+	int			 picking;	/* picker overlay is active	*/
+	int			 loading_models;/* M_MODELS request in flight	*/
+	char			**models;	/* all model ids from the provider */
+	size_t			 nmodels;
+	char			 pquery[128];	/* fuzzy filter text		*/
+	size_t			 pqlen;
+	int			*pmatch;	/* model indices matching pquery, ranked */
+	size_t			 npmatch;
+	int			 psel;		/* selected row (index into pmatch) */
+	int			 ptop;		/* first visible row (scroll)	*/
+	wchar_t			**hist;		/* prompts sent this session	*/
+	size_t			*histlen;	/* length of each hist entry	*/
+	size_t			 nhist;
+	size_t			 histpos;	/* == nhist when not browsing	*/
+	wchar_t			*histsave;	/* live line saved while browsing*/
+	size_t			 histsavelen;
+	int			 busy;
+	int			 compacting;	/* awaiting a /compact summary	*/
+	int			 scroll, follow;
+	int			 quit;
+	int			 ascii;		/* transliterate to ASCII	*/
+	int			 rows, cols;
+	char			 status[160];
+};
+
+static volatile sig_atomic_t	resized;
+
+static void	on_winch(int);
+static size_t	block_new(struct tui *, enum bkind);
+static void	blocks_free(struct tui *);
+static void	replay_block(void *, enum convo_part, const char *, size_t);
+static const char *prefix_of(enum bkind);
+static int	attr_of(enum bkind);
+static int	md_attr(int);
+static size_t	block_display(struct tui *, struct block *, wchar_t **, int **);
+static int	cw(wchar_t);
+static const char *ascii_rep(wchar_t);
+static wchar_t *fold_wcs(const wchar_t *, size_t, size_t *);
+static wchar_t *utf8_to_wcs(const char *, size_t, size_t *);
+static int	render(struct tui *, int draw, int vfrom, int vto);
+static void	draw(struct tui *);
+static void	draw_picker(struct tui *);
+static void	request_models(struct tui *);
+static void	picker_open(struct tui *, char *, size_t);
+static void	picker_close(struct tui *);
+static void	picker_free_models(struct tui *);
+static void	picker_refilter(struct tui *);
+static void	picker_key(struct tui *, wint_t, int);
+static void	model_label(const char *, char *, size_t);
+static void	do_resize(struct tui *);
+static int	drain_imsg(struct tui *);
+static void	handle_msg(struct tui *, struct fugu_msg *);
+static void	handle_input(struct tui *, wint_t, int iskey);
+static void	insert_key(struct tui *, wint_t);
+static void	normal_key(struct tui *, wint_t);
+static void	in_insert(struct tui *, wchar_t);
+static void	in_set(struct tui *, const wchar_t *, size_t);
+static void	complete_tab(struct tui *, int);
+static void	in_backspace(struct tui *);
+static void	in_delch(struct tui *);
+static void	del_range(struct tui *, size_t, size_t);
+static void	do_dmotion(struct tui *, wint_t);
+static size_t	first_nonblank(struct tui *);
+static int	wclass(wchar_t);
+static size_t	word_fwd(struct tui *, size_t);
+static size_t	word_back(struct tui *, size_t);
+static size_t	word_end(struct tui *, size_t);
+static int	max_scroll(struct tui *);
+static void	hist_push(struct tui *);
+static void	hist_browse(struct tui *, int dir);
+static void	hist_reset(struct tui *);
+static void	hist_free(struct tui *);
+static void	submit(struct tui *);
+static void	do_command(struct tui *, enum ui_cmd);
+
+static void
+on_winch(int sig)
+{
+	(void)sig;
+	resized = 1;
+}
+
+int
+ui_curses_run(struct imsgproto *ip, struct convo *cv, struct journal *j,
+    const struct skills *sk, int ascii, const char *resume_id)
+{
+	struct tui		t;
+	struct sigaction	sa;
+	struct pollfd		pfd[2];
+	size_t			b;
+
+	/*
+	 * Honour the terminal's encoding, but if the environment did not select
+	 * UTF-8, try to enable it anyway: terminals are essentially always UTF-8
+	 * now, and the wide curses output below needs a UTF-8 ctype to emit
+	 * multi-byte glyphs instead of one cell per byte.  The fallbacks are
+	 * best-effort -- if no UTF-8 locale exists, setlocale leaves the ctype
+	 * unchanged.
+	 */
+	setlocale(LC_CTYPE, "");
+	if (strcmp(nl_langinfo(CODESET), "UTF-8") != 0)
+		if (setlocale(LC_CTYPE, "en_US.UTF-8") == NULL)
+			(void)setlocale(LC_CTYPE, "C.UTF-8");
+
+	journal_unveil();
+	if (unveil("/usr/share/terminfo", "r") == -1 && errno != ENOENT)
+		fatal("unveil terminfo");
+	if (unveil(NULL, NULL) == -1)
+		fatal("unveil lock");
+	if (pledge("stdio tty rpath wpath cpath", NULL) == -1)
+		fatal("pledge");
+
+	memset(&t, 0, sizeof(t));
+	t.ip = ip;
+	t.cv = cv;
+	t.j = j;
+	t.sk = sk;
+	t.ascii = ascii;
+	t.follow = 1;
+	strlcpy(t.status, "ready", sizeof(t.status));
+
+	/*
+	 * Resume (which reads the journal) and reopen it for appending now, while
+	 * rpath is still held; replayed messages become transcript blocks.
+	 */
+	if (resume_id != NULL) {
+		if (convo_resume(cv, resume_id, replay_block, &t) == -1) {
+			b = block_new(&t, BK_ERROR);
+			buf_printf(&t.blocks[b].text,
+			    "cannot read session %s", resume_id);
+		}
+		journal_open(j, resume_id);
+	} else
+		journal_open(j, NULL);
+
+	b = block_new(&t, BK_INFO);
+	if (resume_id != NULL && j->id[0] != '\0')
+		buf_printf(&t.blocks[b].text, "fugu - resumed session %s "
+		    "(%zu messages).  Esc for vi normal mode (J/K scroll, "
+		    "j/k recall); Ctrl-D quits.", j->id, cv->n);
+	else
+		buf_puts_cstr(&t.blocks[b].text, "fugu - type a message and press "
+		    "Enter.  Esc for vi normal mode (J/K scroll, j/k recall); "
+		    "/clear, /compact, /model (Tab completes /commands); "
+		    "Ctrl-D quits.");
+
+	if (initscr() == NULL)
+		fatalx("ui: cannot initialise the terminal");
+	raw();
+	noecho();
+	keypad(stdscr, TRUE);
+	nodelay(stdscr, TRUE);
+	/*
+	 * Shorten the wait used to tell a lone Esc (switch to vi normal mode)
+	 * from the start of an arrow/function-key sequence.  The default is a
+	 * full second, which makes Esc feel laggy; 50ms still comfortably spans
+	 * a multi-byte sequence arriving in one terminal write.
+	 */
+	set_escdelay(50);
+	curs_set(1);
+	getmaxyx(stdscr, t.rows, t.cols);
+
+	/* terminfo has been read; no more file access is needed. */
+	if (pledge("stdio tty", NULL) == -1)
+		fatal("pledge");
+
+	memset(&sa, 0, sizeof(sa));
+	sa.sa_handler = on_winch;		/* sa_flags 0: interrupt poll(2) */
+	sigaction(SIGWINCH, &sa, NULL);
+
+	draw(&t);
+	while (!t.quit) {
+		pfd[0].fd = STDIN_FILENO;
+		pfd[0].events = POLLIN;
+		pfd[0].revents = 0;
+		pfd[1].fd = ip_fd(ip);
+		pfd[1].events = POLLIN;
+		pfd[1].revents = 0;
+
+		if (poll(pfd, 2, -1) == -1) {
+			if (errno == EINTR) {
+				if (resized) {
+					resized = 0;
+					do_resize(&t);
+					draw(&t);
+				}
+				continue;
+			}
+			break;
+		}
+		if (resized) {
+			resized = 0;
+			do_resize(&t);
+		}
+		if (pfd[1].revents & (POLLHUP | POLLERR))
+			break;
+		if (pfd[1].revents & POLLIN) {
+			if (drain_imsg(&t) == -1)
+				break;
+		}
+		if (pfd[0].revents & POLLIN) {
+			wint_t	wch;
+			int	r;
+
+			while ((r = get_wch(&wch)) != ERR)
+				handle_input(&t, wch, r == KEY_CODE_YES);
+		} else if (pfd[0].revents & (POLLHUP | POLLERR))
+			break;			/* terminal closed -> exit */
+		draw(&t);
+	}
+
+	endwin();
+	blocks_free(&t);
+	hist_free(&t);
+	picker_free_models(&t);
+	free(t.pmatch);
+	free(t.in);
+	return (0);
+}
+
+static size_t
+block_new(struct tui *t, enum bkind kind)
+{
+	t->blocks = xreallocarray(t->blocks, t->nblocks + 1,
+	    sizeof(*t->blocks));
+	t->blocks[t->nblocks].kind = kind;
+	buf_init(&t->blocks[t->nblocks].text);
+	return (t->nblocks++);
+}
+
+static void
+blocks_free(struct tui *t)
+{
+	size_t	i;
+
+	for (i = 0; i < t->nblocks; i++)
+		buf_free(&t->blocks[i].text);
+	free(t->blocks);
+	t->blocks = NULL;
+	t->nblocks = 0;
+}
+
+/* convo_resume callback: turn a replayed message part into a transcript block. */
+static void
+replay_block(void *ctx, enum convo_part part, const char *text, size_t len)
+{
+	struct tui	*t = ctx;
+	enum bkind	 k;
+	size_t		 bi;
+
+	switch (part) {
+	case CP_USER:
+		k = BK_USER;
+		break;
+	case CP_TOOL:
+		k = BK_TOOL;
+		break;
+	case CP_INFO:
+		k = BK_INFO;
+		break;
+	default:
+		k = BK_ASSIST;
+		break;
+	}
+	bi = block_new(t, k);
+	buf_append(&t->blocks[bi].text, text, len);
+}
+
+static const char *
+prefix_of(enum bkind k)
+{
+	switch (k) {
+	case BK_USER:	return ("you   ");
+	case BK_ASSIST:	return ("fugu  ");
+	case BK_TOOL:	return ("  tool: ");
+	case BK_ERROR:	return ("error: ");
+	default:	return ("");
+	}
+}
+
+static int
+attr_of(enum bkind k)
+{
+	switch (k) {
+	case BK_USER:	return (A_BOLD);
+	case BK_TOOL:	return (A_DIM);
+	case BK_ERROR:	return (A_BOLD);
+	case BK_INFO:	return (A_DIM);
+	default:	return (A_NORMAL);
+	}
+}
+
+/* Map mdrender's MD_* style flags to curses attributes (no colour available). */
+static int
+md_attr(int flags)
+{
+	int	a = 0;
+
+	if (flags & MD_CODEBLOCK)
+		a |= A_DIM;
+	if (flags & MD_CODE)
+		a |= A_DIM;
+	if (flags & (MD_BOLD | MD_HEADING))
+		a |= A_BOLD;
+	if (flags & MD_ITALIC)
+		a |= A_UNDERLINE;
+	return (a);
+}
+
+/*
+ * Build a block's display as parallel wide-char + per-char-attribute arrays:
+ * the kind prefix (in the block's base attribute) followed by the body.  An
+ * assistant block's body is markdown-rendered (styled spans, markers removed);
+ * every other kind keeps its single base attribute.  Caller frees the arrays.
+ */
+static size_t
+block_display(struct tui *t, struct block *b, wchar_t **wcp, int **atp)
+{
+	const char	*pfx = prefix_of(b->kind);
+	wchar_t		*bw, *wc;
+	int		 base = attr_of(b->kind), *at;
+	size_t		 pn = strlen(pfx), bn, i, n;
+
+	bw = utf8_to_wcs(b->text.data ? b->text.data : "", b->text.len, &bn);
+	if (t->ascii) {				/* transliterate to ASCII first */
+		size_t	 fn;
+		wchar_t	*f = fold_wcs(bw, bn, &fn);
+
+		free(bw);
+		bw = f;
+		bn = fn;
+	}
+
+	if (b->kind == BK_ASSIST) {
+		wchar_t	*mw;
+		int	*ms;
+		size_t	 mn = md_render(bw, bn, t->ascii, &mw, &ms);
+
+		free(bw);
+		n = pn + mn;
+		wc = xreallocarray(NULL, n + 1, sizeof(*wc));
+		at = xreallocarray(NULL, n + 1, sizeof(*at));
+		for (i = 0; i < pn; i++) {
+			wc[i] = (unsigned char)pfx[i];
+			at[i] = base;
+		}
+		for (i = 0; i < mn; i++) {
+			wc[pn + i] = mw[i];
+			at[pn + i] = base | md_attr(ms[i]);
+		}
+		free(mw);
+		free(ms);
+	} else {
+		n = pn + bn;
+		wc = xreallocarray(NULL, n + 1, sizeof(*wc));
+		at = xreallocarray(NULL, n + 1, sizeof(*at));
+		for (i = 0; i < pn; i++) {
+			wc[i] = (unsigned char)pfx[i];
+			at[i] = base;
+		}
+		for (i = 0; i < bn; i++) {
+			wc[pn + i] = bw[i];
+			at[pn + i] = base;
+		}
+		free(bw);
+	}
+	*wcp = wc;
+	*atp = at;
+	return (n);
+}
+
+/* Display width of a wide char, treating control/unknown as 1. */
+static int
+cw(wchar_t c)
+{
+	int	w = wcwidth(c);
+
+	return (w < 0 ? 1 : w);
+}
+
+/* An ASCII stand-in for a common non-ASCII char, or NULL if there is none. */
+static const char *
+ascii_rep(wchar_t c)
+{
+	switch (c) {
+	case 0x2018: case 0x2019: case 0x201a: case 0x2032:
+		return ("'");			/* single quotes / prime */
+	case 0x201c: case 0x201d: case 0x201e: case 0x2033:
+		return ("\"");			/* double quotes */
+	case 0x2013: case 0x2212:
+		return ("-");			/* en dash, minus */
+	case 0x2014: case 0x2015:
+		return ("--");			/* em dash, horizontal bar */
+	case 0x2026:
+		return ("...");			/* ellipsis */
+	case 0x2022: case 0x00b7: case 0x25cf: case 0x2023:
+		return ("*");			/* bullets */
+	case 0x2192:	return ("->");
+	case 0x2190:	return ("<-");
+	case 0x21d2:	return ("=>");
+	case 0x00a0:	return (" ");		/* no-break space */
+	case 0x00ab:	return ("<<");
+	case 0x00bb:	return (">>");
+	case 0x00a9:	return ("(c)");
+	case 0x00ae:	return ("(r)");
+	case 0x2122:	return ("(tm)");
+	case 0x00d7:	return ("x");
+	case 0x2264:	return ("<=");
+	case 0x2265:	return (">=");
+	case 0x2260:	return ("!=");
+	case 0x2713: case 0x2714:	return ("[x]");
+	default:	return (NULL);
+	}
+}
+
+/*
+ * Transliterate a wide string to ASCII (lossy, display-only): known punctuation
+ * gets an ASCII stand-in, printable ASCII passes through, everything else
+ * becomes '?'.  Worst-case expansion is 4x ("(tm)").
+ */
+static wchar_t *
+fold_wcs(const wchar_t *w, size_t n, size_t *outn)
+{
+	wchar_t	*o;
+	size_t	 i, j = 0;
+
+	o = xreallocarray(NULL, n * 4 + 1, sizeof(*o));
+	for (i = 0; i < n; i++) {
+		const char	*r = ascii_rep(w[i]);
+
+		if (r != NULL)
+			while (*r != '\0')
+				o[j++] = (unsigned char)*r++;
+		else if (w[i] == L'\n' || w[i] == L'\t' ||
+		    (w[i] >= 32 && w[i] < 127))
+			o[j++] = w[i];
+		else
+			o[j++] = L'?';
+	}
+	*outn = j;
+	return (o);
+}
+
+/* Decode a UTF-8 byte run into a freshly allocated wchar_t array. */
+static wchar_t *
+utf8_to_wcs(const char *s, size_t n, size_t *outlen)
+{
+	wchar_t		*w;
+	mbstate_t	 st;
+	size_t		 i = 0, o = 0;
+
+	memset(&st, 0, sizeof(st));
+	w = xreallocarray(NULL, n + 1, sizeof(*w));
+	while (i < n) {
+		wchar_t	wc;
+		size_t	r = mbrtowc(&wc, s + i, n - i, &st);
+
+		if (r == (size_t)-1 || r == (size_t)-2) {
+			wc = 0xfffd;		/* invalid byte(s) */
+			r = 1;
+			memset(&st, 0, sizeof(st));
+		} else if (r == 0)
+			r = 1;			/* embedded NUL */
+		w[o++] = wc;
+		i += r;
+	}
+	*outlen = o;
+	return (w);
+}
+
+/*
+ * Wrap every block to t->cols display columns and, when draw_it is set, render
+ * the visual lines in [vfrom, vto) into stdscr at row (index - vfrom).  Returns
+ * the total visual line count (a blank line separates blocks).
+ */
+static int
+render(struct tui *t, int draw_it, int vfrom, int vto)
+{
+	size_t	bi;
+	int	g = 0, w = t->cols > 0 ? t->cols : 1;
+
+	for (bi = 0; bi < t->nblocks; bi++) {
+		struct block	*b = &t->blocks[bi];
+		wchar_t		*wc;
+		int		*at;
+		size_t		 o, i;
+
+		o = block_display(t, b, &wc, &at);
+
+		for (i = 0; i < o; ) {
+			size_t	ls = i, lastsp = (size_t)-1, end;
+			int	col = 0;
+
+			while (i < o && wc[i] != L'\n') {
+				int	width = cw(wc[i]);
+
+				if (col + width > w)
+					break;
+				if (wc[i] == L' ')
+					lastsp = i;
+				col += width;
+				i++;
+			}
+			if (i < o && wc[i] == L'\n') {
+				end = i;
+				i++;
+			} else if (i < o && lastsp != (size_t)-1 &&
+			    lastsp > ls) {
+				end = lastsp;
+				i = lastsp + 1;
+			} else
+				end = i;
+			if (end == ls && i < o) {	/* a too-wide glyph */
+				end = ls + 1;
+				i = ls + 1;
+			}
+
+			if (draw_it && g >= vfrom && g < vto) {
+				size_t	r = ls;
+				int	x = 0;
+
+				while (r < end) {	/* draw runs of equal attr */
+					size_t	e = r, k;
+					int	a = at[r];
+
+					while (e < end && at[e] == a)
+						e++;
+					if (a)
+						attron(a);
+					mvaddnwstr(g - vfrom, x, wc + r,
+					    (int)(e - r));
+					if (a)
+						attroff(a);
+					for (k = r; k < e; k++)
+						x += cw(wc[k]);
+					r = e;
+				}
+			}
+			g++;
+		}
+		free(wc);
+		free(at);
+		/*
+		 * Blank line between blocks, except keep a run of consecutive
+		 * tool calls packed line-to-line so a burst reads as one list.
+		 */
+		if (!(b->kind == BK_TOOL && bi + 1 < t->nblocks &&
+		    t->blocks[bi + 1].kind == BK_TOOL))
+			g++;
+	}
+	return (g);
+}
+
+static void
+draw(struct tui *t)
+{
+	int	h, total, maxscroll, prow, i, avail, ccol;
+	size_t	k, start;
+	const char *prompt;
+
+	if (t->picking) {		/* the model picker owns the whole screen */
+		draw_picker(t);
+		return;
+	}
+
+	erase();
+	h = t->rows - 2;
+	if (h < 1)
+		h = 1;
+
+	total = render(t, 0, 0, 0);
+	maxscroll = total - h;
+	if (maxscroll < 0)
+		maxscroll = 0;
+	if (t->follow)
+		t->scroll = maxscroll;
+	if (t->scroll > maxscroll)
+		t->scroll = maxscroll;
+	if (t->scroll < 0)
+		t->scroll = 0;
+	render(t, 1, t->scroll, t->scroll + h);
+
+	/* status bar */
+	attron(A_REVERSE);
+	for (i = 0; i < t->cols; i++)
+		mvaddch(t->rows - 2, i, ' ');
+	mvaddnstr(t->rows - 2, 1, t->busy ?
+	    (t->compacting ? "summarizing..." : "working...") : t->status,
+	    t->cols - 2);
+	{
+		const char	*mode = t->imode == IM_NORMAL ?
+				    "[NORMAL]" : "[INSERT]";
+		int		 mx = t->cols - (int)strlen(mode) - 1;
+
+		if (mx > 0)
+			mvaddnstr(t->rows - 2, mx, mode, (int)strlen(mode));
+		if (t->scroll < maxscroll) {
+			const char	*more = "(scrolled - J/PgDn for latest)";
+			int		 c = mx - (int)strlen(more) - 1;
+
+			if (c > 0)
+				mvaddnstr(t->rows - 2, c, more,
+				    (int)strlen(more));
+		}
+	}
+	attroff(A_REVERSE);
+
+	/* input line: horizontal scroll (by display columns) to show the cursor */
+	prow = t->rows - 1;
+	move(prow, 0);
+	clrtoeol();
+	prompt = t->busy ? "... " : "> ";
+	mvaddstr(prow, 0, prompt);
+	avail = t->cols - (int)strlen(prompt) - 1;
+	if (avail < 1)
+		avail = 1;
+	{
+		wchar_t	*din = t->in;
+		size_t	 dlen = t->ilen, dcur = t->icur;
+		int	 x, wsum;
+
+		if (t->ascii) {			/* fold the input line for display */
+			size_t	 cn;
+			wchar_t	*pre = fold_wcs(t->in, t->icur, &cn);
+
+			free(pre);
+			din = fold_wcs(t->in, t->ilen, &dlen);
+			dcur = cn;
+		}
+
+		start = 0;
+		for (;;) {		/* shrink the window from the left */
+			wsum = 0;
+			for (k = start; k < dcur; k++)
+				wsum += cw(din[k]);
+			if (wsum <= avail || start >= dcur)
+				break;
+			start++;
+		}
+		ccol = (int)strlen(prompt);
+		x = ccol;
+		wsum = 0;
+		for (k = start; k < dlen; k++) {
+			int	width = cw(din[k]);
+
+			if (wsum + width > avail)
+				break;
+			mvaddnwstr(prow, x, &din[k], 1);
+			x += width;
+			wsum += width;
+			if (k < dcur)
+				ccol += width;
+		}
+		if (t->ascii)
+			free(din);
+	}
+	move(prow, ccol);
+
+	refresh();
+}
+
+static void
+do_resize(struct tui *t)
+{
+	struct winsize	ws;
+
+	/* resize_term(), not resizeterm(): the latter ungetch()es a KEY_RESIZE
+	 * that our get_wch() loop would feed back here, spinning forever. */
+	if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0)
+		resize_term(ws.ws_row, ws.ws_col);
+	getmaxyx(stdscr, t->rows, t->cols);
+	clearok(stdscr, TRUE);
+}
+
+static int
+drain_imsg(struct tui *t)
+{
+	struct fugu_msg	m;
+	int		r;
+
+	if (ip_read(t->ip) <= 0)
+		return (-1);
+	for (;;) {
+		r = ip_get(t->ip, &m);
+		if (r == -1)
+			return (-1);
+		if (r == 0)
+			return (0);
+		handle_msg(t, &m);
+		ip_msg_free(&m);
+	}
+}
+
+static void
+handle_msg(struct tui *t, struct fugu_msg *m)
+{
+	switch (m->type) {
+	case M_DELTA: {
+		size_t	bi;
+
+		if (t->nblocks > 0 &&
+		    t->blocks[t->nblocks - 1].kind == BK_ASSIST)
+			bi = t->nblocks - 1;
+		else
+			bi = block_new(t, BK_ASSIST);
+		buf_append(&t->blocks[bi].text, m->data, m->len);
+		t->follow = 1;
+		break;
+	}
+	case M_MESSAGE:			/* a full conversation message to keep */
+		convo_add_raw(t->cv, (char *)m->data, m->len);
+		journal_append(t->j, (char *)m->data, m->len);
+		break;
+	case M_TOOL_CALL: {
+		struct json_doc	d;
+		size_t		bi;
+		char		*name = NULL, *input = NULL;
+		int		nt, it;
+
+		bi = block_new(t, BK_TOOL);
+		if (json_parse(&d, m->data, m->len) == 0 && d.ntok >= 1) {
+			if ((nt = json_obj_get(&d, 0, "name")) >= 0)
+				name = json_tok_strdup(&d, nt);
+			if ((it = json_obj_get(&d, 0, "input")) >= 0)
+				input = json_tok_strdup(&d, it);
+		}
+		buf_printf(&t->blocks[bi].text, "%s %s",
+		    name != NULL ? name : "(tool)",
+		    input != NULL ? input : "");
+		free(name);
+		free(input);
+		json_doc_free(&d);
+		t->follow = 1;
+		break;
+	}
+	case M_TOOL_REQUEST: {
+		/*
+		 * Model-invoked `skill` tool call routed back to us by net's
+		 * tool_dst().  Look the skill up in t->sk and reply with the
+		 * body as a tool_result so net can splice it into the next
+		 * Messages API turn.  On a send failure we're shutting down --
+		 * mark the in-flight turn failed so its already-journaled
+		 * records (user msg / skill synth trio / partial assistant)
+		 * don't replay as orphans on resume.
+		 */
+		struct buf	out;
+
+		buf_init(&out);
+		skills_handle_tool_request(t->sk, m->data, m->len, &out);
+		if (ip_compose(t->ip, M_TOOL_RESULT, EP_NET, EP_UI, out.data,
+		    out.len) == -1 || ip_flush(t->ip) == -1) {
+			log_warnx("tool result send failed");
+			convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+			convo_truncate(t->cv, t->cv_mark);
+			t->quit = 1;
+		}
+		buf_free(&out);
+		break;
+	}
+	case M_USAGE: {
+		struct json_doc	d;
+		char		*in = NULL, *out = NULL;
+		int		ti, to;
+
+		if (json_parse(&d, m->data, m->len) == 0 && d.ntok >= 1) {
+			if ((ti = json_obj_get(&d, 0, "input_tokens")) >= 0)
+				in = json_tok_strdup(&d, ti);
+			if ((to = json_obj_get(&d, 0, "output_tokens")) >= 0)
+				out = json_tok_strdup(&d, to);
+		}
+		if (in != NULL && out != NULL)
+			snprintf(t->status, sizeof(t->status),
+			    "ready  (tokens: %s in / %s out)", in, out);
+		free(in);
+		free(out);
+		json_doc_free(&d);
+		break;
+	}
+	case M_TURN_DONE:
+		t->busy = 0;
+		break;
+	case M_SUMMARY: {			/* /compact: the summary arrived */
+		struct buf	mk;
+		char		*s = xmalloc(m->len + 1);
+		size_t		bi;
+
+		memcpy(s, m->data, m->len);
+		s[m->len] = '\0';
+
+		buf_init(&mk);
+		convo_marker_compact(&mk, s);
+		journal_append(t->j, mk.data, mk.len);
+		buf_free(&mk);
+		convo_set_summary(t->cv, s);
+
+		bi = block_new(t, BK_INFO);
+		buf_puts_cstr(&t->blocks[bi].text, "earlier conversation compacted");
+		bi = block_new(t, BK_ASSIST);
+		buf_append(&t->blocks[bi].text, s, m->len);
+		free(s);
+
+		t->busy = 0;
+		t->compacting = 0;
+		t->follow = 1;
+		break;
+	}
+	case M_ERROR: {
+		size_t	bi = block_new(t, BK_ERROR);
+
+		buf_append(&t->blocks[bi].text, m->data, m->len);
+		/*
+		 * Roll back ONLY when a turn was actually in flight (busy).
+		 * /model failures arrive here with busy=false and a cv_mark
+		 * left over from an EARLIER successful turn -- truncating to
+		 * that stale mark would destroy the prior turn in memory, and
+		 * journaling turn_failed would drop it on resume too.
+		 * convo_journal_turn_failed runs BEFORE convo_truncate so the
+		 * helper sees cv->n > mark and emits; afterwards cv->n == mark
+		 * and a second M_ERROR (e.g. queued, retried) is a clean no-op.
+		 */
+		if (t->busy) {
+			convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+			convo_truncate(t->cv, t->cv_mark);
+		}
+		t->busy = 0;
+		t->compacting = 0;
+		t->loading_models = 0;	/* a /model fetch failure resolves here too */
+		t->follow = 1;
+		break;
+	}
+	case M_MODELS:			/* reply to /model: the id list */
+		t->loading_models = 0;
+		picker_open(t, (char *)m->data, m->len);
+		break;
+	default:
+		break;
+	}
+}
+
+/*
+ * The editor is modal (vi): it starts in insert mode and Esc switches to
+ * normal mode.  A handful of keys mean the same thing in both modes -- the
+ * keypad navigation keys, the terminal controls (Ctrl-L/D/C), transcript
+ * paging, and J/K line-scrolling -- and are handled here; everything else is
+ * dispatched to insert_key() or normal_key() by the current mode.  Scrolling
+ * works even while a turn is in flight; editing does not.
+ */
+static void
+handle_input(struct tui *t, wint_t wch, int iskey)
+{
+	int	page = t->rows - 4 > 1 ? t->rows - 4 : 1;
+
+	/* The model picker is modal: while it is up, it owns every keystroke. */
+	if (t->picking) {
+		picker_key(t, wch, iskey);
+		return;
+	}
+
+	/* Any key but Tab/Shift-Tab ends an in-progress completion cycle. */
+	if (!((!iskey && wch == L'\t') || (iskey && wch == KEY_BTAB)))
+		t->comping = 0;
+
+	/*
+	 * A pending d-operator (set by 'd' in normal mode) is consumed by
+	 * exactly the next key: a plain motion key applies the deletion, while
+	 * any keypad/scroll/control key cancels it.  Resolving it here -- before
+	 * the keypad and scroll keys that return early below -- keeps a stray
+	 * 'd' from silently turning a later keystroke into a deletion.
+	 */
+	if (t->pend == L'd') {
+		t->pend = 0;
+		if (!iskey && !t->busy) {
+			do_dmotion(t, wch);
+			return;
+		}
+		/* fall through: the operator is cancelled, key handled normally */
+	}
+
+	if (iskey) {
+		switch (wch) {
+		case KEY_PPAGE:
+			t->follow = 0;
+			t->scroll -= page;
+			if (t->scroll < 0)
+				t->scroll = 0;
+			return;
+		case KEY_NPAGE:
+			t->scroll += page;
+			t->follow = 1;
+			return;
+		case KEY_RESIZE:
+			do_resize(t);
+			return;
+		case KEY_UP:		/* recall an earlier prompt */
+			if (!t->busy)
+				hist_browse(t, -1);
+			return;
+		case KEY_DOWN:		/* and back toward the live line */
+			if (!t->busy)
+				hist_browse(t, +1);
+			return;
+		case KEY_LEFT:
+			if (!t->busy && t->icur > 0)
+				t->icur--;
+			return;
+		case KEY_RIGHT:
+			if (!t->busy && t->icur < t->ilen)
+				t->icur++;
+			return;
+		case KEY_HOME:
+			if (!t->busy)
+				t->icur = 0;
+			return;
+		case KEY_END:
+			if (!t->busy)
+				t->icur = t->ilen;
+			return;
+		case KEY_BTAB:		/* Shift-Tab: reverse the completion cycle */
+			if (!t->busy && t->ilen > 0 && t->in[0] == L'/')
+				complete_tab(t, -1);
+			return;
+		case KEY_BACKSPACE:
+			if (t->busy)
+				return;
+			if (t->imode == IM_INSERT)
+				in_backspace(t);
+			else if (t->icur > 0)
+				t->icur--;	/* normal mode: move left */
+			return;
+		case KEY_DC:
+			if (!t->busy)
+				in_delch(t);
+			return;
+		default:
+			return;
+		}
+	}
+
+	switch (wch) {			/* terminal controls: any mode, even busy */
+	case 27:			/* Esc: enter vi normal mode */
+		if (t->imode == IM_INSERT) {
+			t->imode = IM_NORMAL;
+			if (t->icur > 0)	/* vi: leaving insert steps left */
+				t->icur--;
+		}
+		return;			/* allowed mid-turn so J/K can scroll */
+	case 12:			/* Ctrl-L: repaint */
+		clearok(stdscr, TRUE);
+		return;
+	case 4:				/* Ctrl-D: quit on an empty line */
+		if (t->ilen == 0)
+			t->quit = 1;
+		return;
+	case 3:				/* Ctrl-C: clear the line, else quit */
+		if (t->ilen > 0) {
+			hist_reset(t);
+			t->ilen = 0;
+			t->icur = 0;
+			t->imode = IM_INSERT;
+		} else
+			t->quit = 1;
+		return;
+	}
+
+	/* J/K scroll the transcript in normal mode, even mid-turn. */
+	if (t->imode == IM_NORMAL) {
+		if (wch == L'K') {
+			t->follow = 0;
+			if (t->scroll > 0)
+				t->scroll--;
+			return;
+		}
+		if (wch == L'J') {
+			int	m = max_scroll(t);
+
+			if (t->scroll < m)
+				t->scroll++;
+			t->follow = (t->scroll >= m);
+			return;
+		}
+	}
+
+	if (t->busy)
+		return;			/* no editing while a turn is in flight */
+
+	/* Tab cycles slash-command completions on a "/..." line (either mode). */
+	if (!iskey && wch == L'\t' && t->ilen > 0 && t->in[0] == L'/') {
+		complete_tab(t, 1);
+		return;
+	}
+
+	if (t->imode == IM_INSERT)
+		insert_key(t, wch);
+	else
+		normal_key(t, wch);
+}
+
+/* Plain (non-keypad) keys in insert mode (Esc is handled in handle_input). */
+static void
+insert_key(struct tui *t, wint_t wch)
+{
+	switch (wch) {
+	case L'\r':
+	case L'\n':
+		submit(t);
+		return;
+	case 8:
+	case 127:			/* Backspace (terminals that send a byte) */
+		in_backspace(t);
+		return;
+	default:
+		if (wch >= 32 && wch != 127)
+			in_insert(t, (wchar_t)wch);
+		return;
+	}
+}
+
+/*
+ * Plain (non-keypad) keys in normal mode.  J/K scrolling and a pending
+ * d-operator are resolved earlier in handle_input(); a 'd' here only arms one.
+ */
+static void
+normal_key(struct tui *t, wint_t wch)
+{
+	switch (wch) {
+	/* enter insert mode */
+	case L'i':
+		t->imode = IM_INSERT;
+		return;
+	case L'I':
+		t->icur = first_nonblank(t);
+		t->imode = IM_INSERT;
+		return;
+	case L'a':
+		if (t->icur < t->ilen)
+			t->icur++;
+		t->imode = IM_INSERT;
+		return;
+	case L'A':
+	case L'o':			/* single line: open-below == append */
+		t->icur = t->ilen;
+		t->imode = IM_INSERT;
+		return;
+	case L'O':			/* open-above == insert at first column */
+		t->icur = first_nonblank(t);
+		t->imode = IM_INSERT;
+		return;
+	/* movement */
+	case 8:
+	case 127:
+	case L'h':
+		if (t->icur > 0)
+			t->icur--;
+		return;
+	case L' ':
+	case L'l':
+		if (t->icur < t->ilen)
+			t->icur++;
+		return;
+	case L'0':
+		t->icur = 0;
+		return;
+	case L'$':
+		t->icur = t->ilen;
+		return;
+	case L'^':
+		t->icur = first_nonblank(t);
+		return;
+	case L'w':
+		t->icur = word_fwd(t, t->icur);
+		return;
+	case L'b':
+		t->icur = word_back(t, t->icur);
+		return;
+	case L'e':
+		t->icur = word_end(t, t->icur);
+		return;
+	/* single-line vertical motion recalls prompts instead */
+	case L'k':
+		hist_browse(t, -1);
+		return;
+	case L'j':
+		hist_browse(t, +1);
+		return;
+	/* deletion */
+	case L'x':
+		in_delch(t);
+		return;
+	case L'D':
+		del_range(t, t->icur, t->ilen);
+		return;
+	case L'd':
+		t->pend = L'd';
+		return;
+	/* submit straight from normal mode too */
+	case L'\r':
+	case L'\n':
+		submit(t);
+		return;
+	default:
+		return;
+	}
+}
+
+/* Apply a pending d-operator to the motion key that followed it. */
+static void
+do_dmotion(struct tui *t, wint_t key)
+{
+	size_t	to;
+
+	switch (key) {
+	case L'd':			/* dd: delete the whole line */
+		del_range(t, 0, t->ilen);
+		return;
+	case L'$':
+		del_range(t, t->icur, t->ilen);
+		return;
+	case L'0':
+		del_range(t, 0, t->icur);
+		return;
+	case L'^':
+		to = first_nonblank(t);
+		if (to < t->icur)
+			del_range(t, to, t->icur);
+		else
+			del_range(t, t->icur, to);
+		return;
+	case L'w':
+		del_range(t, t->icur, word_fwd(t, t->icur));
+		return;
+	case L'b':
+		del_range(t, word_back(t, t->icur), t->icur);
+		return;
+	case L'e':
+		to = word_end(t, t->icur);
+		if (to < t->ilen)
+			to++;		/* inclusive of the word's last char */
+		del_range(t, t->icur, to);
+		return;
+	case 8:
+	case 127:
+	case L'h':
+		if (t->icur > 0)
+			del_range(t, t->icur - 1, t->icur);
+		return;
+	case L' ':
+	case L'l':
+		del_range(t, t->icur, t->icur + 1);
+		return;
+	default:			/* Esc or anything else cancels */
+		return;
+	}
+}
+
+static void
+in_insert(struct tui *t, wchar_t c)
+{
+	hist_reset(t);
+	if (t->ilen + 1 > t->icap) {
+		t->icap = t->icap ? t->icap * 2 : 64;
+		t->in = xreallocarray(t->in, t->icap, sizeof(wchar_t));
+	}
+	memmove(&t->in[t->icur + 1], &t->in[t->icur],
+	    (t->ilen - t->icur) * sizeof(wchar_t));
+	t->in[t->icur] = c;
+	t->ilen++;
+	t->icur++;
+}
+
+/* Replace the input line with a copy of src[0..n) and park the cursor at end. */
+static void
+in_set(struct tui *t, const wchar_t *src, size_t n)
+{
+	if (n + 1 > t->icap) {
+		t->icap = n + 1;
+		t->in = xreallocarray(t->in, t->icap, sizeof(wchar_t));
+	}
+	if (n > 0)
+		memcpy(t->in, src, n * sizeof(wchar_t));
+	t->ilen = n;
+	t->icur = n;
+}
+
+/*
+ * Tab (dir +1) / Shift-Tab (dir -1) on a "/..." input line cycle through the
+ * matching slash commands.  The typed text is captured as the base on the first
+ * press; each consecutive press steps one match in dir, wrapping around (the
+ * first forward press lands on the first match, the first reverse press on the
+ * last).  The cycle resets as soon as any other key is pressed (see handle_input).
+ */
+static void
+complete_tab(struct tui *t, int dir)
+{
+	const char	*matches[16];
+	wchar_t		 wbuf[64];
+	const char	*m;
+	size_t		 i, k;
+	int		 n, first = 0;
+
+	if (!t->comping) {		/* capture the typed prefix (ASCII) */
+		for (i = 0, k = 0; i < t->ilen && k < sizeof(t->compbase) - 1; i++)
+			t->compbase[k++] = t->in[i] < 128 ? (char)t->in[i] : '?';
+		t->compbase[k] = '\0';
+		t->comping = 1;
+		first = 1;
+	}
+
+	n = ui_complete(t->sk, t->compbase, matches, 16);
+	if (n == 0) {
+		beep();			/* no command matches the prefix */
+		t->comping = 0;
+		return;
+	}
+	if (first)
+		t->compidx = dir > 0 ? 0 : n - 1;
+	else {
+		t->compidx += dir;
+		if (t->compidx < 0)
+			t->compidx = n - 1;
+		else if (t->compidx >= n)
+			t->compidx = 0;
+	}
+	m = matches[t->compidx];
+	for (k = 0; m[k] != '\0' && k < sizeof(wbuf) / sizeof(wbuf[0]); k++)
+		wbuf[k] = (unsigned char)m[k];
+	in_set(t, wbuf, k);
+}
+
+/* Delete the character before the cursor (insert-mode Backspace). */
+static void
+in_backspace(struct tui *t)
+{
+	if (t->icur == 0)
+		return;
+	del_range(t, t->icur - 1, t->icur);
+}
+
+/* Delete the character under the cursor (x / Delete). */
+static void
+in_delch(struct tui *t)
+{
+	if (t->icur < t->ilen)
+		del_range(t, t->icur, t->icur + 1);
+}
+
+/* Delete input[from..to); clamp, leave the cursor at `from`. */
+static void
+del_range(struct tui *t, size_t from, size_t to)
+{
+	if (to > t->ilen)
+		to = t->ilen;
+	if (from >= to)
+		return;
+	hist_reset(t);
+	memmove(&t->in[from], &t->in[to], (t->ilen - to) * sizeof(wchar_t));
+	t->ilen -= to - from;
+	t->icur = from;
+}
+
+/* Index of the first non-blank input character (or ilen if all blank). */
+static size_t
+first_nonblank(struct tui *t)
+{
+	size_t	i = 0;
+
+	while (i < t->ilen && (t->in[i] == L' ' || t->in[i] == L'\t'))
+		i++;
+	return (i);
+}
+
+/* vi word class: 0 blank, 1 word (alnum/_), 2 punctuation. */
+static int
+wclass(wchar_t c)
+{
+	if (c == L' ' || c == L'\t' || iswspace(c))
+		return (0);
+	if (c == L'_' || iswalnum(c))
+		return (1);
+	return (2);
+}
+
+/* Start of the next word at or after i (the `w` motion). */
+static size_t
+word_fwd(struct tui *t, size_t i)
+{
+	int	c;
+
+	if (i >= t->ilen)
+		return (t->ilen);
+	c = wclass(t->in[i]);
+	if (c != 0)
+		while (i < t->ilen && wclass(t->in[i]) == c)
+			i++;
+	while (i < t->ilen && wclass(t->in[i]) == 0)
+		i++;
+	return (i);
+}
+
+/* Start of the word before i (the `b` motion). */
+static size_t
+word_back(struct tui *t, size_t i)
+{
+	int	c;
+
+	if (i == 0)
+		return (0);
+	i--;
+	while (i > 0 && wclass(t->in[i]) == 0)
+		i--;
+	c = wclass(t->in[i]);
+	if (c == 0)
+		return (i);
+	while (i > 0 && wclass(t->in[i - 1]) == c)
+		i--;
+	return (i);
+}
+
+/* End of the word at or after i (the `e` motion). */
+static size_t
+word_end(struct tui *t, size_t i)
+{
+	int	c;
+
+	if (i >= t->ilen)
+		return (t->ilen ? t->ilen - 1 : 0);
+	i++;
+	while (i < t->ilen && wclass(t->in[i]) == 0)
+		i++;
+	if (i >= t->ilen)
+		return (t->ilen ? t->ilen - 1 : 0);
+	c = wclass(t->in[i]);
+	while (i + 1 < t->ilen && wclass(t->in[i + 1]) == c)
+		i++;
+	return (i);
+}
+
+/* Largest valid scroll offset: total visual lines minus the visible window. */
+static int
+max_scroll(struct tui *t)
+{
+	int	h = t->rows - 2, m;
+
+	if (h < 1)
+		h = 1;
+	m = render(t, 0, 0, 0) - h;
+	return (m < 0 ? 0 : m);
+}
+
+/* Stop browsing prompt history: the live line is whatever is in the editor. */
+static void
+hist_reset(struct tui *t)
+{
+	t->histpos = t->nhist;
+}
+
+/* Append the current input line to the prompt history (skipping a repeat). */
+static void
+hist_push(struct tui *t)
+{
+	wchar_t	*copy;
+
+	if (t->ilen == 0)
+		return;
+	if (t->nhist > 0 && t->histlen[t->nhist - 1] == t->ilen &&
+	    wmemcmp(t->hist[t->nhist - 1], t->in, t->ilen) == 0) {
+		t->histpos = t->nhist;
+		return;
+	}
+	copy = xreallocarray(NULL, t->ilen, sizeof(wchar_t));
+	wmemcpy(copy, t->in, t->ilen);
+	t->hist = xreallocarray(t->hist, t->nhist + 1, sizeof(*t->hist));
+	t->histlen = xreallocarray(t->histlen, t->nhist + 1,
+	    sizeof(*t->histlen));
+	t->hist[t->nhist] = copy;
+	t->histlen[t->nhist] = t->ilen;
+	t->nhist++;
+	t->histpos = t->nhist;
+}
+
+/* Move through prompt history: dir < 0 older, dir > 0 newer (toward live). */
+static void
+hist_browse(struct tui *t, int dir)
+{
+	if (dir < 0) {
+		if (t->nhist == 0 || t->histpos == 0)
+			return;
+		if (t->histpos == t->nhist) {	/* stash the live line first */
+			free(t->histsave);
+			t->histsave = NULL;
+			t->histsavelen = t->ilen;
+			if (t->ilen > 0) {
+				t->histsave = xreallocarray(NULL, t->ilen,
+				    sizeof(wchar_t));
+				wmemcpy(t->histsave, t->in, t->ilen);
+			}
+		}
+		t->histpos--;
+		in_set(t, t->hist[t->histpos], t->histlen[t->histpos]);
+	} else {
+		if (t->histpos >= t->nhist)
+			return;
+		t->histpos++;
+		if (t->histpos == t->nhist)
+			in_set(t, t->histsave, t->histsavelen);
+		else
+			in_set(t, t->hist[t->histpos], t->histlen[t->histpos]);
+	}
+}
+
+static void
+hist_free(struct tui *t)
+{
+	size_t	i;
+
+	for (i = 0; i < t->nhist; i++)
+		free(t->hist[i]);
+	free(t->hist);
+	free(t->histlen);
+	free(t->histsave);
+	t->hist = NULL;
+	t->histlen = NULL;
+	t->histsave = NULL;
+	t->nhist = 0;
+	t->histpos = 0;
+}
+
+static void
+submit(struct tui *t)
+{
+	struct buf	msgs, text;
+	mbstate_t	st;
+	size_t		bi, i;
+	char		mb[MB_LEN_MAX];
+	enum ui_cmd	cmd;
+
+	if (t->ilen == 0)
+		return;
+	if (t->loading_models)
+		return;		/* a /model fetch is in flight; the picker is next */
+
+	hist_push(t);		/* remember the line for j/k recall */
+
+	/* Encode the wide input line back to UTF-8 for the conversation. */
+	buf_init(&text);
+	memset(&st, 0, sizeof(st));
+	for (i = 0; i < t->ilen; i++) {
+		size_t	r = wcrtomb(mb, t->in[i], &st);
+
+		if (r != (size_t)-1)
+			buf_append(&text, mb, r);
+	}
+	buf_terminate(&text);
+
+	/* A leading-slash command is intercepted, never sent as a message. */
+	cmd = ui_classify(text.data);
+	if (cmd != UC_NONE) {
+		do_command(t, cmd);
+		t->ilen = 0;
+		t->icur = 0;
+		t->imode = IM_INSERT;
+		buf_free(&text);
+		return;
+	}
+
+	/*
+	 * Not a built-in, but a slash-prefixed token may name a loaded skill:
+	 * synthesise an assistant tool_use + user tool_result pair so the model
+	 * receives the body as authoritative retrieved instructions, record a
+	 * journal marker, and submit as a normal turn.
+	 */
+	{
+		const struct skill	*match = NULL;
+		const char		*trailing = NULL;
+
+		if (text.data[0] == '/' &&
+		    (match = skills_match(t->sk, text.data, &trailing)) != NULL) {
+			struct buf	mk, up, am, um;
+			char		id[40];
+
+			buf_init(&mk);
+			skills_marker(&mk, match->name);
+			journal_append(t->j, mk.data, mk.len);
+			buf_free(&mk);
+
+			buf_init(&up);
+			buf_init(&am);
+			buf_init(&um);
+			skills_synth_invocation(match, text.data, trailing,
+			    &up, &am, &um, id, sizeof(id));
+
+			/* Echo the invocation in the transcript, not the body. */
+			bi = block_new(t, BK_USER);
+			buf_append(&t->blocks[bi].text, text.data, text.len);
+			t->cv_mark = t->cv->n;
+			convo_add_raw(t->cv, up.data, up.len);
+			convo_add_raw(t->cv, am.data, am.len);
+			convo_add_raw(t->cv, um.data, um.len);
+			journal_append(t->j, up.data, up.len);
+			journal_append(t->j, am.data, am.len);
+			journal_append(t->j, um.data, um.len);
+			buf_free(&up);
+			buf_free(&am);
+			buf_free(&um);
+			buf_free(&text);
+			goto submit;
+		}
+	}
+
+	bi = block_new(t, BK_USER);
+	buf_append(&t->blocks[bi].text, text.data, text.len);
+	t->cv_mark = t->cv->n;
+	convo_add_user(t->cv, text.data);
+	journal_append(t->j, t->cv->msg[t->cv->n - 1],
+	    strlen(t->cv->msg[t->cv->n - 1]));
+	buf_free(&text);
+
+submit:
+	t->ilen = 0;
+	t->icur = 0;
+	t->imode = IM_INSERT;
+
+	buf_init(&msgs);
+	convo_build(t->cv, &msgs);
+	if (ip_compose(t->ip, M_SUBMIT, EP_NET, EP_UI, msgs.data,
+	    msgs.len) == -1 || ip_flush(t->ip) == -1) {
+		/*
+		 * Submit failed to leave the box; cv + journal already hold
+		 * this turn's user message (or skill synth trio).  Mark the
+		 * turn failed and roll back before quitting so resume drops
+		 * the orphan.
+		 */
+		convo_journal_turn_failed(t->j, t->cv, t->cv_mark);
+		convo_truncate(t->cv, t->cv_mark);
+		t->quit = 1;
+	}
+	buf_free(&msgs);
+
+	t->busy = 1;
+	t->follow = 1;
+}
+
+/* Run a slash command (typed at the input line) instead of sending a message. */
+static void
+do_command(struct tui *t, enum ui_cmd cmd)
+{
+	struct buf	mk, msgs;
+	size_t		bi;
+
+	switch (cmd) {
+	case UC_QUIT:
+		t->quit = 1;
+		return;
+	case UC_CLEAR:
+		buf_init(&mk);
+		convo_marker_clear(&mk);
+		journal_append(t->j, mk.data, mk.len);
+		buf_free(&mk);
+		convo_clear(t->cv);
+		bi = block_new(t, BK_INFO);
+		buf_puts_cstr(&t->blocks[bi].text, "context cleared");
+		t->cv_mark = t->cv->n;
+		t->follow = 1;
+		return;
+	case UC_COMPACT:
+		if (t->cv->n == 0) {
+			bi = block_new(t, BK_INFO);
+			buf_puts_cstr(&t->blocks[bi].text, "nothing to compact");
+			t->follow = 1;
+			return;
+		}
+		buf_init(&msgs);
+		convo_build(t->cv, &msgs);
+		if (ip_compose(t->ip, M_SUMMARIZE, EP_NET, EP_UI, msgs.data,
+		    msgs.len) == -1 || ip_flush(t->ip) == -1)
+			t->quit = 1;
+		buf_free(&msgs);
+		t->cv_mark = t->cv->n;	/* a later M_ERROR truncate is a no-op */
+		t->busy = 1;
+		t->compacting = 1;
+		t->follow = 1;
+		return;
+	case UC_MODELS:
+		request_models(t);
+		return;
+	default:
+		return;
+	}
+}
+
+/* ---- model picker (/model): a modal, fuzzy-searchable overlay ---- */
+
+/* Ask the net worker for the model list; its M_MODELS reply opens the picker. */
+static void
+request_models(struct tui *t)
+{
+	size_t	bi;
+
+	if (t->loading_models)
+		return;
+	if (ip_compose(t->ip, M_MODELS, EP_NET, EP_UI, NULL, 0) == -1 ||
+	    ip_flush(t->ip) == -1) {
+		t->quit = 1;
+		return;
+	}
+	t->loading_models = 1;
+	bi = block_new(t, BK_INFO);
+	buf_puts_cstr(&t->blocks[bi].text, "fetching models...");
+	t->follow = 1;
+}
+
+static void
+picker_free_models(struct tui *t)
+{
+	size_t	i;
+
+	for (i = 0; i < t->nmodels; i++)
+		free(t->models[i]);
+	free(t->models);
+	t->models = NULL;
+	t->nmodels = 0;
+}
+
+/* Open the picker over a newline-separated id list (the M_MODELS payload). */
+static void
+picker_open(struct tui *t, char *list, size_t len)
+{
+	size_t	start, i;
+
+	picker_free_models(t);
+	for (start = 0, i = 0; i <= len; i++) {
+		if (i == len || list[i] == '\n') {
+			if (i > start) {
+				char	*s = xmalloc(i - start + 1);
+
+				memcpy(s, list + start, i - start);
+				s[i - start] = '\0';
+				t->models = xreallocarray(t->models,
+				    t->nmodels + 1, sizeof(*t->models));
+				t->models[t->nmodels++] = s;
+			}
+			start = i + 1;
+		}
+	}
+	if (t->nmodels == 0) {
+		size_t	bi = block_new(t, BK_INFO);
+
+		buf_puts_cstr(&t->blocks[bi].text, "no models available");
+		t->follow = 1;
+		return;
+	}
+	t->pquery[0] = '\0';
+	t->pqlen = 0;
+	picker_refilter(t);
+	t->picking = 1;
+}
+
+static void
+picker_close(struct tui *t)
+{
+	t->picking = 0;
+	picker_free_models(t);
+	free(t->pmatch);
+	t->pmatch = NULL;
+	t->npmatch = 0;
+	t->pqlen = 0;
+	t->pquery[0] = '\0';
+	clearok(stdscr, TRUE);		/* fully repaint over the overlay next frame */
+}
+
+struct pscore {
+	int	idx;
+	int	score;
+};
+
+static int
+pscore_cmp(const void *a, const void *b)
+{
+	const struct pscore	*pa = a, *pb = b;
+
+	if (pa->score != pb->score)
+		return (pb->score - pa->score);	/* higher score first */
+	return (pa->idx - pb->idx);		/* ties keep the provider's order */
+}
+
+/* Recompute the ranked list of models matching the current filter. */
+static void
+picker_refilter(struct tui *t)
+{
+	struct pscore	*sc;
+	size_t		 i, n = 0;
+	size_t		 cap = t->nmodels > 0 ? t->nmodels : 1;
+
+	t->pmatch = xreallocarray(t->pmatch, cap, sizeof(*t->pmatch));
+	sc = xreallocarray(NULL, cap, sizeof(*sc));
+	for (i = 0; i < t->nmodels; i++) {
+		int	score;
+
+		if (fuzzy_match(t->pquery, t->models[i], &score)) {
+			sc[n].idx = (int)i;
+			sc[n].score = score;
+			n++;
+		}
+	}
+	qsort(sc, n, sizeof(*sc), pscore_cmp);
+	for (i = 0; i < n; i++)
+		t->pmatch[i] = sc[i].idx;
+	free(sc);
+	t->npmatch = n;
+	t->psel = 0;
+	t->ptop = 0;
+}
+
+/*
+ * A model row for display.  When several providers are configured net tags each
+ * id "name\tmodel"; show that as "name: model".  Bare ids (one provider) pass
+ * through unchanged.
+ */
+static void
+model_label(const char *raw, char *out, size_t cap)
+{
+	const char	*tab = strchr(raw, '\t');
+
+	if (tab == NULL)
+		snprintf(out, cap, "%s", raw);
+	else
+		snprintf(out, cap, "%.*s: %s", (int)(tab - raw), raw, tab + 1);
+}
+
+/* One keystroke while the picker is up (it owns all input -- see handle_input). */
+static void
+picker_key(struct tui *t, wint_t wch, int iskey)
+{
+	if (iskey) {
+		switch (wch) {
+		case KEY_UP:
+			if (t->psel > 0)
+				t->psel--;
+			return;
+		case KEY_DOWN:
+			if ((size_t)t->psel + 1 < t->npmatch)
+				t->psel++;
+			return;
+		case KEY_BACKSPACE:
+			if (t->pqlen > 0) {
+				t->pquery[--t->pqlen] = '\0';
+				picker_refilter(t);
+			}
+			return;
+		default:
+			return;
+		}
+	}
+	switch (wch) {
+	case 27:			/* Esc / Ctrl-G: cancel */
+	case 7:
+		picker_close(t);
+		return;
+	case L'\r':
+	case L'\n':			/* Enter: choose the highlighted model */
+		if (t->npmatch > 0) {
+			const char	*m = t->models[t->pmatch[t->psel]];
+			char		 lbl[256];
+			size_t		 bi;
+
+			if (ip_compose(t->ip, M_SET_MODEL, EP_NET, EP_UI, m,
+			    strlen(m)) == -1 || ip_flush(t->ip) == -1)
+				t->quit = 1;
+			bi = block_new(t, BK_INFO);
+			model_label(m, lbl, sizeof(lbl));
+			buf_printf(&t->blocks[bi].text, "model set to %s", lbl);
+			t->follow = 1;
+		}
+		picker_close(t);
+		return;
+	case 16:			/* Ctrl-P: up */
+		if (t->psel > 0)
+			t->psel--;
+		return;
+	case 14:			/* Ctrl-N: down */
+		if ((size_t)t->psel + 1 < t->npmatch)
+			t->psel++;
+		return;
+	case 8:
+	case 127:			/* Backspace */
+		if (t->pqlen > 0) {
+			t->pquery[--t->pqlen] = '\0';
+			picker_refilter(t);
+		}
+		return;
+	default:
+		if (wch >= 32 && wch < 127 &&
+		    t->pqlen < sizeof(t->pquery) - 1) {
+			t->pquery[t->pqlen++] = (char)wch;
+			t->pquery[t->pqlen] = '\0';
+			picker_refilter(t);
+		}
+		return;
+	}
+}
+
+/* Render the modal picker over the whole screen. */
+static void
+draw_picker(struct tui *t)
+{
+	int	cols = t->cols, rows = t->rows;
+	int	listh = rows - 3 > 0 ? rows - 3 : 1;
+	int	i, row, fx;
+	char	st[160];
+
+	erase();
+	attron(A_BOLD);
+	mvaddnstr(0, 0, "Select a model", cols - 1);
+	attroff(A_BOLD);
+	mvaddnstr(1, 0, "filter: ", cols - 1);
+	if (t->pqlen > 0)
+		mvaddnstr(1, 8, t->pquery, cols - 9 > 0 ? cols - 9 : 0);
+
+	if (t->psel < t->ptop)
+		t->ptop = t->psel;
+	if (t->psel >= t->ptop + listh)
+		t->ptop = t->psel - listh + 1;
+	if (t->ptop < 0)
+		t->ptop = 0;
+
+	if (t->npmatch == 0)
+		mvaddnstr(2, 0, "(no matching models)", cols - 1);
+	for (i = 0; i < listh; i++) {
+		char	lbl[256];
+		int	sel;
+
+		row = t->ptop + i;
+		if ((size_t)row >= t->npmatch)
+			break;
+		model_label(t->models[t->pmatch[row]], lbl, sizeof(lbl));
+		sel = (row == t->psel);
+		if (sel)
+			attron(A_BOLD);
+		mvaddstr(2 + i, 0, sel ? "> " : "  ");
+		mvaddnstr(2 + i, 2, lbl, cols - 3 > 0 ? cols - 3 : 0);
+		if (sel)
+			attroff(A_BOLD);
+	}
+
+	snprintf(st, sizeof(st), "%zu/%zu   Up/Down or ^P/^N   "
+	    "Enter: choose   Esc: cancel", t->npmatch, t->nmodels);
+	mvaddnstr(rows - 1, 0, st, cols - 1);
+
+	fx = 8 + (int)t->pqlen;
+	if (fx > cols - 1)
+		fx = cols - 1;
+	move(1, fx);
+	refresh();
+}