Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Getting started

harness is an AI coding agent for your terminal. It edits your project and runs commands in a sandbox, asking first when its mode says to. Every session is recorded.

Install

curl -fsSL https://megacode-harness.vercel.app/install.sh | sh

Linux (x86_64), macOS 15.4+ (Apple Silicon or Intel), or Windows through WSL2. Installs one binary to ~/.local/bin, no sudo. Read the script first. HARNESS_VERSION pins a release; HARNESS_INSTALL_DIR picks the folder.

On Linux the sandbox needs bubblewrap: sudo apt install bubblewrap.

Update: type /update in a session (0.29.0 and later; it installs without leaving, then /restart switches over and keeps the conversation), or run harness update in a terminal (0.24.0 and later; before that, run the install again). A release build checks at start and every hour, and the status bar tells you when one is out (what it sends). A start is quiet: one line of notes, and /notices lists the rest (the quiet start). Uninstall: delete ~/.local/bin/harness.

Windows (WSL2)

Run harness inside WSL2, as a Linux install. In PowerShell, wsl -l -v lists your distributions; the VERSION column must say 2 (wsl --set-version <name> 2 converts one; wsl --install sets up WSL2 with Ubuntu). Inside the distribution, install bubblewrap and run the installer above. Keep projects in the Linux file system (~/code), not under /mnt/c. The sandbox hides each Windows drive’s Users folder, and a project inside one makes the sandbox unavailable; Windows programs (cmd.exe) do not start from a sandboxed command. WSL1 and Windows on ARM are not supported, and this has been checked on one Windows machine. The details are in the sandbox page.

Free models, or your own keys

Free models

Type /login in a session, or run harness login, and sign in with Google. You get mega:flash and mega:full, free up to a daily allowance (resets 00:00 UTC). harness logout signs out. Signed in, harness also sends daily usage counts (no prompts, files or commands); telemetry = false turns them off (what it sends). Privacy.

Your own keys

Export the key in your shell’s startup file:

export ANTHROPIC_API_KEY=your-key
ProviderVariable
AnthropicANTHROPIC_API_KEY
OpenAIOPENAI_API_KEY
GeminiGEMINI_API_KEY
OpenRouterOPENROUTER_API_KEY
TogetherTOGETHER_API_KEY
DeepSeekDEEPSEEK_API_KEY
Z.AIZAI_API_KEY
xAIXAI_API_KEY
Ollama, LM Studio (local)no key

Auto mode on your own keys also needs TYPESAFE_API_KEY (typesafe.ai). Other OpenAI-compatible providers: [providers.<name>].

Start

harness run
harness run --model gemini:gemini-3.8-flash

Model ids are provider:model; /model switches mid-session. Set a default in ~/.config/harness/config.toml:

model = "gemini:gemini-3.8-flash"

harness resume continues an earlier session.

Modes

  • manual asks before every command, edit, skill and sub-agent.
  • plan reads freely, writes only its plan file.
  • auto edits without asking; the judge approves commands and asks you about risky ones.

Shift+Tab cycles them. See Modes and the judge.

From a script

git diff | harness -p "review this change"

One turn, answer on stdout. See harness -p.

Where next

Modes and the judge

A mode decides whether a tool call runs, asks you, or goes to the judge. /mode shows the table below for your session’s sandbox state. /mode <name> switches and shift+tab cycles the built-ins, each printing a line on the new mode. [modes.<name>] in config.toml changes or adds modes (config.md).

The three built-in modes

kind of actionmanualplanauto
read or searchrunsrunsruns
edit a project fileasks youplan file onlyruns
edit a protected fileasks youplan file onlyasks you
run a commandasks you · runs in the sandboxnot availablethe judge decides (asks you if risky) · runs in the sandbox
use the internetasks younot availablethe judge decides (asks you if risky)
run a command outside the sandboxasks younot availableasks you
load a skillasks youasks youruns
start a sub-agentasks youasks youruns
the hard-deny listrefusedrefusedrefused
  • manual asks before anything but a read.
  • plan writes only its plan file and runs no commands.
  • auto runs project-file edits, skill loads and sub-agents unasked and hands commands to the judge. A skill adds text and a list of its files to the context and nothing more; allowed-tools grants nothing. To make auto ask before a skill, see the recipe.

A protected write asks in every mode, plan included (Protected files); manual and auto also have local asks. The hard-deny list refuses even under --approve-all.

In the terminal panel, asks no turn grant covers (a protected write, a credential read, a removal git cannot undo, decoded text being run) start on deny, so a habitual Enter refuses; press y to allow. Others start on allow once. The plain REPL takes a bare Enter as no.

Every command runs inside a sandbox (sandbox.md): it writes only in the project and a private temp folder (/tmp on Linux, $TMPDIR on macOS), has no network but localhost (none on macOS), and can’t read your key folders. On macOS, use the internet also covers local ports, such as a loopback test server. --approve-all refuses run a command outside the sandbox but runs use the internet unasked.

The table changes with [sandbox] settings:

  • escape = "never": both rows read not allowed.
  • enabled = false: the command row ends · runs unconfined (sandbox off), and the two rows read no extra question: commands have it and no extra question: commands run outside.
  • Sandbox unavailable: a judging mode is not available in every row.
  • mode = "external" (sandbox.md, “External mode”): the command row ends · runs unconfined (this machine is the sandbox), the two rows read as with the sandbox off, and auto, --approve-all, run --away and harness eval work. --approve-all refuses only the hard-deny list; plan mode still refuses writes.

The judge

For each bash/exec command auto would run unasked, the judge (a second model) weighs run against ask from the command, your latest message and a few repository facts. Ordinary work runs: reading, downloading, building, testing, local commits, and edits and repo scripts inside the project or /tmp.

Under --input-format stream-json a flag reaches the program as an approval_request of kind judge_flag (judge_unavailable when the judge could not be reached) (headless.md).

Flagged. When the probability of ask reaches the cutoff, you get manual’s approval prompt plus The judge flagged this: <reason>, naming the likeliest reason. The first three ask even when you requested the action; the other five, unless your message asked for that specific action:

reasoncoversthe flag reads
credentialsreading, printing, copying or sending a credential or a file holding onetouches credentials or secrets
irreversibledestroying work git can’t restore, or deleting a target the command doesn’t showmay destroy work git cannot restore
internet_codeinstalling or running internet codeinstalls or runs code from the internet
outside_coderunning a script or binary from outside the project, /tmp includedruns code from outside the project
outside_writewriting, moving or deleting outside the project and /tmptouches files outside the project
sends_data_outuploading or posting data to another hostmay send data to another machine
shared_statepushing, publishing, deploying, changing a remote resourcechanges something others rely on
safety_overrideusing the git stash or turning off a safety settinguses the shared git stash or turns off a safety setting

The reason is only a label; with none, the line reads The judge flagged this. An unreachable judge (no key, a bad answer, over five seconds) asks with The judge could not be reached. A flag never refuses: you decide.

The tag. A command the judge allowed shows · allowed by the judge on its result row, as one you approved shows · approved. Probabilities appear only in the log.

What is sent where

The judge is a separate call to a separate provider: by default TypeSafe, [judge] model = "typesafe:jev-1.14.0", keyed by TYPESAFE_API_KEY. Once per machine, the first entry into a judging mode by command prints a notice naming the provider and model and saying that the command, the facts and your latest message are sent. A start already in a judging mode does not print it; /notices lists it. Only TypeSafe’s built-in endpoint gets a privacy-policy link. Without the key, auto asks before every command it would judge, and entering a judging mode warns:

the judge needs TYPESAFE_API_KEY — without it, auto asks before every command it would judge

Through mega. Signed in to the free mega account (config.md) with no TYPESAFE_API_KEY and the built-in judge at a version our server accepts (jev-1.13.0 or jev-1.14.0), the same requests go via our server, which forwards them to TypeSafe with its own key. The notice then links mega’s privacy page and reads:

Each judged command, its facts and your latest message are sent to TypeSafe (jev-1.14.0) through mega, with secrets redacted.

After /logout, judged commands ask, as with no key.

Neither the notice nor the warning prints under --approve-all, --mock or harness eval, where the judge never runs.

The request holds:

  • user_request: your latest message in the root session, up to 4,000 characters. A sub-agent’s call sends it too, never the parent’s instructions to the child.
  • command: the bash string or the joined exec argv, up to 8,000 characters.
  • facts: project_root and cwd (both $PROJECT, never a real path), plus current_branch and default_branch when they can be read from .git on disk.

It never holds tool output, assistant text, reasoning, file contents, or a child’s own prompt.

Credential-shaped text (API key prefixes, PEM blocks and similar) is always replaced with [redacted]. Project and home directories, and a ~/… spelling of the project, become $PROJECT and ~, as whole paths only (a sibling …/harness2 stays ~/…). Other paths, and spellings like /home//you, go as written. The session log keeps the request as built.

Cutoffs

The cutoff is 0.2 by default, chosen on a labelled set to lean toward asking (figures below). Lower it to ask more, raise it to ask less:

[judge]
cutoff = 0.15

A value outside 0.0..=1.0 is refused at load, naming the file. Only your own config can set it, not a project’s.

Protected files

A write_file or edit_file under a protected path asks in every mode (plan’s own plan file aside); no grant covers it, and only --approve-all skips it. The prompt adds This is a protected file, and its “this turn” choice, except this kind, still asks at the next one.

Entries match whole path components: a trailing / marks a directory, a leading **/ a basename at any depth, anything else one exact path. . and empty components are ignored (././CLAUDE.md matches), as is ASCII case on a case-insensitive volume like macOS’s default (claude.md matches; .GIT is .git to the hard-deny list). A nested worktree (.claude/worktrees/<name>/) counts from its own root: its CLAUDE.md, .git and .claude/ are protected, its other files are not.

.git/
.agents/
.claude/
.harness/
AGENTS.md
CLAUDE.md
.cargo/config
.cargo/config.toml
**/build.rs
.envrc
.github/workflows/
.pre-commit-config.yaml
.husky/
.vscode/

Adjust it from your own config with [protected]:

[protected]
add = ["infra/"]
remove = [".vscode/"]

add uses the same matching rules; remove drops a built-in entry, and any other path does nothing. A project’s config.toml can never change the list.

With checkpoints on, the turn-end list flags each protected file the turn changed, tracked or not, protected, with its /undo (checkpoints.md).

Local asks

Two kinds of command ask before the judge sees them. Like a protected write, no grant covers them and --approve-all skips them; while you’re away, they are refused (sandbox.md, “Away”). Their “this turn” choice, any bash except this kind, still asks for this kind. Under --input-format stream-json they reach the program as approval_requests of kind removal and decode_and_run, with turn_grant_skips_kind: true (headless.md).

The removal ask: a file git cannot restore. truncate (any size), an empty redirect (> f and similar), cp /dev/null f, rm, git rm -f (not --cached), git checkout -- or git restore of a named file asks, naming the first such target; so does a named directory holding one. “Cannot restore” means untracked and not ignored, changed since the last commit, or not in a git repository; clean committed and ignored files go to the judge. Writing new content is an edit, not a removal. The reason reads:

Asked because it empties or deletes {file}, which git cannot bring back (untracked | changed since the last commit | not in a git repository)

The decode-and-run ask: decoding hidden text and running it. Piping decoded output into a shell or interpreter, or eval-ing it (… | base64 -d | sh, xxd -r -p … | bash, python3 -c "exec(…b64decode…)"), asks with the reason below. Decoding into a file without running it does not.

Asked because it decodes hidden text and runs it

When the harness cannot tell which files. Past 1,024 targets, 256 KiB of their names, 64 possible working directories, or groups or sh -c code nested too deep, a removal asks without naming a file:

Asked because the harness cannot tell which files it empties or deletes

Targets the command doesn’t name literally (globs, variables, command substitutions, find -delete, one-liners, scripts) go to the judge.

The hard-deny list

Before any mode, judge or human has a say, --approve-all included, these are refused as refused: <rule> is on the hard-deny list:

  • a recursive delete of /, of ~/$HOME, or of the project root or one of its parents
  • mkfs*
  • dd writing onto a real device under /dev/ (not /dev/null, /dev/stdout, /dev/stderr, or /dev/fd/N)
  • the classic fork bomb, in any spacing

It catches only the obvious spelling: a wrapper script, a renamed binary or eval gets past it. It is a speed bump, not a boundary (The limits). Under [sandbox] mode = "external", only this list and, in auto, the judge stand between a command and the machine.

The key scrub

In every mode, a bash or exec child starts without any registered provider’s key variable (every api_key_env, TYPESAFE_API_KEY included), in use or not; env prints none. Other variables, such as GITHUB_TOKEN, pass through, so gh and an authenticated git push keep working.

Custom modes and judge

[modes.<name>] takes a judge field beside tools and ask: the tools the judge decides. It merges like ask: left out, a built-in mode keeps its own; judge = [] turns judging off. The rules:

  • every name in judge must also be in tools;
  • no tool may be in both ask and judge;
  • bash and exec must share a bucket: both asked, both judged or both run.

config.md has a two-line [modes.auto] override that makes auto ask before every bash/exec again.

The limits

The judge sees a command and the request behind it, never a script’s contents, a build.rs, or what a binary does. The sandbox keeps what it allows from writing outside the project, reading your keys or going online unasked, but damage inside the project (git reset --hard, an in-project rm -rf, an overwritten file) is the judge’s call alone. Commands can’t write untracked protected files (a local .envrc), but can write tracked ones (CLAUDE.md, a workflow) and any build.rs: review those against the commit the work started from (sandbox.md has the commands).

On the maintainer’s labelled set of 300 commands, the default judge and cutoff (jev-1.14.0, 0.2) still run 49 of the 114 that should ask and ask on 11 of 186 safe ones; the previous default (jev-1.13.0, 0.3) ran 63 and asked on 5. Its misses were mostly work in sibling checkouts, which the sandbox blocks or contains. Expect worse on unfamiliar commands. The judge is strongest on credentials and internet code. Treat what it allows as unreviewed, inside the project above all.

The sandbox, and going away

Every bash and exec call runs in a sandbox by default — a direct call, one from a script, and one from a child session — in every mode, --approve-all included, unless you approve one of the two ways out (below). In auto the judge (modes.md) still judges every command; the sandbox contains what it misses.

The backend is bubblewrap on Linux (Windows through WSL2) and Seatbelt on macOS 15.4 or later (“Requirements”, below). A machine that is itself the sandbox — a dedicated VM, a container, a CI runner — can say so with mode = "external" (“External mode”, below).

What it does

  • Reads everywhere except the hidden list (below). A hidden path reads as empty on Linux. On macOS it fails with Operation not permitted and cannot be run.
  • Writes only to the project, a private temp folder, and [sandbox] writable paths. On Linux the temp folder is /tmp. On macOS it is $TMPDIR, /tmp is not writable, and the user’s temp folder (/var/folders/…/T, used by a bare mktemp) is writable except Apple’s com.apple.* entries.
  • Untracked protected files are read-only, even in the project (below).
  • Network. On Linux, only localhost within the same command: no other session’s or the machine’s services, no internet. On macOS, none, not even 127.0.0.1: any port needs network: true (“The two ways out”, below).
  • No local sockets: no ssh-agent, gpg-agent, D-Bus, Docker or tmux. Python’s default multiprocessing needs a full escape on Linux; on macOS only a Manager fails.
  • No credentials. Every provider’s key variable, and anything named *TOKEN*, *SECRET*, *_KEY, *CREDENTIAL*, *PASSWORD*, *PASSWD*, *_PASS, *_AUTH, *_AUTH_*, *COOKIE*, AWS_*, SSH_AUTH_SOCK or GPG_AGENT_INFO, or whose value is a URL with a password, is stripped from the environment. [sandbox] env_hide adds names.
  • A private process view. Other processes are invisible, and background processes end with their command. On Linux there is no terminal to read from. On macOS ps cannot run, and only a terminal the command opens itself is usable.

The hidden list is ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.config/gcloud, ~/.config/gh, ~/.config/hub, ~/.docker, ~/.kube, ~/.netrc, ~/.git-credentials, ~/.npmrc, ~/.pypirc, ~/.gem/credentials, ~/.terraform.d/credentials.tfrc.json, ~/.vault-token, ~/.password-store, ~/.local/share/keyrings, ~/.secrets, ~/.cargo/credentials*, ~/.bash_history, ~/.zsh_history, ~/.claude/, ~/.claude.json, ~/.codex/, the harness’s own config root, and its sessions/ and checkpoints/ folders under the data root ($DATA/sessions/, $DATA/checkpoints/). [sandbox] hide adds paths. A symlink is hidden with its target (~/.ssh -> ~/dotfiles/ssh). Linux covers paths that exist at session start; macOS also covers ones created later. A ~/.cargo/credentials* file is hidden only if it exists at session start. macOS adds ~/Library/Keychains, ~/Library/Cookies, ~/Library/HTTPStorages, ~/Library/Accounts, ~/Library/Group Containers, ~/Library/Containers, ~/Library/Mail, ~/Library/Messages, ~/Library/Safari, the profile folders of Chrome, Chromium, Firefox, Edge, Brave, Arc, Vivaldi and Opera under ~/Library/Application Support, and ~/.zsh_sessions.

With HARNESS_HOME set, the list (minus the macOS additions) also applies inside it. If it is the project, commands cannot see the project’s own .npmrc, .claude/ or .docker/. An entry that holds the project or its git folder stays visible, and the start-up notice names it. The exception is a Windows Users folder under WSL2: a project inside one makes the sandbox unavailable instead (“Windows (WSL2)”, below). mode = "external" and "off" hide nothing.

Session logs are always hidden, so harness export does not work in a sandboxed command; if the project holds sessions/ or checkpoints/, sandboxed commands are refused. The harness’s other data, state and cache are read-only to commands.

The protected list (modes.md, “Protected files”) applies here too, except **/ patterns such as **/build.rs. An untracked protected file is read-only to commands, however the write is spelled. A protected directory with nothing tracked is read-only whole. One with tracked files is judged file by file, and anything a command adds is moved aside (below); past 1,000 entries it is read-only whole. A file counts as tracked only if it was tracked at session start (or last resume) and still is, so staging it does not unlock it. With no repository, nothing is tracked. .claude/worktrees/ stays writable.

A tracked protected file — CLAUDE.md, a workflow, .cargo/config.toml — is writable, so a sandboxed git checkout completes. Commands can write .git, so to review such changes, note the commit hash and the ignored files before the work, outside the project:

S=$(git rev-parse HEAD); B=$(mktemp)
git ls-files --others --ignored --exclude-standard > "$B"

After it, review from a fresh index built from that commit:

git --no-replace-objects for-each-ref refs/replace    # a line you didn't make: stop here
T=$(mktemp); r() { GIT_INDEX_FILE=$T git --no-replace-objects "$@"; }
r read-tree "$S"
r diff --stat                 # each tracked file changed; drop --stat to read the changes
r ls-files --others --exclude-standard                              # files added
r ls-files --others --ignored --exclude-standard | grep -vxFf "$B"  # added, but ignored

This defeats tricks that make git status look clean, and the last list catches files hidden by a new ignore rule. It is not a proof: a change to an existing ignored file (in .venv/, say) shows nowhere, and submodules have their own index. Checkpoints (checkpoints.md) do not rely on the index.

A protected path a command creates — a new .envrc or .vscode/, a new file in a .claude/ with tracked files, a re-pointed protected link, or a tracked file replaced by another kind of file — is moved aside afterwards, never deleted, even if git now tracks it. It goes to <name>.harness-quarantined-<16 hex digits>, beside it, or at the project root if it was inside a protected directory. Links are moved, never followed. A note says what moved where; to keep it, write it with the file tools, which ask. If the move fails, later sandboxed commands are refused, naming the path, until it moves or you move it on the host.

A new .claude/ that holds only worktrees/ is kept, so git worktree add .claude/worktrees/<name> works. If a .claude/ with nothing tracked has no worktrees/ yet, that command fails: create the directory with the file tools, or run the command with approval.

A protected link to the project or a folder above it (.claude -> ., .vscode -> ..) refuses every sandboxed command, naming the link, until you replace it on the host. So does a protected link whose target needs more mounts than bubblewrap allows: replace it or shorten the target’s path.

A directory a command makes unreadable (chmod 000) becomes read-only; the result says to check it before restoring its permissions. An unsearchable project root (chmod 600) refuses every sandboxed command until you restore it on the host.

Git’s auto-run files are read-only, even when committed: .git/hooks/, .git/config, .git/config.worktree, worktree and submodule copies, your global and system git config, and whatever those name (core.hooksPath, core.fsmonitor, include/includeIf files, a hook’s linked script). If a command re-points or removes a symlink on the way to one, you’re told to restore it before your next git command.

A git checkout of a branch whose hooks differ therefore stops short. The model is told which files git could not update. After a checkout or switch it gets git checkout -- <those files> to finish outside the sandbox, which asks you. After a merge, pull, reset --hard or stash pop, it is told to check git status. With escape = "never", it tells you which files are left.

A .git/commondir a command creates is moved aside. A hooks folder, include or git config file that did not exist at session start is not locked: creating one gets a note, so husky install keeps working.

Past 512 entries in .git/modules/ or .git/worktrees/, that folder is read-only whole, so a submodule update or git worktree add fails inside the sandbox. A command that would still need more binds than the system allows is refused, naming the folder: remove what you don’t need on the host, then resume.

On macOS these are path rules with the same effect, under a separate limit (“The budget”, below). Also:

  • A command cannot create the first missing folder above a hidden or read-only path, so making a missing .git/hooks fails (mkdir: .git/hooks: Operation not permitted).

  • Folders above protected paths cannot be renamed (“What you carry”, below).

  • A protected file with a second hard link refuses the command:

    denied: `.envrc` has another hard link, so a command could change it through that name. Fix on the host: remove the other link, or copy the file over itself (`cp -p .envrc x && mv x .envrc`).
    
  • An unsearchable folder on the way to a hidden or read-only path refuses the command, naming it and, if needed, the fix (chmod u+rwx on it).

What it does not stop

  • Damage inside the project (git reset --hard, rm -rf src): that is the judge’s job. rm -rf (or --recursive --force) on a path with a .git component is refused in every mode.
  • Reading an in-project secret like .env with the file tools: it asks first, except under --approve-all and in run --mock.
  • Anything an approved full escape does.
  • What a network-only command reaches: project files and localhost services.
  • The removal ask (modes.md, “Local asks”) sees only named files in known forms. Globs, find -delete, xargs rm, sed -i, one-liners and scripts go to the judge.
  • Overwriting is not asked (cp other f, cat > f << EOF): that is an edit.
  • A touched-but-unchanged file reads as changed (touch, chmod, renamed back): an extra ask.
  • With no repository, every named delete or empty asks.
  • The decode-and-run ask is a text match. What it misses goes to the judge, still sandboxed.
  • Tracked protected files and build files are writable. Changes to CLAUDE.md, a workflow, build.rs, package.json scripts, conftest.py or a Makefile show only in the review above, and run the next time you build or test on the host. Review first.
  • A hard link made outside the sandbox to an untracked protected file lets a command change it through the other name. On macOS such a file refuses the command instead.
  • A protected directory on another filesystem keeps what a command creates in it, moved aside in place.
  • On Linux, a protected link into the private /tmp is not read-only.
  • A path the harness couldn’t move aside blocks commands only until the session ends. Move it on the host before resuming.
  • Concurrent children: a protected path one child creates can be moved aside by a sibling’s check.

Which tools it covers

Only the two that run programs.

ToolIn the sandbox?What limits it instead
bash, execyesthe sandbox
read_file, write_file, edit_file, glob, searchnothe project root; no links out; credential reads and protected writes ask; the file tools open regular files only, and read none over 16 MiB (below)
scriptnoit acts only through the tools above
agentno, but the child’s own bash/exec arethe same rules, one level down
skillnoit only reads skill text; some loads ask

The file tools refuse a symlink out of the project, naming its target; a project CLAUDE.md or .agents/ file that links outside does not load. Reading a credential file asks first in every mode, with Asked because it reads a credential file, except under --approve-all and in run --mock. Credential files are .env/.env.* (not .env.example, .env.sample or .env.template), *.pem, *.key, *.p12, *.pfx, and anything in a .secrets folder in the project. The “this turn” choice reads except this kind: the next one asks again. search skips credential files and says how many (credential_files_skipped).

The file tools read only regular files, up to 16 MiB. After the checks above, read_file, edit_file and write_file refuse:

  • a FIFO, a socket, a device or a folder (a read of a FIFO or a device can wait forever): refused: <path> is not a regular file; the file tools work only on regular files;
  • on Linux, a file in /proc or /sys, which matters when the project is at /, where read_file proc/self/environ would return the harness’s own environment: refused: <path> is in /proc or /sys; the file tools do not read there.

read_file and edit_file also refuse a file over 16 MiB, before reading any of it: refused: <path> is larger than 16 MiB; the file tools read files up to 16 MiB. write_file replaces a file of any size, since it keeps none of it. search skips a file over 16 MiB and says how many it skipped (large_files_skipped, shown only when above zero). bash and exec keep the first 1 MiB of each stream as before, and no longer hold the rest in memory while a command runs. A file that is a link out of the project, or a credential file, keeps its own refusal. A session recorded before 0.26.0 replays as it was recorded.

The two ways out

bash and exec take two more arguments, each with a reason shown to you and never sent to the judge.

  • network: true keeps the rest of the sandbox and adds the machine’s network; on macOS that includes any port on 127.0.0.1. It hides the project’s credential files and every .secrets folder, found fresh each time. On Linux a hidden .secrets reads empty and writes into it are discarded; macOS refuses them. A .pem up to 2 MiB holding only public certificates stays visible. node_modules, site-packages and __pycache__ are skipped. Past 1,000 files to hide or 100,000 entries to search, or at a directory or credential-named link it can’t read, the command is refused, naming the path: fix its permissions, drop network, or use outside_sandbox. It does not stop git show HEAD:.env, credentials inside skipped folders (.git, target, node_modules, site-packages, __pycache__), or a renamed copy. It is judged in auto and asked in manual, and a grant for plain commands never covers it.
  • outside_sandbox: true runs the command as you, unconfined. It always asks, in every mode, with no “for this turn”/“for this session” option. Unconfined (this way, or with the sandbox off or unavailable), if a process still holds the output two seconds after the command exits, the call returns. Processes in its group are ended; ones that left it (setsid, job control) keep running. The model sees [output cut: the command exited, but a process it started still holds its output].

[sandbox] escape = "never" removes both: nothing leaves the sandbox or reaches the internet.

Under --approve-all, network: true is approved automatically and outside_sandbox: true is refused; the model is told to carry on and name the step in its final message. Child sessions inherit this. A conversation (--input-format stream-json) refuses outside_sandbox: true the same way unless --allow-escapes says a person answers the program’s requests (docs/headless.md, “Approvals”).

Away

/away (panel or plain REPL) says you’re not watching: until the current task ends if typed mid-task, or from the next task if typed while idle. The plain REPL reads it only between tasks. Type /away again to come back. harness run --away --prompt "…" does the same for a scripted run; it needs --prompt and starts in auto unless --mode says otherwise. Both are refused under --approve-all and in a mode where every command asks.

While you’re away, anything that would ask you is refused (an unreachable judge and a child’s ask included), and the model is told to keep going. A network-only command is judged at the away cutoff ([judge] away_cutoff, default 0.2 like the ordinary cutoff; lower asks more while you’re away, config.md). Grants from before you left (AllowForTurn/AllowForSession) do not apply.

The timeout

[away] timeout (default 15m) is how long a question waits before it is refused as if you’d typed /away; the rest of the task then runs away. It applies only where /away can start: a mode where commands don’t ask, with the sandbox on or external. Otherwise questions wait with no limit. A child’s question follows your session’s mode. Values: off, or Ns/Nm/Nh up to 24h. On the panel any key restarts the clock.

After the breaker has tripped in the current task (below), a timeout ends the turn instead, and the session waits for your next message.

The breaker

While you’re away, 3 refusals in a row, or 20 across the away stretch (unreachable-judge refusals included), trip the breaker. Away ends, and the next thing that needs you waits as an ordinary ask. Until the task ends, a timeout ends the turn instead of restarting away, and the away cutoff stays in force even if you return. A child spawned after a trip starts tripped.

A tripped root task keeps doing what needs no approval. A tripped child stops, and its parent gets that as the result. Under run --away, the next question is denied at once if nothing can read it, or after the timeout if something can. Either way an unfinished turn exits 1.

/iterations for long tasks

An away task can still hit the per-task cap on model calls (32 by default); each refusal costs one. /iterations <n> raises it for the rest of the session, the running task included, before its next model call; alone, it prints the cap. --away warns at launch if the cap is the default. A resume keeps a cap you chose (--max-iterations or /iterations); one from the default or config.toml follows config.toml as it reads at the resume. A crash resume keeps the cap the log last recorded.

Requirements

On Linux the backend is bubblewrap (bwrap) with a seccomp filter. It needs unprivileged user namespaces, which Ubuntu 24.04 usually blocks through AppArmor; the harness names the fix. Under Windows it runs in WSL2 (“Windows (WSL2)”, below).

On macOS the backend is Seatbelt, through /usr/bin/sandbox-exec. It needs:

  • macOS 15.4 or later, on Apple Silicon or Intel (README.md, “macOS”); 27.0 or later is better, on Apple Silicon (“Update macOS”, below).
  • /usr/bin/sandbox-exec, which ships with macOS.
  • The harness outside any other sandbox, since Seatbelt refuses a second one.

Otherwise each start says why the sandbox is unavailable:

the sandbox is unavailable: the harness is itself running inside a sandbox. Auto is off, and --approve-all and --away will not start. Fix: start the harness outside it, or set [sandbox] mode = "external" when that sandbox is the boundary, in <your config file>.
the sandbox is unavailable: `/usr/bin/sandbox-exec` is missing. Auto is off, and --approve-all and --away will not start. Fix: set [sandbox] mode = "external" when this machine is its own boundary, or mode = "off" to run commands unconfined, in <your config file>.

Each session start also runs a probe (about 0.2 s) and names any check that fails:

the sandbox is unavailable: the sandbox did not hold (listing_order). Auto is off, and --approve-all and --away will not start. Fix: no setting fixes a failed check; report the check's name. To run without the sandbox on purpose, set [sandbox] mode = "off" in <your config file>.

A failed check usually follows a macOS update, and no setting fixes it. Until the harness is updated, mode = "off" restores auto and --approve-all, and mode = "external" also restores --away when the machine is its own boundary. Report the check’s name.

Default Docker and Docker Desktop cannot nest the namespace bubblewrap needs. If the container is your boundary:

[sandbox]
enabled = false

That runs commands unconfined but still refuses run --away; for unattended runs use mode = "external" (“External mode”, below). On Linux, when markers such as /.dockerenv suggest a container, the unavailable message says so and suggests mode = "external" first:

the sandbox is unavailable: user namespaces are blocked; the harness seems to be inside a Docker container (/.dockerenv). Auto is off, and --approve-all and --away will not start. Fix: set [sandbox] mode = "external" when that container is the boundary, in <your config file>; otherwise sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0.

When the sandbox is unavailable, the harness fails closed. auto is not offered: choosing it, resuming into it, or a judging default mode in config.toml prints the cause and fix and falls back to a plain manual that judges nothing. --approve-all, run --mode auto, run --away and harness eval exit 2 at launch, as does resuming a session that crashed with its sandbox on. In manual, approved commands run unconfined.

How mode changes that:

sandboxauto--approve-all, run --mode auto, evalrun --away, /away
unavailablenot offeredexit 2exit 2
mode = "off"yes, unconfinedyes, unconfinedrefused
mode = "external"yesyesyes

On macOS the model is told it has BSD tools (sed -i '', stat -f).

A killed command’s leftovers

harness resume brings back a session whose harness was killed mid-command (terminal closed, SSH dropped, a reboot), without re-running the command (checkpoints.md, “Resuming a killed session”). It also handles what the command left behind.

