simple-harness is a terminal agentic loop built with Zig 0.17 dev and one
vendored TUI dependency. It talks to
two LLM providers, runs tools (including subagents and MCP servers) in
parallel, and compacts its own context. This document explains how the
pieces fit together.
┌──────────── REPL (main) ────────────┐
stdin ──line──▶ │ read line / slash command │
│ append user msg → Agent.runTurn │
└──────────────┬──────────────────────┘
│
┌───────────────────────▼────────────────────────┐
│ Agent.runTurn │
│ buildBody → POST → step{Anthropic,OpenAI} │
│ ▲ │ │
│ │ loop until ▼ │
│ │ no tool calls runTools │
│ └─────────────────────┤ │
└──────────────────────────────────┼─────────────┘
│
┌──────────────── runTools ───────┴───────────┐
│ meta tools ──── handled inline (state) │
│ everything else ── io.async fan-out ──┐ │
└────────────────────────────────────────┼────┘
│
┌──────────────┬──────────────┬─────────────┴────────┐
▼ ▼ ▼ ▼
bash read/write subagent mcp.Registry
std.process Io.Dir.cwd (Agent.runTurn, JSON-RPC 2.0
one level deep) over stdio
| File | Role |
|---|---|
src/main.zig |
The whole agent: provider routing, the turn loop, both wire formats, tools, subagents, compaction, the REPL and slash commands. |
src/mcp.zig |
MCP client: spawns stdio servers from .mcp.json, JSON-RPC handshake, tool discovery, tool dispatch. |
build.zig |
Single executable target; zig build run. |
Everything is one binary. The std HTTP client brings its own TLS, so there is no libcurl/openssl link step; the result is a self-contained executable that compiles in well under a second.
A single Agent struct is the unit of execution. It owns a message history
(std.json.Array), a provider, an arena for that history's lifetime, and the
loop methods. The root REPL agent and every subagent are the same type —
a subagent is just an Agent with sub = true, a fresh arena, an empty
history, and out = null (so it logs to stderr instead of stdout).
runTurn is the loop:
buildBodyserializes the request for the current provider.postsends it (thread-safestd.http.Client.fetch).stepAnthropic/stepOpenAIparse the response, echo the assistant turn into history verbatim, collect tool calls, dispatch them, append the results, and return either the final text (turn done) or null (loop).
Two axes, decoupled: a wire format (Provider.Kind — anthropic vs
openai) and an auth style (Provider.Auth — x_api_key vs bearer),
so e.g. minimax serves the Anthropic Messages format with bearer auth. The
provider_specs table (endpoint, kind, auth, env var, default model — values
from models.dev/api.json) is the single source of truth; post() picks the
header by auth and adds anthropic-version whenever kind == .anthropic.
Keys holds one optional key per spec. Keys.providerFor(model) walks
model_table (provider-tagged rows) and picks the first row whose provider
has a key; unknown claude* → anthropic, other unknowns → codegraff.
defaultProvider() is the first key-bearing spec on its default model.
Provider.context (from contextFor) drives compactAt() = 80% of the
window, so compaction is per-provider-per-model.
| Provider | Kind | Auth | Key env |
|---|---|---|---|
| anthropic | anthropic | x-api-key | ANTHROPIC_API_KEY |
| codegraff | openai | bearer | CODEGRAFF_API_KEY |
| deepseek | openai | bearer | DEEPSEEK_API_KEY |
| openai | openai | bearer | OPENAI_API_KEY |
| minimax | anthropic | bearer | MINIMAX_API_KEY |
| xiaomi | openai | bearer | XIAOMI_API_KEY |
| codex | responses | bearer | ~/.codex/auth.json (ChatGPT OAuth) |
| claude | anthropic | bearer | Claude Code OAuth (~/.claude/.credentials.json or macOS keychain) |
Provider.Kind has a third variant, responses — the OpenAI Responses
API as served by the ChatGPT backend (Codex login). loadCodexAuth reads
the access token + account id from ~/.codex/auth.json at startup and injects
them into the codex key slot (its env_key is a non-existent var, so env
never populates it). post() adds the chatgpt-account-id, OpenAI-Beta,
originator, and session_id headers for that kind. buildBody emits the
Responses shape (instructions + input items + flat function tools +
stream:true); since the backend only streams, request() buffers the SSE
and parseResponses collects the response.output_item.done items into a
synthetic {output, usage} object that stepResponses consumes — appending
message/function_call/reasoning items verbatim (they're valid next-turn input)
and emitting function_call_output items for tool results.
The claude provider is the subscription-login analogue of codex, on the
Anthropic side: same anthropic wire format as the anthropic provider, but
it authenticates with the Claude Code subscription instead of an API key.
loadClaudeAuth reads the sk-ant-oat… OAuth access token from Claude Code's
credentials at startup — ~/.claude/.credentials.json on Linux, or the macOS
login keychain (service Claude Code-credentials, read by spawning the
security CLI) — and injects it into the claude key slot (its env_key is a
non-existent var). Two extra requirements gate that token to the official
client, both satisfied for this provider: providerHeaders adds
anthropic-beta: oauth-2025-04-20, and buildBody sends system as an array
of text blocks whose first block is exactly the Claude Code identity line,
with the harness's real prompt as a second (cache-broken) block. Routing places
claude late in provider_specs, so a real ANTHROPIC_API_KEY always wins for
the same model name; the subscription is the fallback. The token is read, never
refreshed — on a 401 after expiry, run claude once to refresh it (same caveat
as codex).
Root-agent requests stream on all three wire formats. request() decides
per call: live = !sub && out != null && !stream_quiet (compaction sets
stream_quiet; subagents always take the buffered post() path). When live,
buildBody adds stream:true (openai also gets
stream_options:{include_usage:true} so token counts survive — dropped and
retried once if a provider rejects it, same pattern as the soft-strict
tool_choice retry) and postStream() issues the request with the low-level
std.http.Client.request API, pumping the body line-by-line via
Reader.streamDelimiterEnding. Each SSE line is teed: appended to the full
body buffer, and printDelta prints the text delta for the active format
(content_block_delta/text_delta, choices[0].delta.content, or
response.output_text.delta) immediately. Afterwards the buffered events are
reassembled into the exact non-streaming response shape — assembleAnthropic
(accumulates per-index content blocks incl. thinking/signature and
partial_json tool inputs, merges message_start/message_delta usage),
assembleOpenAI (joins content deltas, merges tool_calls fragments by
index, picks up the final usage chunk), and the pre-existing parseResponses
for codex — so step*, history echo, compaction, and /save//resume are
untouched. A non-SSE body (error envelope, or a provider that ignores
stream) produces no deltas and falls through to the regular JSON parse.
streamed_text tells step* not to re-print text that already streamed.
The two formats differ in three places, all isolated to buildBody and the
two step* functions:
- Request shape — Anthropic has top-level
system/toolsand adaptivethinking; OpenAI carries the system prompt as the firstmessagesentry. - Tool-call encoding — Anthropic emits
tool_usecontent blocks with a parsedinputobject; OpenAI emitstool_callswhoseargumentsis a stringified JSON blob that we parse before dispatch. - Tool-result encoding — Anthropic wants
tool_resultblocks in a user turn; OpenAI wants onerole:"tool"message per call.
Both are normalized to a single internal ToolCall { id, name, input } so the
tool executors are provider-agnostic. /model <name> switches providers
mid-session (it clears history, since the stored turn shapes differ).
Three kinds, all dispatched through runTools:
- Built-in (
bash,read_file,edit_file,write_file,subagent,workflow) — run on thestd.Iothread pool.workflowis dynamic workflows as data (the pi-dynamic-workflows idea without a JS sandbox): sequential phases of parallel subagents,{{prev}}substitution carrying results between phases, final phase returned.edit_fileis an exact string replace (std.mem.count+std.mem.replace): theold_stringmust match exactly once unlessreplace_allis set, which keeps the model honest about what it's changing. - Meta (
todo_write,todo_read,attempt_completion) — act on the agent's own state or conversation flow, so they're handled inline by the orchestrator (handleMeta), never on a pool thread.todo_write/todo_readmutate the task list;attempt_completioncarries the final answer out;ask_usercarries a human reply in (see below). The key distinction: a meta tool acts on the agent/conversation; an external tool touches the outside world. - MCP — discovered at runtime from
.mcp.json, namespacedmcp__<server>__<tool>, dispatched tomcp.Registry.call.
Tool definitions for the built-ins are comptime ToolSpec records rendered
into both wire formats. For subagents the lists are fully comptime constants;
for the root agent they're built once at startup by renderRootTools, which
merges the built-in specs with the discovered MCP tool schemas.
State-changing tools run behind a gate backed by one shared Approvals struct
(an Io.Mutex, a list of approved keys, and a yolo flag):
gateTool(root only, called fromrunToolsbefore dispatch) coversbash,write_file,edit_file, and MCP tool calls. An unapproved call prompts on stdin — yes once / always / no. The approval key is the bash command's first word, or the tool name for writes and MCP. A denial becomes the tool result so the model adapts.read_file,subagent,workflow, and meta tools are not gated.- Approval matching:
allowed()does prefix-matching for bash (against the seed list + approved words);allowedExact()does exact-name matching for writes/MCP. Both short-circuit underyolo. - Subagents run on pool threads with no stdin, so they're gated
structurally in
execToolInner: bash is allowlist-only (unapproved → denied), file writes are allowed but path-confined, and MCP isn't exposed. - Seed allowlist: read-only basics (
ls,cat,rg, …),zig build/zig fmt,git status/diff/log/shownever prompt.findis excluded —-exec/-deletemake it an exec tool, not read-only. - Metacharacter rule (
isSimple): commands containing;,|,&,>,<,$, backticks, or newline/CR/tab/null never match a prefix — chaining (includingsh -c "ls\nrm") can smuggle a second command — so they always prompt at root and are denied in subagents. - Interpreter heads-up: approving
python3/node/bash/… as a bash word (isInterpreter) prints a note that it grants arbitrary code execution. - Path confinement (
confinedPath):read_file/write_file/edit_filereject absolute paths and any..component, confining them to the cwd subtree. Enforced inexecToolInnerfor root and subagents, and not bypassed by/yolo(it's structural, not a trust setting). - bash cwd-lock (
escapesCwd): a seed/approved command auto-runs only if every whitespace-token path argument stays in the cwd (no leading/or~, no=//=~, no..component).cat /etc/passwdtherefore isn't auto-allowed — it prompts at root (per-callystill works) and is denied for subagents. A heuristic, butisSimplehas already excluded the metacharacters that would let args hide.
/yolo lifts the prompt gate and the bash cwd-lock (allowed() returns
true under yolo before escapesCwd is consulted). The one thing /yolo never
lifts is confinedPath on the file tools — that's a structural invariant, not
a trust toggle.
runTools separates meta calls (inline) from external calls, then fans the
external ones out with io.async — one Future per call — and awaits them
in order. Because subagents are themselves external tool calls, three
subagents requested in one assistant message become three Agent.runTurn
loops running concurrently on the pool, each making its own HTTPS calls
through the shared client. The concurrency primitive is Zig 0.17's structured
std.Io: no async runtime, no manual thread management — io.async(fn, args)
returns a typed Future executed on std.Io.Threaded.
execSubagent builds a fresh Agent (own arena, empty history, the parent's
provider and client), seeds it with the task prompt, and runs it to
completion. Its final text is returned verbatim as the tool result. Depth is
capped at one level: subagents get only the base tool set (no subagent, no
MCP, no meta tools), and from_sub rejects nested spawns. Subagents don't
share the parent's context — the orchestrator must put everything needed into
the prompt, which the tool description states.
The loop is symmetric: both directions of the human↔agent conversation can flow through the tool channel.
Agent → human. The final answer comes through the attempt_completion
meta tool; its result is printed and ends the turn.
Human → agent. The ask_user meta tool is the inverse. The agent calls
it with a question (and optional options); the orchestrator prints it,
blocks on stdin, and hands the typed reply back as the tool's result — so
the human turn arrives as a tool result, not a fresh top-level user message.
The human is, in effect, just another tool the agent can call. Only the root
agent has stdin (Agent.in); a subagent calling ask_user gets an error
telling it to make an assumption.
Strict mode (/strict) closes the loop: tool_choice is forced
({"type":"any"} for Anthropic, "required" for OpenAI), so the model must
call a tool every turn — free text never ends a turn. Combined with
ask_user and attempt_completion, every message in the conversation is a
tool call or a tool result.
Provider wrinkles, all handled by the same soft-retry idiom in request()
(detect the rejection in the error message, flip a flag, retry once):
- On Anthropic, forced
tool_choiceconflicts with adaptive thinking, sobuildBodydrops thethinkingfield while forcing. - The codegraff gateway (deepseek with thinking on) rejects a forced
tool_choiceoutright.requestretries once without forcing, falling back to "soft strict" — the strict system prompt still drives the discipline even where the hard constraint isn't allowed. - A provider that rejects
stream_optionsgets a retry without it (losing streamed usage counts, nothing else). - A provider that rejects
max_tokens("usemax_completion_tokens" — the gateway proxying gpt-5.x does this) flips the per-providercap_newflag and retries with the post-deprecation name. This mirrors graff'sMakeOpenAiCompattransformer; like graff, the directopenaiprovider getsmax_completion_tokensunconditionally./modeland/resumereset the flag since it's provider-specific.
It's the Cline/Roo pattern (ask_followup_question + attempt_completion);
codegraff uses a similar discipline with plan_create and completion-style
tools.
The harness is an MCP client. Registry.init reads .mcp.json
({"mcpServers": {"name": {"command", "args", "env"}}}), and for each server:
std.process.spawnwithstdin = .pipe,stdout = .pipe,stderr = .ignore(so server logs stay off the JSON channel).- Speaks newline-delimited JSON-RPC 2.0 over the pipes — the MCP stdio
transport. Pipes aren't seekable, so it uses
writerStreaming/readerStreaming. - Handshake:
initialize→notifications/initialized→tools/list. - Registers each discovered tool, namespaced
mcp__<server>__<tool>, with itsinputSchemapreserved for the model — after one sanitization pass:rewriteOneOfrecursively renames the JSON Schema keywordoneOftoanyOf(graff'srewrite_one_of_to_any_of), because OpenAI's tool-schema validator — includingchatgpt.com/codex/responses— rejectsoneOfoutright, and one bad tool schema fails the whole request.anyOfis accepted everywhere and equivalent for the discriminated unions MCP servers emit in practice. Runs once at discovery, so the rendered tools JSON stays KV-cache-stable.
Registry.call serializes tools/call requests behind an Io.Mutex (a
server is one bidirectional pipe; concurrent calls would interleave) and
flattens the result's content text blocks. The request helper skips
interleaved notifications until the response id matches.
This is the same protocol codedb speaks as a server — pointing .mcp.json
at codedb mcp . gives the agent 22 structural code-intelligence tools
(codedb_outline, codedb_search, codedb_callers, …), pure-Zig client to
pure-Zig server, zero dependencies on either side.
The loop is structured for prefix cache hits: a frozen system prompt (no
per-request timestamps), strictly append-only message history, and comptime
tool-list rendering so tool order is byte-stable. buildBody adds an
explicit cache_control: {type: ephemeral} breakpoint only for provider.id == "anthropic" (the real API; the codegraff gateway and OpenAI-format
providers do prefix caching automatically). recordUsage extracts the
cache-read count from whichever field the provider uses
(cache_read_input_tokens, prompt_cache_hit_tokens, or
prompt_tokens_details.cached_tokens) into Agent.last_cache_read, which
request() writes to the cache_read_tokens field of each api trace line.
/save serializes {provider, model, strict, messages} with
std.json.Stringify to <name>.session.json; /resume parses it back into
the arena, rebuilds the provider via Keys.providerById, and swaps in the
restored messages array. Because each provider stores its history in its own
native wire shape (and switching providers clears history), a resumed session
always pairs the right wire format with the right provider — including the
codex Responses-item format. /sessions iterates the cwd via Io.Dir.
main() reads the first of AGENTS.md/HARNESS.md/CLAUDE.md present in the
cwd and builds two arena-owned strings — sys_normal (= main_system_prompt
- the file) and
sys_strict(+strict_note) — stored on the rootAgent.systemPrompt()returnssys_strict/sys_normalfor the root and the leansub_system_promptfor subagents. Built once at startup, so there's no per-request cost and the prompt stays byte-stable for the KV cache.
main() scans each user line (case-insensitive) for ultracode; on a hit it
prints a banner, emits an ultracode trace note, and appends a steering note
to the message telling the model to decompose the turn into a workflow
(phases of parallel subagents → synthesis) rather than working solo. It's a
pure prompt-augmentation hook — no new control flow, just a per-turn nudge
toward the existing multi-agent machinery.
Codex/Claude-style hooks: user-supplied shell commands that run at three
points in the loop, configured in .harness/settings.json (the same file
that persists approvals — savePersisted merge-writes so an "always allow"
mid-session never clobbers them):
{
"hooks": {
"pre_tool": [{ "match": "bash", "command": "./guard.sh" }],
"post_tool": [{ "match": "edit_file|write_file", "command": "zig fmt ." }],
"turn_end": [{ "command": "osascript -e 'display notification \"turn done\"'" }]
}
}Events and contract. Each hook entry is {match, command, timeout_ms};
match is "*" (default) or pipe-separated exact tool names, timeout_ms
defaults to 10 000. The command runs as /bin/sh -c with the event JSON on
stdin: {"event","tool","input"} for pre_tool, plus "is_error" and
the first 4 KB of "output" (UTF-8-safe cut) for post_tool, and
{"event":"turn_end","ok":true} for turn_end.
pre_toolis the only hook with power: exit 2 blocks the call, and the hook's stderr is returned to the model as the (is_error) tool result — so the model learns why and can adapt. Any other exit code allows.post_toolruns after the tool, sequentially (a formatter must finish before the next tool runs); exit codes are ignored.turn_endfires after each completed root turn (interrupted/errored turns never reach it).
Choke point. Both tool hooks live in execTool — the single function
every external tool call flows through (root, subagents, and MCP tools
alike, on pool threads). That placement is what makes a pre_tool guard a
real boundary rather than a root-only courtesy: a subagent's bash call hits
the same gate. Meta tools (todo_*, ask_user, attempt_completion) are
handled inline by the orchestrator and never reach execTool, so hooks
don't see them — by design, they're conversation plumbing, not effects.
Fail-open by construction. A hook that times out is killed and treated
as allow; spawn failures and weird exit codes likewise. The asymmetry is
deliberate: hooks are user automation, not the security model — the
permission gate (approvals/allowlists) stays the enforcement layer, and a
broken guard script degrading to "no guard" is strictly better than a hung
hook bricking every tool call. Anything that must fail closed belongs in
the gate, not a hook. runHookCmd enforces the deadline with a poll-tick
read loop and child.kill (which reaps — the wait is skipped), and caps
captured stderr at 4 KB.
Loading. loadHooks parses once at startup into arena-owned slices
(g_hooks); /hooks lists them. No hot reload: the hook list is part of
the session's trust surface, same reasoning as the startup-frozen system
prompt — and it keeps the per-call cost of the disabled case at one
len == 0 check.
One shared Tracer (an Io.Mutex, an Io.Writer over the run-exclusive
.graff/traces/<run-id>.jsonl, and a monotonic Io.Timestamp start) is
threaded through every agent and
ToolCtx, the same way as Approvals. Agent.request records each API call
(latency ms, request/response bytes, context tokens, error flag, agent
label); execTool wraps every external tool with timing. Events are structs
serialized by std.json.Stringify — one line each, flushed immediately, so
the file is always valid JSONL mid-session. Every line carries the run id,
PID, and runtime session id. Consumers must still tolerate a malformed final
tail after a crash or I/O failure. The system prompt points the agent at the
file, which is what makes self-debugging work: the agent reads its own trace
with read_file and analyzes it. /trace locates and toggles it, best-effort
(a failed open just disables tracing).
BehaviorTrace is a second, mutex-protected producer with two independent
sinks: a per-run .graff/trajectories/<run_id>.jsonl local stream and an optional
bounded behavior_upload.Upload projection. Both use flat
codegraff.behavior.v1 events and retain one logical source sequence. A healthy
local file is a contiguous prefix; bounded upload admission can preserve sparse
source sequence numbers and reports gaps with dropped_events. Main owns both
sinks, joins session workers before terminal closure, emits run_finished, and
then makes at most one best-effort three-second POST beside the configured OTLP
endpoint at /v1/behavior. An upload claims complete=true only when terminal
event admission succeeds; a dropped terminal remains complete=false and is
included in dropped_events. Timeout cancellation synchronously joins the HTTP
task before its payload is released. The local file itself is never uploaded.
providers.runTurnWithFallback is the common root-turn boundary. API/tool rows
in the operational trace carry that active turn until the provider operation
returns; subsequent administrative or resume-preprocessing work is turn 0.
The upload accumulates content-free per-turn API/tool-category metrics. Local
run-start provider/model/effort/prompt-fingerprint fields are explicitly an
initial snapshot, not per-turn wire facts. The deterministic prompt fingerprint
is omitted from every network projection because low-entropy prompt variants can
be enumerated. With ordinary telemetry enabled,
the upload defaults to a fixed-field allowlisted metadata projection, including
only a controlled client class rather than arbitrary HARNESS_CLIENT content;
rich-event metadata is limited to correlation, controlled tool class,
byte/duration/error counters, and truncation flags. Only an explicit content
opt-in can serialize opaque fields supplied through the typed
commitment/misprediction APIs or exact rich tool/text fields. Provider/tool
plumbing does not infer dedicated
prompt-text, generated-output, hidden-reasoning, source, path, argument, result,
or task-state fields. Initial provider and model identifiers are serialized
exactly as configured and are not inspected or value-redacted. The eval-driven
loop is the first production producer of typed commitment/misprediction events:
it stops on red, preserves an incomplete repair state, and requires a fresh
green verifier result before completion. The local tournament recomputes a
bounded behavior score from closed rich traces. General task adapters,
automatic semantic verification, and fleet-wide behavioral consumption remain
deferred.
The pre-existing append-only Trajectory DGM ledger and its record shapes remain
unchanged. See
docs/behavioral-trajectories.md.
startup.zig dispatches graff learn before ordinary provider/key/session
initialization. learn_cli.zig owns strict command parsing and orchestration;
learn_eval.zig owns typed mutation/evaluation protocols, deterministic seeds,
paired aggregation, and evidence re-verification; learn_store.zig owns the
private content-addressed store and activation transaction chain. The engine is
separate from the append-only DGM trajectory and behavioral streams: neither is
accepted as promotion evidence.
A run binds the immutable config, harness version, parent genome, parent generation, parent transaction, random nonce, and a recomputed trial ID. Candidate and pair seeds derive from that envelope. Adapter requests/responses, genomes, and the final run record are domain-separated immutable objects. On promotion the engine reloads every object, reconstructs seeds and aggregates, checks mutation output bytes against the mutator's declared SHA-256, and requires the run's complete parent ref to equal the active ref.
Repetitions remain useful measurements but are clustered by unique suite case
for the one-sided sign test and minimum_pairs; repeating one case cannot create
independent statistical units. Critical regressions reject immediately,
Bonferroni correction uses the planned candidate count, holdout gates follow the
primary gate, and cost participates only after correctness.
Configured tools, declared inputs, and suites are read and hash-checked through
single open handles. Each invocation executes a private verified program
snapshot; the program must remain directly executable after relocation. Exact
declared-input arguments are rewritten to private snapshots, and the evaluator
sees a private suite.json. This closes pathname-reopen TOCTOU for the bytes
Codegraff claims to pin. It is not process isolation: interpreters, libraries,
undeclared dependencies, subprocesses, network data, and all other files visible
to the user's OS account remain in the trusted computing base.
On POSIX, activation publishes and synchronizes an immutable transaction, then
atomically replaces refs/active.json and synchronizes its directory. Windows
uses synced file contents and atomic replacement but skips unsupported directory
flushes, so its power-loss guarantee is weaker; learning files there inherit
ambient workspace/temp ACLs. Loads verify the complete ancestry; rollback
appends a new transaction rather than deleting history. fleet.zig
marks the verified learned agent as local activation authority so later remote
elite merges cannot replace that name. Full schemas, operational guidance,
privacy implications, and the unimplemented collective-learning design are in
docs/local-learning.md.
Client-side and provider-agnostic. Every response's usage is recorded
(input+output+cache for Anthropic, total_tokens for OpenAI) and shown in
the prompt. Past the model's compaction threshold (80% of its context window, looked up
in a comptime model_table — Anthropic numbers from the model catalog,
gateway numbers from its /v1/models endpoint; unknown models fall back to a
conservative 200k context) — or on /compact — the
harness sends the history plus a handoff instruction with no tools offered,
forcing a text summary, then replaces the whole history with one user message
embedding that summary and resets the counter. If the summary request fails,
history is left untouched.
- Root history lives in the process arena (whole-session lifetime); each subagent gets a private arena freed when it returns.
- Parsed responses use
parseFromSliceLeakywith.allocate = .alloc_alwaysso Values survive the raw response buffer being freed. - Per-request bodies and tool outputs use the general-purpose allocator and are freed each iteration; tool results are duped into the arena before entering history.
- The MCP registry has its own arena for the session.
- Streaming (SSE) — both providers support it; the request shape barely changes, and it's the enabling feature for a web UI.
- HTTP server mode — swap the REPL for
std.http.Server; sessions become a map ofAgentinstances. History is alreadystd.json.Array, so persistence is one stringify away. - Next.js / DigitalOcean — the cleanest path is the HTTP server above with
Next.js as a pure frontend over SSE; the quick path is a process-wrapper
bridging stdin/stdout to a WebSocket. Either way,
bash/write_fileare arbitrary code execution, so each session needs a sandbox (one container or microVM per session) before any public exposure. - MCP over SSE/HTTP — currently stdio only; the transport is isolated in
mcp.zig, so an SSE transport slots in beside it.
Measured budgets — a change that blows past one of these deserves a look
before it merges. Re-measure with hyperfine -N '<bin> --version' for
startup and /usr/bin/time -l <bin> … for memory.
| metric | v0.1.0 | budget |
|---|---|---|
| binary size (ReleaseFast, arm64) | 2.74 MB | < 3 MB |
cold start (--version / --schema) |
~1.8 ms mean | < 10 ms |
peak RSS, no network (--schema) |
1.8 MB | < 8 MB |
peak RSS, full one-shot turn (-p) |
12.1 MB | < 64 MB |
| CPU share of a one-shot turn | ~4% (0.11 s of 2.7 s) | network-bound |
For scale: the entire feature set added in the post-90c5cce UX pass (esc overhaul, retry/backoff, version stamping) cost 816 bytes of binary.
Subagents are threads in the same process — each gets a throwaway arena and shares the root's gpa, HTTP client, approvals, and tracer. Measured with the workflow tool fanning out trivial parallel tasks:
| metric | measured | budget |
|---|---|---|
| marginal RSS per parallel subagent | ~0.4 MB | < 2 MB |
| root + 8-way fan-out (max), peak RSS | 15.0 MB | < 64 MB |
| tool output captured per bash call | ≤ 1 MB + 32 KB stderr (truncated, exit code kept) | hard cap |
| tool output entering model history | ≤ 4 KB (the #440 handle threshold), whatever the result's size | hard cap |
Child processes a tool spawns (python, make, …) are outside the budget by
design: they live in their own process, so their memory never lands in the
harness's RSS — verified with a 500 MB python allocation under an 8-line
harness that stayed at 12 MB. runCapped keeps the first 1 MB of a chatty
child's output and discards the rest as it streams, so even a 10 MB print can't
inflate retained memory. History is bounded separately and much harder: past the
handle threshold a result is written to .graff/tool-results/ and only a
preview, a path, a byte count, and a shape hint reach the model (#440), so what
a tool prints and what a turn pays for are no longer the same number.
Different scope — the Rust codegraff ships zsh integration, VS Code commands, semantic-search workspaces, commit generation, and more — so this is a "what does the architecture cost" comparison, not a fairness contest. The turn rows are apples-to-apples: both one-shots ran the same model through the same endpoint (deepseek-v4-pro via gateway.codegraff.com), interleaved 3× to spread network variance.
| metric | rust graff v0.2.16 | zig harness v0.1.0 | ratio |
|---|---|---|---|
| binary (arm64 release) | 39.2 MB | 1.69 MB | 23× |
cold start (--version) |
4.9 ms | 1.4 ms | 3.5× |
peak RSS, --version |
13.0 MB | 1.8 MB | 7× |
| one-shot turn, same model, wall | 2.94 s mean | 2.93 s mean | tie (network-bound) |
| one-shot turn, same model, RSS | 48.5 MB | 11.3 MB | 4.3× |
| dependencies | 934 crates | 0 (std only) | — |
| agent-loop source | ~5.8 MB Rust | ~280 KB Zig (2 files) | ~20× |