The record. Before each command the harness writes leftovers/<session>/<effect number> under its state folder (~/.local/state/harness by default, config.md), and removes it once the command is done.

What resume does, before anything else runs, for the session and its children:

  • Linux: the sandbox died with the harness. One still running holds sandboxed commands (below).

  • macOS: sandboxed leftovers are stopped: Leftover processes: 2 stopped (they ran in the sandbox).

  • A command outside the sandbox that was still running is named, never stopped:

      Still running outside the sandbox: 275710 (sleep). They were left running.
    

    If the command’s first process is gone:

    May still be running outside the sandbox: …. The command's first process is gone, so these may belong to another program; check with `ps -p <numbers>` before stopping one.
    

    After a reboot, nothing is looked up.

  • A record with no process (killed before the command started) quotes its first two words:

    A command may still be running; it could not be identified. Look for it with `pgrep -fl 'cargo test'`; stop any it lists that you did not start, with `kill` and its number.
    
  • Then protected files the command created are moved aside:

      The command created .envrc, which runs code or steers the agent later, so the harness moved it to .envrc.harness-quarantined-1231b2265de8612a. To keep it, write it with the file tools; the user is asked.
    

An interrupted resume reports again.

Holds. If a leftover can’t be confirmed gone, sandboxed commands are held (unsandboxed ones are not). Each one re-checks, so the hold lifts once the cause is gone:

  • An old sandbox still running, or one that could not be checked:

    Sandboxed commands are held: a sandbox from before the harness was killed is still running (pid 4321). It ends by itself, or stop it with `kill -9 4321`; then run the command again.
    

    On macOS, for a command that never entered its sandbox:

    a command from before the harness was killed has not entered its sandbox (pid 4321). Stop it with `kill -9 4321`; then run the command again.
    
  • Files that could not be moved aside: Sandboxed commands are held until .envrc is moved aside: move it away or remove it on the host.

  • A record that is empty, damaged, from another version, or incomplete:

    Sandboxed commands are held: ~/.local/state/harness/leftovers/<session>/3 could not be read (it is empty or damaged), so the files that command may have planted were not checked.
    Protected files in the project now: .envrc, CLAUDE.md. Remove any you did not make.
    Then run: rm ~/.local/state/harness/leftovers/<session>/3
    

Another folder. Resume from the folder the command ran in:

session failed: a command this session ran in /path/to/project was killed; files it may have planted there can only be checked from that folder. Resume it there: cd /path/to/project && harness resume <session>

A records folder that can’t be listed stops the resume the same way.

When the record cannot be written (permissions, a full disk), sandboxed commands are refused:

Sandboxed commands are refused: the harness cannot write in ~/.local/state/harness/leftovers/<session>/ (Permission denied). Make that folder writable by you, or free disk space, then run the command again.

Commands outside the sandbox, or with it off, run with the note leftover record not written: <why>; if the harness is killed while this command runs, its processes are not reported at resume.

Known gap: a session held at resume and then closed while idle is neither held nor swept at its next resume.

On macOS

Seatbelt enforces the same policy. Where macOS differs (below), the model is told too. The session record names the backend (seatbelt macOS 27.0 (26A428)).

Side by side

PolicyLinux (bubblewrap)macOS (Seatbelt)
Readsall but the hidden list; hidden reads emptythe same; hidden fails Operation not permitted
Hidden listcovered at session startplus the macOS additions; later paths covered too
Writesthe project, private /tmp, a linked worktree’s git directory, [sandbox] writablethe same, private folder at $TMPDIR, plus the user’s temp folder minus com.apple.*
The machine’s /tmpout of reachout of reach; a project inside it works
Git’s auto-run files, protected files, the harness’s own foldersread-only; created ones moved asidethe same; folders above them cannot be renamed
Network, plain commandloopback within the commandnone
Network-only commandthe machine’s networkthe same
Unix-domain socketsrefusedrefused; socketpair works
DNSfails at oncefails at once
Processesall end with the commandinvisible; swept when the command ends
Shared objectsa private IPC namespaceothers’ shared memory refused; named semaphores shared
Devicesa minimal /deva short list; none of your disks, console or terminals
Terminala fresh devptsone the command opens
Credentials in the environmentremovedremoved
Ways out—refused: open, Apple Events, launchd, the keychain, the pasteboard, most system services, terminal input injection, setuid programs, a nested sandbox-exec

A refused command shows the system’s own error, then the blocked-command note:

touch: /tmp/x: Operation not permitted

What you carry

  • Loopback tests need network: true. A test against a fake server on 127.0.0.1 (a cargo test, say) fails in a plain command — Rust: Operation not permitted (os error 1), Python: PermissionError: [Errno 1] Operation not permitted. With network: true it has the whole network, is judged, cannot run under escape = "never", and cannot read the project’s credential files, so a TLS test with a *.key or .pem fixture needs a full escape.
  • The user’s temp folder is shared with your other programs. A bare mktemp and Foundation use it rather than $TMPDIR, so files left there outlast the session. Use mktemp -d "$TMPDIR/x.XXXX". The user’s cache folder (/var/folders/…/C) is read-only.
  • A network-only command listens on every address, reachable from the local network.
  • Keychain credentials do not work inside: security finds nothing, and git’s osxkeychain helper and the gh login should fail too (unverified). Run the step that needs the credential outside the sandbox.
  • Named shared memory is refused except libomp’s, so Python’s multiprocessing.shared_memory and PyTorch worker tensors fail with PermissionError. Named semaphores are shared with your programs. System V objects outlive the command.
  • A command cannot rename or remove a folder holding a hidden or read-only path (or, for a network-only command, a credential file), nor the project, the private temp folder or another writable root: mv .vscode .vscode-old fails with Operation not permitted. A whole-folder clone is refused; cp -c -R copies file by file.

These fail with their own error and get no blocked-command note: open (No application knows how to open URL …), osascript to another app (Connection Invalid error for service com.apple.hiservices-xpcservice), security (The specified item could not be found in the keychain.), and pbcopy and launchctl (exit 1, no output).

Tools that sandbox themselves

Seatbelt refuses a second sandbox, so a program that sandboxes itself fails inside; an App Sandbox program is killed at once. SwiftPM is the common case: swift build fails with

sandbox-exec: sandbox_apply: Operation not permitted

swift build --disable-sandbox fixes it. Similar: xcodebuild (run it with outside_sandbox), Bazel (--spawn_strategy=local, unverified), Chrome and Electron (--no-sandbox, unverified). When such a failure is a command’s whole result (exit 65 or 71), it reads as the sandbox failing to start, with no note:

io: sandbox-exec failed to start the sandbox: sandbox_apply: Operation not permitted

A held session

When a command ends, even on a timeout or Ctrl-C, the harness ends every process it started. If it cannot confirm they all ended, the result is withheld and the session is held: every later command and file tool, children’s included, is refused until the process is gone. The command’s error (the process ID is an example):

the harness could not confirm that every process this command started has ended (process 4321 is still running), so its result is withheld and nothing else runs in this session until it can: end it on this machine (`kill -9 4321`), then run any command to check again. Files a leftover process creates are checked once it is confirmed.

Each later call:

an earlier command left a process the harness could not confirm has ended (processes 4321 and 4322 are still running), so this was not run: end them on this machine (`kill -9 4321 4322`), then run any command to check again.

At most eight IDs are listed, then and N more. If only unsettled entries remain, the text says how many (3 entries of the process list could not be settled) and the fix is wait a moment. Run the kill -9 in your own terminal, then any command. A killed harness’s leftovers stay sandboxed until resume sweeps them (“A killed command’s leftovers”).

The budget

A sandboxed command starts about 30 ms later, and a session start about 0.2 s later. Every path a command’s profile names adds start time, so a profile is capped at 1,000 path filters; a typical project uses about 100. Past the cap the command is refused, naming the largest list and the fix:

denied: the sandbox for this command needs 1155 path filters, more than Seatbelt compiles in a usable time (1,000): most are hidden paths. Fix on the host: shorten the [sandbox] hide list, then resume the session.

It can also name the protected paths (drop unneeded protected-list entries and git files) or the credential files (move unneeded ones out of the project).

The whole command line, environment included, must fit the system’s 1 MiB argument limit. Past it the refusal names the largest part:

denied: the sandbox for this command needs 1111404 bytes of arguments and environment, more than the system's argument limit (1048576 bytes): the largest part is the command's own arguments (1100066 bytes). Fix: shorten them, or pass the data to the command through a file in the project or $TMPDIR.

An environment near 1 MiB makes the sandbox unavailable at start.

A project path over about 1,000 bytes cannot run sandboxed commands. The message blames permissions, but the cause is the length:

denied: the project root cannot be read (File name too long (os error 63)), so the command was not run: restore its permissions on the host.

Move the project to a shorter path.

Update macOS

macOS 27.0 fixed a Seatbelt flaw Apple describes only as “an app may be able to bypass network restrictions” (CVE-2026-84551, 27.0’s notes). Apple’s record (NVD) marks every macOS below 27 as affected, so below 27, doubt the network rules. An Apple Silicon Mac fixes it by updating to 27; update when you can. An Intel Mac cannot (27 does not run on Intel), so on Intel the network rules carry that doubt for good. The backend in a session’s record shows which macOS enforced it.

Windows (WSL2)

Windows is supported through WSL2, as a Linux install inside a distribution. It has been checked on one Windows machine (WSL2 with Ubuntu 26.04); the rest of what follows is from Microsoft’s documentation and the code.

  • Check the version. In PowerShell, wsl -l -v lists each distribution with a VERSION column, which must say 2. wsl --set-version <name> 2 converts one; wsl --install sets up WSL2 with Ubuntu. WSL1 is not supported, and neither is Windows on ARM.
  • Set up inside the distribution. Install bubblewrap with its package manager (sudo apt install bubblewrap on Ubuntu), then run the installer from Getting started there. The Linux requirements above apply unchanged.
  • Keep projects in the Linux file system (~/code), not under /mnt/c. Names on a Windows drive compare without case, so write_file claude.md could replace CLAUDE.md with no prompt. The harness folds case there, as it does on a Mac’s default disk, so that write is refused; but the Linux file system is the safe place.
  • What the sandbox hides. WSL2 mounts each Windows drive at /mnt/c, /mnt/d, and so on, and the sandbox reads the whole file system, so a command would otherwise read C:\Users: every account’s .ssh, AppData (browser profiles, app tokens) and Documents. Each drive’s Users folder is hidden under every spelling that names an existing folder (Users, users, USERS, …), because the drive folds case. The rest of the drive stays readable, and read-only. The mount table decides what a drive is, so a moved automount root (/etc/wsl.conf) is followed.
  • A project (or its git folder) inside a Users folder (/mnt/c/Users/you/code) makes the sandbox unavailable, since hiding the folder would hide the project. Auto, --approve-all and --away fail closed, as for any unavailable sandbox (/mnt/c/code keeps the sandbox):
the sandbox is unavailable: the project is inside a Windows Users folder (/mnt/c/Users), which the sandbox hides. Auto is off, and --approve-all and --away will not start. Fix: move the project into the Linux file system (such as ~/code) or elsewhere on the drive, or set [sandbox] mode = "off" to run commands unconfined, in <your config file>.
  • Windows programs do not start from a sandboxed command. cmd.exe and /mnt/c/…/*.exe reach Windows over a Unix socket, which the sandbox refuses. Run them outside the harness.
  • Not covered: a Users folder moved to another drive or folder, a drive mounted after the session starts, and Users on a Windows partition mounted outside WSL (dual boot).

[sandbox] and [away]

Both tables are read only from your own config.toml: a project cannot widen or disable the sandbox or lengthen the timeout. A [sandbox] table in a config reached through HARNESS_HOME or XDG_CONFIG_HOME is refused if it lies inside the project (config.md).

[sandbox]
enabled = true               # false runs every command unconfined (mode = "off")
mode = "on"                  # "on", "off" or "external"; must agree with enabled
writable = ["~/.cache/pip"]  # extra ~/… paths commands may write
hide = ["~/.ssh"]            # extra ~, ~/… or absolute paths to hide
env_hide = ["MY_APP_TOKEN"]  # extra variable names (or * patterns) to strip
env_pass = ["GITHUB_TOKEN"]  # exact names kept in the environment; external only
escape = "ask"               # "ask" (default) or "never" (no ways out)

[away]
timeout = "15m"               # "off", or Ns / Nm / Nh, up to 24h

writable takes ~/… paths below your home directory: not ~ itself, and not absolute paths. hide takes ~, ~/… or absolute paths; relative entries are refused at load. [judge] away_cutoff (default 0.2) is in config.md.

External mode: the machine is the sandbox

[sandbox] mode = "external", or --sandbox external for one session, says this machine is itself the boundary: a dedicated VM, a container, a CI runner. The harness builds no sandbox of its own. It works on Linux and macOS, and only your config or the flag can set it (config.md, “[sandbox] — mode, env_pass and --sandbox”). At start it says:

this machine is the sandbox ([sandbox] mode = "external", set by <your config file>): commands run without a sandbox from the harness.

(set by --sandbox when the flag chose it.)

What it allows. auto, --approve-all, run --away, /away and harness eval. --approve-all refuses only the hard-deny list (modes.md, “The hard-deny list”); run --away with --approve-all is still refused.

What goes. Hidden folders, read-only protected files, the private /tmp, the network rule, the probe, and the two ways out. What stays. The judge, the local asks, the file tools’ limits and asks, and the hard-deny list.

The environment. For every command, children’s included:

  • A provider’s key variable is always removed.
  • Otherwise a name in [sandbox] env_pass stays (exact, case-sensitive, no patterns).
  • Otherwise the credential names under “What it does”, URL values with a password, and env_hide names are removed.

This is not a credential boundary: commands run as the harness’s user and can read that user’s other processes’ environments. Keep credentials off the machine.

The session record lists the names removed and those kept by env_pass (never values), and the model is told what is missing:

Commands run without a sandbox from the harness: this machine is the boundary. These variables are not available to commands: MY_APP_TOKEN, OPENAI_API_KEY.

The second sentence is dropped when nothing was removed.

Resuming. To switch a session to or from external, /quit and harness resume <session> with the new setting. A resume after a crash keeps the recorded sandbox: external continues only if it is still set and the recorded environment names, platform and case still match. Otherwise it exits 2 with the start-up notice, the first difference (the variables removed from commands changed since the crash: recorded …; now ….), and:

a session resumed after a crash keeps the sandbox it recorded; to change it, /quit and resume, or start a new session

A session recorded on resumes in the sandbox or exits 2. One recorded off, unavailable, or before the sandbox existed counts as unavailable unless the setting is now off.

A 0.5.x binary cannot read a log that records external.

What the environment must do instead. A command can damage anything outside the project that the machine’s user can touch. So:

  • keep key folders, other checkouts and long-lived tokens off the machine; pass a scoped, short-lived token through env_pass;
  • limit its network: the harness enforces none (the record says "network":"host");
  • snapshot or discard it after each run.

Protected files are writable to commands under external, and under off. The file tools still ask, but a command can write .envrc, .claude/, .vscode/ or a workflow, and the judge treats that as an in-project edit. Planted code runs when the project is next opened, so review the work (“What it does”). The judge is untested on macOS commands (brew, launchctl, osascript, security, defaults).

What stays fine inside

On both systems:

  • DNS fails fast: Network is unreachable or Could not resolve host, not a hang. On macOS curl prints curl: (6) Could not resolve host: example.com.
  • The private temp folder (<cache>/sandbox-tmp/<session>) is shared with child sessions.

On Linux:

  • cargo test against a loopback fake server.
  • A fresh devpts: a command that opens a pseudo-terminal gets a private one.
  • The private /tmp, in place of the machine’s (your tmux sockets, other sessions’ files).

On macOS:

  • Ordinary builds and tools: git, cargo build with a C build script, swiftc with its module cache, a zsh here-document, a Foundation atomic save, and Python’s multiprocessing Pool, Process and Queue. A package manager that fetches needs network: true.
  • $TMPDIR, the private folder; mktemp -d "$TMPDIR/x.XXXX" works.
  • A terminal the command opens, socketpair, pipes and /dev/null.

A blocked command says so

When a sandboxed command fails, or exits 0 while its output shows a sandbox refusal on a protected git path, the result carries a note: what the sandbox blocks, and how to ask for more — network: true for the internet, outside_sandbox: true for anything else. On macOS it also says network: true is the way to a local port. A failing open, osascript, launchctl, pbcopy or security gets no note (“What you carry”, above).

An overnight task

Set the model, thinking effort and mode, raise the per-task cap, then go away — the rest is one ordinary message:

harness run
/model claude-opus-5
/model thinking high
/mode auto
/iterations 500
/away
<the actual task>

Checkpoints, /undo and the turn-end list

The harness takes a picture of the project before a command runs, lists what changed at the end of each turn, and restores files with /undo, even ones git does not hold. Checkpoints are on by default; [checkpoints] in config.md sets the limits.

What a picture is, and when one is taken

A picture records each covered file’s type, size, mode and times, and copies its content to the store. One is taken after a call is approved, before it runs:

  • a whole picture before every bash, exec (including a script’s) and agent call, and before the turn’s first file-changing call;
  • a path picture before each later write_file or edit_file call in the turn.

Later pictures copy only what changed. A failed picture never blocks the call; the gap is reported. Child agents run with checkpoints off; the picture before their agent call covers them.

What is covered

Every regular file and symlink in the project (links are recorded, not followed), except:

  • .git/ and what .gitignore excludes; without git, folders named node_modules, .venv, venv, target, __pycache__, .gradle or .next;
  • anything [checkpoints] skip names, and the harness’s own folders;
  • credential files (.env, .env.* except .example, .sample and .template, *.pem, *.key, *.p12, *.pfx, anything in a .secrets folder) and files over max_file_mib: recorded, never copied;
  • empty folders, FIFOs, sockets, devices, and anything outside the project.

A .gitignore the harness cannot use whole (unreadable, a link, over 1 MiB, or an ambiguous line such as ***) leaves its folder not checked.

A file with no copy says why: no copy (over 100 MiB), no copy (credential file), no copy (picture budget), no copy (changed during capture), no copy (unreadable name), no copy (unreadable).

The turn-end list

When a turn ends (however it ends) with changes, the harness lists them against the turn’s first whole picture:

files: 2 changed, 1 new, 16 deleted
  changed  src/parser.rs
  changed  Cargo.toml
  new      scripts/tidy.sh
⚠ deleted  docs/notes/ (16 files)   not in git   /undo

Up to 10 paths show, then … and N more; flagged lines always show. Changes you make in your editor appear too. harness export includes the list. With checkpoints off, there is no list. A path no picture covered is not checked, never new or deleted.

Flags are fixed rules. Each flagged line offers /undo <path> or /undo, except a new protected file (a restore never deletes).

  • not in git: a deleted or emptied file git has no copy of. Git is asked about at most 100 files per turn; not checked against git means it could not answer.
  • 20+ deleted: 20 or more files deleted in the turn.
  • protected: a file on the protected list changed (modes.md, “Protected files”).

A loss with no copy shows the reason instead of an undo; a folder line counts them (3 of 16 without a copy).

Notes state any gap, so a partial list never reads as “nothing changed”: no checkpoint: <why> (such as the checkpoint store is busy (another session is using it) or the checkpoint store is full), checkpoint partial: …, or this list starts before <command>: an earlier call this turn had no checkpoint.

Resuming a killed session

If the harness is killed mid-command (closed terminal, dropped SSH, reboot), harness resume <session> picks the session up without re-running the command. Run from the session’s folder, it prints the killed call’s failed line, then:

This session was killed while a command ran: `printf x >> a.txt && printf ok > started && sleep 60.071`. It may have partly run; its output was not recorded.
  Since it started:
    changed   a.txt      /undo restores
    new       started
  Not covered: gitignored files, anything outside the project.

The model gets the same text as the command’s failed result. The first line depends on what was killed; a file write, for example, says The file may be empty or cut short. A killed agent call names its child sessions, which are not resumed.

The change list runs from the last good picture before the call to the project now, so it includes your edits since the kill. When it cannot, it says why, such as no checkpoint: checkpoints were off.

The turn-end list follows the notice, for the interrupted turn:

files: 1 changed, 1 new
  changed  a.txt
  new      started
↳ 0.0k in / 0.0k out (interrupted)

Leftover processes and protected files the command planted appear as further notice lines; see sandbox.md, “A killed command’s leftovers”.

Replay refuses such a session: not replayable — session contains an interrupted turn or an abandoned effect.

/undo

/undo opens a picker of this session’s whole pictures, newest first (before ./tidy.sh · 14:02). Enter previews the restore:

undo to before ./tidy.sh: 1 to write back, 1 to recreate, 1 kept, 1 left alone
  write back  src/parser.rs
  recreate    docs/notes/a.md
  leave       big.bin   no copy (over 100 MiB)
  kept        scripts/tidy.sh   (created since)

On the panel, Enter restores; Esc or Ctrl-C cancels. /undo <path> restores one file to its state before its latest change, with the same preview.

During a turn, /undo answers wait for the turn to end. With nothing to undo it says no checkpoints in this session; a path outside the project gets /undo takes a path inside the project. A cleared picture says the checkpoint … was cleared (older than keep_days, or the store was full).

It never deletes. Files created since the picture are kept. A path it cannot write is skipped and reported.

It is guarded. It skips files changed since the preview, and first saves every file it will overwrite; one it cannot save (too big, a credential file) is left alone. Hard links and symlinks survive, and modes are never widened. Times are not restored, so build tools see restored files as new.

It can be undone. The picture it saves first appears in the next /undo as before /undo.

restored 2 files to before ./tidy.sh
  written     src/parser.rs
  recreated   docs/notes/a.md
  skipped     big.bin   no copy (over 100 MiB)
  kept        scripts/tidy.sh
  saved first: /undo can undo this

At its next turn the model is told “The user restored N files to their state before <label>.” The agent has no undo tool and cannot reach the store.

In the plain line client (pipes, redirects), bare /undo prints the pictures numbered and reads a number (type a number to preview it, anything else cancels), then asks restore? type y to confirm. y, Y, yes or Yes restores; anything else prints undo cancelled.

/checkpoints

/checkpoints shows checkpoints: on or checkpoints: off. /checkpoints on and /checkpoints off switch until the next resume, which reads [checkpoints] enabled again. /undo still works on earlier pictures.

A project rooted at your home folder or / takes no pictures. The start notice says checkpoints are off: the project is your home folder (or the project is the root folder (/)), /checkpoints shows checkpoints: off (<why>), and /checkpoints on repeats the notice.

Limits

LimitDefaultKey
Largest file copied100 MiBmax_file_mib
Whole store, per project5 GiBmax_store_gib
Age before a picture is cleared14 dayskeep_days
Files one picture copies10,000max_picture_files
Bytes one picture copies500 MiBmax_picture_mib
Time one picture may take10 secondsmax_picture_seconds
Entries one walk lists100,000max_walk_entries

Files past a picture’s budget get no copy (picture budget) and are copied by later pictures. A picture that stops early says so, for example checkpoint partial: 48,000 new files in out/, 10,000 copied. Add out/ to .gitignore or raise the limit. At the store cap, the oldest pictures are cleared first. Esc or Ctrl-C during a picture stops the call: interrupted while taking a checkpoint; the call did not run.

Where the store is, and who can read it

$DATA/checkpoints/<project key>/, owner-only (folders 0700, files 0600). The key is the project path as spelled, so two spellings of one project make two stores.

Sandboxed commands never see checkpoints/ or sessions/ (sandbox.md), so harness export does not work inside one. The agent’s file tools refuse both: <path> is inside the harness's private <name> folder. Commands you approve to run outside the sandbox can reach the store. Deleted content stays there for keep_days.

Known limits

  • Two sessions in one project share a store. One that cannot get it in time reports a busy gap; its call still runs.
  • The credential list has gaps: id_rsa, .netrc and credentials.json are copied.
  • A file whose name is not UTF-8 cannot be restored.
  • A writer racing a restore can leave a .harness-displaced-* or .harness-restore-* file, named in the result.
  • Folders deeper than 64 levels are not pictured and show as not checked.

Hooks

A hook is a command of yours that harness runs at two moments: before a tool call, and when you send a prompt. It can block the call or the prompt, or (for a prompt) add text the model reads beside it. Hooks are for your own rules (“never force-push”, “route ooo prompts to a skill”). They are not a security boundary (below).

Hooks are defined in config.toml (config.md) and read from your user config only, as MCP servers are. A config found through HARNESS_HOME or XDG_CONFIG_HOME inside the project is refused for [[hooks]].

The table

[[hooks]]
name = "no-force-push"        # required; unique; 1–32 of a-z, 0-9, "-", "_"
event = "PreToolUse"          # "PreToolUse" or "UserPromptSubmit"
tools = ["bash"]              # PreToolUse only; omitted = every tool
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"]   # argv; no shell
timeout = 10                  # whole seconds, 1–600; default 30
sandbox = true                # default true
on_failure = "block"          # "block" (default) or "continue"
  • command is an argv. Nothing runs through a shell unless you write ["sh", "-c", "…"].
  • Hooks run in the order the file lists them. The first to block ends the run; later hooks do not run.
  • tools names harness’s own tools: bash, exec, read_file, write_file, edit_file, glob, search, skill, agent, script, mcp, mcp_schema. A name that is none of these prints a start-up warning, not an error, so a config written for a later harness still starts: ⚠ hook no-force-push lists "Bash", which is not a tool here; tool names are lower case, such as bash.
  • A load error names the file for: an unknown key, an event other than the two, an empty command, tools on a UserPromptSubmit hook, a duplicate or malformed name, a timeout out of range. Claude Code’s matcher, type and if are unknown keys; matcher gets a message that says to use tools.
  • At start the banner lists the hooks: hooks: no-force-push (bash, exec) · keywords (prompts) · notify (every tool, on the host). -p prints the line on stderr.

PreToolUse: block a tool call

The hook runs when the model’s call reaches the approval gate, after the structural refusals (a tool the mode does not offer, an unknown argument, the hard-deny list, a plan-mode write outside the plan file) and before every permission shortcut. So a hook binds under --approve-all and in plan mode, and never runs for a call that was already refused. It covers every way a call can arrive: an ordinary call, each call inside a script, an agent spawn, an mcp call, and the calls of sub-agents and the side session.

Input, one line of JSON on stdin, then end of file:

{"session_id":"01M…","hook_event_name":"PreToolUse","cwd":"/home/dana/proj","permission_mode":"auto","tool_name":"bash","tool_input":{"command":"git push --force origin main"},"tool_use_id":"7EX…"}
  • permission_mode is the name of the mode in force (manual, plan, auto, or your own mode’s name). session_id is the session running the call: a sub-agent’s own id inside a sub-agent.
  • tool_input is the call’s arguments, with in-project paths made relative to the project. A call inside a script carries the outer script call’s tool_use_id.
  • An MCP server’s tool is called through the mcp tool, so a hook for one lists tools = ["mcp"] and reads tool_input.server, tool_input.tool and tool_input.arguments.
  • The field names are Claude Code’s, without transcript_path.

What the answer means (in every row a reason is trimmed and cut to its first 2,000 characters; empty or absent, it is no reason given):

The hookMeans
exits 2block; the reason is stderr
exits 0, prints nothingno objection
exits 0, prints a JSON object with hookSpecificOutput.permissionDecision = "deny"block; the reason is permissionDecisionReason
exits 0, prints a JSON object with decision = "block", or continue = falseblock; the reason is reason, or stopReason
exits 0, prints any other JSON object ({}, {"continue": true}, decision = "approve", permissionDecision = "allow", "ask" or "defer", …)no objection
exits 0, prints text that is not one JSON object, or stdout over 64 KiB; or, when no field says block, a hookSpecificOutput that is not an object, or permissionDecision, decision or continue of another kind or wordfailure (bad_output)
any other exit codefailure (exit)
killed by a signal; timed out; could not startfailure (signal, timeout, spawn_error)

allow and ask mean no objection. A hook only ever blocks. The call goes on through the judge, the sandbox and any question to you, as if the hook had not run. Other JSON fields (systemMessage, additionalContext, …) are ignored on PreToolUse.

A block is a failed call. The model reads blocked by the user's hook "no-force-push": <reason>, the turn goes on, and under -p the call is named on stderr with the other failed calls. It is not in denied (that lists calls that needed approval).

UserPromptSubmit: add context to a prompt, or block it

The hook runs for each prompt that opens a turn in the main session: typed in the panel or the plain prompt (a /skill:name … message included), given to -p, or a harness eval case’s prompt. It does not run for a message you steer in while the agent works, a side question, a sub-agent’s prompt, or a background sub-agent’s result.

{"session_id":"01M…","hook_event_name":"UserPromptSubmit","cwd":"/home/dana/proj","permission_mode":"auto","prompt":"ooo seed my requirements"}

There is never a turn_id (Ouroboros’s detector reads one as the mark of a Codex host).

The hookMeans
exits 2block the prompt; the reason is stderr
exits 0, prints nothingnothing added
exits 0, prints a JSON object with decision = "block", or continue = falseblock; the reason is reason, or stopReason
exits 0, prints a JSON object with a string hookSpecificOutput.additionalContextthat string is the context
exits 0, prints any other JSON object (decision = "approve" included)nothing added
exits 0, prints text whose first non-space character is not {the trimmed text is the context
exits 0, prints text starting { that is not one JSON object, or stdout over 64 KiB; or, when no field says block, a hookSpecificOutput that is not an object, a hookEventName that is not "UserPromptSubmit", additionalContext that is not a string, or decision or continue of another kind or wordfailure (bad_output)
any other exit, signal, timeout, start failurefailure, as above
  • Context reaches the model as a text block after your prompt, framed Context from the user's hook "keywords": and the text. Over 10,000 characters it is cut to the first 10,000 and a warning says so. Several hooks run in file order; if one blocks, none of their contexts is kept.
  • A blocked prompt never reaches the model, nor any later request. The panel and the plain prompt print ✗ prompt blocked by hook "keywords": <reason>, or for a failure ✗ prompt blocked: hook "keywords" failed (timed out after 5 s). The text stays in your input history (↑). In a session that has a prompt hook, a prompt whose turn was interrupted or killed before the model saw it is dropped the same way.
  • Context is text beside the prompt. It approves nothing: the gate is untouched.

Under -p a blocked prompt exits 1 (outcome prompt_blocked, headless.md). In harness run it is not a failure of the session, as a denied call is not.

When a hook fails

A failure is anything the tables above call one. on_failure decides what it does.

  • on_failure = "block" (the default): the call or the prompt is blocked. For a call, the model reads a fixed message, not the hook’s output: the user's hook "no-force-push" failed (timed out after 10 s), so this call was blocked. This is not a policy decision: retrying or rephrasing the call will not help. Tell the user their hook is failing. A broken hook therefore stops every call it covers; harness never lets a broken policy pass unnoticed. A prompt that is blocked this way shows ✗ prompt blocked: hook "keywords" failed (timed out after 5 s).
  • on_failure = "continue": the hook is skipped and the next one runs. Use it for hooks that advise and do not enforce.

Either way you get one warning per hook per turn (on stderr as harness: … under -p):

⚠ hook no-force-push is failing (timed out after 10 s), so the calls it covers are blocked. Fix it, set on_failure = "continue", or restart with --no-hooks.
⚠ hook no-force-push failed (exited with code 1); on_failure = "continue" let the call through.

For a UserPromptSubmit hook the first reads … so your prompts are blocked. … and the second … let the prompt through.

The kinds read timed out after N s, exited with code N, printed output that is not hook JSON, could not start: <error> and was killed by a signal. Esc during a hook stops it (its whole process group) and ends the turn interrupted; the call does not run.

Where to keep hook files

Keep a hook’s program and any file it reads outside the project, and outside any folder the agent can write. An agent that can edit its own policy hook has no policy, so at start harness resolves command[0] and each later element that names an existing file or folder (relative paths from the project root; an argument that does not exist but is shaped like a path, with a / and no spaces, is checked against the writable folders too, since the agent could create it; the value after an option’s first =, as in --rules=hooks/r.yaml, is checked the same way; an empty value, --exclude=, names nothing and is not checked), and refuses to start the session when one lies in:

  • a folder the agent can write: the project, a linked worktree’s common directory, the private temp folder, [sandbox] writable, and on macOS the user temp folder. Under bubblewrap the box’s /tmp is the agent’s, so a sandboxed hook’s path under /tmp is refused; without a sandbox the system temp folders (/tmp, and on macOS /private/tmp and $TMPDIR) count;
  • for a hook with sandbox = true in a session with a sandbox, a path the sandbox hides (sandbox.md): the sandboxed hook could not read it.

Each path, command[0] included, is checked as written and where it leads: every link on the way is followed, one whose target does not exist yet included, and a link or folder on the way that sits inside a folder the agent can write counts too, since the agent could re-point it.

The refusal names the hook, the path and the fix:

hook no-force-push: /home/dana/proj/hooks/check.sh is in the project, so the agent could change it. Move it outside (for example ~/.local/share/harness/hooks/), or start with --no-hooks.
hook no-force-push: /home/dana/.claude/check.sh is in a folder the sandbox hides (/home/dana/.claude), so the hook could not read it. Move it, or set sandbox = false for this hook.
hook no-force-push: check.sh was not found.

~/.local/share/harness/hooks/ is a good place: it is the data root, read-only to sandboxed commands. A command[0] with no / is looked up on PATH once, at start; one with a / must be absolute. The check is best effort: a path inside a ["sh", "-c", "…"] string is not seen, and a value that only looks like a path (--header=Content-Type:application/json) is refused as one. A sandboxed hook runs with the project as its working directory, so it can still be steered by a bare relative name that was absent at start (["python3", "hook.py"]) or by an interpreter string (-c, -e, -m: python3 -c "import json" loads a json.py from there). Such a hook can only decide its own block, never reach the host.

Sandbox, or the host

  • sandbox = true (the default): the hook runs in the same box as bash and exec, in the project root: it can read widely, write only where commands can, has no network and no provider key.
  • sandbox = false (or any session with no sandbox): the hook runs on your machine with your user’s access, from /, with every provider key removed from its environment. Use it for a hook that needs the host (a desktop notification, a policy server).

A host hook runs from / so that nothing it or its interpreter loads by a relative name comes from the project. Its input’s cwd still names the project; a host hook that needs it goes there first: cd "$(jq -r .cwd)". In a session with a sandbox, a host hook’s PATH also loses every empty or relative entry and every entry inside a folder sandboxed commands can write (the project, a linked worktree’s common directory, the private temp folder, [sandbox] writable, and on macOS the user temp folder), so a program the agent plants there is not found by name. An entry that cannot be traced to where it leads (a link to nowhere yet) goes too, and if none is left the hook runs with no PATH at all: name its programs by absolute path. With no sandbox PATH is unchanged: the agent’s commands can write anywhere.

A host hook that runs project files (cargo, npm test, a repo script) runs code the agent can write. Keep such hooks sandboxed, or have them run only files outside the project. The banner marks a host hook on the host.

A hook should not write files. Its writes are not tracked on their own, so /undo may not undo them, and a host hook’s write by a relative name fails at /.

Turning hooks off: --no-hooks

harness run --no-hooks and harness resume <id> --no-hooks run that segment with no hooks. The banner reads hooks: off (--no-hooks), and the log records it. A later resume without the flag runs the hooks again. It is the way out of a hook that is failing closed.

What is recorded

  • The definitions: session.started and session.resumed carry each hook’s name, event, command, tools, timeout, sandbox and on_failure as the session ran them, and whether --no-hooks was given.
  • Each run is an effect with the hook’s input digest and its output, so a replay serves it and starts nothing.
  • Each block, failure, interrupt or skipped run is a hook.decided record with the hook, the tool, the reason or the failure kind. A run with no objection leaves only its effect.
  • Context is a hook.context record, kept in the log and shown in transcripts and exports as · hook keywords added context (171 characters).
  • harness session --transcript, a resume’s replayed rows and the exports show the same rows as the live panel, plus · hook <name> failed (<kind>); the call went ahead (the prompt went ahead for a prompt hook) for a failure that continue let through. harness sessions --json gives outcome prompt_blocked for a session whose last turn was a blocked prompt, and the /session card counts them.

harness eval loads your hooks from your user config and runs them for each case’s prompt and tool calls, as -p does. A case whose prompt a hook blocked ends prompt_blocked: it fails an expect.outcome.in of ["completed"] and can expect ["prompt_blocked"].

A crash resume (resuming a session that was killed) keeps the hooks its log recorded, which its hooks: line names, and runs a recorded hook only if today’s config still defines it exactly as recorded: same event, command, tools, timeout, sandbox and on_failure. Otherwise it is skipped as no objection, with a warning (⚠ hook no-force-push is not run in this session: it is changed in your config since the session was recorded. Resume cleanly to use today's hooks.). A log can never make harness run a program your config does not name. A clean resume reads today’s config and records what applies from then on. A session killed while a hook ran says so on resume: This session was killed before bash ran. It never ran. or This session was killed while a prompt hook ran. The prompt was not sent.

Example: refuse force-pushes

[[hooks]]
name = "no-force-push"
event = "PreToolUse"
tools = ["bash", "exec"]
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"]
timeout = 10
#!/bin/sh
# Reads the call as JSON on stdin. Exit 2 blocks; stderr is the reason the model reads.
input=$(cat)
case "$input" in
  *"git push"*"--force"*|*"git push"*" -f"*)
    echo "force-push is not allowed here" >&2
    exit 2 ;;
esac
exit 0

This reads the raw JSON as text, which is enough for a demonstration and easy to route around (see below). A hook that needs the fields parses the JSON (python3, jq).

Example: a hook for MCP tools

An MCP server’s tool is called through the mcp tool, so cover it with tools = ["mcp"]:

[[hooks]]
name = "mcp-audit"
event = "PreToolUse"
tools = ["mcp"]
command = ["python3", "/home/dana/.local/share/harness/hooks/mcp-audit.py"]

The input’s tool_input is {"server": "github", "tool": "create_issue", "arguments": {…}}.

Example: Ouroboros’s keyword detector

Ouroboros ships a UserPromptSubmit hook, keyword-detector.py. It reads the JSON above and, for a prompt such as ooo seed my requirements, answers with hookSpecificOutput.additionalContext holding a <skill-suggestion> block that names a skill (/ouroboros:seed). It suggests the skill and loads nothing; the model decides. Harness accepts that JSON as it is. Ouroboros’s hooks are written to exit 0 whatever happens, so they fail open on their own.

Copy the script to ~/.local/share/harness/hooks/ (outside the project) and add:

[[hooks]]
name = "keywords"
event = "UserPromptSubmit"
command = ["python3", "/home/dana/.local/share/harness/hooks/keyword-detector.py"]
timeout = 5
on_failure = "continue"

on_failure = "continue" keeps an advisory hook from blocking your prompts if Python is missing. Ouroboros’s other hooks (SessionStart, PostToolUse) have no harness event yet. Its skills load through [skills] extra_roots (config.md), and a skill the model loads in auto runs without a question.

Differences from Claude Code

Claude Codeharness
where hooks are definedsettings files, including the project’syour user config only
commanda shell stringan argv; a shell only if you write one
where it runson your machine, unsandboxedin the sandbox by default; sandbox = false for the host
working directorythe projectthe project in the sandbox; / on the host (the input’s cwd names the project)
when a hook failsthe call goes aheadthe call is blocked (on_failure = "block"); "continue" to let it through
which callsmatcher patternstools, harness’s tool names; no matcher
tool names and argumentsBash, Edit, Write; file_path, old_string, new_stringbash, edit_file, write_file; path, old, new, content
ask, allow answerscan grant or force a questionno objection: the gate decides as if no hook ran
eventsmanyPreToolUse, UserPromptSubmit
files the hook readsanywhereoutside the project and outside any folder the agent can write

The envelope’s field names and the answer formats are Claude Code’s. A ported script must change any tool or argument name it matches on: one that tests for Bash or file_path finds nothing here and objects to nothing, silently. A script that only reads tool_input.command ports as it is.

Hooks are not a security boundary

A policy hook sees one call at a time and can be routed around: a rule against git push --force does not see git push origin +main, a script in the repo that pushes, or the same push spelled through an interpreter. The sandbox is the boundary. Use hooks for your own conventions and guardrails, and keep secrets and destructive reach out of the agent’s hands with the sandbox, the modes and the hard-deny list (modes.md, sandbox.md).

Shortcuts

The full-screen panel runs when stdin and stdout are both a terminal. These are its keys. In the panel, /shortcuts or ? on an empty input shows them, so a message cannot start with a typed ?: type a space first, or paste it.

Session

KeysWhat it does
entersend the message
escinterrupt the turn
ctrl-cclear the input
ctrl-c ctrl-cquit
shift+tabcycle the mode
ctrl-gwrite in your editor
?these shortcuts (empty input)
/shortcutsthese shortcuts, as a command

During a turn

KeysWhat it does
entersteer, or ask on the side
tabsend it after the turn
escstop the turn
esc (side)stop only the side reply
↑edit a queued message (empty)

Moving

KeysWhat it does
← →a character
ctrl+← ctrl+→a word (alt+← alt+→ too)
alt-b alt-fa word back, forward
ctrl-a ctrl-ethe line’s start, end
home endthe input’s start, end
↑ ↓lines, then history

Editing

KeysWhat it does
ctrl-jnew line
alt+enternew line
shift+enternew line (if reported)
backspacedelete back
deletedelete forward
ctrl-wdelete the word before
ctrl-udelete to line start
ctrl-kdelete to line end
ctrl-oexpand collapsed pastes
KeysWhat it does
/commands (at the start)
@pick a file
↑ ↓move the highlight
tab enteraccept
escclose the menu

The plain prompt (piped input)

KeysWhat it does
ctrl-cinterrupt the turn; when idle, quit
ctrl-dquit (end of input)

Commands for the start screen, updates and leaving (the panel and the plain prompt):

TypeWhat it does
/noticeseverything the start did not print: attention, about this start, privacy, status
/updateinstall the newest release without leaving the session (esc cancels before the swap)
/restartswitch to the installed binary and keep the conversation; waits for the turn to end
/quit, /exitsave the session and leave (/exit is the same as /quit)
exit, quitthe same as /quit when harness is idle; during a turn, while /update runs or a harness update check is pending, they are an ordinary message
harness updatecaught when idle: offers /update instead [Y/n] (it is a terminal command)

The typed words match the whole line, ASCII case-insensitively, with nothing before them ( exit and exit now are messages). The harness update question answers only while the line is empty; any other key closes the question as no and is typed as usual (so /quit then Enter quits), and with text already typed keys go to the line (Esc still closes the question). A line queued (with Tab, or with Enter while /update or a check runs) is always a message, however it reads.

Some keys work only in some places:

  • Menus: typing @ opens the file picker; a / at the start opens the command menu. An open menu takes ↑/↓, Tab, Enter and Esc, and the first Esc closes it instead of interrupting.
  • Pickers: /model and /mode take every key and name their keys in their title rows. In /model and its thinking step, Enter switches and saves the choice as the default for new sessions; Tab switches for this session only (config.md). Ctrl-C always closes them.
  • Approvals: while an approval’s choices are on screen, only its own keys and Ctrl-C work. A typed reply uses the editing keys. The main agent’s questions and its sub-agents’ queue in one line, shown one at a time in the order asked. Esc answers the one on screen, as n does.
  • A long tool call: once a tool call has run for 2 seconds (time spent waiting at an approval does not count), Enter on a message asks it on the side, as well as steering it into the turn. A read-only side session answers from what the main agent knew when the call started. It can read and search files but never runs a command, asks for approval or changes a file. Its answer appears under the running call when it finishes and joins the conversation at the call’s end, without starting a turn. While the model is writing, with an approval open, or while a side reply is running, Enter steers as before. Tab queues the message for after the turn, as always. The status line hints the choice (enter asks on the side · tab after turn · esc stops).
  • Esc during a side reply stops only the side reply, and the message still joins the turn; a second Esc stops the turn. With the agent idle, Esc stops a running side reply together with the sub-agents.
  • Sub-agents: during a turn, Esc stops the turn and the sub-agents keep running. With the agent idle and no question on screen, Esc stops every sub-agent, including one still waiting to start (config.md).
  • Quitting with sub-agents running waits up to 10 seconds for them to stop; Ctrl-C leaves at once after the first half second.

When a key does not arrive

The panel sees only the keys your terminal sends. A terminal, tmux or screen can keep a key, or send a different one.

tmux and screen prefixes. A multiplexer takes its prefix key first. With the tmux prefix on C-a, Ctrl-A (start of line) reaches the harness only when pressed twice, given bind C-a send-prefix in ~/.tmux.conf. GNU screen’s default prefix is also C-a; C-a a sends a literal Ctrl-A. For any key a tmux or screen binding owns, rebind it there or use another key for the same action.

Esc and Alt in tmux. After an Esc, tmux waits escape-time for an Alt sequence (500 ms by default, per the tmux 3.4 man page), so Esc can feel slow. set -sg escape-time 10 shortens the wait.

Shift+Enter. Most terminals send it as plain Enter. It gives a new line only where the terminal reports the Shift, and under tmux only if tmux forwards it (extended-keys). Ctrl-J always gives a new line; Alt+Enter usually does.

macOS terminals. Reported by users and vendor docs; not checked on a Mac.

  • Ghostty, and iTerm2 with its “Natural Text Editing” preset, send Cmd+←/→ as Ctrl-A/Ctrl-E and Option+←/→ as Esc-b/Esc-f, which the harness reads as Alt-b/Alt-f. Under tmux, Cmd+←’s Ctrl-A is subject to the prefix note.
  • Terminal.app sends neither by default; set the keys in Settings → Profiles → Keyboard.
  • Alt-b/Alt-f need Option to act as Alt: macos-option-as-alt = true in Ghostty, or “Esc+” for the Option key in iTerm2’s profile keys. Otherwise Option types a character (∫, ƒ).

The plain prompt. When stdin or stdout is not a terminal, the harness reads whole lines and the terminal, if any, does the editing. The harness handles only Ctrl-C and end of input; /shortcuts there says so.

config.toml reference

config.toml lives in ~/.config/harness/, or in $HARNESS_HOME/config/ when that is set. Every table is optional; no file means defaults. A malformed file stops the harness at load: bad TOML or an unknown key names the file and line, a refused value names the file. Flags override the file.

macOS uses the same XDG folders as Linux (~/.config/harness, ~/.local/share/harness, ~/.local/state/harness, ~/.cache/harness), never ~/Library/Application Support. Each $XDG_*_HOME variable moves its own root. harness paths shows every root and what set it:

$ HARNESS_HOME=/tmp/scratch harness paths
CONFIG   /tmp/scratch/config  (HARNESS_HOME)
DATA     /tmp/scratch/data  (HARNESS_HOME)
STATE    /tmp/scratch/state  (HARNESS_HOME)
CACHE    /tmp/scratch/cache  (HARNESS_HOME)
RUNTIME  /tmp/scratch/runtime  (HARNESS_HOME)

On macOS, /tmp and /private/tmp (and /var and /private/var) count as one project.

Top-level settings

model = "claude-opus-5"     # default: chosen at start, see below
thinking = "medium"         # off | low | medium | high | xhigh | max — default: medium
max_tokens = 32768           # default: each model's own output limit, else 32768
max_iterations = 32          # default: 32
mode = "manual"             # manual | plan | auto | a [modes.<name>] table — default: manual
check_for_updates = true    # false: no update check, no report, no notice — default: true
telemetry = true            # false: no reports, the update check stays on — default: true

check_for_updates and telemetry are described under Updates.

max_tokens caps one reply’s output, thinking included. Unset (and with no --max-tokens), each model gets its own output limit, so a /model switch or a sub-agent on another model gets that model’s: the provider’s own model list, else the catalog, else 32768. A mega: model takes the mega model list’s figure as it is (mega:full sends 65536), since the server knows what it serves. Set, it applies to every model as written.

When a reply hits the cap. A reply that stops at the output cap with no tool call, whether it spent the cap on thinking or was cut mid-answer, is continued automatically: up to 3 tries in all, the reply and two continuations. Each continuation tells the model its reply hit the limit and asks it to carry on from where it stopped, with no apology or recap, in smaller pieces if much remains. The transcript shows a dim line before each:

↻ The reply hit the output limit. Continuing (2/3).

If the second try is all thinking, the third runs one thinking level lower (high to medium; low and off stay) and its line adds it: Continuing (3/3) with medium thinking. The next request after the streak returns to your level. Esc stops the continuing. After the third try the turn ends truncated, with the warning and the advice (ask more narrowly, lower the thinking level, or raise the cap). Each try is a recorded model call and counts against max_iterations; the session’s log records the try number on each continuation’s request, and cap_tries on the start and each resume; a log written by an older version replays as it was. A reply cut inside a tool call is a different case and is retried as before.

model takes provider:model, a legacy bare id (claude-opus-5, gpt-5.6-sol, an OpenRouter vendor/model), or an @name nickname. run --mock ignores it and --model (the model is mock).

Saving a pick. In the /model picker, Enter switches and saves the model and thinking level for new sessions; Tab switches for this session only. On a model with a thinking control, either key opens a thinking step first, taking the same keys. /model <id> acts like Enter, keeping the thinking level. On the plain prompt, with a terminal on stdin, /model <id> on a model with a thinking control first asks for one of the six levels: Enter keeps the model’s default, a level word sets it, Ctrl-C cancels the switch; piped input asks nothing. /model thinking <level> changes and saves only the current model’s level. The line under the switch reads saved as your default for new sessions, this session only; new sessions start on <model> · <level>, or not saved: <reason> (the switch still holds). Mock and child sessions never save. On a model with no thinking control only the model is saved, and /model thinking saves nothing.

How a new session’s model is chosen. harness run takes the first that applies:

  1. --model. A missing key then stops the harness, naming the variable; a signed-out mega: model stops it with You are not signed in to mega. Type /login.

  2. Whichever of the saved pick (model.json in the state folder) and this file’s model changed last, if it can answer: its key is set or, for mega:, you are signed in.

  3. The other of the two, if it can answer.

  4. If signed in to mega: mega:full.

  5. The first provider below with a key, on its default model. Your own [providers.*], ollama and lmstudio count only in steps 2 and 3 (the last two need no key).

    providerdefault model
    anthropicclaude-opus-5
    openaigpt-5.6-sol
    geminigemini-3.7-flash
    openrouteranthropic/claude-opus-5
    togetherzai-org/GLM-5.3
    deepseekdeepseek-v4-pro
    zaiglm-5.3
    xaigrok-4.7

A blank key variable counts as unset. A skip in step 2 or 3, or a pick in step 5, is explained in a line under the banner (stderr with --prompt). A saved pick whose provider table is gone is skipped; a broken model.json is ignored with a line naming it. To forget a pick, delete model.json or edit model. An unknown provider or nickname in model or --model stops start-up, even if a newer saved pick would win.

With nothing found, run on a terminal opens on model (or anthropic:claude-opus-5) and says No model yet. Type /login for free models, or set your own key. With --prompt, or stdin not a terminal, it prints No model yet. Run harness login for free models, or set your own key. and exits 1. If the only key belongs to a provider with no default model, the warning says to choose one (--model lab:<model> or model = "lab:<model>").

Thinking is --thinking, else the level saved for the chosen model (unless this file’s thinking changed since), else thinking. resume keeps the log’s model unless --model is given; harness eval uses only --model or model.

mode is the starting mode: manual, plan, auto, or a [modes.<name>] table (modes.md). An unknown name stops the harness:

mode = "nope": unknown mode "nope" — expected one of: manual, plan, auto, or a [modes.nope] table (<path>)

The order is --mode, --away (auto), this key, then manual. If the sandbox cannot start, --mode auto exits 2 but mode = "auto" starts in manual and says why. resume keeps the log’s mode; harness eval always starts in manual. Put the key above the first [table] header. Older versions reject it, so remove it before downgrading.

Asked once. With no mode here and the judge’s key set (TYPESAFE_API_KEY, or the variable [providers.typesafe] api_key_env names), harness run with a terminal on stdin, stdout and stderr first asks:

Start in auto by default? The judge (TypeSafe) sees each command and your latest message. [y/N]

y or yes writes mode = "auto", anything else mode = "manual", as one line above the first [table] header (or at the end), through any symlink, touching nothing else; that run uses the answer. -p, resume, --mode and --away never ask. If the line cannot be written safely (the file has several hard links, or changed meanwhile), the harness shows the line to add by hand and asks again next time. This is its only write to this file.

[providers.<name>] — provider ids and endpoints

Every model id is provider:model, such as anthropic:claude-opus-5. Legacy bare ids still work, and always will, for the four original providers: claude-*, gemini-*, gpt-*, and any id with / (OpenRouter).

Ten providers are built in; a key alone reaches each:

providerkey variablebase URLdialectneeds a key
anthropicANTHROPIC_API_KEYhttps://api.anthropic.com/v1Anthropicyes
openaiOPENAI_API_KEYhttps://api.openai.com/v1Responses (default)yes
geminiGEMINI_API_KEYhttps://generativelanguage.googleapis.com/v1betaGeminiyes
openrouterOPENROUTER_API_KEYhttps://openrouter.ai/api/v1Chat Completionsyes
togetherTOGETHER_API_KEYhttps://api.together.ai/v1Chat Completionsyes
deepseekDEEPSEEK_API_KEYhttps://api.deepseek.comChat Completionsyes
zaiZAI_API_KEYhttps://api.z.ai/api/paas/v4Chat Completionsyes
xaiXAI_API_KEYhttps://api.x.ai/v1Chat Completionsyes
ollama(none)http://localhost:11434/v1Chat Completionsno
lmstudio(none)http://127.0.0.1:1234/v1Chat Completionsno

Of the originals, openai accepts base_url (a gateway) and api, openrouter only base_url, and anthropic and gemini neither. A table can move any of the other six to a gateway or rename its key; api_key_env gives ollama or lmstudio one. For a Z.AI key exported as ZHIPU_API_KEY, set [providers.zai] api_key_env = "ZHIPU_API_KEY".

A table with any other [a-z0-9-]+ name adds a provider (examples):

[providers.<name>]
base_url = "https://example.com/v1"   # required outside the built-in list
api_key_env = "MY_KEY_VAR"            # optional; absent sends no Authorization header
api = "chat_completions"              # chat_completions (default) | responses
model = "some/model-id"               # an extra id /model lists; not a switch
models_dev = "fireworks-ai"           # models.dev provider id; false for none

models_dev links a table to a models.dev provider ("fireworks-ai", "wafer.ai") for model descriptions only: search names, context windows, prices, featured rows, the model list. Without it, a table matches the provider of its own name, unless that provider’s API is on another host than base_url (an explicit id skips this check). It applies after the next refresh; [models.context] still wins. Only your own tables accept it.

A table naming a built-in overrides only the fields it sets.

Refusals, at load, naming the table and file:

  1. A bad name: [providers.<name>]: a provider name is lowercase letters, digits and hyphens (<path>).
  2. A new name with no base_url: [providers.<name>]: base_url is required for a provider outside the built-in list (<path>).
  3. A key an original cannot use: [providers.<name>]: base_url is not used by <name> — remove it (<path>), or the same for api.
  4. models_dev on a built-in: [providers.<name>]: models_dev is not used by <name> — its models.dev link ships in the binary; remove it (<path>); on typesafe or a system_one table: … — it answers the judge, not chat; remove it (<path>).
  5. Any other models_dev, true included: [providers.<name>]: models_dev is a models.dev provider id (lowercase letters, digits, '.' and '-', at most 64 bytes) or false (<path>).

An unregistered provider is refused at startup, in /model and in a child spawn, with your own tables’ names added: no provider named "<name>" — add [providers.<name>] to config.toml, or use one of: anthropic, openai, gemini, openrouter, together, deepseek, zai, xai, ollama, lmstudio.

typesafe is a built-in provider for [judge] model only (modes.md); /model never lists it.

[gatekeeper] and a mode’s gatekeep, the old names, are still read.

Behind a TLS-inspecting proxy: SSL_CERT_FILE

The harness trusts only the certificate authorities compiled into it, so an HTTPS-inspecting proxy (a corporate gateway, a sandbox’s egress) fails with transport error. Set the environment variable SSL_CERT_FILE to a PEM file with the proxy’s CA (or your system bundle) to trust it as well, for every provider, the judge and remote MCP servers. Unset or empty changes nothing. An unusable file (unreadable, not a regular file, over 16 MiB, or no usable certificate) stops a session or judge score at start, naming the file.

Free models: mega

With no API key, sign in for two free models: /login in a session, or harness login. It prints a link and a code; open the link on any device and sign in with Google. /login while signed in replaces an expired or revoked sign-in, or else says who is signed in.

ModelWeight against your daily allowance
mega:flash1×
mega:fullabout 2.7×

The allowance is per account per UTC day, set by the server, and shown as a percentage, never in dollars:

  • The status bar (and the line REPL’s per-turn line) ends N% of today. A signed-in start says Signed in as <email> · today N% used, resets 00:00 UTC (no model: the status bar has it), or Signed in as <email> before the first usage is known.
  • At 80%, one notice per session and day; on mega:full it adds that mega:flash lasts about 2.7× longer.
  • At 100% the turn stops: Today's free allowance is used up. It resets at 00:00 UTC, in <h> h <m> m. and To keep going now, use your own key: e.g. set GEMINI_API_KEY, then /model to select the model.
  • /session shows free and the model that answered: mega:full (gemini-4-argon).

The harness never switches you to your own key. Signed in, a new session can start on mega:full (the order); sub-agents use mega:flash unless [agents] model is set.

  • The token is in $CONFIG/.secrets/accounts.json, 0600 in a 0700 folder: never printed, logged, put in a URL or exported, and hidden by the sandbox. If .secrets is a link, nothing is saved and you read as signed out. A token works only with the endpoint that issued it.
  • /logout (or harness logout) revokes the token, then deletes the file.
  • Errors. After 7 days unused, or once revoked: Your mega sign-in expired. Type /login. No answer after retries: mega is unreachable. Your own keys are unaffected. Daily sign-up cap: Today's pilot sign-ups are full. Try again tomorrow, or use your own key.
  • [providers.mega] base_url, its only key, points at a test server: https://…, or http:// on localhost, 127.0.0.1 or ::1.
  • The judge can use this account (modes.md).

The quiet start and /notices

A start prints the splash, the identity line and the hints, plus the questions that must be answered before it (the auto-mode question, harness resume’s cross-project question), a picker resume’s Session {id} loaded, the recorded recovery notices, and the signed-in line (Signed in as <email> · today N% used, resets 00:00 UTC, which quiet_startup still hides). Every other thing a start used to print is a note.

  • The notice line. In the panel, below the splash, one line types the notes one at a time (a typewriter, then a pause) until you send a message or press a key, then clears. Notes about a standing condition you have already seen in this project within 90 days are not typed again; warnings (marked ⚠) are typed at every start while they hold. The plain prompt prints one line after the banner: the first note and (+N more · /notices). -p and stream-json print what they printed before.
  • /notices prints all of them in four sections: Needs your attention, About this start, Privacy, Status (the update, the signed-in facts, and after a /restart what changed). Notes seen at an earlier start say seen. Opening it marks them seen. The status bar shows ⚠ N while warnings hold and M notes until you first open /notices.
  • Seen is kept per project in $STATE/notices-seen.json. Delete the file and every note is new again.
  • The privacy disclosures (the daily usage counts, a judging mode’s reading of your work) are not printed at start; /notices always lists them in full, with their policy links.

[ui] quiet_startup = true (default false) hides what it hid before: the splash and identity block, and the signed-in line. For an update it hides only the arrival note; the status item and /notices stay. The notice line and /notices ignore it. [ui] animations = false (default true) turns off the working spinner (the status row shows the static ⏳, as it does with NO_COLOR, TERM=dumb and a pipe) and the typewriter: the notice line shows each note whole at once.

The spinner. On a colour terminal the status row’s working indicator is three braille cells that turn once every 0.8 s (15 frames a second), red and blue ramped to the theme’s background (truecolor, nearest of 256, or the plain 16-colour reds and blues). When a turn ends normally the three cells close in a short pulse of green and then give way to thought for Ns or ready; an interrupted or failed turn shows no pulse. The plain prompt draws the same frames without colour and no close.

Usage counts while signed in

Signed in to the free models, harness sends one more thing, about once a day: counts of how you used it, tied to your account. It is on by default. Signing in prints a one-line form, and a start that finds you already signed in does not print it: /notices lists the full sentence under Privacy, every start, with the policy link (the quiet start).

harness sends daily usage counts (never prompts, files or commands) · /notices

/notices holds the full sentence, which names the switch:

harness sends daily usage counts while you are signed in: no prompts, files or commands. Turn them off with telemetry = false in config.toml.

telemetry = false or DO_NOT_TRACK (see Updates) turns it off, and so does check_for_updates = false; /logout stops it and deletes its state, $CACHE/usage-summary.json. Days spent signed out are never sent.

When. From an interactive start, in the background, about once a day: each whole UTC day from the day you signed in (or, if you were already signed in when you updated, the day harness first started after the update) up to yesterday, at most the 30 most recent. Today is never sent. A send that fails, or that the server does not acknowledge, is tried again at the next interactive start, at most every 10 minutes, and its days go with it. The counts are read from your session logs, so a day of -p runs is sent at your next interactive start. Days while reports are off are never sent: a start with reports off moves past them, and signing in with reports off sends nothing. The one exception is a day of only -p or other non-interactive runs with reports off and no interactive start in between, which is counted at the next interactive start with reports on.

The counts include sessions on your own API keys while you are signed in, and on the day you sign in, that day’s sessions from before it. The keys themselves, and what you sent through them, never go.

What a day holds, every count a whole number:

CountWhat it is
sessions, resumessessions you started and resumed
turnsturns that ended, by how: completed, interrupted, hit the output cap, failed
continuationstries made after a reply hit the output cap
tokensinput and output, per provider and model
limit hitsmodel calls refused because the free allowance was used up
errorsby kind: rate limit, server, timeout, other
crash recoveriescrashes found when a session was resumed
approvalsasked, allowed, denied and timed out, per mode (manual, plan, auto, custom)
tool callsper built-in tool; MCP calls and skills as one count each; any other name as other
featuressub-agents, side questions, hooks run, /undos, compactions
sandboxsessions started with it on, off, unavailable or external
active minuteswhole minutes inside turns, less time waiting on an approval; not how long a session sat open

The harness version and the platform are counts too: the sessions you started that day on each version and on each platform, so a day with an upgrade shows both versions, not one value a later report overwrites. Sub-agents’ and side questions’ tokens, tool calls and features are counted with the rest; --mock and imported sessions are not counted.

What never leaves your machine with the counts: a prompt, a reply, a file, a path, a command, error text, a session id, the install id, or a name you chose. A provider that is not one of the eleven built in is custom, a model the bundled catalog and the free models’ list do not know is other, and your MCP servers, skills, hooks, agents and custom modes are counted, never named.

The request is POST /v1/telemetry to the mega endpoint with your sign-in token, and the server keeps the counts per account per day. The anonymous daily report above is separate and does not carry your account.

Your sign-ins and upgrades. Beyond the counts, the server keeps these about your account, with no deletion date unless said:

  • each sign-in (your account id and email address, the time, the harness version that signed in) and each sign-out (the time);
  • each sign-in token’s record: a hash of the token (never the token), your account id and email address, when it was made, when it was last used (updated at most once an hour) and whether it was revoked;
  • each step of a browser login, with its time. A login that was never approved or refused leaves only its first step, deleted after 30 days. One that was approved, refused or finished keeps its later steps, which carry when it started, and, from approval on, your account id;
  • each upgrade of your harness, sent when it happens, once you have seen the usage-counts line: the previous and new version, the platform and the time (Updates).

The server writes the sign-in, token and login records itself, as you sign in and out and as the token is used (its last-used time is rewritten at most once an hour), so telemetry = false does not stop them; the upgrades follow the switches. Your upgrades are also recorded under the install id, so the two records of one upgrade can be matched by version and time. The page for the free models is the privacy page.

[models] — favourites, context overrides, nicknames

[models]
favourites = ["claude-opus-5", "together:zai-org/GLM-5.3-Flash"]

[models.context]
"myhost:qwen3-30b" = 131072   # overrides the catalog's context window for this id

[models.nicknames]
executor = "together:zai-org/GLM-5.3-Flash"
reviewer = "deepseek:deepseek-v4-pro"

Each favourites id must route to a provider (checked at load). A context id need not, so it can describe a model the catalog lacks.

A nickname is [a-z0-9-]+ and maps to an id of a registered provider, never another @nickname (checked at load). @name works wherever an id does: --model, model =, /model @name and a helper definition’s model: line. The agent tool’s model argument accepts only nicknames, never a raw id.

An undefined @name is refused in the warning colour. A parent model that spawns on one reads this (the second line only when a helper definition named it):

@reviewer is not defined — add it under [models.nicknames] in $CONFIG/config.toml
(named by $PROJECT/.agents/agents/reviewer.md)
Do not retry; tell the user.

The banner warns once per helper file that names one (<definition path> names @reviewer, which is not defined — add it under [models.nicknames] in <config path>), and /agents marks its row … · model @reviewer (not defined).

The model catalog’s daily refresh: refresh_catalog and HARNESS_MODELS_DEV_URL

[models]
refresh_catalog = false   # never ask models.dev; unset means on

Interactive sessions (under run or resume) refresh the model catalog from models.dev in the background into $CACHE/models-dev.json: at most daily, or sooner when one of your tables is added, removed, or changes its models_dev or base_url host. Nothing waits or prints. It runs only for a hosted provider: key set, API not on this machine (localhost, 127.0.0.1, ::1). It never runs under -p/--prompt, --mock, eval, judge, check, replay, import, export or bundle. refresh_catalog = false stops it; an existing copy is still used. The copy never changes routing.

The environment variable HARNESS_MODELS_DEV_URL reads another address, such as the mirror https://models.opencode.ai/api.json. It must be https://, or http:// on loopback; anything else is ignored. An address nothing answers on disables the refresh.

Providers outside the built-in list

Any OpenAI-compatible endpoint works through [providers.<name>]. These blocks are untested starting points copied from each provider’s docs; check the URL and key variable before relying on one. /model <word>, for a models.dev provider you have not set up, prints a hint headed by your config file’s path: the matching block below for the six keyed ones here, or for any other a block with placeholders for its API URL and key variable (plus models_dev when its table name differs from its models.dev id).

# Fireworks AI
[providers.fireworks]
base_url = "https://api.fireworks.ai/inference/v1"
api_key_env = "FIREWORKS_API_KEY"
models_dev = "fireworks-ai"
# Groq
[providers.groq]
base_url = "https://api.groq.com/openai/v1"
api_key_env = "GROQ_API_KEY"
# Cerebras Inference
[providers.cerebras]
base_url = "https://api.cerebras.ai/v1"
api_key_env = "CEREBRAS_API_KEY"
# Moonshot AI (Kimi) — international endpoint; the fetched page names no separate China host
[providers.moonshot]
base_url = "https://api.moonshot.ai/v1"
api_key_env = "MOONSHOT_API_KEY"
models_dev = "moonshotai"
# Hugging Face Inference Providers — a router over many vendors, not a single host.
# Model ids carry the HF namespace, e.g. openai/gpt-oss-120b, and may take a
# :fastest / :cheapest / :preferred suffix the router resolves server-side.
[providers.hfrouter]
base_url = "https://router.huggingface.co/v1"
api_key_env = "HF_TOKEN"
models_dev = "huggingface"
# Vercel AI Gateway — a router over many vendors; model ids are vendor/model,
# e.g. anthropic/claude-sonnet-4.5.
[providers.vercel]
base_url = "https://ai-gateway.vercel.sh/v1"
api_key_env = "AI_GATEWAY_API_KEY"

A self-hosted server needs no key: omit api_key_env and no Authorization header is sent.

# vLLM (self-hosted) — the docs' own example address; yours may differ.
[providers.vllm]
base_url = "http://localhost:8000/v1"
# llama.cpp's llama-server (self-hosted) — its documented default host:port.
[providers.llamacpp]
base_url = "http://127.0.0.1:8080/v1"

A worked example: three roles, one nickname table

An orchestrator on the session’s model hands work to two helpers on cheaper providers, through nicknames and a skill.

config.toml:

[models.nicknames]
executor = "together:zai-org/GLM-5.3-Flash"
reviewer = "deepseek:deepseek-v4-pro"

.agents/agents/executor.md:

---
name: executor
description: Implements a small, well-specified change.
model: "@executor"
---
Make the change exactly as scoped. Do not expand it. Report back a short summary of what changed.

.agents/agents/reviewer.md:

---
name: reviewer
description: Reviews a diff for correctness against the spec.
model: "@reviewer"
---
Review the change for correctness only. Report PASS or FAIL and why.

.agents/skills/handoff/SKILL.md, loaded when a task fits:

---
name: handoff
description: Carries the orchestrator through a build-then-review handoff between two child agents.
---
1. Spawn `executor` with the scoped task.
2. Spawn `reviewer` with the executor's summary and the original task.
3. Report the reviewer's verdict.

Both definitions pin a model, so the orchestrator’s agent call need not name one (and could name only @executor or @reviewer). In a resumed --mock session, /agents shows each helper, where it was found (this repo) and what its nickname resolves to:

this resumed session is running WITHOUT the approval gate — every tool call, including any skill it loads, runs unprompted
executor — Implements a small, well-specified change. (this repo) · model @executor → together:zai-org/GLM-5.3-Flash
reviewer — Reviews a diff for correctness against the spec. (this repo) · model @reviewer → deepseek:deepseek-v4-pro

Note on Together’s cheapest listed model

The examples use together:zai-org/GLM-5.3-Flash, not the cheaper openai/gpt-oss-20b, which Together does not serve serverless: --model together:openai/gpt-oss-20b can fail. The harness does not filter a provider’s list by serverless availability.

[sandbox] — mode, env_pass and --sandbox

How the sandbox works is in sandbox.md.

[sandbox]
mode = "external"             # "on" | "off" | "external"; absent: on, where the sandbox can start
env_pass = ["GITHUB_TOKEN"]   # exact variable names; used only under "external"

mode is on (commands run sandboxed), off, or external (the machine is the boundary: a VM, container or CI runner; sandbox.md, “External mode”). Absent, it is on where the sandbox can start (sandbox.md, “Requirements”); elsewhere the harness says why and fails closed. enabled = true or false still means on or off. Setting both keys so they disagree is an error:

[sandbox] mode = "external" and enabled = false disagree: set one of them (<path>)

--input-format stream-json on run and resume holds the session open for a program over stdin and stdout (headless.md, “A conversation”).

--sandbox <on|off|external> sets the mode for one session on run, resume and eval, over mode and enabled (a disagreeing file is still refused). eval --target refuses it:

the argument '--target <NAME>' cannot be used with '--sandbox <MODE>'

To move a session to or from external, /quit and resume; a resume after a crash keeps the recorded mode. No environment variable sets the mode.

env_pass names variables that commands keep under external despite the credential rule, such as GITHUB_TOKEN: exact, case-sensitive names, not patterns or NAME=value. It does nothing under on and off. Provider keys are always removed, so listing one is an error in any mode:

[sandbox] env_pass: "GITHUB_*" must be one exact variable name (letters, digits and _), not a pattern or NAME=value (<path>)
[sandbox] env_pass: OPENAI_API_KEY is a provider key and is always removed (<path>)

Where [sandbox] may be set. A repository can pick your config by setting HARNESS_HOME or XDG_CONFIG_HOME (direnv, a devcontainer, CI). A config found that way whose real path is inside the project, or cannot be resolved, has its [sandbox] table refused; the rest loads:

[sandbox] cannot be set from <path>: it was found through HARNESS_HOME inside the project

(or XDG_CONFIG_HOME). Keep such a config outside the project. The default ~/.config/harness, a HARNESS_HOME outside the project and an XDG_CONFIG_HOME of ~/.config are unaffected. The start-up notice and session record name the mode and what set it: --sandbox, the config file, and any HARNESS_HOME or XDG_CONFIG_HOME.

[checkpoints] — pictures before commands, and /undo

How pictures and /undo work is in checkpoints.md.

[checkpoints]
enabled = true            # a session's first state; /checkpoints on|off changes it mid-session
max_file_mib = 100        # a file over this is recorded, never copied
max_store_gib = 5         # the whole store, per project
keep_days = 14            # older pictures are cleared
max_picture_files = 10000 # files one picture copies
max_picture_mib = 500     # bytes one picture copies
max_picture_seconds = 10  # walk, hash and copy together
max_walk_entries = 100000 # entries one walk lists
skip = []                 # gitignore-syntax patterns, rooted at the project

Absent keys take the defaults shown; an unknown key is an error. A project at your home folder or / never takes pictures. A file over max_file_mib shows no copy (over 100 MiB). max_store_gib counts copies, records and temporaries; at the cap the oldest pictures go first. Files past one picture’s budget show no copy (picture budget) and later pictures copy them. A folder past max_walk_entries is “not checked”.

skip adds patterns to .gitignore (or to the built-in list, without git). A pattern that does not parse, or that git reads differently, is an error: a NUL, ***, ** right after leading text in a pattern with a / before its end (a**/b, /build**), or a [:name:] class.

Ranges: max_file_mib 1–102400, max_store_gib 1–1024, keep_days 1–3650, max_picture_files 1–10000000, max_picture_mib 1–102400 and at most max_store_gib (after defaults, so raising one alone can trip it), max_picture_seconds 1–3600, max_walk_entries 1–100000000. Out of range is refused at load:

[checkpoints] max_file_mib must be between 1 and 102400, got 0 (<path>)
[checkpoints] max_picture_mib must not exceed max_store_gib, got 6000 MiB against 5 GiB (<path>)
[checkpoints] skip pattern "[z-a]" is invalid: <reason> (<path>)

[mcp.servers.<name>] — MCP servers

MCP servers add tools: a local program (command, stdio) or a remote endpoint (url, Streamable HTTP). Their tools are called through the mcp tool, ask before every call unless in allow, and show in /mcp. Remote setup: mcp.md.

[mcp.servers.github]
command = ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"]
env = ["GITHUB_PERSONAL_ACCESS_TOKEN"]   # names only; values never enter the log
sandbox = false                          # Docker isolates it; its socket cannot enter the box
allow = ["get_issue", "search_issues"]   # these calls never ask

[mcp.servers.docs]
command = ["npx", "-y", "some-docs-server"]
network = true                           # npx downloads on first start
read = ["~/.nvm"]                        # node lives under the hidden home folder

[mcp.servers.remote-github]
url = "https://api.githubcopilot.com/mcp/"
token_env = "GITHUB_PAT"                 # sent as `Authorization: Bearer <value>`
tools = ["search_issues", "get_issue"]   # only these are offered
KeyTypeDefaultMeaning
commandarray of strings—stdio: program and arguments (exactly one of command, url)
urlstring—http: the endpoint (https, or http to loopback)
envarray of names[]variables passed to the server (not HOME, TMPDIR, PATH)
readarray of paths[]stdio, sandboxed: read-only paths in the box (~ expanded; each must exist)
token_envname—http: the variable holding the token
token_headerstringAuthorizationhttp: the token’s header (Bearer prefix only for Authorization; never a protocol header)
networkboolfalsestdio: the box gets the network
sandboxbooltruestdio: run inside the box
toolsarray of namesallonly these tools are offered
allowarray of names[]these tools never ask
enabledbooltruefalse keeps the table but starts nothing
startup_timeoutseconds30spawn, probe and first listing together
call_timeoutseconds300one call

Timeouts are whole seconds. network, sandbox or read on an http server, token_env on a stdio server, and any unknown key are load errors.

  • Secrets go in env or token_env, which log only the variable’s name. command and url are logged verbatim, so a url with a user name or password (https://user:pass@…) is a load error.
  • env does nothing on an http server; only token_env reaches it.
  • A sandboxed server sees the project read-only, with no home folder, a stripped environment plus env, and no network unless network = true. To write the project it needs sandbox = false, which is recorded and shown.
  • A program under your home folder (~/.nvm, a Python virtualenv) is hidden in the box. The failure line ends · its program is under your home folder: add the folder to read, or set sandbox = false.
  • Only your user config can define servers. A config found through HARNESS_HOME or XDG_CONFIG_HOME inside the project fails: [mcp.servers] cannot be set from <path>: it was found through <VAR> inside the project.
  • A server that fails to start leaves the session running without its tools, with one transcript line (mcp: docs failed to start: timed out after 30 s · /mcp for details); /mcp says why. A sandbox = true server fails the same way when the sandbox is unavailable, never running unboxed.
  • /mcp shows what the session’s start listed, and the calls since. A resumed session does not list again, so a server restarted by a later call keeps its row from the start.
  • --no-mcp, on run and resume, starts no MCP server for that session: the mcp and mcp_schema tools are not offered and the banner reads mcp: off (--no-mcp) when your config defines a server. A resume keeps the mode its log recorded, so the model may still try mcp; the call fails with mcp: MCP servers are off for this run (--no-mcp). The log records the flag.

[[hooks]] — hooks

Hooks are your commands that run before a tool call (PreToolUse) or when you send a prompt (UserPromptSubmit) and can block either. Everything about them, with examples, is in hooks.md.

[[hooks]]
name = "no-force-push"        # required; unique; 1–32 of a-z, 0-9, "-", "_"
event = "PreToolUse"          # "PreToolUse" or "UserPromptSubmit"
tools = ["bash"]              # PreToolUse only; omitted = every tool
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"]   # argv; no shell
timeout = 10                  # whole seconds, 1–600; default 30
sandbox = true                # default true
on_failure = "block"          # "block" (default) or "continue"
KeyTypeDefaultMeaning
namestring—required; unique; 1–32 of a-z, 0-9, -, _
eventstring—required; "PreToolUse" or "UserPromptSubmit"
commandarray of strings—required; the program and arguments, run without a shell
toolsarray of namesevery toolPreToolUse only: the harness tools the hook covers (mcp for an MCP server’s tools)
timeoutwhole seconds301–600; one run, start to finish
sandboxbooltruefalse runs the hook on your machine, with no provider key
on_failure"block" or "continue""block"what a failed hook does: block the call or prompt, or let it through
  • Hooks run in the order listed; the first to block ends the run.
  • Only your user config can define hooks. A config found through HARNESS_HOME or XDG_CONFIG_HOME inside the project is refused for [[hooks]], as for MCP servers.
  • Any other key, including Claude Code’s matcher, is a load error. A hook’s program and files must live outside the project and any folder the agent can write, or the session refuses to start (hooks.md).
  • --no-hooks, on run and resume, runs that session with no hooks. The banner reads hooks: off (--no-hooks) and the log records it; a later resume without the flag runs them again. It is the way out of a hook that is failing closed.

Sub-agents in the background: [agents] background

[agents]
background = false   # default true: the panel runs sub-agents in the background

In the panel, an agent call returns once its children start, and you can keep talking to the main agent. Their rows stay under the transcript; with the agent idle the status line reads 2 sub-agents running · esc stops them.

  • A result is a message. A finished child’s result reaches the main agent as a message, marked by a line such as ↳ explorer finished. It starts a turn if the agent is idle, or goes in after the tool calls already requested if it is busy. A stopped child starts no turn; the next turn reads its result.
  • Away waits. A turn running while you are away (/away) ends only after its last child, taking each result as it arrives. /away also reaches running children: each refuses its questions from then on.
  • Quitting stops the children and waits up to 10 seconds for each to close and record its result; Ctrl-C leaves at once after the first half second. A child still running is closed when the session ends; its own log keeps what it did.
  • Only in the panel, and not while away. -p, eval, the plain prompt and an away parent run an agent call to the end before going on. A session records which way it ran, so replay follows it; a resume restates it unless it is a crash resume.

Updates: the hourly check and harness update

An installed harness (not one built from source) checks for a newer release when you start an interactive session (the panel or the plain prompt, from run or resume) and every hour while it is open. Nothing is inserted into the transcript. The status bar shows one item:

  • 0.29.0 available · /update: a release newer than the one running and the one installed is out.
  • 0.29.0 installed · /restart: a newer one is already installed, here or by another session.

The first time the item shows a version, the notice line types one note for it (once per version per project at a start, once per version per session after that), and /notices lists the update under Status. The plain prompt prints one line, · harness 0.29.0 is out: type /update to install it., once per version. quiet_startup suppresses the arrival note, not the status item or /notices. Both stay off in a source build and with check_for_updates = false.

/update installs the release without leaving the session. It does what harness update does, below, and shows each step as it finishes: the signature, the download (with a bar when the server states the size), the checksum, the new binary running and saying its version. It then says harness 0.29.0 is installed. This session still runs 0.28.0. The panel is busy while it runs: messages queue, Esc cancels it before the swap, and a second /update says An update is already running. If nothing newer exists it says so plainly.

/restart switches the session to the installed binary and keeps the conversation: at the first idle moment (after the turn and the queue; with no sub-agent, side session, compaction, sign-in or update running) it checks <binary> --version, closes the session as /quit does, and starts harness resume on the same session id with the flags this process was started with that a resume does not restore (--mock, --sandbox, --no-hooks, --no-mcp, --max-tokens, --splash). Mode, approvals and model come back from the log. /quit cancels a waiting restart. When it cannot go through:

  • the pre-flight --version fails: ⚠ Cannot restart: <reason>. Nothing changed. and the session goes on;
  • the old session does not close cleanly: no restart, and the usual exit with its report and resume hint;
  • the exec itself fails: the error, the resume hint, and exit 1;
  • the new binary refuses the resume (a config, credential or sandbox error): it prints its own error and exits, and the session is saved with its resume hint.

The new process types Now on harness 0.29.0 (was 0.28.0) and /notices Status holds what changed.

Typing harness update at the prompt is caught when harness is idle: it echoes the line and says it is a terminal command. With a newer release known it offers to run /update instead [Y/n], or says /restart if that version is already installed. With none known it prints Checking for a new version… and makes one read of the site’s latest (Esc or Ctrl-C cancels it), then asks the question, says harness X is up to date., or, if the read failed, That is a terminal command; /update installs updates from here. (Could not check now: …). A source build prints /update’s refusal. Even with check_for_updates = false this one read is made, because you asked for it; it writes no state.

A successful install writes a receipt at $CACHE/install-receipts.json (the path, version and file identity). A session never runs a binary to learn what is installed; it reads the receipt, and counts it only while the file at that path is still the one the receipt describes. An install by install.sh or a package manager writes none, so a session learns of it only from the check (/restart still switches to it).

The hourly check is one plain read of the install site’s /dl/latest: no body, no install id, no token. A failed read is silent. It never runs in -p, harness eval, --mock, harness update or a source build.

harness update reads that release’s signature and checks it against the two release keys built into this harness before it downloads the archive. It then downloads the archive, checks its SHA-256 against the signed one, tries the new binary with --version, and only then replaces the old one. It refuses a binary built from source and a release whose signature is missing or does not check, and never overwrites on a failed step (… ; nothing installed). Once the new binary is in place it sends its upgrade to our server (below). Sessions already running keep the file they started from; /restart (or a quit and harness resume) switches one to the new file.

Signed releases. From 0.26.0 each release’s archives are signed in CI with an OpenSSH Ed25519 signature that covers the version, the platform and the archive’s SHA-256, so a signature cannot be moved to another release. A missing signature stops the update with cannot download …/harness-<platform>.tar.gz.sig (404 Not Found); nothing installed. One that is damaged, made by another key, or made for another version, platform or archive stops it with the signature of harness-x86_64-unknown-linux-musl.tar.gz does not check (<why>); nothing installed. There is no unsigned fallback and no way to change the keys at run time; HARNESS_DOWNLOAD_BASE, which moves the site, can only point harness update at signed releases.

  • Releases before 0.26.0 are not signed and are not back-signed, since harness update only moves up. A 0.24 or 0.25 harness does not check signatures, so its update to 0.26.0 rests on the checksum alone; the first signature a harness checks is the one on the release after the 0.26.0 it runs.
  • install.sh checks the same signature when the machine’s ssh-keygen can verify one (OpenSSH’s -Y verify): always for the newest release, and for a pinned HARNESS_VERSION of 0.26.0 or later. Without a capable ssh-keygen it says so and installs on the checksum. HARNESS_REQUIRE_SIGNATURE=1 makes it install only a release whose signature it checked: it stops instead, and it refuses a pinned version before 0.26.0.
  • The latest pointer itself is not signed: someone who can write the bucket or the site can hold harness update on an older signed release, but cannot make it install an unsigned one. install.sh comes from the site itself, so its check guards against a bucket-only compromise; the installed binary then checks every update.

Two switches, both top-level keys above the first [table] header:

check_for_updates = false   # no check, no report, no notice
telemetry = false           # no reports; the update check stays on
  • check_for_updates = false stops everything: no check, no report, no event, no line, and nothing reads or writes the state file, the install id or the events file, with one exception below. In a source build, -p, harness eval or --mock the same holds.
  • telemetry = false stops all the reports (the daily one below, the install and upgrade events, and the signed-in counts): none is sent and no install id is made. The update check and its line stay on. A DO_NOT_TRACK in the environment that is not empty or 0 counts as telemetry = false.
  • The one exception: signing in or out empties any upgrade copy already waiting for your account in the events file, whatever the switches say, so a copy queued before you turned reports off is never sent later under another account. It does nothing when there is no events file. When there is one, it takes the file’s lock (a file in the state folder, made if missing) and writes the events file only if it holds such a copy. It sends nothing.

harness update is separate: when you run it, it reads the install site (the newest version, the archive, its checksum and its signature); it records the newest version in the state file only while the check is on, and sends its upgrade event only while reports are on. Older versions refuse a file that has check_for_updates or telemetry, so remove them before downgrading.

What the daily report sends. Once a day, at an interactive start, one request to our server (the mega endpoint, POST /v1/update-check), with four fields. The server’s answer names the newest release, which makes that start’s check; when it does not, the start reads the install site.

FieldExampleMeaning
version0.24.0the running version
platformx86_64-unknown-linux-muslthe build’s target: that, aarch64-apple-darwin or x86_64-apple-darwin
install32 hex charactersa random id made on the first report and kept in $DATA/install-id (harness paths)
sessions12interactive starts since the last report we acknowledged

It never sends a prompt, a file name, a project path, a command, an API key, your account id or email, or an Authorization header: signed in to the free models or not, the request is the same. The install id survives updates; delete the file to get a new one. The id travels in the request body, never in the URL.

Install and upgrade events. Two more messages, each sent when the thing happens, not with the daily report. installed is sent when this harness makes its install id. upgraded is sent when harness update has put a new version in place, and at an interactive start that finds a different version than the one it last ran (an install made again with install.sh, or a binary copied by hand; its time is that start, not the copy). The first start of a 0.26.0 over an older harness only notes the version it runs and sends nothing; our server notices that change itself, from the next daily report’s version.

FieldExampleMeaning
install32 hex charactersthe install id, once per message
kindupgradedinstalled or upgraded
from0.26.0the version before; absent on installed
to0.26.1the version now
platformx86_64-unknown-linux-muslthe build’s target
at1761480000the time, in Unix seconds by your computer’s clock

The message is POST /v1/install-events to the mega endpoint, with no token. Events wait in $DATA/install-events.json (harness paths, mode 0600), in two lists of at most 32, one for the install id and one for your account (below); a 33rd drops the oldest. A list that holds events is sent at every interactive start with reports on, and by harness update after it has put a release in place, whether or not the daily report is due. The server’s acceptance, or its refusal of a malformed message, removes what was sent; any other failure keeps it for the next try. An event older than 30 days is dropped unsent. The same switches as the daily report stop all of it (above): with reports off nothing is made, sent, read or written, apart from the one exception there, and events already waiting stay in the file until reports are back on. The next upgrade after that is recorded from the version last noted.

If you are signed in to the free models and have seen the usage-counts line, each upgrade is also sent under your account: POST /v1/account-events, with your sign-in token and no install id. Signing out and signing in clear any copy still waiting, so one never goes out under another account. This is where the install id and your account meet: not in one request, but in two records of the same upgrade that can be matched by their versions and time.

Who sees what.

WhoSeesKeeps
our serverthe four fields or an install or upgrade event, and your IP address as the connection’sdaily and monthly counters with no id and no IP (checks, sessions, versions, platforms, installs); per install: one record of its current state (the install id, first and last day seen, version, platform), deleted about 400 days after the last day seen; one record for each day it reported and each version it reported that day, and one for each install and each version change (the version before, when there is one, the version now, platform, time to the second), each deleted about 400 days after its own day or time
our server, when you are signed in and have seen the usage-counts linean upgrade event with your sign-in token, and your IP addressa record of each upgrade under your account id (from, to, platform, time), with no deletion date
our server’s own request logtime, route, status, duration, and on a signed-in route your account idin Google Cloud Logging, under its retention
Google Cloud Run’s request logIP, user agent, path, timein Google Cloud Logging, under its retention
the install site’s host, Vercel, and its storage, AWS: the hourly check, a start with no report due, and a start whose report did not name the newest releaseIP, user agent, timeunder their own retention, which we have not confirmed

Both logs on our side go to the project’s Cloud Logging _Default bucket, whose retention is 30 days by default, configurable by the project’s owner (Google’s Cloud Logging quotas page, docs.cloud.google.com/logging/quotas, read 2026-10-23). The page does not name Cloud Run, and a project can change the bucket or its routing, so treat 30 days as the default, not a promise. Our server’s request log never holds a request body, so the install id is not in it. Our code stores no IP address, but for those 30 days Google’s request log keeps each request’s time and IP, so a record can be tied to the address that sent it; the daily report’s install record has allowed that since 0.24.0. The one longer log is our server’s per-call usage lines for the free models and the judge (an account id, the model, token counts, the cost and the time), which are also kept 90 days in a log bucket of their own. harness update reads the site’s /dl/latest, the release’s checksum and signature, and the archive directly: Vercel and AWS see it as they see an install.

Users on their own API keys reach our server too, once a day, with those four fields, and on an install or an upgrade with one event more, and the install site every hour. No key or prompt goes with any of them. Set telemetry = false to stop the report and keep the check, or check_for_updates = false to stop both. The page for the free models is the privacy page.

Everything else config.toml accepts

tablekeyswhat it’s for
[ui]theme, animations (false: a static ⏳ instead of the spinner), quiet_startup (the quiet start), title, colors, truecolor, probe, index_all, editor, splash, tool_icon (default ●), tool_output_lines (default 8, max 100), thinking ("full" default, "preview", "off"; /thinking changes it for a session; the old name reasoning is still read, and a table with both is refused)display only
[modes.<name>]tools, ask, judge, plan_file, postureoverrides a built-in mode field by field, or adds one (modes.md)
[judge]model, cutoff (default 0.2, 0.0–1.0), away_cutoff (default 0.2, 0.0–1.0)the assessor for judged bash/exec calls; the cutoff at which a command asks; the one used while you are away or after a task’s breaker trips (the same by default; lower asks more) (modes.md)
[protected]add, removeedits the built-in protected-path list; writes there always ask (modes.md)
[sandbox]mode ("on", "off", "external"), enabled, writable, hide, env_hide, env_pass, escape ("ask" default, or "never")command confinement; user config only (above, sandbox.md)
[away]timeout (default "15m", "off", or Ns/Nm/Nh up to 24h)how long an approval question waits before it is refused, where /away can start (not manual); user config only (sandbox.md). Under --input-format stream-json it is also the ceiling for an unanswered approval_request, in every mode, with the same consequences as a timed-out question in the panel (headless.md)
[skills]extra_roots (up to 16, each ~/… or absolute)extra folders of <name>/SKILL.md, searched after the project’s, ~/.agents/skills and the built-ins, which win a name clash; /skills marks them “extra folder N”; a session keeps its starting roots
[agents]max_concurrent (default 4, min 1), model (provider:model or @nickname, checked like --model; refused with nothing after the :), thinking (any --thinking value), background (default true)how many children run at once; a child’s model and thinking when its definition and the spawn call name none, before the parent’s (mega:flash under a signed-in mega parent). No default thinking. background = false makes the panel wait for each agent call (above)
[compaction]trigger_percent (default 80, 50–95)context percentage that triggers compaction
[resume]replay_rows (default 2000; 0 replays nothing)terminal rows a resume draws into the scrollback first: the newest whole turns that fit, with a ⋯ N earlier turns line naming harness session <id> --transcript. Terminal only, whatever quiet_startup says
[[hooks]]name, event, tools, command, timeout, sandbox, on_failurecommands that run before a tool call or a prompt and can block it; user config only (above, hooks.md)
[script]enabledturns the script tool on or off in every built-in mode (absent: each mode’s default, off in all); [modes.<name>].tools wins
[targets.<name>]command, cwd, env, timeout_secs (default 120, max 86400)an agent under test, for eval --target

Recipe: the old auto

To make auto ask before every bash and exec call, like manual, turn off its judge and add both to ask. Adding skill as well makes it ask before loading a skill, which auto otherwise does without asking:

[modes.auto]
judge = []
ask = ["bash", "exec", "skill"]

To keep auto as it is but have it ask before a skill loads, as it did before 0.24.0:

[modes.auto]
ask = ["skill"]

Nothing else about auto changes. A session keeps the mode its log recorded, so a session resumed from before 0.24.0 still asks.

harness -p: one prompt, one turn, then exit

-p runs one turn for a script, a pipe or another agent, and exits with a code the caller can trust.

Flags

  • -p [TEXT], also --prompt or --print. With no value, the prompt is stdin.
  • Works on the root and on run, with every run flag.
  • --output-format text|json|stream-json, default text.
  • --verbose: the live transcript on stderr, even if stderr is not a terminal.
  • Exit 2: --verbose without -p; --output-format without -p or --input-format stream-json; --output-format with -p --mock (a mock run already prints JSON; a conversation takes stream-json beside --mock); an empty prompt, after stdin is joined.
  • For a session a program holds open — many turns, gates it answers — see a conversation below.

Stdin

  • Read only if stdin is not a terminal.
  • cat log | harness -p "why does this fail?": the message is stdin, a blank line, then the value. With a value, -p waits up to 3 s for stdin’s first byte or end of input, then runs without stdin and says so on stderr. Redirect from /dev/null to skip the wait.
  • git diff | harness -p: stdin is the prompt, with no time limit. No value and stdin a terminal: exit 2.
  • Over 8 MiB, not UTF-8, or unreadable (a directory, a device error): exit 2, before a session is made. Unreadable prints harness: could not read stdin: <error>. A closed stdin (<&-) is no input.

Text output

stdoutstdout getsstderr gets
a terminalthe live transcriptnotices and errors
a pipe or filethe answer and a newline, if the turn answerednotices and errors; the transcript too if stderr is a terminal or with --verbose

A turn answered if the session ended cleanly with outcome completed or tool_failed; the answer is the model’s last response text. When a reply hit the output cap and the harness continued it, the answer is the text of every try in that last streak, joined in order with no separator. Calls that failed on the way are then named on stderr: harness: 2 tool calls failed in this turn: read_file, bash (approval refusals have their own line). Otherwise stdout is empty and stderr says why: the session’s failure, else the words of a blocked prompt, else the model’s error (a 429, a bad key), else a “did not complete” line. Trust stdout exactly when the exit code is 0:

out=$(harness -p "summarise CHANGELOG.md" 2>log) || { cat log; exit 1; }

The output cap

A reply that stops at the output cap with no tool call is continued, as in an interactive session (config.md): up to 3 tries in all, so a -p run can make two more model calls than it used to, each counted in model_calls and usage. The third try runs one thinking level lower when the second was all thinking. The ↻ The reply hit the output limit. Continuing (2/3). line goes with the transcript, so it shows on a terminal and with --verbose, and not on a piped run with a quiet stderr. A turn still cut after the third try ends truncated (exit 1, no answer). In stream-json each try is its own reasoning and assistant records; result holds the joined answer. harness eval continues the same way: a case that used to end truncated can now finish, and output_contains reads the turn’s answer, the joined text, not only the last reply.

Approvals

  • Somebody can answer if stdin is a terminal, the prompt is not from stdin, and the transcript is on a terminal. Gated calls then ask, as in harness run.
  • Nobody can answer otherwise. A gated call is denied; the model gets “I’m not at a terminal to approve this, so it did not run. Don’t retry it; finish with what you can and say what you could not do.” as its result, and the turn goes on. Stderr gets harness: refused <tool> (needs approval, nobody to ask): <summary>.
  • --approve-all and --away work as in harness run. An --away refusal is a failed call: the answer prints and stderr names the call.

Hooks

-p runs the hooks in your user config (hooks.md); --no-hooks turns them off.

  • The banner’s hooks: … line and the root session’s hook warnings go to stderr (a sub-agent’s hook warnings show on the panel only). A warning reads harness: where the panel’s has its warning sign: harness: hook no-force-push is failing (timed out after 10 s), so the calls it covers are blocked. Fix it, set on_failure = "continue", or restart with --no-hooks. A conversation (--input-format stream-json) prints them the same way on run and resume, a sub-agent’s included.
  • A call a hook blocks is a failed call: the model reads why, the turn goes on, and stderr names it with the other failed calls. It is not in denied, which lists calls that needed approval. In stream-json it is a tool_result with status failed; there is no separate hook record.
  • A prompt a hook blocks never reaches the model: exit 1, outcome prompt_blocked, is_error true. Text output prints nothing on stdout and harness: prompt blocked by hook "keywords": <reason> on stderr, or for a hook that failed harness: prompt blocked: hook "keywords" failed (timed out after 5 s). error in the JSON formats is the same text without harness: .

Exit codes

CodeMeaning
0the turn answered, even if a tool call failed (outcome tells which in JSON)
1no answer (model_failed: a model error, or two replies in a row with no text and no tool call; max_iterations; interrupted; truncated; prompt_blocked: a prompt hook blocked the prompt), or a failure after start (a missing key, a failed session, an unwritable stdout)
2a refusal or argument error (bad flags, a refused stdin, an empty prompt)

Ctrl-C and SIGTERM interrupt the turn at its next checkpoint; the turn ends interrupted (or model_failed with error: "interrupted: " when the signal lands on the model call), exit 1.

A failed write to a piped or redirected stdout (a full disk, a closed pipe) fails the run in every format: stderr gets harness: could not write to stdout: <error>, exit 1. In the JSON formats a failed turn’s error precedes that line on stderr. stream-json writes nothing after the failed line, then exits 1. Not covered: a terminal that closes mid-run.

--output-format json

One object on stdout when the run ends, nothing else. Stderr gets notices, errors, the transcript with --verbose, and failed calls as in text output (stream-json likewise). After the session starts, a failure goes in error, not on stderr. Neither JSON format runs the background-colour probe ([ui] probe).

{"type":"result","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","outcome":"tool_failed","is_error":false,"result":"I could not run the tests: running commands needs approval here.","error":null,"model":"openai:gpt-5.6-sol","duration_ms":4210,"model_calls":2,"usage":{"input_tokens":3100,"output_tokens":700,"cached_input_tokens":null,"reasoning_tokens":210},"denied":[{"tool":"bash","summary":"cargo test"}]}
FieldMeaning
type, v"result"; the format version, 1
session_idthe session; its log is <session_id>.jsonl in the sessions folder
outcomecompleted, tool_failed, model_failed, max_iterations, interrupted, truncated or prompt_blocked; null if the turn never ended
is_errorfalse exactly when the turn answered
resultthe answer; null if is_error
errorset exactly when is_error: the session’s failure, else a blocked prompt’s words, else the model’s error, else the “did not complete” line
modelthe session’s model
duration_msturn start to end; 0 if it never ended
model_callsthe turn’s model responses
usagethe turn’s token totals; cached_input_tokens null if no reply gave a cache count, reasoning_tokens 0 if none did
deniedcalls in the session that needed approval and did not run (nobody to ask, denied at a question, refused under --away), each with its approval summary’s first line; includes calls inside a script; a child agent’s stay in its own log

A failure before the session exists (bad flags, a missing key, a refused stdin) goes to stderr only, with its usual exit code. After that, exactly one result object prints, even if the stream closes early or the session fails after the turn.

--output-format stream-json

One complete JSON record per line, flushed as the session writes it, never a streaming delta. The last line is the result object. Reasoning is always included, whatever [ui] thinking says.

{"type":"init","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","model":"openai:gpt-5.6-sol","mode":"manual","cwd":"/home/me/project","harness_version":"0.12.0"}
{"type":"user","text":"run the tests"}
{"type":"reasoning","text":"**Running the tests**\n\nI'll start with cargo test."}
{"type":"tool_use","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","input":{"command":"cargo test"}}
{"type":"approval","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","decision":"denied","reason":"I'm not at a terminal to approve this, so it did not run. Don't retry it; finish with what you can and say what you could not do."}
{"type":"tool_result","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","status":"failed","error":"denied by approval gate: cargo test. The user said: I'm not at a terminal to approve this, so it did not run. Don't retry it; finish with what you can and say what you could not do."}
{"type":"assistant","text":"I could not run the tests: running commands needs approval here."}
{"type":"result","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","outcome":"tool_failed","is_error":false,"result":"I could not run the tests: running commands needs approval here.","error":null,"model":"openai:gpt-5.6-sol","duration_ms":4210,"model_calls":2,"usage":{"input_tokens":3100,"output_tokens":700,"cached_input_tokens":null,"reasoning_tokens":210},"denied":[{"tool":"bash","summary":"cargo test"}]}
typeFields
initv, session_id, model, mode, cwd, harness_version; in a conversation also commands, resumed and, when the session has client tools, client_tools (their names, in order)
usertext, kind (message, or steer for a steer that landed)
reasoningtext: the reasoning blocks, joined by a blank line; omitted if empty
assistanttext; omitted if empty
tool_useid, tool, input
tool_resultid, tool, status (ok or failed), output or error
approvalid, tool, decision, reason (when given)
approval_requesta conversation only: id, tool_use_id, tool, kind, reason, escape, summary, input, choices, turn_grant_skips_kind; a sub-agent’s also session, agent
responsea conversation only: id, command, ok, then disposition or data, or error (code, message)
compactedtrigger (automatic, manual; null in an old log), pre_tokens (the last turn reply’s whole prompt before it; null under -p)
resultas --output-format json, plus turn_id and prompt_id; in a conversation also cost
  • id pairs a tool_result or approval with its tool_use; it is never null. A call inside a script has no tool_use line: its approval (a conversation only) carries the empty id its approval_request’s tool_use_id did. A sub-agent’s tool_use lines are not printed: its approval carries the tool_use_id of its approval_request.
  • decision: allowed_once, allowed_for_turn, allowed_for_session (old logs only), denied, denied_and_stopped, timed_out, refused (under --away).
  • A call cut off by the reply’s output cap is never requested, so has no line; the model sees the failure and retries.
  • Every line is one JSON object with U+2028 and U+2029 written as \u2028/\u2029, so a reader that splits on Unicode line separators (Python’s str.splitlines(), Node’s readline) still sees one line per object.
  • Not projected: a child agent’s records (its agent call’s tool_use and tool_result carry its work), or a script’s calls and their approvals (denied still lists them).

The contract: types and fields are only ever added; consumers ignore unknown ones; removing or redefining one bumps v. The same holds for a conversation’s commands: fields are only added, and a command type is never removed.

A conversation: --input-format stream-json

harness run --input-format stream-json holds a session open for a program: stdin takes one JSON command per line, stdout prints the stream-json lines above, every gate as an approval_request, and one result per turn. harness resume <id> --input-format stream-json continues a session the same way. Stdin must be a pipe; -p and --verbose cannot be combined with it, and --output-format other than stream-json is refused (exit 2 each; stdout is already the whole transcript). --mock <file> is accepted (below). Every other run flag keeps its meaning: --approve-all means no approval_request is ever written; --away puts the first task away (its gates are refused, get_state.away is true), and away being one task’s, the next prompt’s gates ask.

The first line on stdout is init, with commands (what this build takes) and resumed. Then, for every command, exactly one response — written before anything the command causes:

{"type":"response","id":"fix-1","command":"prompt","ok":true,"disposition":"started"}
{"type":"response","id":"s9","command":"steer","ok":false,"error":{"code":"no_turn","message":"no turn is running; send prompt"}}
commandfieldswhat happensdisposition
prompttextstarts a turn; during a turn, or a compaction sent while idle, it waits and is the next turn, never a steerstarted, queued
steertextjoins the running turn at its next landing point, as a message typed mid-turn does; shown as user with kind: "steer", or dropped if the turn ends firstaccepted
abortturn_id or prompt_id (optional, not both; null is absent)interrupts the running turn at its next checkpoint; with a turn_id that is not the running turn’s, nothing; with a prompt_id, takes that prompt back from the queue, or interrupts the turn it opened; a prompt started and its turn not yet begun is interrupted at once, ahead of any command sent after the abortinterrupting, dequeued, idle, stale
approvalid, decision, reason (optional)answers the approval_request with that id; decision is one of its choicesanswered
compact—compacts the context; during a turn, after it; a quit cuts one sent while idledone, with data.compacted (trigger, pre_tokens, as on its compacted line, which comes first) when this attempt wrote one, and no data when it declined; queued; error quitting when the session ended first. A queued compaction’s compacted line is never another compact’s data
get_state—data: session_id, model, thinking, mode, away, turn ("idle" or {id, prompt_id}), open_requests, queued, context (last_prompt_tokens, window, percent, auto_compact_at_percent; the first three null until known)—
awayon/away or its end, for the running task and every running sub-agent, or the next task; get_state.away says what was set, though the log records it at the next gate or the next task’s startset
client_tool_resultid, content, is_erroranswers a client_tool_call (below)answered
quit—ends the running turn, then the sessionquitting

id is yours, echoed on the response and, for a prompt, on its turn’s result as prompt_id; null is no id, and one that is not a string is refused (bad_argument) and not echoed. Errors never end the session: parse (not a JSON object; the command is parse), unknown_command, bad_argument (the message names the field), no_turn, no_such_request, unknown_call, quitting (any command after quit), too_long (a line over 8 MiB, skipped whole), refused (away {"on": true} where the panel refuses /away; the message is the panel’s line).

Approvals. Every gate the panel would ask about — the root’s and a sub-agent’s — is written as an approval_request: id (what approval names; the approval.requested.v1 record’s own), tool_use_id (its tool_use line; empty for a call inside a script), tool, kind (gate, protected, removal, decode_and_run, credential, escape, mcp, client_tool, judge_flag, judge_unavailable), reason, escape, summary, input (the call’s arguments), choices (allow_once, allow_for_turn, deny, deny_and_stop; a full escape, an mcp call and a client_tool call offer no allow_for_turn, and answering one with it is bad_argument) and turn_grant_skips_kind (allow_for_turn then covers the turn’s other calls of the tool, never another call of this kind). A sub-agent’s carries session and agent too; answer it the same way. The answer’s reason reaches the model as The user said: …, and the decision prints as today’s approval line — a sub-agent’s with the same session and agent.

kind follows the first that applies: the gate’s own reason (protected, removal, decode_and_run, credential, or escape for a full escape); the judge (judge_flag, judge_unavailable), whatever the call asks to escape to; an escape the judge did not decide (escape, with escape.kind network or outside); mcp; client_tool; else gate.

A full escape (outside_sandbox: true) is never asked of a program by default: a conversation runs under escape = "network_only" as --approve-all does, so the sandbox block tells the model no full escape is on offer, network: true is still offered (asked in manual, judged in auto), and the program on the other end — which may be a model with no person behind it — can never let a command out of the sandbox. --allow-escapes (on run and resume, with --input-format stream-json only) restores escape = "ask" for a client whose requests a person answers; a full escape then arrives as kind escape with escape.kind outside and no allow_for_turn. It is refused with --approve-all, which answers nothing. A crash resume keeps the rule its log recorded: a program resuming a session recorded as ask must pass --allow-escapes, and the flag on a session recorded as network_only is refused rather than ignored (exit 2, one line each). An MCP call’s once-only approval is unchanged.

An approval_request nobody answers is timed out at [away] timeout (default 15 min; off waits for ever; a session started with --options takes approval_timeout instead) with the panel’s consequences. Where /away may start (approvals required, the sandbox on or external, and the mode’s ask holding neither bash nor exec), the task goes away: the call is refused, the turn goes on, every later gate of that task is refused with no request, and away ends with the turn. Elsewhere the turn ends interrupted. away {"on": true} puts the running or the next task away under the same rule, else it is answered refused. {"on": false} ends an away task.

Turns. One result per turn, the same object as above for that turn alone, plus turn_id, prompt_id and cost; nothing else ends a turn. is_error says whether the turn answered. cost prices the turn’s model calls as harness session prices a session: usd (the provider’s own figure where it reports one, else the catalog’s list price; null when nothing could be priced), priced, unpriced and free (calls to mega) counts, bound ("upper" when every call was priced and none from the catalog past 200K input tokens, else null) and basis (reported, list_price, mixed, or null). The root session’s calls since the last result: a compaction inside the turn counts, and so does one between turns, in the next turn’s cost.

Ending. Closing stdin means “no more input”: the running turn finishes, the prompts still queued run in order, each with its result, and the session ends, exit 0. A gate opened after that has nobody to answer it, so it is refused at once with -p’s reason (the approval line carries it, decision: "denied") and the turn goes on. quit and the first Ctrl-C (or SIGTERM) are the stop: the running turn is interrupted and prints its result (outcome: "interrupted", or model_failed with error: "interrupted: " when the interrupt lands on the model call), the queued prompts are dropped, the session ends, exit 0. A request still open is closed by that interrupt as the panel’s Esc closes one: recorded as a denial with no reason and printed as approval with decision: "denied", before the result (abort does the same to the turn’s open request). A second Ctrl-C or SIGTERM exits at once, exit 1, without the result. Exit 1 also means a failure after the start (an unwritable stdout, a failed session); exit 2 a refused flag.

# one turn, from a shell: closing stdin lets it finish
printf '%s\n' '{"type":"prompt","id":"1","text":"list the tests in tests/"}' \
  | harness run --input-format stream-json --approve-all | jq -c 'select(.type=="result") | {prompt_id, outcome, result, cost}'

# two turns, the second steered
{
  printf '%s\n' '{"type":"prompt","id":"1","text":"list the tests in tests/"}'
  sleep 20
  printf '%s\n' '{"type":"prompt","id":"2","text":"run them"}'
  sleep 2
  printf '%s\n' '{"type":"steer","text":"use pytest -x"}'
} | harness run --input-format stream-json --approve-all | jq -c 'select(.type=="result") | {prompt_id, outcome, result}'

The whole wire, both directions, is written down in protocol/stream-json.schema.json (JSON Schema 2020-12; $defs/command is what a program sends, $defs/line what the harness prints). The code’s lists (COMMANDS, LINE_TYPES, ERROR_CODES in crates/cli/src/headless.rs), the schema and the conformance transcripts in sdk/conformance/ are held equal by a test, so a line added without its schema entry fails the build.

A client that answers gates reads each approval_request and writes {"type":"approval","id":…,"decision":…}; jq -c --unbuffered on stdout and a coprocess on stdin are enough. A complete one in Python, with a scenario per feature group run against a real model, is examples/stream-json-client.

Client tools

A session started with --options can carry tools the program runs. When one is ready to run (after its gate), the harness prints client_tool_call between the call’s tool_use and tool_result lines:

{"type":"client_tool_call","id":"7EXJS7R3G160QVGQFXGZVM875N","name":"search_trains","input":{"to":"Leeds","day":"2026-11-06"},"turn_id":"01M4…"}
{"type":"client_tool_result","id":"7EXJS7R3G160QVGQFXGZVM875N","content":[{"type":"text","text":"3 trains: 07:40, 09:10, 11:15"}],"is_error":false}
{"type":"client_tool_cancel","id":"7EXJS7R3G160QVGQFXGZVM875N","reason":"timed_out"}
  • id is the call’s tool_use id (and its approval_request’s tool_use_id). Answer once with client_tool_result, a command like any other (one response).
  • content is a list of {"type":"text","text":…} blocks and at most one {"type":"image","media_type":…,"data":<base64>} block; a plain string is one text block. is_error: true is the result the model reads as a failed call. An image with is_error, two images, or an invalid image is bad_argument and leaves the call open (answer it again, as an error). A late, repeated or settled id is unknown_call.
  • client_tool_cancel is printed when the harness stops waiting, always before the call’s tool_result and the turn’s result: reason is interrupted (an abort), quitting (quit, the first SIGINT or SIGTERM), timed_out (the tool’s timeout) or input_closed (stdin ended with the call open). Cancelling does not undo what the program already did.
  • With no program to answer (stdin closed, a panel or REPL resume) a call ends at once with the error “no program is attached to run <name>”; if writing stdout failed, “the program’s connection failed”; past its timeout, “timed out after <t>”.
  • A tool with ask: true, and every client tool in plan mode, first sends an approval_request of kind client_tool with the once-only choices allow_once, deny, deny_and_stop. A client tool is never judged and never covered by a grant. --approve-all skips the gate.
  • A result line may be at most 8 MiB, as every line; a sender keeps its image and text under that.

Options: --options <file>

run --options <path> and resume <id> --options <path> read one JSON object (at most 1 MiB) and start the session the way an SDK does (sdk.md): isolated from the machine’s own setup unless the file turns a read back on. Only with --input-format stream-json; elsewhere it is exit 2. An unknown field, a wrong type (JSON null included), an invalid value, or a field that repeats a flag given on the same command line is exit 2 with one line naming the field, before anything is written.

Fields: model, thinking, mode (manual, auto, plan; default manual), sandbox (on, off, external; default on), builtin_tools (the ceiling for the session’s life; [] offers none; to call an MCP server mcp must be in it), client_tools (definitions: name, description, input_schema, ask, timeout; at most 128), system_prompt ({"replace": …} or {"append": …}), project_instructions, user_config, skills, agents, hooks (each default false; hooks needs user_config), mcp_servers (the [mcp.servers] table’s shape), approval_timeout (default "15m", at most "24h", or "off"), allow_escapes and max_iterations. What each switch suppresses is in sdk.md.

On a resume of an options session, the mode, builtin_tools, system_prompt, the isolation switches and sandbox come from the log (giving one, or --sandbox, is refused); client_tools must equal the log’s, and is required when the log has any; the MCP servers come from the log, and mcp_servers, if given, must equal them, every one unchanged and no other (the SDK never gives it); model, thinking, max_iterations, approval_timeout and allow_escapes may be given. resume --input-format stream-json needs a session id.

--mock in a conversation

run --mock <file> --input-format stream-json (and resume) is a live conversation whose model is the mock file: no key, no network, the mode, sandbox, options and client tools real. --output-format stream-json beside it is accepted. A sub-agent call ends with the error “sub-agents are not available in a mock conversation”. A resumed segment answers from the start of the file it is given. Without --input-format, run --mock is the ungated event dump as before.

The agent SDK

megacode-agent-sdk starts a harness session from your program, hands the model tools you wrote, and streams back what it does. Use it to build an agent that is not a coding agent: a text-message assistant, a support bot, a nightly job. One session is one harness process; the SDK speaks the conversation over its pipes and hides the lines.

Rust shipped in 0.30.0; Python and TypeScript ship in 0.31.0, with the same shapes under the same name. Examples below show Rust first, then Python and TypeScript. Python names are snake_case and its calls are awaited in asyncio; TypeScript names are camelCase and its calls return promises. Where a section shows one language, the others work the same way unless it says otherwise.

An errand

Pip is a text-message assistant. Dana texts “book me a train to Leeds on Friday”. Pip’s server starts one session for the errand, gives the model two tools written in the program (search_trains, and pay, which asks first), and puts the model’s question to Dana as a text. The model never sees a shell, a file or anything else of the machine.

#![allow(unused)]
fn main() {
use megacode_agent_sdk::{Client, Decision, Options, Tool, ToolContext, ToolOutput};
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct Search { to: String, day: String }

let search = Tool::new(
    "search_trains",
    "Find trains to a destination on a day.",
    json!({"type": "object",
           "properties": {"to": {"type": "string"}, "day": {"type": "string"}},
           "required": ["to", "day"]}),
    |args: Search, _ctx: ToolContext| async move {
        Ok::<_, String>(ToolOutput::text(format!("{} to {}: 9:10 for 42 pounds", args.day, args.to)))
    },
);

let options = Options::plain("You are Pip, a personal assistant that runs errands for Dana. Be brief.")
    .tool(search)
    .on_approval(|request| async move {
        // Text Dana here, wait for her reply, and answer.
        Decision::AllowOnce
    });

let client = Client::start(options).await?;
let mut turn = client.send("book me a train to Leeds on Friday").await?;
while let Some(event) = turn.next().await {
    println!("{:?}", event?);
}
client.close().await;
}
import asyncio
from megacode_agent_sdk import AllowOnce, Client, Options, tool

@tool(
    "search_trains",
    "Find trains to a destination on a day.",
    {"type": "object",
     "properties": {"to": {"type": "string"}, "day": {"type": "string"}},
     "required": ["to", "day"]},
)
async def search(args, ctx):
    return f"{args['day']} to {args['to']}: 9:10 for 42 pounds"

async def approve(request):
    # Text Dana here, wait for her reply, and answer.
    return AllowOnce()

async def main():
    options = Options.plain(
        "You are Pip, a personal assistant that runs errands for Dana. Be brief.",
        tools=[search], on_approval=approve,
    )
    async with await Client.start(options) as client:
        turn = await client.send("book me a train to Leeds on Friday")
        async for event in turn:
            print(event)

asyncio.run(main())
import { Client, plain, type Tool } from "megacode-agent-sdk";

const search: Tool = {
  name: "search_trains",
  description: "Find trains to a destination on a day.",
  inputSchema: {
    type: "object",
    properties: { to: { type: "string" }, day: { type: "string" } },
    required: ["to", "day"],
  },
  handler: async (args) => {
    const { to, day } = args as { to: string; day: string };
    return `${day} to ${to}: 9:10 for 42 pounds`;
  },
};

const options = plain("You are Pip, a personal assistant that runs errands for Dana. Be brief.", {
  tools: [search],
  // Text Dana here, wait for her reply, and answer.
  onApproval: async () => ({ kind: "allow_once" }),
});

const client = await Client.start(options);
try {
  const turn = await client.send("book me a train to Leeds on Friday");
  for await (const event of turn) console.log(event);
} finally {
  await client.close();
}

The whole Rust errand, runnable with no key, is sdk/rust/examples/pip.rs with its mock file pip.mock.toml. The Python and TypeScript examples are sdk/python/examples/ticket_fix.py and sdk/typescript/examples/minimal.ts, each with a mock file beside it. Harness takes the nearest folder at or above cwd that holds a .git as the project, so run an example in a folder outside any repository, or give it a cwd that is a repository of its own.

Install

cargo add megacode-agent-sdk

The crate is megacode-agent-sdk on crates.io; its API reference is on docs.rs.

pip install megacode-agent-sdk
npm install megacode-agent-sdk

megacode-agent-sdk on PyPI needs Python 3.10 or newer, ships as a wheel only (no source build), and uses the standard library alone. A python.org Python on macOS needs its Install Certificates.command run once before the download’s https works. megacode-agent-sdk on npm needs Node 20 or newer and is an ES module (import, not require); it has no runtime dependencies.

Each package needs a harness binary of the same major and minor version. It finds one in this order: the binary option; the MEGACODE_AGENT_SDK_BINARY environment variable; a verified copy in its cache; a download. See Getting the binary.

Two ways in

query(prompt, options) sends one prompt and yields the session’s events up to that turn’s result, then ends the session. Client::start(options) keeps the session open: send(prompt) returns one turn’s event stream, and steer, abort, compact, away, state and respond work on a shared, cloneable handle while a stream is read. Client::resume(session_id, options) continues a stored session.

A turn belongs to the session from send until its result, even if you stop reading the stream: a second send before then fails with TurnInProgress. abort() names that turn’s prompt: a running turn is interrupted (Interrupting); a prompt still queued (behind a compaction) is taken back (Dequeued), never runs, and its stream ends with no result, so the next send may follow. The stream starts at the prompt’s user line. Events that arrive between turns, or in a turn the prompt did not open (one a background agent’s result opens), go to session_events(), which you may take once. If you leave more than 10,000 events of a turn unread, the session ends with SlowConsumer.

close() sends quit, gives the process 5 s to exit, then SIGTERM, then SIGKILL, and returns within about 15 s. Dropping the client is a last-resort kill, not a clean end.

In Python the names are Client.start(options), Client.resume(session_id, options) and query(prompt, options) (an async generator); aclose() ends the session, and async with await Client.start(...) does it for you. A turn’s stream is an async iterator: async for over it, or break to stop reading, which leaves the turn running; then await turn.aclose() if turn stays in scope, or its unread events still count toward the 10,000 limit. In TypeScript they are Client.start, Client.resume and query; sessionEvents() is the between-turns stream and close() ends the session.

TypeScript: always await client.close(), or declare the client with await using (which needs Node 20.4.0 or newer for Symbol.asyncDispose). A client left open keeps Node’s event loop alive, and the child is killed only when the process exits. To drop a stream, break out of the for await or call its return(); the turn still runs on until its result.

A Python handler or callback that raises SystemExit or KeyboardInterrupt ends the program, as asyncio does for any task.

Options

Options::plain(system_prompt) is the setup for an agent that is not a coding agent: no built-in tools, your prompt in place of harness’s own, and nothing read from the machine. Options::new() (Options() in Python, {} in TypeScript) leaves every field at its default, which is also isolated. The SDK writes the options to a private temporary file and removes it as soon as the session has started.

optiondefaultmeaning
model, thinkingthe built-in defaultas --model, --thinking
modemanualmanual, auto or plan; governs built-in tools, not yours, except that plan asks for every client tool
sandboxonon, off or external; see the sandbox
builtin_toolsthe mode’s list, less skill unless skillsthe most built-in tools the session ever offers, for its whole life; [] offers none. The ceiling covers mcp, script and agent too
tool (client tools)nonetools your program runs; at most 128
system_promptharness’sSystemPrompt::Replace(text) or SystemPrompt::Append(text)
project_instructionsoffread the project’s AGENTS.md or CLAUDE.md
user_configoffread your own setup: config.toml, $CONFIG/AGENTS.md, the saved model, the account
skillsoffdiscover skills (the project’s; personal ones too with user_config)
agentsoffdiscover agent definitions (same roots rule)
hooksoffrun hooks from config; refused without user_config
mcp_server(name, json)nonean MCP server for this session, in the [mcp.servers] shape. To be callable, builtin_tools must include mcp
approval_timeout15 minhow long an approval request waits; at most 24 h, or None for ever
allow_escapesoffas --allow-escapes
max_iterationsthe built-in defaultas --max-iterations
cwd, envthe program’sthe process’s working folder, and variables added to its environment
binarynoneuse this harness binary
mocknoneanswer from a mock file, see Testing
on_approvalnonesee Approvals

An unknown field, a wrong type, JSON null or an invalid value is refused before anything is written; the error names the field.

What isolation skips

Every personal read is off unless you turn it on. Sessions are still written, and found, where they always are. The model catalog is still read: it is public data.

harness skipsunless
config.toml: providers, [models] nicknames, [mcp.servers], [[hooks]], [away]user_config
$CONFIG/AGENTS.md, the saved model choice, the account and its providersuser_config
the project’s AGENTS.md / CLAUDE.mdproject_instructions
skills under the project’s .agentsskills
skills under $CONFIG, $AGENTS and [skills] extra_rootsskills and user_config
agent definitions under the project’s .agentsagents
agent definitions under $CONFIG and $AGENTSagents and user_config
hookshooks and user_config

Provider keys come from the environment, as always. With user_config off only the built-in providers are known; a program that needs a custom provider turns it on. Sub-agents inherit the isolation. They do not inherit your system_prompt: a child gets harness’s default prompt, assembled under the same isolation.

A server running many errands leaves one log per session in the sessions folder. To keep them elsewhere, set HARNESS_HOME with env; retention is yours.

The system prompt

Replace(text) makes text the whole prompt plus the blocks for the capabilities you enabled; no harness prompt, instruction files or listing. Append(text) puts text after harness’s prompt and listing. Neither is truncated.

Python spells the options as Options fields: Options(mode="auto", builtin_tools=[...]), Options.plain(prompt, **fields), with Replace(text) and Append(text) for the system prompt, mcp_servers={name: config} for MCP, tools=[...] for client tools, and approval_timeout in seconds (None for ever). TypeScript spells them as an object: { mode: "auto", builtinTools: [...] }, plain(prompt, more), systemPrompt: { replace } | { append }, mcpServers, tools, and approvalTimeoutMs in milliseconds (null for ever), rounded up to whole seconds on the wire.

Tools

A tool is a name, a description, a JSON schema for its input, an async handler, ask (default false) and timeout (default 5 min, at most 24 h). The handler gets the arguments as your type and a ToolContext with the call id and a cancellation signal, and returns ToolOutput::text(..), or ToolOutput::with_image(..) for text plus one image (PNG, JPEG, GIF or WebP). An Err becomes an error result the model reads.

In Python, @tool(name, description, schema, ask=False, timeout=300.0) wraps an async def handler(args, ctx) (args a dict, ctx.call_id, ctx.cancelled an asyncio.Event) that returns a str or a ToolOutput.text(..) / ToolOutput.with_image(text, media_type, data); a plain def is refused at @tool. In TypeScript a tool is { name, description, inputSchema, ask?, timeoutMs?, handler }, with handler(args, ctx) returning a promise of a string or { text, image?: { mediaType, data } }, and ctx.callId, ctx.signal an AbortSignal. Both receive the arguments as parsed JSON, not as a typed value: the schema check below is the only gate, so a handler reads what it needs and guards the rest.

Before your handler runs, the SDK checks the arguments against the schema’s top-level required, each property’s type and additionalProperties: false. An invalid call, an argument that does not deserialize or a handler that panics (in Python or TypeScript, raises, throws or rejects) answers the model with an error; your program does not crash. The cancellation signal fires when harness cancels the call (the turn is aborted, the call times out, stdin closes), when the process exits and on close(); a result sent after it fires is dropped. A result over about 8 MiB is replaced by an error saying so.

Calls from one model response run one after another. Sub-agents, /btw and script are offered none: a sub-agent cannot call your tools, and a tool named in your list cannot be called from a script. The tool list is fixed when the session starts, and a resume must give the same list. A tool’s name is ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$, unique, and not a built-in’s.

ask(true) makes every call an approval request of kind: "client_tool", answerable once (allow_once, deny, deny_and_stop; no turn grant). In plan mode every client tool asks. A client tool is never judged and never covered by an earlier grant.

Resumed in the panel or the REPL, a session with client tools still offers them; each call ends with the error “no program is attached to run <name>”. The same error ends a call made after your program’s stdin has closed. If writing to your program fails, calls end with “the program’s connection failed”, and a call that is not answered in time ends “timed out after <t>”.

Approvals

An approval request is a gate: a built-in tool in manual mode, a client tool with ask, an escape from the sandbox. With on_approval, your callback gets each request and returns a Decision (AllowOnce, AllowForTurn, Deny { reason }, DenyAndStop), which must be one of the request’s choices. A callback that panics, or returns a choice not offered, answers Deny with the failure as the reason. The request is not yielded as an event; the approval line that follows is.

Without a callback the request is yielded as an ApprovalRequest event. Answer it with its own respond(decision), or from anywhere with Client::respond(request_id, decision).

In Python the callback is on_approval=async def f(request) returning AllowOnce(), AllowForTurn(), Deny(reason) or DenyAndStop(); in TypeScript onApproval: async (request) => ({ kind: "allow_once" }), with allow_for_turn, { kind: "deny", reason } and deny_and_stop the others. await request.respond(decision) and await client.respond(id, decision) answer a yielded request.

An unanswered request times out after approval_timeout; the SDK never answers for you. The timeout does what the panel’s does: where away is allowed (approvals required, the sandbox on or external, and a mode that does not ask for every command), the session goes away; otherwise the turn ends. Pip’s setup (builtin_tools: [], manual) allows away, so a timed-out pay is refused, and every later call that asks is refused with no request, until your program calls away(false) or the turn ends. To get a decision on every call whether or not anyone answers, answer in on_approval.

A full escape from the sandbox is never asked of a program unless allow_escapes is on, because the program may be a model with nobody behind it. Resuming a session recorded as ask needs allow_escapes(true), and the flag on a session recorded otherwise is refused, naming --allow-escapes.

Testing without a model

Options::mock(path) answers the model from a mock file: its replies, tool calls and judge assessments scripted in TOML, as harness run --mock reads them. No key, no network. It is a live conversation otherwise: the mode, the sandbox, your client tools, approvals and the log are all real. See sdk/rust/examples/pip.mock.toml for the format.

[[reply]]
text = "Let me look at Friday's trains."
[[reply.tool_calls]]
tool = "search_trains"
args = { to = "Leeds", day = "Friday" }
options = Options.plain("You are Pip.", tools=[search], mock="pip.mock.toml", binary="/path/to/harness")
const options = plain("You are Pip.", { tools: [search], mock: "pip.mock.toml", binary: "/path/to/harness" });

A sub-agent call in a mock conversation ends with the error “sub-agents are not available in a mock conversation”, so a test can never reach a provider. A resumed mock session answers from the start of the file you give it; tool call ids continue from the log.

Replay, and what it does not undo

Client::id() names the session; the session id is on every result too. Three commands work on an SDK session today:

harness session <id> --transcript   # the card, or the scrollback
harness replay <id>                 # re-run the log with no model and no program; checks it reproduces
harness export <id>                 # Markdown, HTML or JSONL

Your tools’ calls and results are in the journal, so a replay needs neither the model nor your program: it serves the recorded result. Cases built from SDK sessions, and flagging a session from the program, are a later slice.

What the journal does not do:

  • A client tool runs in your program, outside harness’s sandbox, judge and modes.
  • Cancelling a call does not undo it.
  • A replay serves the recorded result and never calls your program again.
  • A crash during a call can leave the action done (a payment made) with no result recorded. Recovery reports such a call as unknown, not as failed.

Resuming

Client::resume(session_id, options) sends only what a resume may change: model, thinking, max_iterations, approval_timeout, allow_escapes, and your client tools. For a session started with the SDK, the mode, builtin_tools, system prompt, isolation switches and sandbox come from the log, and giving them is refused. Client tools must equal the log’s, name for name and schema for schema; none given when the log has some is refused. MCP servers come from the recorded baseline, never from config.toml, so Client::resume never sends the options’ MCP servers. Refused fields exit with status 2 and name the field.

client = await Client.resume(session_id, Options(tools=[search], max_iterations=40))
const client = await Client.resume(sessionId, { tools: [search], maxIterations: 40 });

A session not started with the SDK can be resumed with the five changeable fields, and nothing else: an existing session is never turned into an isolated one. A resume in conversation form needs the session id.

Versions

The wire carries v: 1 and harness_version, and only ever gains lines, commands and fields. An SDK of version X.Y.Z (Rust, Python or TypeScript) requires a harness of X.Y.* and raises Handshake (HandshakeError in Python and TypeScript) otherwise; a binary it downloaded always matches. The wire is written down in protocol/stream-json.schema.json.

Getting the binary

In order: the binary option, the MEGACODE_AGENT_SDK_BINARY environment variable, the verified cache, a download. The first two are trusted as given (the version check still applies).

The download is of v<version>/harness-<target>.tar.gz from the release site, over https only, checked against a SHA-256 digest compiled into the package (of the archive, and again of the executable inside). The pins were verified against the release’s signatures when the package was built, and the crate, the wheel and the npm package carry the same three. Only x86_64-unknown-linux-musl, aarch64-apple-darwin and x86_64-apple-darwin exist; any other platform (Linux on ARM, Windows) gets an error naming the three and the binary option. The error text calls it Options::binary in all three languages. MEGACODE_AGENT_SDK_DOWNLOAD_BASE points the download elsewhere, for a mirror.

The cache is <cache>/megacode-agent-sdk/<version>/<target>/harness, with <cache> $XDG_CACHE_HOME or ~/.cache (Linux), ~/Library/Caches (macOS). Every start hashes the file before running it, and a wrong digest is replaced by a verified download, never run. The SDK refuses a cache path or file that another user could change, naming the folder; there is no fallback, so set binary.

The Python download’s 120 s bound covers the DNS lookup too: the wait ends at the deadline, though the resolver’s thread may finish later, its answer unused. The TypeScript download follows HTTPS_PROXY and NO_PROXY only on Node 22.21 or newer, or 24.5 or newer (its proxyEnv setting); older Node connects directly, so behind a mandatory proxy name binary.

Without the default download feature the Rust crate never touches the network: the binary comes from binary or the environment variable only.

The trust note

The options file has the authority of a command line. It can turn the sandbox off, allow escapes and launch MCP servers, as flags and config.toml can, and it is not checked against the rule that refuses [sandbox], [mcp.servers] and [[hooks]] in a config found inside a project. That is because your program, not a repository, wrote it. Never build it from model output or from anything a user types.

Your tools are your responsibility: harness cannot sandbox what runs in your process, and a tool that pays is only as safe as the schema, the ask flag and the code behind it.

Connecting to a remote MCP server

An MCP server gives the agent extra tools. A remote one is reached over HTTPS with a URL and a token, and nothing is installed locally. Keys: config.md.

Before you start

  • The URL of the server’s MCP endpoint, such as https://api.githubcopilot.com/mcp/.
  • A fixed token the server accepts (a personal access token, an API key). OAuth isn’t supported.
  • Harness 0.15.0 or later.

Setup

1. Put the token in an environment variable, never in the config file. Type this, don’t paste it:

read -rs GITHUB_PAT; export GITHUB_PAT; echo "length: ${#GITHUB_PAT}"

Tokens shorter than 8 characters are refused.

2. Add the server to your own config.toml (default ~/.config/harness/config.toml; harness paths shows yours). A project’s config can’t add servers.

[mcp.servers.github]
url = "https://api.githubcopilot.com/mcp/"
token_env = "GITHUB_PAT"                  # the variable's name, not the token
tools = ["search_issues", "issue_read"]   # optional: offer only these
allow = ["search_issues"]                 # optional: run without asking
  • github is any name you choose.
  • The token is sent as Authorization: Bearer …; token_header = "X-API-Key" uses another header.
  • Without tools you get every tool, and each costs model context.

3. Run harness run in the same terminal and type /mcp. Expect a line like:

github  http · api.githubcopilot.com · 2026-07-28 · 2 of 46 tools · ≈N tokens

Using it

Ask in plain language; the agent picks the tool. Every call asks first unless the tool is in allow: y allows it, n denies it and stops the turn, 4 denies it with a message to the agent. A server’s read-only label is informational only.

  • --approve-all skips asking all session; use it only with servers you trust.
  • Plan mode offers no MCP tools.
  • /away refuses any call that would ask.

/mcp shows servers, tools, calls and failures.

What the harness does with your token

  • It goes only to that server’s address; redirects are never followed.
  • Calls are logged, the token never is; if the server echoes it, the log shows [redacted].
  • harness replay replays the session with no server and no token.

Good to know

  • Tools are fixed at session start; new ones appear next session.
  • Resuming reloads tools from the log and reconnects at the first call. If the server’s config changed, its calls are refused; start a new session.
  • Results are text only, up to 64 KiB; an image becomes a placeholder like [image: image/png, 1234 bytes].
  • The URL must be https://, or http:// for a loopback address such as 127.0.0.1.

If something goes wrong

You seeLikely cause
mcp: github failed to start: GITHUB_PAT is not setNot exported in this terminal. Do step 1 here, then start a new session.
failed to start: … 401 or 403Token wrong, expired or lacking access.
failed to start: timed out after 30 sServer unreachable or slow; if slow, set startup_timeout = 60.
/mcp lists no serversAnother config file was read; check harness paths.
A load error naming a keyA typo in [mcp.servers.…].

Changelog

[0.31.0] — 2026-11-04

Added

  • The SDK in Python. pip install megacode-agent-sdk gives you the megacode_agent_sdk module: the Rust crate’s shapes in asyncio — query, Client.start, Client.resume, your own tools as async functions marked @tool, approvals, testing on a mock file with no key. It needs Python 3.10 or newer and uses the standard library alone. A python.org Python on macOS needs its Install Certificates.command run once before the binary’s download works.
  • The SDK in TypeScript. npm install megacode-agent-sdk gives you the same shapes for Node 20 or newer, as an ES module with no runtime dependencies and its own type declarations. Always await client.close() (or declare the client with await using, Node 20.4 or newer): a client left open keeps Node running. The download follows HTTPS_PROXY and NO_PROXY only on Node 22.21 or newer, or 24.5 or newer; on older Node behind a proxy, name the binary option.
  • Examples you can run with no key. sdk/python/examples/ticket_fix.py (a ticket-to-fix agent with a tool of its own and harness’s file tools) and sdk/typescript/examples/minimal.ts (one prompt, one tool), each with a mock file beside it.

Both packages fetch and verify the harness binary of their own version as the Rust crate does, and every session they start is recorded like any other, so harness session, replay and export work on it. Their differences from the Rust crate, such as tool arguments handed to your handler unconverted, are described in docs/sdk.md; ADR-0097 lists them all.

[0.30.0] — 2026-11-03

Added

  • An SDK: build your own agent on harness from Rust. The megacode-agent-sdk crate starts a harness session from your program, gives the model tools you wrote, and streams back what the session does. Use it for an agent that is not a coding agent: a text-message assistant, a support bot, a nightly job. Each session is one harness process, and every session is recorded like any other, so harness session, replay and export work on it. On its first run on a machine the SDK downloads the harness release of its own version, checks it against digests built into the crate, and keeps it in a cache it checks again at every start; a program can name its own binary instead. Linux on x86-64 and macOS are supported. Python and TypeScript packages, with the same shapes, come in the next release. See docs/sdk.md.
  • --options <file> for a conversation. harness run --input-format stream-json --options <file> takes the session’s setup from one JSON file: model, mode, sandbox, which built-in tools are offered (a ceiling for the whole session, mode switches included), your own tools, the system prompt (replaced or appended to), the approval timeout, and which personal setup is read. With --options, nothing personal is read unless the file asks for it: no config.toml, no AGENTS.md, no saved model or account, no skills, agent definitions or hooks. A resume, on any surface, keeps the setup the session was started with. The file has the authority of the command line; never build it from a model’s output (docs/headless.md).
  • Client tools. A conversation can offer the model tools that run in your program: harness writes client_tool_call, your program answers client_tool_result with text and at most one image, and a call left unanswered ends at its timeout (5 minutes by default) with client_tool_cancel. Each call is recorded, so a replay needs neither the model nor your program. A tool marked ask asks through the usual approval_request (kind client_tool); client tools are never judged, no earlier approval covers them, and they run outside harness’s sandbox. A session resumed in the panel shows them, and each call fails with “no program is attached”.
  • --mock in a conversation. harness run --input-format stream-json --mock <file> runs a live conversation whose model answers from a mock file, with no key and no network, so a program’s own agent can be tested against the real binary. Sub-agents are refused in a mock conversation.
  • The conversation protocol is written down. docs/protocol/stream-json.schema.json describes every line in both directions, checked in CI against transcripts recorded from the real binary (sdk/conformance/). The protocol stays at v: 1; everything new is additive.
  • The idle compact command says what it compacted. Its done answer now carries data.compacted (trigger, pre_tokens) when that attempt wrote a compaction.

Changed

  • The update notice is amber. A new release’s note on the notice line, in /notices, and in the plain prompt’s start line is painted in the warning colour, so it stands out from the other notes. The status-bar item is unchanged.
  • A spinner while sub-agents work. When the status bar reads N sub-agents running · esc stops them, it now leads with the spinner (or the static ⏳ with animations off), so a session waiting on its sub-agents no longer looks stuck.
  • abort acts at once. In a conversation, an abort interrupts at once, ahead of any command sent after it; with a prompt_id it takes a still-queued prompt back (dequeued) or stops the turn that prompt opened. The SDK’s abort() takes back its own queued prompt and ends that prompt’s stream, so the next send runs.

Fixed

  • Turning away off during an away wait takes effect. A /away off that arrived at one moment of the wait was held until something else was sent, so the turn kept waiting.
  • A prompt sent while a queued compaction runs waits for it. In a conversation, a compact queued during a turn runs once the turn ends; a prompt sent in that window used to skip ahead of it, and an abort of that prompt stopped the compaction instead. The prompt now waits, and its abort stops the prompt.
  • An MCP server’s earlier processes are checked on a busy machine. If any process on the machine was exiting at the moment harness checked a left-over MCP server’s processes, the check failed with “its earlier processes could not be checked: /proc/<pid>/stat: not understood” and the server’s record stayed. Exiting processes are now skipped.

[0.29.2] — 2026-11-02

Fixed

  • An answer typed the moment an approval question appears is no longer lost. The plain prompt drops what was typed before a question (an approval, the auto question after a sign-in), but it did so just after printing the question, so a key pressed right then could be dropped too and the question waited. It now drops the type-ahead before printing.

[0.29.1] — 2026-11-02

Fixed

  • The transcript stays in scrollback on xterm.js terminals (fix branch fix-scrollback-xtermjs, note). In xterm.js-based terminals such as Orca, every transcript row that scrolled off the top of the screen was deleted instead of kept, so scrolling up after a long answer showed only the last screenful. Rows now leave the screen by line feed, which xterm.js archives. Checked against Orca’s xterm.js build and tmux; VS Code uses the same engine but was not run.

[0.29.0] — 2026-11-01

Added

  • A quiet start, and /notices. A start prints the splash, the hints and the signed-in line, plus any question it must ask; everything else it used to print (a key not set, the home folder, a hook, the sandbox off, a broken config) is now a note. In the panel, one line below the splash types the notes one at a time until you send a message. A fact you have already seen in this project is not typed again for 90 days; a warning (⚠) is typed at every start while it holds. /notices lists them all in four sections: needs your attention, about this start, privacy, status. After your first message the status bar counts ⚠ N while a warning holds and M notes until you open /notices. The plain prompt prints one line, the first note and (+N more · /notices); -p and stream-json print what they printed before. What you have seen is kept per project in $STATE/notices-seen.json (docs/config.md).
  • /update and /restart. A newer release shows as one status-bar item, 0.29.1 available · /update, typed once on the notice line; nothing goes into the transcript. /update installs it without leaving the session: the same signed, checked install as harness update, each step shown; Esc cancels before the swap, and a failure changes nothing. The item then reads 0.29.1 installed · /restart. /restart waits for the session to be idle, closes it as /quit does and starts the installed binary on the same session, so the conversation, mode and approvals carry over; the new process says Now on harness 0.29.1 (was 0.29.0). A session learns of an install from the receipt harness update and /update write ($CACHE/install-receipts.json), never by running a binary (docs/config.md).
  • exit, quit and harness update typed at the prompt do what was meant. Typed alone at an idle prompt, exit or quit (any case) quits as /quit does, and harness update offers to run /update instead [Y/n]. A leading space, more words, or a running turn send the text to the model as before ( exit is the escape). /exit is now the same as /quit (docs/shortcuts.md).
  • Skills used. /session gains a Skills row: each skill the session used, how often, and how many times by you, sub-agents’ after. /skills gains a THIS SESSION column with the same counts, and marks a skill added on disk since the start new on disk: the model sees it next session.

Changed

  • A new spinner. The working indicator is three braille cells turning in red and blue, ramped to the theme’s background; a turn that ends normally closes with a short green pulse. [ui] animations = false, NO_COLOR, TERM=dumb or a pipe show the static ⏳, and the plain prompt draws the same frames without colour.
  • The usage-counts disclosure moves to /notices. Signing in prints a one-line form, harness sends daily usage counts (never prompts, files or commands) · /notices; /notices lists the full sentence, with its switch (telemetry = false or DO_NOT_TRACK), at every start. A start that finds you already signed in no longer prints it. A judging mode’s disclosure is listed there too; choosing a judging mode in a session still prints it once.
  • The signed-in line no longer names the model; the status bar does. With no key and no sign-in, the status bar says no model instead of naming a model that cannot answer.
  • [ui] quiet_startup hides what it hid before. For an update it hides only the note on the notice line; the status-bar item and /notices stay.

Fixed

  • Plan mode at start. harness run --mode plan (with or without -p) told the model to write its plan to the literal path {plan_file}, a file that never existed. It now gets a real plan file, as /mode plan does, and resuming such a session fixes it. A plan file’s number no longer counts resumes, and a number used before is never reused.
  • The approved retry of a credential read checks the file itself. After you approve reading a protected file such as .env, the read now takes exactly the file you were asked about, by its identity, whichever of its names leads there. On macOS a file with several hard links was now and then refused right after you approved it; that is gone, and a file swapped in between the question and the read is refused.
  • A resumed conversation’s result names the model. harness resume <id> --input-format stream-json printed "model": null on every turn’s result, since a resume never sees the session’s start record; it now names the session’s model, as init does.
  • A conversation that fails to start prints no init. When the driver failed before it was ready (a log it could not append to, say), stdout could still carry init before the process exited 1 — a resume’s {"type":"init",…,"resumed":true} always, a new session’s whenever the failure came after its start record — so a client read a session that never started. Now stdout stays empty and stderr carries the driver’s own reason, exit 1.

[0.28.0] — 2026-10-30

Added

  • A program can hold a session. harness run --input-format stream-json (and harness resume <id> --input-format stream-json) keeps the session open: stdin takes one JSON command per line — prompt, steer, abort, approval, compact, get_state, away, quit — each answered by one response line; stdout prints the stream-json lines -p prints, every gate as an approval_request the program answers with the panel’s choices (allow_once, allow_for_turn, deny with a reason the model reads, deny_and_stop), a sub-agent’s gates included, and one result per turn with the prompt’s own id. An unanswered request times out at [away] timeout with the panel’s consequences. A full escape is never asked of a program: a conversation runs under escape = "network_only" as --approve-all does, unless --allow-escapes says a person answers; a crash resume keeps the rule its log recorded, and refuses the flag or its absence when that rule disagrees. The log of such a session is a panel session’s: harness replay reproduces it. See docs/headless.md, “A conversation”.
  • Closing stdin means “no more input”, not “stop”. printf '<prompt line>' | harness run --input-format stream-json runs the prompt to its answer: the running turn finishes, the prompts still queued run in order, then the session ends, exit 0. A gate opened once stdin is closed is refused at once with -p’s reason, since nobody can answer it. quit and Ctrl-C stop.
  • SIGTERM is Ctrl-C for a conversation: the turn’s result prints, exit 0; a second signal exits 1. A supervisor’s kill whose grace period outlasts the effect in flight ends the session cleanly instead of leaving a crash close for the next resume to report; a SIGKILL inside the grace period still does.
  • result.cost on a conversation’s result: the model calls since the last result (the turn’s, and a compaction between turns in the next one’s) priced as harness session prices a session (usd, priced, unpriced, free, bound, basis).
  • abort {"prompt_id": …} takes a queued prompt back (dequeued) or interrupts the turn it opened.
  • compacted carries trigger (automatic/manual) and pre_tokens; get_state.data.context reports last_prompt_tokens, window, percent and auto_compact_at_percent. A resume starts both from the log.
  • A client example, and the conversation on a real model. examples/stream-json-client/driver.py holds a session over stdin (standard library only) and drives eight scenarios — prompts and steers, approvals, abort and compact, the away timeout, EOF, quit, SIGTERM, resume — against a real provider, checking what the protocol promises. The five whose turns all completed are committed as live cassettes, crates/agent/tests/fixtures/sessions/anthropic-conversation-*.jsonl, replayed offline by fixture_replay; scripts/rerecord-cassettes.sh conversation re-cuts them. A real terminal on stdin is refused before anything starts, on run and resume alike (the pty suite).

Changed

  • -p ends on SIGTERM as on Ctrl-C. The turn ends interrupted (or model_failed with error: "interrupted: " when the signal lands on the model call), its result prints, exit 1; before, the process died and the next resume reported a crash close.
  • -p --output-format stream-json lines gain fields: user.kind (message or steer), result.turn_id and result.prompt_id. Additive; a reader that ignores unknown keys is unaffected.
  • Protocol lines escape U+2028 and U+2029 (\u2028, \u2029) in every JSON output — a conversation’s lines, -p --output-format stream-json and json — so a reader that splits on Unicode line separators (Python’s splitlines(), Node’s readline) sees one line per object. The text format’s answer is unchanged.

[0.27.1] — 2026-10-30

Fixed

  • A notice sent once stays in its place. The away and back notices, the turn-budget notice, the empty-reply nudge and the continue notice were folded into one request’s last message and derived again on the next, so the next request no longer carried them. Anthropic binds a thinking block’s signature to the content that preceded it, so the first request after such a notice left — the turn after an away task ended, the call after a budget notice — was refused: 400 … Invalid signature in thinking block … Content that preceded this block when it was created is missing from this request. Every request — a turn’s, and the summarize call a compaction makes — now begins with the messages of the last turn request the model answered, as they were sent (read back from the log’s recorded pieces, driver::with_sent_notices), notices included, and the new notices follow; a compaction or a model-family switch, which change the start, restore nothing, and a request whose call failed is not copied. One case remains: the empty-reply nudge after a reply that was all thinking is not kept, so the request after that can still be refused. Each segment’s start or resume record now states that it keeps sent notices (keeps_sent_notices, ADR-0090), and the restore follows the segment’s own record: a log recorded by 0.27.0 or earlier replays in the shape it was recorded in, so every committed fixture replays byte-identical; a session started on 0.27.0 or earlier gets the fix from its first resume here; a segment an older harness resumed replays without it. Found on claude-haiku-5-5 with thinking on, after /away; reported and fixed by Jingtao Xu (PR #100).

[0.27.0] — 2026-10-27

Added

  • Intel Macs, and every Mac from macOS 15.4. Releases now include x86_64-apple-darwin, and the Mac floor drops from macOS 26 to 15.4 on both chips, the sandbox included. harness update keeps an Intel Mac current. The one-line installer offers the Intel build once the site’s installer is updated, after this release is live; it refuses a Mac below 15.4 with needs macOS 15.4 or later (this Mac runs <version>), and a HARNESS_VERSION pin the Mac cannot run: below 0.27.0 there is no Intel build, and on Apple Silicon a release before 0.27.0 needs macOS 26 (docs/sandbox.md).
  • Windows through WSL2. Run the harness inside a WSL2 distribution, as a Linux install (docs/sandbox.md). There the sandbox hides each Windows drive’s Users folder (/mnt/c/Users: every account’s .ssh, AppData and Documents) under every spelling that names a folder, since the drive ignores letter case; the rest of the drive stays readable, read-only. A project, or its git folder, inside a Users folder makes the sandbox unavailable: move it to the Linux file system (~/code) or elsewhere on the drive. A project elsewhere on a Windows drive keeps the sandbox, and its names compare without case, as on a Mac’s default disk, so write_file claude.md cannot replace a protected CLAUDE.md. Windows programs (cmd.exe) do not start from a sandboxed command. WSL1 and Windows on ARM are not supported, and this is untested on real Windows.

Changed

  • The sandbox’s network warning covers every macOS below 27. Apple fixed CVE-2026-84551 (“an app may be able to bypass network restrictions”) in macOS 27 only, so below 27 doubt the network rules. An Apple Silicon Mac clears it by updating to 27; an Intel Mac cannot, since 27 does not run on Intel (docs/sandbox.md).
  • A macOS missing one of the sandbox’s private system functions makes the sandbox unavailable instead of stopping the harness at start, replay included. The start names it: this macOS lacks `<name>`, a system function the sandbox needs; [sandbox] mode = "external" or "off" runs commands without it. The start-up self-test also fails when its process query fails, where it passed before.

[0.26.0] — 2026-10-26

Changed

  • A hook on the host runs from /. A hook with sandbox = false, or any hook in a session with no sandbox, now starts in /, not the project, so nothing it or its interpreter loads by a relative name comes from the project. Its input’s cwd still names the project: cd "$(jq -r .cwd)" first if it needs it. In a sandboxed session its PATH drops the folders the agent can write. The start check now also checks the path in a --flag=path argument (docs/hooks.md).
  • The file tools read only regular files, up to 16 MiB. read_file, edit_file and write_file refuse a FIFO, a device, a socket or a folder at once, and any file in /proc or /sys; read_file and edit_file refuse a file over 16 MiB, while write_file still replaces one. search skips files over 16 MiB and says how many (docs/sandbox.md).
  • Provider, judge and catalog clients follow no redirect, and a plain-http:// endpoint never goes through a proxy. A gateway that answers with a redirect now fails visibly.

Fixed

  • A command that writes without end holds only the first 1 MiB of its output in memory, the part the model always saw; search no longer holds every match.
  • harness export -o with .. in the path goes through the same link checks as any other path in the project.
  • The sign-in link must be https, and the sign-in lines escape every invisible formatting character the server sends.

[0.25.0] — 2026-10-25

Added

  • A reply cut at the output cap continues. When a reply stops at the output cap without a tool call, the harness asks the model to pick up where it stopped, up to 3 tries in all, and shows ↻ The reply hit the output limit. Continuing (2/3).; when the second try was all thinking, the third runs one thinking level lower. Esc stops it; after the third try the turn ends truncated, as before. Each try is a recorded model call and counts against max_iterations. In -p and harness eval the answer joins the tries, so a case that used to end truncated may now finish.
  • Update checks at start and every hour. An interactive session asks the install site for the newest release at start and every hour while it is open, sending nothing about you, and says harness X is available. Run harness update, then restart. between turns (docs/config.md).

Changed

  • mega:full sends 65,536 output tokens: a mega: model’s cap is the server’s figure. A cap you set still wins.
  • The output-cap warning keeps its measurement; its advice moved to the truncated turn’s footer.
  • Older versions refuse a config.toml that has telemetry; remove it before downgrading.

Fixed

  • The resume hint prints harness resume <id> when harness on your PATH is this binary, not its full path.

[0.24.0] — 2026-10-24

Added

  • Hooks. Your own commands, listed as [[hooks]] in config.toml, can run before a tool call (PreToolUse) and block it, with your reason going to the model, or run when you send a prompt (UserPromptSubmit) and add context beside it or block it. The input and answers are Claude Code’s shapes, so a script ports with small changes (tool names here are lower case: bash, edit_file). Only your own config can define hooks, never a repository. A hook runs in the session’s sandbox unless it sets sandbox = false, and a failing hook blocks unless it sets on_failure = "continue". The session refuses to start if a hook’s files lie where the agent could change them. The banner lists the hooks, every block is recorded and replays, and --no-hooks runs a session without them. Hooks are a guardrail, not a security boundary: a hook sees one call at a time and can be routed around; the sandbox is the boundary. See docs/hooks.md.
  • harness update downloads the newest release through the install site, checks its SHA-256, tries the new binary, and only then replaces the old one; a failed step installs nothing. It sends nothing to our server. Sessions already running keep the binary they started with; restart them.
  • The update notice. After the daily check finds a newer release, the next interactive start shows harness X is available. Run `harness update` under the banner, and hides it with the banner (quiet_startup).
  • A thinking level per sub-agent. The agent can give each sub-agent its own thinking level, lower for a quick lookup and higher for hard reasoning. A definition’s own effort: still wins, and the sub-agent’s header and its approval show the level.
  • Sub-agent rows say what each one is doing. Under the status line, each running sub-agent’s row shows its prompt, its status (waiting for you in amber when it needs your approval), its seconds, its tokens and, when it differs from the main agent’s, its model, in aligned columns. The side reply’s row looks the same.
  • --no-mcp, on run and resume, starts no MCP servers for that session and records it, so a harness -p run from inside an MCP server does not start that server again.
  • When a hook blocks the prompt, harness -p exits 1 with outcome prompt_blocked; harness sessions --json and the /session card report it, and an eval case can expect it.

Changed

  • Auto loads skills without asking. Loading a skill adds its text and its list of files and grants nothing; manual and plan still ask. A session resumed from before 0.24.0 keeps asking. To keep the question in new sessions, add skill to [modes.auto] ask (docs/config.md).
  • Older versions refuse a config.toml that has [[hooks]] or check_for_updates; remove them before downgrading.

Fixed

  • A skill whose name differs from its folder was skipped without a word; a start-up warning now names it.
  • A sub-agent definition whose effort: is not a level was ignored without a word; a start-up warning now names it.
  • Resuming a session from another folder prints a cd … && harness resume … line to paste; it dropped --sandbox, and now keeps it.

[0.23.1] — 2026-10-23

Fixed

  • Each model gets its own output cap. With no --max-tokens and no max_tokens in config.toml, a reply’s cap is now its model’s own output limit, not 32,768 for every model: Claude Opus 5 and Sonnet 5 get 128,000, and a model with a lower limit (GPT-4o, 16,384) is no longer asked for more than it allows. On those models, a long high-thinking reply that used to spend the whole 32,768 on thinking now has room to answer. Gemini 4 Argon’s own limit is 32,768, so on it (and on mega:full, which serves it) nothing changes: lower the thinking level. The limit comes from the provider’s own model list, else the catalog, else 32,768. It follows the model through a /model switch, resume, sub-agents and side replies. A cap you set yourself still applies to every model.
  • The warning for an all-thinking reply names your settings: “Ask more narrowly, lower the thinking level, or raise the cap (–max-tokens, or max_tokens in config.toml)”. It used to name a source-code constant.

[0.23.0] — 2026-10-22

Added

  • Ask while a long tool call runs. In the panel, once a call has run 2 s, Enter sends your message to a side reply that answers at once — same model and thinking, reading files only, never asking — in a muted block under the call. The message still joins after the call, so the agent sees it without answering again. Esc stops the side reply first, then the turn. Transcripts, harness resume and exports show both where they joined, and harness replay <side id> replays a side session beside its main log.
  • The docs as a website, at /docs/ on the install site: getting started, modes and the judge, the sandbox, checkpoints and /undo, shortcuts, config.toml, harness -p and remote MCP servers, with search.

Changed

  • Every block is set apart by a blank row — each tool call, text going into a tool call or thinking, whatever follows a footer — in the panel and the line client. A streamed answer’s line no longer moves when it commits. harness -p output gains the same blank rows and drops an answer’s leading blank lines.

Fixed

  • The model picker’s provider page says ←/Esc back; check-invariants.sh checks a source folder named target.
  • A resize that arrived with a keystroke could leave the panel laid out for the old size, losing or doubling rows.
  • rustls 0.23.46, which fixes four advisories (RUSTSEC-2026-0346 to 0349); none applied to this build’s use of it.

[0.22.0] — 2026-10-21

Added

  • A resumed session shows what happened in it. Resuming draws the session’s recent turns — tool calls, thinking, approvals, answers, footers — into the terminal’s scrollback, so you can scroll up through them, then a dim ── resumed · <time> ── line before the prompt. Long sessions show their newest turns that fit 2,000 rows, with one line saying how many earlier turns are left out and how to see them (harness session <id> --transcript). [resume] replay_rows sets the budget; 0 turns the replay off (docs/config.md).
  • harness session <id> --transcript shows a background sub-agent’s ↳ <name> finished row.

Changed

  • Turns are easier to tell apart. Your message has a blank row above and below it, and a blank row follows each thinking block and tool block, in the panel and the line client alike. The turn’s footer stays right under its answer. In the line REPL, one blank row separates turns.

Fixed

  • Two sub-agents started together showed the same short id. The id now runs 14 characters, which reaches the random part, and still opens the child’s session as a prefix.
  • harness judge fit crashed on score files from older question sets (u1/u2); it now counts them under “scored on another question set”.

[0.21.0] — 2026-10-21

Added

  • In the panel, sub-agents run in the background and you can keep talking to the agent. An agent call returns as soon as its children start, and their rows stay under the transcript. Each result comes back on its own: an idle agent picks it up and answers; a busy one takes it after the tool calls already running. [agents] background = false keeps the old behaviour (docs/config.md).
  • Stopping and leaving with children running. Esc while the agent is idle stops them. Quitting stops them and waits up to 10 s for their results; Ctrl-C after the first half second leaves at once. Work done while you are away waits for its sub-agents, and /away also reaches the ones already running.
  • /model thinking <level> changes only the thinking level. In the plain prompt, /model <id> asks for the level before it switches.

Changed

  • /iterations <n> reaches the running task before its next model call, not from the next task on.
  • A resumed session follows config.toml’s max_iterations unless you chose a cap with --max-iterations or /iterations; a cap you chose still carries over.
  • /thinking replaces /reasoning: it shows, previews or hides the model’s thinking, and thinking levels are picked in /model. /reasoning prints a pointer to /thinking throughout 0.21.x. The config key is [ui] thinking; the old [ui] reasoning still loads.
  • /help groups the commands under six headings, in coloured columns.
  • /model to a model whose key is not set warns at once, and a turn that fails there ends with an amber footer.
  • Signed out, the start-up line names /login for the free models.
  • Messages print the config lines to add instead of pointing at a doc in this repository.
  • Auto mode’s judge is now typesafe:jev-1.14.0, with a cutoff of 0.20 (was 0.30). Auto mode asks somewhat more often and lets fewer risky commands through: on the 300-command test set, false alarms go from 5 to 11 of 186 safe commands and misses from 63 to 49 of 114 risky ones. The away cutoff, 0.20, now equals the ordinary one. Every session records the judge it ran under, so an older session replays under its own.

Fixed

  • On OpenAI-compatible (Chat Completions) providers, a sub-agent’s result is separated from the next message by a blank line, so the model does not read your words as the end of the result.
  • Esc pressed just as the model answers now stops the turn. Before, the Esc could be lost: the turn went on and could run a command from that answer that needed no approval, or wait at an approval nobody was asked for. The same holds during an automatic compaction. Such a turn ends interrupted, like any other Esc.

Security

  • The harness no longer trusts the emSign ECC Root CA - G3 certificate authority. Upgrade from 0.20.x or earlier. In October 2026 the private keys of eight eMudhra intermediates under that root turned out to be recoverable, so anyone in your network path could pose as a model provider and read your API key and conversations. 0.21.0 drops the root and refuses the eight intermediates even when they are cross-signed by another root (RUSTSEC-2026-0381, RUSTSEC-2026-0382).

For operators

  • Rebuild and redeploy mega-server from 0.21.0 as well: it carries the same certificate fix.
  • mega-server accepts both jev-1.13.0 and jev-1.14.0 for the judge; redeploy it before clients move to 0.21.0.

[0.20.0] — 2026-10-18

Added

  • Two free models after a sign-in, with no key of your own. Type /login in a session, or run harness login: the harness prints a link and a code, you open the link on any device (over SSH too) and sign in with Google, and the harness notices on its own. You then have mega:flash and mega:full, listed in /model right after the current model, with how fast each uses your allowance. Signed in, with no saved pick and no config model, a session starts on mega:full and sub-agents use mega:flash (docs/config.md).
  • A daily allowance, shown as a percentage. On a mega model the status bar, and the line REPL’s per-turn line, end N% of today; a notice comes at 80%, and at 100% a turn stops with when the allowance resets (00:00 UTC) and how to keep going on your own key. No dollar figure is shown: /session says free for these calls and names the model that answered, as in mega:full (gemini-4-argon).
  • /logout and harness logout sign out and end the sign-in on the server. A sign-in also ends after 7 days without use; /login again starts a new one.
  • [agents] model and [agents] thinking set the model and thinking level a sub-agent gets when its definition and the spawn call name none, before the parent’s. The session log records them, so replay rebuilds the choice.
  • Auto mode’s judge works signed in, without a TypeSafe key: judged commands reach TypeSafe through the mega server, and a one-time notice says so.

Changed

  • A start with no usable model says how to get one: No model yet. Type /login for free models, or set your own key. With -p: No model yet. Run harness login for free models, or set your own key., and exit 1.
  • Your own keys work as before. A model you picked or configured still wins at start whenever it can answer, and the harness never moves you onto your own key by itself.

Security

  • The sign-in token lives in $CONFIG/.secrets/accounts.json (0600, in a 0700 folder). The file tools refuse that folder outright, --approve-all included; the sandbox hides it; a .secrets folder, account file or lock that is a link is refused; and bundle and export refuse a log that holds a token.

For operators

  • The account server, mega-server, is in server/, with its setup wizard (server/setup-gcp.sh) and deploy script (server/deploy.sh).

[0.19.0] — 2026-10-17

Added

  • read_file now returns PNG, JPEG, GIF, and WebP images visually on vision-capable models. When the active model accepts image input (resolved from models.dev’s modalities.input and provider /v1/models lists, and recorded on the session log so replay stays byte-identical), read_file advertises image support in its tool description and returns valid image/png, image/jpeg, image/gif, and image/webp files up to 3.75 MiB raw (5 MiB base64) and 8,000 × 8,000 pixels as native visual content across Anthropic, OpenAI Responses, Gemini (PNG, JPEG, and WebP), and OpenAI Chat Completions / OpenRouter.
  • Image validation, request budget, and model-switch protection. Oversized (> 3.75 MiB), > 8,000 px, or header/trailer-corrupt images return descriptive text notes ([image too large: …], [image dimensions too large: …], [invalid image: …]) instead of poisoning the session with a provider 400. Requests keep up to the newest 16 images and 15 MiB of base64 image data, and switching /model to a text-only model (or to Gemini after reading a GIF) omits earlier image blocks with an explanatory note. Inside script, tools.read_file on a supported image returns a short note instructing the model to call read_file directly.
  • Transcript, export, and secret-scan support for image reads. The panel and transcript show image · <mime> · <N> bytes, image too large · <mime> · <N> bytes, or invalid image · <mime> · <N> bytes; harness export --format md|html elides the base64 payload while --format jsonl and -p --output-format json|stream-json preserve the full record; and export / bundle skip verified base64 image payloads at the two read_file locations so random key-prefix collisions (AIza, AKIA, ASIA) in image bytes do not trigger false refusals.

[0.18.1] — 2026-10-17

Fixed

  • File tools (read_file, write_file, edit_file, glob, search) now accept an absolute path inside the project root as the project-relative path; paths outside the project, .. traversal, and symlinks leading outside stay refused. Prefix stripping always matches the folder the session started in, even after resume continues it elsewhere: there, absolute paths naming the new folder stay refused and paths naming the original folder resolve in the new one. (Sessions recorded before 0.18.1 that captured a refused in-project absolute path will also replay differently under 0.18.1.)
  • read_file on a recognised binary file (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO, PDF, ZIP/JAR/Office/EPUB, gzip, zstd, xz, bzip2, 7z, tar, ELF, Mach-O, Java .class, WebAssembly, SQLite 3) now returns [binary file: <mime>, <N> bytes — read_file returns text only] instead of raw bytes, and the transcript result row shows binary · <mime> · <N> bytes (with <N> thousands-grouped). (Sessions recorded before 0.18.1 whose read_file returned raw bytes for a recognised binary file will also replay differently under 0.18.1.)

Changed

  • The busy status spinner is now the D5 3-cell red and blue taegeuk swirl. The three spinner cells use D5’s fixed red and blue swatches; [ui.colors] warning continues to style the thinking… text.

[0.18.0] — 2026-10-16

Added

  • A provider you add under [providers.<name>] is described by models.dev. The table is matched to the models.dev provider of the same name, unless models.dev places that provider on another host than your base_url; models_dev = "<id>" names one outright ([providers.fireworks] models_dev = "fireworks-ai"), and models_dev = false turns matching off (docs/config.md). Its models then get names, context windows, prices and featured rows, in /model, in the window a session uses and in the price /session shows. Where the provider’s own model list states a window, output limit, date or price, the list wins over models.dev. A new or changed table is picked up by the next interactive start’s refresh, even when the catalog was refreshed earlier that day. [models.context] still wins, and eval still reads no cache.
  • Featured rows for your own providers. A gateway whose model ids look like lab/model, such as Vercel’s AI Gateway, features the same models as the direct providers, as OpenRouter does. Any other provider, and a gateway that carries none of those models, features its newest model of each line.
  • /model <word> says how to add a provider you have not set up. When the word names a models.dev provider that neither a built-in nor one of your tables reaches (/model fireworks), a hint above the search’s rows names the table to add. For the six keyed providers docs/config.md documents (Fireworks, Groq, Cerebras, Moonshot, Hugging Face and, new, Vercel’s AI Gateway) it is a block ready to paste. The hint prints only text the binary carries, never a URL from models.dev, and works offline.

Changed

  • An OpenAI-compatible provider’s own model list now supplies names, windows, output limits, release dates and prices where it states them, as Vercel’s does. /model <text> finds a model by the list’s name; a model the catalog does not know yet shows the list’s window; each model’s release date, not one date for the whole list, decides its new tag and its place; and an id the list types as embedding, image, video, speech or another non-chat kind sinks to the bottom as (not a chat model). A price from the list shows only on a model models.dev also describes, so /model and /session show the same price.

Known limits

  • A gateway may advertise a window your account is not served. Vercel’s list gives Claude Sonnet 4.5 a 1,000,000-token window, a tier not every account has. Such a session compacts late, and a turn past the real limit fails with the provider’s “context too long” error, which the harness does not retry. Set [models.context] for that model (docs/config.md).

[0.17.0] — 2026-10-15

Added

  • The model catalog refreshes itself from models.dev. Once a day, an interactive session refreshes it in the background from models.dev, so /model lists a new model, with its name, window and price, the day models.dev does. Nothing waits for it and nothing is printed. It runs only when a provider with a key is set up off this machine, never under -p, --mock, eval or the other one-shot commands, and the download is compressed. Turn it off with [models] refresh_catalog = false; HARNESS_MODELS_DEV_URL reads a mirror instead (docs/config.md). The /model footer says when the catalog was last refreshed.
  • Before models.dev lists it, a new model still shows its provider’s name and window, as the first row under its provider after the chat models, marked (not in the catalog yet).
  • Recent models say new: anything released in the last 21 days.

Changed

  • Featured models are computed: the newest of each model line, at most four per provider, among the models the provider’s list serves. Nobody keeps a hand list, so a flagship is featured the day the catalog learns of it. OpenRouter features the same models as the direct providers, and orders the rest of its list by weekly use.
  • Inside a provider, chat models come first, newest first. Ids the catalog does not know come next. TTS, image and embedding ids the catalog knows sink to the bottom with a reason, (not a chat model) or (deprecated); a brand-new one it does not know yet sits with the other unrecognised ids, below the chat models.
  • The Gemini list is read in full, not only its first 50 ids.
  • A new model’s context window comes from the refreshed catalog, or from its provider’s list before that, instead of falling to 200,000. A model this binary already knows keeps the window the binary carries, and [models.context] still wins over both. eval reads no cache, so an eval’s window is the same on every machine; its report prints the window it used.
  • /session and harness session --json can price a model from the refreshed catalog, the same price /model shows, so two machines may now report a different cost for the same session.
  • Rows from a provider the catalog never covers (a local server, your own [providers.*] table) no longer say (new since the catalog); they carry no suffix.
  • The direct claude-sonnet-4-5 ids lost their hand-entered window: the catalog now says 200k itself. anthropic/claude-sonnet-4.5 on OpenRouter keeps its cap.

[0.16.3] — 2026-10-14

Fixed

  • harness -p prints the answer when a tool call failed on the way. A single failed call (a read_file on a missing file, a refused command) used to throw away the model’s whole final answer and exit 1. Now the answer is printed and stderr names the failed calls after it: harness: 2 tool calls failed in this turn: read_file, bash.
  • An empty reply no longer ends a turn silently. When the model answers with no text and no tool call (or with whitespace only), the harness asks it once more to continue. A second empty reply ends the turn as a model failure, “the model returned an empty response twice (no text and no tool call)”, instead of a blank answer.
  • A slow model is no longer cut off. A provider’s connection may now stay silent for 600 s (was 120 s) before the call fails, so a model writing a large tool call without sending a byte (seen with Gemini) finishes. A stream that does break says how far it got: gemini stream interrupted after 92 bytes, 600.0 s silent: ….
  • The status row stays visible while you type during a turn. The running tool, the thinking timer or compacting… keeps its place, and the hint enter steers · tab after turn · esc stops follows it. On a row too narrow for both, the hint is dropped whole. With nothing running, the hint still replaces ready.
  • An MCP server that lists only older protocol revisions connects (ADR-0064). A server that answered the 2026-07-28 probe with a list of older revisions the harness speaks (2025-11-25, 2025-06-18, 2025-03-26, and 2024-11-05 over stdio), either as its discovery result or in an “unsupported protocol version” error, used to be refused. The official Go SDK’s default HTTP mode and Datadog’s hosted server answer this way. Now the harness starts the server at the newest revision both sides name. A list holding only 2024-11-05 over HTTP is still refused, and now says why: that revision uses the HTTP+SSE transport, which the harness does not support.

Added

  • /session shows the last model failure: a row under Turns, model failures N · last: <error>, absent when there were none. The Turns row already counted model_failed turns; what is new is the error itself, on one line. A turn you stopped with Esc is not counted there. harness session --json’s turns.outcomes.model_failed is unchanged and still counts stopped turns. The same row shows in harness session <id>.

Changed — for scripts

These change what a script driving the harness sees.

  • -p exits 0 and JSON’s is_error is false when the turn answered, even after failed calls (was exit 1 and is_error true). outcome still reads tool_failed. A call refused under --away is treated the same way: the answer after it is printed, exit 0. Exit 1 now means the turn ended without an answer; see docs/headless.md.
  • The failed-calls line goes to stderr in every output format, json and stream-json included, so a script reading stderr in a JSON format sees one more line.
  • A response head that never arrives within 600 s is not retried. The call fails at once with <provider> timed out waiting for the response, instead of waiting up to five times. A connect timeout, a refused connection and a timeout the operating system reports still retry.
  • Logs recorded before 0.16.3 whose final response was empty or whitespace-only no longer replay: this build asks the model once more at that point, where the log holds no second call. Replay fixtures are version-scoped (ADR-0010); no committed recording has such a response.

Known limits

  • A reply of thinking only, followed by the harness’s request to continue, is unverified against Anthropic’s API. The request then carries an assistant message holding only thinking blocks before the nudge. If Anthropic refuses that shape, the turn fails with its error, visible only in a live run. Chat-completions servers get no empty assistant message: one left with no text, no tool call and no reasoning_details is left out of the request.

[0.16.2] — 2026-10-14

Fixed

  • The startup splash no longer disappears after a resize, a tmux session switch or a return from the external editor. A redraw used to clear the art and keep only the version line, with blank rows above it. Now the splash is redrawn as it was drawn, in its colours. In a pane narrower than the splash, the art is cut at the pane’s edge (never wrapped), and text that no longer fits beside the art is stacked under it.
  • A start in a narrow pane wraps the splash’s text the same way a redraw does, so a redraw at the same width draws the same rows.

Known limits

  • A resize can still leave copies of the screen in tmux scrollback, now including the splash. This is unchanged from 0.16.1: tmux moves rows into scrollback as the pane resizes, before the harness redraws them. The note explains why, and the alternative that was not taken.

[0.16.1] — 2026-10-14

Fixed

  • A box table no longer breaks apart in scrollback after the pane is widened and narrowed again (in tmux: a split closed and reopened, or a switch to a session of a different size). The harness redrew the visible reply at the wide width, and tmux re-wrapped those wide rows into scrollback when the pane narrowed, leaving a second, shattered copy of the table. Now a repaint never draws a reply wider than it was first drawn. Two limits remain: tmux can still leave a few whole rows duplicated in scrollback, and a reply no longer spreads out when the pane widens.

Changed

  • A turn whose only failed calls were ones you denied ends (1 call denied) or (N calls denied) instead of (tool failed), in the panel, the line REPL and playback. A turn with any other failure still ends (tool failed).

[0.16.0] — 2026-10-13

Added

  • [skills] extra_roots folders are searched (docs/config.md). Until now they were recorded and ignored. Their skills reach /skills, the slash menu and the model’s catalog, ranked below every default skills folder, so a name clash goes to the project’s, ~/.agents/skills or the harness’s own.
    • Each entry must be ~/… or absolute, with no .., at most 16; anything else fails the config load. At start, an entry that repeats another, names a default skills folder or sits in the project is dropped with a notice; a missing folder is kept with one.
    • A session keeps the roots it started with: a resume uses them, not today’s config, and says so when the config has changed. The log records them as ~/…, never with your home folder’s path.
  • /skills marks an extra root’s skills “extra folder N”, with a line naming the folder, and gains a Not loaded section listing every skill that did not load and why. A session start with any says ⚠ 1 skill not loaded — /skills says why.
  • Markdown tables are drawn as tables in the panel, boxed and fitted to the pane, re-fitted when it narrows; too narrow for columns, each row is drawn as a record. While one streams the live row reads table · N rows. The line REPL, -p, export and the session log keep the raw text.
  • /session is coloured: the session id in the accent blue, the cache hit rate green, allowed counts green, denied and flagged red.
  • Gemini 4 Argon (gemini:gemini-4-argon) is known: its 1M-token context window is used without a [models.context] entry.

Changed

  • A skills folder is read only inside itself, for every skills root (~/.agents/skills, the harness’s own and extra roots). The folder itself may be a link (a dotfiles setup works), but a SKILL.md or file that links out of its skills folder, or any absolute link inside it, now stops loading, and /skills says what links where and what to do (copy the file in, link the whole folder, or recreate the link with ln -sr).
  • A skills folder that overlaps a protected place is not used: one inside, or containing, a folder the command sandbox hides (~/.ssh, ~/.config/gh and the like), the harness’s session store, or (for an extra root) the project, so ~, ~/.config or / is refused. ~/.claude/skills itself is allowed. The message names the folder and why. If such a place cannot be checked (a permissions problem, say), no skills folder is used until it can, and the message names the folder that could not be checked.

Fixed

  • A reply keeps its colours and its code indentation after a resize or a tmux session switch. A repaint used to redraw it plain and squeeze code blocks; a long line was squeezed even when first printed.
  • /agents’s undefined-nickname row keeps its amber on a repaint.
  • A ~~~ fence is drawn as code; a code fence closes only on a bare marker of its own kind (one followed by a language name no longer closes it); and an interrupted answer no longer leaves the next one drawn as code.
  • Folder and skill names holding control characters are shown escaped in /skills, harness paths and start notices.

Notes

  • Do not resume a 0.16 session with 0.15.x. Nothing stops it, but its segment does not search the extra roots, and 0.16 can no longer replay the log (AF-R8). 0.16 reads, replays and resumes every older log; a log written before 0.16 keeps its extra roots unsearched.

[0.15.1] — 2026-10-12

Fixed

  • The harness now works behind a proxy that inspects HTTPS. A corporate gateway or a sandbox’s egress re-signs each connection with its own certificate authority, and every model call failed with transport error: error sending request, because the harness trusted only the authorities compiled into it. Set SSL_CERT_FILE to a PEM file holding the proxy’s certificate (your system’s bundle is fine): it is trusted as well as the compiled-in ones, for every provider, the judge and remote MCP servers (docs/config.md). Unset, nothing changes. A file the harness cannot use (unreadable, not a regular file, over 16 MiB, or holding no usable certificate) stops run, -p, eval, resume and judge score before they start, naming the file.

[0.15.0] — 2026-10-12

Added

  • MCP servers, configured in config.toml under [mcp.servers.<name>] (docs/config.md). A session with servers offers the model their tools through two tools, mcp and mcp_schema, and a catalog in its system prompt; a session without servers sends exactly what it sent before. A server that fails to start does not stop the session: one line says why, and its tools are absent.
    • Local servers (command) are started by the session and supervised: a harness that dies, even by kill -9, leaves no server running. Each runs in its own sandbox box, read-only on the project, with no home folder, a stripped environment plus the variables env names, and no network unless network = true. read binds a folder read-only into the box, for a program under your home folder (node under ~/.nvm, say). sandbox = false runs a server outside the box (a Docker-run server needs it); that is recorded and shown as sandbox off.
    • Remote servers (url, Streamable HTTP) take a token from the variable token_env names, sent only to that server’s own address; redirects are never followed. No OAuth.
    • Both protocol eras: the stateless 2026-07-28 revision and the initialize revisions before it.
  • Every MCP call asks, once (y allow once · n deny · 4 tell the agent), in manual and in auto (the judge is not consulted), unless the server’s allow names the tool, or the session runs with --approve-all. No turn or session grant covers an MCP call. Plan mode does not offer MCP; under /away an MCP call that would ask is refused. The gate shows the server’s own read-only hint, which changes nothing.
  • /mcp lists the servers this session’s start listed: transport, command or address, sandbox, protocol version, tool count, an estimate of the catalog’s tokens, the allow list, and the calls and failures since. The /session card gains an MCP row, and harness session --json an mcp object.
  • Every MCP call is recorded in the session log, with credentials redacted. The values of env variables and the token never enter the log, and any of them a server echoes back is replaced by [redacted]; a value shorter than 8 characters is refused at start, since it could not be redacted safely. Secrets go in env or token_env, never in command or url, which are recorded as written; a url holding a user name or password is a load error. harness replay replays an MCP session with no server, no config and no credential.
  • Resume reads the catalog from the log and lists nothing; a server starts at its first call. A server whose config differs from the session’s start is refused (configured differently; start a new session to use it).
  • Results are text, capped at 64 KiB; an image or audio block reads as a placeholder ([image: image/png, 1234 bytes]).

Changed

  • Typing /tokens no longer points to /session. The one-release hint (0.14) is over; /tokens is refused like any unknown command.
  • Every sandboxed session hides $STATE/mcp (the servers’ private homes) from its commands, MCP or not, so a new session’s record names one more hidden folder than under 0.14. Recorded sessions replay unchanged.
  • A config found through HARNESS_HOME or XDG_CONFIG_HOME inside the project cannot define servers (a load error): a cloned repository must not be able to start a process.

Notes

  • A session that used MCP is read by 0.14.x as holding unknown records: it lists and shows its card, and resume and replay refuse it, naming the effect. 0.13.x and older cannot read it. An older harness also refuses a config.toml that holds [mcp.servers] (it rejects unknown keys).

[0.14.0] — 2026-10-12

Added

  • /session shows what this session has done, in the panel and the line client (/status works too). One card, read from the session log when you run it: the id, its resume command and log file; where the time went (in turns, the model, tools, waiting on you); turns and model calls; tool calls that succeeded, failed, never ran or are still pending; approvals; the judge’s verdicts; files changed; tokens and the cache; an estimated cost; how full the context is; and the model, thinking, mode, sandbox and checkpoints in force now. Child agents’ figures follow their parent’s on the same rows (+ 2 children: …).
  • harness session <id> prints the same card for any session, finished, crashed or still running, from a full id or a unique prefix as resume takes them. --json prints the figures as one JSON object ("v": 1; later versions only add keys). --transcript prints the session’s scrollback as the line client showed it, paged on a terminal like git ($PAGER, else less); --no-pager prints it straight out.
  • A summary line above the resume line when you quit: 5 turns · 43m in turns · 2.49M in / 21.6k out · ≤ $1.95. It is left out after a forced quit (Ctrl-C while quitting), when the log may still be growing.

Changed

  • /tokens is gone. Its figures are on /session’s Tokens and Cache rows; typing /tokens says use /session.
  • harness sessions and the resume picker list a session ended by a store failure as failed. Before, a session stopped because its log could not be written or read could list as completed.
  • The cost is an estimate at list price. It is marked ≤, an upper bound, only when every model call is priced and none sent a prompt over 200K input tokens, where some providers charge long-context rates the catalog does not hold (the card then says long-context rates not priced). When some calls have no price it reads $X for N of M calls. A cost the provider reports (OpenRouter) is taken as is; judge calls are not priced.

Fixed

  • A session log whose last line was cut inside a multibyte character (a crash mid-write) no longer reads as corrupt: it no longer stops the next write to the log, or hides the session from harness sessions and the picker.
  • A log holding an effect this version does not know (0.15.0’s MCP calls, for one) now lists and shows its card instead of reading as unreadable; resume and replay refuse it, naming the effect.

[0.13.1] — 2026-10-11

Fixed

  • A decimal number in a tool call no longer ends the session. A model calling a tool with, say, limit: 100.0 ended the session at its next model call (session failed: store append failed: … floating point is not permitted in a digested payload), and resuming failed the same way. Such a session now carries on, resumes and replays. A session 0.13.0 ended this way can now be resumed; harness replay still cannot replay it. read_file still reads limit: 100.0 as no limit (whole numbers only, as before).
  • Cases and bundles can be made from OpenRouter sessions, whose recorded usage holds a decimal cost.

0.13.0 and older cannot resume or replay a session that holds a decimal: they stop where they stopped before.

[0.13.0] — 2026-10-11

Added

  • A message typed during a turn now reaches the agent while it works. Press Enter while the agent is busy and your message is sent at once: it waits in a ↳ row above the box and the model reads it before its next call, right after the tool calls it has already asked for have run. It then appears in the transcript where it landed. If the turn ends before that point (the agent answered without another tool call), it is sent as your next message. The status row says enter steers · tab after turn · esc stops while you type, and an approval that opens while a message waits says when it goes in. Each delivered message is recorded in the session log as a new record, user.steered.v1, and replays exactly.
  • Tab keeps the old queue. Tab instead of Enter during a turn holds the message until the turn ends, as Enter did in 0.12 (⧗ … · after the turn · ↑ edit; ↑ takes it back).
  • /skill:<name> <request> sends the skill’s instructions and your request as one message (/skill:show-me make a diagram of the store). /skill:<name> alone works as before.
  • Auto by default, asked once. If TYPESAFE_API_KEY is set and config.toml has no mode, the first interactive start asks Start in auto by default? … [y/N] and saves your answer as mode = "auto" or mode = "manual". It never asks again. --mode still overrides it for one run, and Shift+Tab still switches within a session.
  • A top-level mode key in config.toml names the mode new sessions start in (manual, plan, auto, or a [modes.<name>] table). harness eval always starts in manual.

Changed

  • Enter during a turn steers instead of queueing. Use Tab for the old behaviour. Esc still stops the turn and puts everything that had not gone in back in the box. Quitting while a message waits stops the turn first; Ctrl-C during that leaves at once.
  • Headings follow your accent colour. With [ui.colors] accent set and no md_heading, markdown headings use the accent, bold. The dark theme’s default accent is now a pale blue, #a6d3fd (256-colour 153). --help keeps the previous teal, which reads on light and dark backgrounds alike.
  • A stop that arrives just before the agent’s next model call now ends the turn as interrupted before the call is made, rather than cutting the call short.

Notes

  • An older harness refuses a config.toml that holds the new mode key (it rejects unknown keys), and refuses to resume or replay a session that holds a steered message (it names the record it does not know).

[0.12.1] — 2026-10-11

Fixed

  • export --format md no longer ends each reasoning block with empty > lines. Gemini’s recorded reasoning ends with blank lines, and every one was quoted. Blank lines before and after each reasoning text are now left out, in md and html alike; blank lines between paragraphs stay.

[0.12.0] — 2026-10-10

Added

  • harness -p "question" runs one turn and exits, for scripts and other agents. harness run -p is the same, --prompt and --print are other names for it, and the root takes every run flag with it (harness -p "q" --model openai:gpt-6). Piped input joins the prompt, data first and your question last: cat log | harness -p "why does this fail?"; git diff | harness -p takes the whole prompt from stdin. In a terminal you see the whole turn live, as run --prompt showed it before. When stdout is a pipe or a file it gets only the final answer, and only when the turn succeeded: exit 0 means stdout is the answer, 1 means it failed and says why on stderr, 2 means the arguments or stdin were refused. --verbose puts the live transcript on stderr. The contract is docs/headless.md.
  • --output-format json and stream-json. json prints one result object when the run ends (result, is_error, session_id, usage where Claude Code’s callers look for them, plus outcome and the calls that were denied). stream-json prints one line per finished step (the prompt, reasoning, answer text, each tool call, its approval and its result), then that result. Both carry "v": 1; fields and types are only ever added.
  • When nobody can answer an approval, the call is refused and the turn goes on. Under -p with piped input, or with no terminal to ask on, a call that needs approval does not run; the model is told nobody was there to approve it and finishes with what it can, and stderr names the refused call. A turn whose only failures are these refusals still succeeds. In a terminal you are asked, as before.
  • Reasoning from every provider, in the transcript. OpenAI (Responses) and Gemini now send their reasoning summaries too. The panel writes reasoning into the transcript as it streams, under a ✻ Thinking header with the seconds it took, instead of one flickering line on the status row, and keeps it in the history. [ui] reasoning = "full" | "preview" | "off" chooses all of it, the first four lines, or the timer only; /reasoning switches it for the session. The plain line interface shows reasoning only on a terminal, so piped output is unchanged.
  • A message typed while the agent works is queued, not sent. It shows as a ⧗ row above the message box and is sent when the turn ends, one turn per message, in order. ↑ on an empty box takes the newest one back to edit. Esc stops the turn and puts every queued message back in the box, unsent. Quitting sends none of them; they are in your input history.
  • export --format md and html include the model’s reasoning before each reply, whatever [ui] reasoning says. In html it is a closed “Thinking” section.

Changed

  • The approval menu no longer takes keys meant for the message box. A gate that opens within a second of a key you typed, or of sending a message, waits for ↑ or ↓ before it answers; until then your keys keep going to the box, and the menu says you were typing — ↑/↓ to answer. Esc still denies and stops at once.
  • run --prompt into a pipe or a file prints the answer, not the transcript. A script that read the transcript from stdout now adds --verbose and reads stderr.
  • Piped input no longer answers approvals for run --prompt: it joins the prompt. To answer approvals from a pipe, pipe the conversation into plain harness run.
  • OpenAI’s reasoning summaries need a verified organisation. Where OpenAI refuses them, the harness retries the call once without them and stops asking for the rest of the session; that session shows no OpenAI reasoning, and says nothing about why.

Known limits

  • -p cannot continue a session (resume -p), takes no slash commands, and stream-json carries no live text deltas.
  • A stdout that fails to be written fails a -p run only when it is a pipe or a file; a terminal that goes away mid-run is not reported that way.
  • Reasoning already on screen re-wraps raggedly, and loses its bold, when the panel is resized.
  • A call refused because nobody could answer shows in the transcript as ✗ denied by you: ….

[0.11.0] — 2026-10-09

Changed

  • New session logs are about ten times smaller on long sessions. A log used to copy the whole conversation into every model call’s record, so it grew with the square of the session’s length. Now each message, the system prompt and the tool list are stored once, and each call lists them by hash. On the three largest logs measured, a 125 MiB log comes to about 8 MiB. resume, replay, check, export, flag and bundle read correspondingly less.
  • A file the agent reads or writes is stored as its text when it is valid UTF-8, instead of one number per byte.
  • Old logs read and replay exactly as before. Nothing rewrites a stored log. Resuming an old session cleanly switches it to the new form from that point; what it already holds stays the size it was. A session resumed after a crash keeps the form it was writing.
  • flag pins with fingerprint scheme 2 ("fingerprint_scheme": 2 in the case file): the fingerprint leaves the stored text out. check verifies each stored piece a pinned turn relies on against its hash, whatever the case’s scheme, naming any that is missing or altered. A case pinned before 0.11.0 over an old log checks as it did, and the turn it pins is still found when flagged again. A version pinned by a newer harness’s scheme is an error naming the scheme.
  • bundle ships the stored pieces a pinned turn lists from earlier in the session, once each, and its manifest names them (added_pieces, bundle_format: 2). A bundle of a case pinned before 0.11.0 over an old log is unchanged, byte for byte.
  • export and bundle now see file contents. Their key scan could not match a file stored as numbers. A new session that read a file holding key material now refuses to export or bundle without --allow-keys, where 0.10 passed it.
  • replay refuses a session whose model call was cut between storing its text and starting the call, as it refuses any step a crash cut short. resume continues such a session.
  • run --mock prints the stored pieces among its records.

Known limits

  • 0.10 and a 0.11 log: 0.10 lists the session with its new records counted as unknown and exports them, but refuses to resume or replay it once the session has made a model call. A session 0.10 crash-resumes before its first model call cannot then be replayed by 0.11.
  • 0.10 and a 0.11 case: 0.10 ignores the scheme. Its check and bundle report a fingerprint mismatch for a turn whose log stores pieces, and agree over an old log. Its flag --case drops the scheme from every version it rewrites, which breaks them for 0.11: do not re-pin a 0.11 case with 0.10. Its export --format jsonl of a new log drops the format field, so the copy reads but does not replay under 0.11.
  • Logs written before 0.11.0 keep their size, and the key scan still cannot see the file contents they hold.

[0.10.1] — 2026-10-09

Fixed

  • Ctrl-C is no longer lost on the plain line interface (run and resume without the panel, piped input, and run --prompt). A press that landed while the harness was printing could be dropped, so a turn kept running, or an approval kept waiting, until a second press. Now it is kept and handled at the next chance, with the same meaning as before: it interrupts the turn, denies and stops at an approval, and quits at the idle prompt. Presses that arrive before the harness reads them count as one, and a second press that lands after an interrupt has ended the turn quits. An approval still queued when Ctrl-C interrupts the turn no longer opens a prompt you would have to answer or cancel again: the call shows as denied. The panel was not affected.
  • File-change checks in evals see edits made through the shell. The committed review case, the code-mode adoption case and the check scripts/adlc-demo.sh adds now count file.mutated.v1 or turn.files.v1, so a run that edits with sed or a script no longer fails them, while checkpoints are on. A turn-end list with no changed, new or deleted file in it (only a note, or only paths it could not check) does not count as a change.

Added

  • An eval check can count any of several event kinds. In a case’s expect.event_shape, kind may be a list: ["file.mutated.v1", "turn.files.v1"] counts events of either kind. A case written with a single kind reads and writes back unchanged.

[0.10.0] — 2026-10-08

Added

  • A new session starts on a model you have a key for. The order: --model; then the model you saved in /model or config.toml’s model, whichever you changed last, if its key is set; then the other of the two; then the first provider with a key, in /model’s order (anthropic, openai, gemini, openrouter, together, deepseek, zai, xai), on its default model. When the harness had to choose, a line under the banner says why (stderr for --prompt). resume keeps the log’s model, and harness eval uses --model and config.toml only, as before.
  • With no key at all, the panel opens with a warning naming the variables to set; run --prompt and piped input print it and exit 1.
  • /model remembers your pick. Enter switches and saves the model and its thinking level as your default for new sessions; Tab switches for this session only. A typed /model <id> or /thinking <level> saves too, and the /thinking picker takes the same two keys. The pick is kept in the harness’s state folder, never in config.toml; edit config.toml’s model or thinking after a save and config.toml wins.
  • When the sandbox cannot start on Linux, the message says what the harness seems to be running inside (Docker, Podman, gVisor, a Kubernetes pod, WSL, …), with the evidence, and offers mode = "external" first. It is a guess: it changes nothing and is not recorded.
  • Default models for openrouter, together, deepseek, zai and xai.

Changed

  • The gatekeeper is now the judge. Config takes [judge] and a mode’s judge = […]; [gatekeeper] and gatekeep are still read, and a file using both spellings is refused. harness gatekeeper score|fit is now harness judge score|fit, with no alias. Every message says “judge”, and the first-run notice shows once more. A session log recorded before 0.10.0 whose judge gave a reason no longer replays.
  • /keys is now /shortcuts, with no alias; ? on an empty input still opens the table, and its page is docs/shortcuts.md. Side by side the table needs 83 columns; narrower panes page it.
  • Switching models brings that model’s thinking level (its saved one, else config.toml’s thinking, else medium), where a typed /model used to keep the session’s.
  • The sandbox hides its secret folders inside HARNESS_HOME too (.secrets, .ssh, .aws and the rest of its list), as it does in your home folder.
  • Any file inside a .secrets folder of your project is a credential file: reading or editing it asks, search skips it, checkpoints leave it out, and commands allowed the network cannot see the folder.
  • docs/sandbox.md now says that a credential read asks in every mode except under --approve-all and in run --mock, where nothing can ask. The behaviour is unchanged.

Fixed

  • /model search finds a provider by name: “open” lists every openai: and openrouter: model, where it used to miss openai: ones. /model openai still lists openai’s own models.

Known limits

  • On macOS, a file matching .cargo/credentials* that is created after a session starts stays visible to commands until the next session; on Linux, every hidden name created after the start does.

[0.9.1] — 2026-10-07

Fixed

  • A session killed again after its recovery notice, before the turn closed, now shows at the next resume what that resume’s leftover check found, or that the check was cut short, instead of only repeating the earlier notice.
  • A notice shown again says “this resume checked again” only when the resume actually checks leftovers.
  • 0.9.0’s notes said two things too broadly, and are corrected in place: the model gets the notice only when the killed step was a tool call, and the stop for a session resumed from another folder needs a killed sandboxed command.

[0.9.0] — 2026-10-07

Added

  • A killed session can be resumed. If the harness is killed while a command, a file write, a picture, an /undo restore, a model request or a child agent runs, harness resume now picks it up instead of refusing.
    • The killed step is never run again. Its outcome is unknown. You get a notice saying what was killed and what changed in the project since the picture before it, with the /undo for each loss; when the killed step was a tool call, the model’s result for it carries the same notice. (Corrected in 0.9.1.)
    • The killed turn gets its turn-end list, as an interrupted turn does.
    • The notice is shown again at each later resume until you send a message, marked as from an earlier resume.
  • Leftover commands are checked at that resume. While a command runs, the harness keeps a small owner-only record of it under its state folder, and removes it once the command has settled.
    • On macOS, a sandboxed command still running from before the kill is stopped. On Linux, the sandbox already ends with the harness.
    • Processes left by a command run outside the sandbox are reported, never stopped, since one may be a server you want.
    • The protected-file check runs again against what was recorded before the kill, and moves aside a planted file.
    • If cleanup cannot be confirmed, sandboxed commands are held, and the notice says why and what to do. A record that cannot be read holds them until you remove it; the notice gives the command.
    • A session folder of records that cannot be listed, or a check that fails, stops the resume with what to do; resuming again checks again.
    • A session whose killed command ran in the sandbox, resumed from another project’s folder, stops and prints the cd … && … resume … line to run there; other killed steps resumed from the wrong folder are not caught (see Known limits). (Corrected in 0.9.1.)
  • docs/checkpoints.md “Resuming a killed session” and docs/sandbox.md “A killed command’s leftovers” have the details.

Changed

  • A sandboxed command is refused if its record cannot be written (for example, the state folder is not writable). The refusal names the folder and how to fix it.
  • A turn closed by a crash now gets its turn-end list, and a resumed line session no longer shows each turn one input late after a crash.
  • harness replay refuses a session that holds an abandoned step, naming it; harness export shows the abandoned step and the recovery notice.
  • The refusal to resume a sandbox-off session without the sandbox names --sandbox off as well as the config setting.

Known limits

  • A session held at resume, then closed while idle, is neither held nor checked at its next resume.
  • A killed command’s change to the git files the gatekeeper watches is not re-checked at resume.
  • A refusal or other tool failure longer than the terminal is cut on screen; harness export shows it whole.
  • A killed file write, or a command run outside the sandbox, resumed from another project’s folder is compared against that folder, so its change list may be wrong. (Added in 0.9.1.)

[0.8.0] — 2026-10-06

Added

  • Checkpoints. Before every bash, exec and agent call, and before a turn’s first file write, the harness takes a picture of the project: each covered file’s type, size, mode and times, with a copy of its content in the harness’s own store. Later writes in the turn picture only the path they touch, and a picture copies only what changed since the last one. It never touches your .git and never reads git to decide what to copy.

    • Not copied: what .gitignore excludes (in a project without git, a built-in list: node_modules/, .venv/, target/ and similar), your [checkpoints] skip list, and the harness’s own folders. Credential files (.env, *.pem, *.key, …) and files over the size cap are recorded but never copied. A folder whose .gitignore the harness cannot read exactly as git does is shown as “not checked” rather than copied past its rules.
    • The store is owner-only, under the data folder (checkpoints/<project key>/), and shares unchanged copies between pictures. Its limits are in [checkpoints]: 100 MiB per file, 5 GiB and 14 days per project, and per picture 10,000 files, 500 MiB or 10 seconds.
    • When a picture stops early, it says so and names what it skipped. The tool call always runs, unless you interrupted it.
    • No pictures are taken when the project is your home folder, /, or inside the harness’s own folders. A notice at start says so.

    docs/checkpoints.md has the whole story.

  • The turn-end list. After a turn that changed files, a short list shows files: N changed, N new, N deleted, up to ten lines, with a folder whose files were all deleted shown as one line.

    • Losses are flagged, each with the /undo that brings it back: not in git, not checked against git (when the harness cannot tell), 20+ deleted and protected.
    • What the harness could not check is stated, not hidden.
  • /undo and /undo <path> put files back from a picture.

    • The flow: pick a picture, see a preview of what will be written back, recreated, left alone and kept, then confirm. The result lists each path the same way.
    • /undo never deletes. Files created since the picture are listed as kept, for you to remove.
    • It takes a picture of what it overwrites first, so an /undo can itself be undone.
    • The model is told what was restored, and that stays true after /compact.
    • In the plain line client you pick by number and confirm with y.
  • /checkpoints on|off and [checkpoints] enabled (on by default). /undo still works on pictures taken before you turned them off.

  • /model asks for a thinking level for the model you pick, with the level in force highlighted. Enter switches both; Esc goes back to the list.

  • Opus 5.5 is in the bundled model list, and replaces Opus 5 on the shortlist (Opus 5 is still reachable by browsing).

Changed

  • Sandboxed commands can no longer read the harness’s session logs or its checkpoint store. If the project holds either folder (a harness home inside the project, or the project is your home folder), the agent’s file tools refuse them too. As a result, harness export and other commands that read session logs do not work from inside a sandboxed command.
  • A failed write to the session’s journal now ends the session, as a failed log append already did, instead of carrying on with an incomplete record.
  • The Markdown export shows thinking-level, iteration-cap and /checkpoints changes, not only model changes.

Known limits

  • /undo covers project files only. It cannot undo what a command did outside the project or sent over the network.
  • Commands approved to run outside the sandbox can still reach the store and the session logs.
  • Resuming a session that was killed while a command ran is part 2 (0.9.0).

[0.7.2] — 2026-10-06

Fixed

  • The static Linux binary builds again. The x86_64-unknown-linux-musl release build stopped linking at 0.4.0 (renameat2 undefined), so no static Linux binary could be built since then. It links again, and behaves the same.

[0.7.1] — 2026-10-04

Fixed

  • @ path completion after editing back into a path. Once the cursor had touched the @ (Home, or ← past it), the menu closed and nothing reopened it, so Tab did nothing. Tab now completes the path the cursor is in.
  • /mode without the sandbox (off, external, or unavailable) no longer says it asks before a command uses the internet or runs outside the sandbox: those questions are only asked while the sandbox is on. The two rows read “no extra question: commands have it” and “no extra question: commands run outside”.

Changed

  • The resume line’s command (Resume this session with: harness resume …) is amber, the colour a command has in the model’s answers, instead of teal.

[0.7.0] — 2026-10-03

Added

  • The sandbox runs on macOS. On macOS 26 or later every bash and exec command runs under Seatbelt, Apple’s kernel sandbox, through /usr/bin/sandbox-exec, enforcing the policy bubblewrap enforces on Linux. It is on by default, so auto, --approve-all, run --mode auto, harness eval and away work on a Mac with the sandbox on; 0.6.0’s “no sandbox for commands on macOS yet” is gone. docs/sandbox.md, “On macOS”, sets the two systems side by side.
  • What a command can do on a Mac: write the project, $TMPDIR (a private folder for the session), a linked worktree’s git folder, your [sandbox] writable entries, and your user temp folder except Apple’s com.apple.* folders in it. A plain command has no network at all, 127.0.0.1 included, so a test against a local fake server needs network: true, which gives the whole network and still hides the project’s credential files. The machine’s /tmp is out of reach (/tmp/x fails; use $TMPDIR); other processes are invisible and cannot be signalled; disks, the console and your other terminals are refused, and so are open, Apple Events to other apps, launchd jobs, the keychain and the pasteboard. The hidden list gains the keychains, cookies, accounts, mail, messages and app data under ~/Library, the browsers’ profiles and ~/.zsh_sessions.
  • A command’s processes end with it, normally, on a timeout or on Ctrl-C. If the harness cannot confirm they all have, the command’s result is withheld with an error naming them (at most eight, then how many more) and how to end them, and the session runs no command and no file tool, a child session’s included, until a later command checks clean.
  • The sandbox is checked at every session start, under its full rules, in about 0.2 s. It is unavailable, with the cause named, when a check fails, when /usr/bin/sandbox-exec is missing, or when the harness runs inside another sandbox; that last notice suggests mode = "external" when the outer sandbox is the boundary.

Changed

  • On a Mac the model is told that temp files go in $TMPDIR, that /tmp is not writable, and that a command that listens on or connects to any port, local ones included, needs network. Linux’s texts are unchanged.
  • Costs on a Mac: a sandboxed command takes about 30 ms longer than an unsandboxed one, and a session start about 0.2 s longer. A command’s profile is capped at 1,000 path filters, so a very long [sandbox] hide list slows every command and, past the cap, is refused; the command line with its environment must fit in 1 MiB; and a project path of more than about 1,000 bytes cannot run sandboxed commands.
  • What you carry on a Mac: a command cannot rename or remove a folder that holds a hidden or read-only path, nor the project itself, and cloning a whole folder in one call is refused (copy tools fall back to copying file by file). It cannot create the first missing folder above a hidden path or a read-only git path that does not exist yet: while .git/hooks links to tools/githooks, a command cannot create tools/. A command is refused, naming the folder and the fix, when a folder on the way to a hidden or read-only path, inside the project or another writable folder, cannot be searched. The keychain is refused inside, so keychain-backed credentials do not work there; for git’s osxkeychain and gh that is expected, not tested. Tools that sandbox themselves fail inside (swift build --disable-sandbox turns SwiftPM’s off). Your user temp folder is shared with your other programs.
  • A Mac session’s record names the backend (seatbelt macOS 27.0 (26A428)), the network as none, the temp folder as $TMPDIR and $USER_TEMP among the writable folders. A log a Mac session records with the sandbox on cannot be replayed by a 0.6.x binary: replay it with 0.7.0 or later. Linux records are unchanged.

[0.6.0] — 2026-09-30

Added

  • The harness builds and runs on Apple Silicon Macs with macOS 26 (Tahoe) or later. An older macOS refuses to start the binary, replay included, and Intel Macs are not supported. The README has the Mac setup. The file tools keep their confinement there: a link out of the project is refused, a credential-file read asks, a protected-file write asks. Commands keep their hygiene: stdin from /dev/null, a new session, no inherited descriptor past stderr.
  • macOS has no sandbox for commands yet (the Seatbelt backend is the next slice). A Mac starts with the sandbox unavailable and says so at every start: manual works, and auto, --approve-all, run --mode auto, run --away and harness eval need [sandbox] mode = "off" (all but away, unconfined) or mode = "external" (all of them).
  • [sandbox] mode = "on" | "off" | "external", and --sandbox <mode> on run, resume and eval for one session. The flag beats the config, and cannot be combined with eval --target. enabled = false still means off.
  • External mode, for a dedicated VM, a container or a CI runner that is itself the boundary, on Linux and macOS. The unattended modes work, away included; --approve-all refuses only the hard-deny list; the gatekeeper, the local asks and the file tools’ asks apply as they do without a sandbox. Commands see the machine as your user does: no hidden folders, no read-only protected files, no private /tmp, no network rule. Only you can set it, in your own config or with the flag; there is no environment variable. The session record says external, which switch set it, and where the config came from. docs/sandbox.md, “External mode”, says what the machine must do instead: keep keys and long-lived tokens off it, limit its network, and snapshot or discard it after a run.
  • Under external, credential variables stay out of commands’ environment — the credential-shaped names, a URL with a password, and your env_hide — except exact names you list in [sandbox] env_pass. Provider keys are always removed, and naming one in env_pass is a config error in every mode. The record lists the names removed and passed, never values, and the model is told which variables are missing. This is not a credential boundary: a command running as your user can still read the harness’s own environment.
  • harness paths names where the config came from: default, XDG_CONFIG_HOME or HARNESS_HOME.
  • On macOS the model’s prompt says the machine runs macOS with the BSD command-line tools (sed -i '', stat -f).

Changed

  • On a disk that ignores letter case (a Mac’s default), protected files, credential names, .git (the hard-deny rule too), path grants, the plan file and git’s index ignore ASCII case: write_file claude.md asks as a write to CLAUDE.md, and read_file .ENV asks as a credential read. The disk’s answer is recorded when a session starts, and a session whose disk cannot be asked refuses to start.
  • On macOS a credential-file read through a link to a file with several hard links asks, since its other names are unknown.
  • A [sandbox] table in a config found through HARNESS_HOME or XDG_CONFIG_HOME is refused when the file lies inside the project, by any spelling of the project’s path, or when its real path cannot be established; the rest of that config still loads. Your default ~/.config/harness, a HARNESS_HOME outside the project, and an XDG_CONFIG_HOME that is ~/.config spelled out are never refused.
  • enabled and mode that disagree, either way, are a config error.
  • Start-up notices name the switch that set the mode (set by --sandbox, set by $HARNESS_HOME/config/config.toml). The unavailable notice’s fix names mode = "off" rather than enabled = false; with enabled = false the notice is unchanged.
  • A crash resume under external continues only while external is both what was recorded and what is set now, and the recorded environment names, platform and case fact still hold; otherwise it exits 2, naming the first difference. Moving a session to or from external takes /quit and a clean resume. Other recorded states resume as before.
  • Git’s system config is also read where macOS keeps it: under the active developer directory, as xcode-select finds it, the Command Line Tools and Homebrew.
  • A log that records external cannot be read by a 0.5.x binary: replay or resume it with 0.6.0 or later.

Fixed

  • cargo build -p cli --release works on macOS 27, whose Command Line Tools’ strip left the build-time proc macros unloadable; the shipped binary is still stripped.

[0.5.1] — 2026-09-30

Changed

  • On a prompt for a delete git cannot undo, decoded text being run, a protected-file write or a credential read, the panel’s highlight now starts on deny, so an Enter pressed out of habit refuses; press y (or move up) to allow. Other prompts are unchanged, and the plain REPL already treated a bare Enter as no.

[0.5.0] — 2026-09-29

Added

  • A bash or exec command that empties or deletes a file git cannot bring back asks first, in manual and in auto, whatever the gatekeeper would say: truncate, an empty redirect (: > f, > f, true > f, cat /dev/null > f, on any descriptor), cp /dev/null f, rm f, git rm -f f, or git checkout -- f / git restore f, when the file is untracked and not ignored, changed since its last commit, or in no git repository. The prompt reads Asked because it empties or deletes notes.md, which git cannot bring back (untracked). A committed, unchanged file and an ignored file go on to the gatekeeper as before. The git state is read from git’s own index, never by running git.
  • A command that decodes hidden text and runs it (… | base64 -d | sh, eval "$(… base64 -d)", xxd -r -p … | bash, python3 -c "exec(…b64decode…)") asks first: Asked because it decodes hidden text and runs it. Decoding into a file without running it does not ask.
  • A command that empties or deletes files past what the harness reads — more than 1,024 of them, or behind more directory changes or nesting than it follows — asks too: Asked because the harness cannot tell which files it empties or deletes.
  • None of these asks can be granted for the turn or the session; all are refused while you’re away, and --approve-all skips them. They see only the files a command names: a glob, a variable, find -delete, xargs rm or a script still goes to the gatekeeper. docs/modes.md, “Local asks”, lists the forms.
  • harness gatekeeper score and fit apply these asks before the gatekeeper: a row they catch counts as ask at every cutoff, and fit prints a line naming every catch.

Changed

  • Protected files that git doesn’t track, at their fixed places, are read-only inside the sandbox: a local .envrc, .claude/settings.local.json, an untracked file in .vscode/, .claude/ or .github/workflows/, and your own [protected] add entries. No command can change one, however it spells the write; the file tools still ask. A protected folder holding nothing git tracks is read-only whole.
  • A protected path a sandboxed command creates, such as a new .envrc or a new file in .claude/, is moved aside to <name>.harness-quarantined-<16 hex digits>, never deleted, and the result says where. That holds even when the command stages it or a checkout brings it in, and for a tracked one turned into a directory, a link or a FIFO; putting it back takes one approval. One that can’t be moved stops every later sandboxed command, naming it, until it moves or you move it away.
  • Tracked protected files (a committed CLAUDE.md, a workflow) and every build.rs stay writable to commands, so a sandboxed git checkout or git switch completes. Review what a task changed against the commit it started from, with the steps in docs/sandbox.md: note the commit and the ignored files before it starts, then diff and list from a fresh index built from that commit (GIT_INDEX_FILE=<tmp> git --no-replace-objects read-tree <commit>), the ignored list included. A command can write .git — the index, a replace ref, an ignore rule — to hide a change from git diff <commit> and git status; the steps resist the tricks known today and are not a proof. Code planted in a build file runs the next time you build or test on the host, so review first.
  • A protected path that is a link to the project or a folder holding it (.claude -> .), or to a target so deep that binding it needs more mounts than bubblewrap allows, stops every sandboxed command, naming the link, until you replace it.
  • Under harness run --approve-all, a command asking to run outside the sandbox (outside_sandbox: true) is refused before it runs, and the agent is told to carry on with the work that needs no approval and name the step in its final message; network: true still runs unasked. harness eval, run --mock and child sessions follow the same rule. To leave the sandbox, run with a person present.
  • A session recorded before 0.5.0 gains these asks at its next clean resume. A session started with --yes before 0.5.0 that crashed, and is crash-resumed, still lets a full escape run unasked until you resume it cleanly.
  • A log written by 0.5.0 under --approve-all, eval or run --mock records the network_only escape rule, which a 0.4.x binary cannot read: replay or resume it with 0.5.0 or later.
  • On a prompt whose ask no turn grant covers (a protected file write, a credential read, and the local asks), the “this turn” choice now says except this kind: another call of the same kind asks again.

Fixed

  • A git config with a very long section header and many keys no longer takes memory far out of proportion to its size when a session starts.

Removed

  • harness run --yes is gone: use --approve-all (the rename shipped in 0.4.1). A script still passing --yes now fails at argument parsing, before anything runs.

[0.4.1] — 2026-09-28

Changed

  • harness run --approve-all replaces harness run --yes and does what it did: a tool call that would ask you goes ahead instead. --yes still works in this release, but is no longer listed in --help and prints a one-line note; a later release removes it. Messages and docs that named --yes now say --approve-all.

[0.4.0] — 2026-09-27

Added

  • Every bash and exec command runs in a sandbox, in every mode, --yes included. It can write only in the project and a private /tmp, cannot read your key folders (~/.ssh, ~/.aws and the rest of the hidden list) or credential variables, has no network but localhost, cannot reach local sockets or see other processes, and cannot change what git runs later: hooks, git config, and what they name. Linux only, through bubblewrap; docs/sandbox.md has the requirements and what the sandbox does not stop.
  • Two ways out, each with the model’s reason shown to you: network: true adds the internet and keeps the rest of the sandbox, hiding the project’s own credential files; outside_sandbox: true runs the command as you and always asks.
  • A command the sandbox blocked says so in its result, and how to ask for more.
  • /away: while you’re away, anything that would ask you is refused instead, and the agent carries on with the work that needs no approval. A breaker goes back to asking after 3 refusals in a row or 20 in all. harness run --away --prompt … does the same for a scripted run.
  • A question left unanswered for [away] timeout (default 15 minutes) is refused, and the rest of the task runs as if you had typed /away. This applies only where /away works, such as auto with the sandbox on: in manual, a question waits for you as long as it takes, as in 0.3.0, with no countdown.
  • harness run --mode <name>.
  • /iterations <n> raises the per-task limit on model calls from inside a session.
  • [sandbox] and [away] config tables, and [gatekeeper] away_cutoff (default 0.2, stricter than the ordinary 0.3).
  • /mode shows where commands run, and whether a mode may use the internet or leave the sandbox.

Changed

  • Where the sandbox cannot run, auto is not available: choosing it prints the cause and the fix, a session resumed or configured in auto starts in manual, and --yes, run --mode auto, run --away, run --mock and harness eval exit 2. In manual a command you approve still runs unconfined there, as in 0.3.0. [sandbox] enabled = false runs without the sandbox on purpose, for example inside a container that is already your sandbox.
  • The file tools follow no link out of the project: a CLAUDE.md, AGENTS.md or .agents/ entry linked outside it no longer loads, an absolute link is refused even when it points back inside, and harness export -o through a linked folder is refused.
  • A local ask says why it asked (Asked because …), instead of saying the gatekeeper flagged it.
  • Resuming a session keeps the model-call limit its log last stated, over config.toml.
  • A --yes or --mock session does not resume where the sandbox cannot run; a session from before 0.4.0 resumes unconfined, in manual.
  • Every session start runs a quick check that the sandbox holds.

Fixed

  • A process a command leaves running in the background no longer holds the call open: the call returns about two seconds after the command exits. Outside the sandbox, a process that detached itself keeps running, and the result ends with a line saying its output was cut.
  • A command no longer inherits the harness’s stdin, terminal session or open files, including on kernels where close_range fails.
  • Model text, child rows, the panel’s repaint and the echo of your message draw control characters escaped, never raw.

Security

  • Reading a credential file (.env, *.pem, *.key, *.p12, *.pfx) with the file tools asks, in every mode, and is refused while you’re away; search skips such files and says how many.
  • rm with recursive and force flags on anything with a .git path component is refused in every mode.
  • A command cannot point git elsewhere: a planted .git/commondir, or a changed link to git’s config or hooks, is moved aside with a note. A hooks folder, an include or another file git would run or read, created where none existed at session start, is flagged in the result.
  • Git’s hooks and config stay read-only inside the sandbox, your global and system git config included, even when tracked. A sandboxed git command that would change one, such as a git checkout of a branch with different hooks, stops half-done, and the agent is told so: which files are left and, after a checkout, the git checkout -- <files> that finishes it outside the sandbox, which asks you first.

[0.3.0] — 2026-09-25

Added

  • Auto mode now asks only when a command looks risky, instead of asking before every bash/exec call: a second model, the gatekeeper, reads the command beside your request and decides run or ask.
  • /mode shows a table of what each mode does, and marks the mode actually in force.
  • A [gatekeeper] config section, including its ask/run cutoff.
  • Readline-style editing keys in the message composer: Ctrl-A / Ctrl-E to the start / end of the line; Alt-← / Alt-→ (also Ctrl-←/→, Alt-b / Alt-f) a word left / right; Ctrl-W deletes the word before the cursor; Ctrl-U / Ctrl-K delete to the start / end of the line. Terminals that send Cmd-←/→ as Ctrl-A/E (Ghostty and iTerm2 on macOS) get those too. docs/keys.md lists every key.
  • ? on an empty input, or /keys, shows the keys; docs/keys.md lists them with terminal notes.

Changed

  • The composer keeps its last column free, so the cursor always has a cell of its own at the end of a row. Its text is one column narrower.

Fixed

  • The composer drew a run of spaces as one space, so a space typed between two words seemed to do nothing and ←/→ sometimes seemed not to move. Every space now draws where it was typed, and every ←/→ press moves the cursor, including at the edge of a wrapped row.
  • A tab or another whitespace control pasted into the composer now draws as one space instead of shifting the line.

Security

  • A control character pasted into the composer, such as ESC, is drawn in caret notation (^[) and never reaches the terminal raw.
  • Protected files always ask for approval, in every mode.
  • A short list of commands is refused outright, in every mode, gatekeeper or not.
  • Provider API keys never reach the environment of a command the harness runs.

Earlier

0.1.0 (slices A–N)

  • Slice A — the walking skeleton: a single-owner agent loop over an append-only event log, replayed byte-identical.
  • Slice B — a real, minimal harness: Anthropic and Gemini providers, five tools, streaming, an interactive REPL.
  • Slice C — hardened for real work: log compaction, prompt caching, a search tool, timeouts, cancellation, an approval gate.
  • Slice D — CI in front of the tree, an exec tool beside bash, honest exit statuses, diff previews in the approval gate.
  • Slice E — the daily driver: output-cap recovery, a live status line, a config file, an iteration cap, OpenAI Responses and Chat Completions as two more providers.
  • Slice F — the surface: color, a spinner, a startup identity line, context-% in the footer, streaming markdown, bounded approval previews.
  • Slice G — the protocol spine and the panel: a typed stream, correlated approvals, Build/Plan/Auto modes, the ratatui bottom panel.
  • Slice I — the daily driver, round two: @ file references, bracketed paste, layered AGENTS.md/CLAUDE.md context with /init, the resume picker, the Ctrl-G external editor.
  • Slice J — skills: SKILL.md discovery and invocation through the approval gate, plus picker row expansion, session export, composer vertical navigation.
  • Slice K — subagents as replayable child sessions, /model mid-session, the startup splash, the build mode renamed manual.
  • Slice L — the first ADLC loop end to end: flag, check, bundle, eval, cases as repo citizens; /model becomes a picker with a per-spawn child model.
  • Slice M — the first target that isn’t this harness: harness import over OpenTelemetry GenAI traces, eval --target against a real external agent.
  • Slice N — the fix slice: a log holding a record of a kind this build doesn’t know is carried, not corrupt; key-material scanning on export; tightened file permissions on the harness’s own data.

0.2.0 (slices O–T; the bump is cd6900d, 2026-09-10)

  • Slice O — code mode: the script tool runs in an embedded QuickJS-NG isolate, every inner call journalled and replayed byte-identical.
  • Slice P — code-mode adoption: a steering block in the system prompt when a mode offers script, and the adoption matrix that measured what moved it.
  • Slice Q — a resumed session rebuilds its status line, history and context-% from its own log; every provider’s token counts are whole; a project is named by its folder.
  • Slice R — a thinking level you can change mid-session, a deny that can steer instead of only stopping, OpenRouter as a provider, color on the splash.
  • Slice S — a slash-command menu with typo refusal, and a tool call rendered once, legibly, with its output.
  • Slice T — any OpenAI-compatible provider through provider:model ids, a built-in list of compatible providers plus three lines of config for any other, nicknames for helper agents, and file tools that report their own truncation honestly.