How the loop is built
The loop is two programs inside each other. The supervisor runs for hours; the runner runs one unit and exits.
| Layer | Lives for | Does |
|---|---|---|
| supervisor | the whole run | takes the lock, runs batches of units, reads PAUSE and STOP between units, reacts to exit codes, waits out usage limits |
| runner | one unit | picks a task, takes a snapshot, starts the coordinator, checks the contracts, records the result, exits with a code |
| coordinator | one unit | the claude -p process: reads the task and calls specialists |
| specialists | part of a unit | do one narrow job each and leave a verdict |
| contracts | the end of a unit | scope, task file, trace, gate, closing: code that decides whether the unit counts |
Step through five runs to see which layer is busy, what the trace folder holds, and how pause, stop, a hard stop and a usage limit play out.
Units
.harness/trace/
- supervisor.lock
Every command below takes --project, with a path, or the id or name the project was registered under. Without it,
the command uses the current folder, which must contain a .harness/ folder. The examples use the demo's sample
project, created and registered with harness example ~/code/bookmarks; the project name defaults to the folder name.
Start
From the dashboard, press Start. It spawns harness run --project <path> as a detached process, with the dashboard's own environment and its output
in .harness/trace/supervisor.out. The dashboard's Pause, Resume and Stop write the same files as the CLI;
the dashboard never signals or kills a process. Its dock shows the supervisor as Running, Paused, Stopping (STOP is
written, the unit is finishing) or Not running. The button names who a Start runs: Start · claude, or Start · stand-in when AGENT_CMD or
agent.command is set; the dashboard's first line in the terminal says the same.
From a terminal:
harness run --project bookmarks
By default the run does not end when the queue is empty: it checks for new work every supervisor.idlePollSeconds
(30 seconds by default) until you stop it. A human action wakes it at once: a submit, an answer or an approval, from
the CLI or the dashboard, lands in the human-write ledger, and the idle supervisor watches that file every
supervisor.pausePollSeconds (2 seconds). These options change that, and harness unit runs a single unit to try
things out:
harness run --project bookmarks --deadline 120 # a time limit for the run, in minutes
harness run --project bookmarks --max-units 5 # at most five units
harness run --project bookmarks --once-through # exit as soon as nothing is left to take
harness unit --project bookmarks # exactly one unit; exits with the runner's code
Two commands are worth running first, and neither spends a token:
harness agents --check --project bookmarks # the registry, standing-rules drift, the model split
harness preview T-002 --project bookmarks # the exact command and prompt of the next unit
Before the first unit, the supervisor checks two things and refuses to start if either fails:
- No other loop runs on this checkout. It holds the lock file
.harness/trace/supervisor.lockfor the whole run. - The base branch is clean. It refuses a dirty tree it did not make, because the scope contract would blame your changes on the agent.
Right after its start line, it logs one line, supervisor · billing: …, that says how the run is billed, from the
environment it starts in:
| The line says | When |
|---|---|
billing: subscription login — usage limits, not a per-token bill; costs shown are API-equivalent | claude is logged in with a subscription |
billing: ANTHROPIC_API_KEY is set — claude -p bills the API per token | the environment has ANTHROPIC_API_KEY |
billing: a stand-in replaces claude — no tokens | AGENT_CMD or agent.command is set |
On a subscription, the costs on the dashboard and in harness stats are what the same tokens would cost on the API.
They are not a bill.
Pause and resume
harness pause --project bookmarks
harness resume --project bookmarks
harness pause writes the file .harness/trace/PAUSE. harness resume deletes it. The dashboard's Pause and
Resume buttons write the same file.
The pause does not interrupt the unit that is running. The supervisor reads the file between units, so the unit in flight finishes, its contracts run, and then the loop waits.
Stop
harness stop --project bookmarks
harness stop writes .harness/trace/STOP. The supervisor finishes the current unit, exits, and does not come back
until you start it again.
If you have to stop it at once, stop the supervisor process you started, by its PID. Because the disk is the state,
you lose only the unit that was running. Never kill processes by pattern (pkill -f, killall): the pattern matches
far more than you think, including other Claude sessions.
Exit codes
Every unit ends with an exit code from the runner. The supervisor reads it and decides what to do next.
| Runner code | Meaning | What happens next |
|---|---|---|
0 | the unit is done | the supervisor takes the next unit |
1 | a hard stop: scope, task file, trace, gate, or the agent crashed | the unit's work goes into a named stash, the task is parked, the supervisor continues |
2 | the configuration is broken: config.json, drifted standing rules, or a judge on the implementer's or the tester's model | the supervisor stops: waiting will not fix it |
3 | nothing is left to take | the supervisor polls for new work; with --once-through it ends the run |
4 | the agent could not run: usage limit, network, login | the supervisor waits, then retries |
A hard stop throws nothing away. The runner moves the unit's changes into a git stash named
harness quarantine: T-NNN unit N, restores the tree, and writes the reason into the task. You can inspect the stash
before you reopen the task.
The supervisor has its own exit codes, for when the whole run ends. The numbers repeat, but the meanings differ: runner code 3 is an empty queue, and supervisor code 3 is a stuck loop.
| Supervisor code | Why the run ended |
|---|---|
0 | it was stopped, the queue drained under --once-through, or the deadline or --max-units was reached |
2 | the configuration is broken, or deliver.pr is on and a remote, gh, its login or the base branch on the remote is missing |
3 | barren: three units in a row moved nothing |
4 | it gave up waiting for the agent |
The barren rule keeps a stuck loop from spinning: three units in a row that moved nothing stop it. Progress means new commits or new handoffs, not just one of them.
Usage limits and outages
Exit code 4 means the agent never got to work, so the task is untouched. The supervisor waits and tries again: it
probes the network every 30 seconds, and when the network is fine but the agent is still refused, for example by a
usage limit, it naps for 30 minutes before the next try. When the refusal names a reset time, such as "You've hit
your session limit · resets 9:10pm (Europe/Kiev)", it waits until that time plus one minute instead, and logs
agent could not run (…) — waiting until <time>. Session and weekly limits count as usage limits too. After six hours of waiting it gives up and stops with code 4.
The timings are supervisor.usageProbeSeconds, supervisor.usageNapSeconds and supervisor.usageCeilingSeconds in
config.json.
A project can add its own probe: command to config.json, for example a check that the dev server answers. When the
probe fails, the unit ends with code 4 and nothing is touched.
Do not edit while a unit runs
The scope contract compares the whole working tree before and after a unit. It cannot tell your edit from the agent's. If you save a file in the project while a unit runs, the unit fails, and the task is parked for something you did.
The safe order:
- Pause.
- Wait until the running unit finishes. The dashboard shows when nothing is in flight.
- Make your edit and commit it.
- Resume.
Task actions are the exception. Approve, answer, revise and the rest, from the dashboard or the CLI, go through a
ledger: the harness records each human write to a task file in .harness/trace/human-writes.jsonl, and the scope
contract excuses a task file whose content matches that record. An approve made while another task's unit runs is
kept. Inside a unit, the human CLI actions refuse to run, so an agent cannot pose as you. The ledger stops mistakes,
not an agent that forges the ledger itself.
The same reason allows only one loop per checkout. Running tasks side by side needs one git worktree per task, which is planned after the first version.
Watching a run
harness status --project bookmarks
harness status --project bookmarks --json # the same, for scripts
harness stats --project bookmarks # units, time, tokens and API-equivalent cost per stage and task
harness stats --project bookmarks --json # the same, for scripts
The dashboard's Live view shows more: the supervisor's state, the unit in flight, its agents with their role, elapsed time and current tool, the pace, and the supervisor log. The snapshot behind it carries role names, tool names and file names only, never tool arguments or message text.
Telegram notifications
The harness can tell you in Telegram when a task needs you
(harness/notify.ts). It needs two keys,
TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID, and looks for them in this order. The first place that defines the token
decides:
- the environment;
<project>/.env;$HARNESS_HOME/.env, which is~/.harness/.envby default.
An empty token switches notifications off. harness projects add puts .env into the project's .gitignore, so the
keys stay out of git.
| Event | When it fires |
|---|---|
needs-input | a task waits for your answers |
planned | a plan waits for your approval (only with autonomy.mode: gated) |
prototype | a prototype round waits for your review (only with gated) |
parked | a task is parked, with the reason |
done | a task is confirmed |
delivered | a pull request is open, with its link |
supervisor | a run ended by itself, or the first usage-limit wait began |
All events are on by default; notify.events in config.json narrows the list. A message carries the project name,
the task id and title, the event and the pull request link. It never links to the local dashboard. Notifications go one
way: the harness only sends. A failed send never fails a unit; it adds one notify · … not sent line to
progress.log, with the token removed.
Telegram gets each message once. A repeat, with the same project and text after ids and times are set aside, is held
back for six hours, and the whole chat gets at most six messages a minute. A held-back message logs
notify · <event> held back: <why>, and the record lives in $HARNESS_HOME/notify-sent.json. A project folder that no
longer exists sends nothing, and an agent outage is reported once.
harness notify --project bookmarks # where the keys come from; never prints the token
harness notify --project bookmarks --test # send one test message
What is in the trace folder
Everything the loop knows about itself is in .harness/trace/. The project's .gitignore keeps trace/, artifacts/,
tasks/ and JOURNAL.md out of git; only config.json is versioned. Task files are ignored on purpose: the runner
rewrites them while it switches between the base branch and the task branch, and a versioned task file that differs
between branches would make the checkout fail. A task's history lives in its ## Log and in handoffs.jsonl.
| File | What it is |
|---|---|
supervisor.lock | held by the running supervisor; a second loop refuses to start |
PAUSE | present = pause at the next unit boundary |
STOP | present = stop at the next unit boundary |
running.json | the task a unit is working on right now |
progress.log | the supervisor's log |
supervisor.out | the supervisor's output when the dashboard started the run |
handoffs.jsonl | what each unit handed to the next: start time, verdict, tokens, cost and any permission denials |
unit-seq | the counter that numbers units |
unit-NNNN.jsonl | one file per unit: the agent's stream-json output, each line stamped with harness_ts; the trace contract finds the verdict here |
human-writes.jsonl | every human write to a task file, with its hash; the scope contract excuses a matching file |
unit-NNNN.agents.json | the specialists passed to that unit with --agents |
unit-NNNN.app.json | the app the runner started for that unit: its pid, port and owner; removed once the app stops |
unit-NNNN.app.log | that app's output |
unit-NNNN.mcp.json | the MCP config a browser unit got: the Playwright server and its flags |
preview.mcp.json | the same config, written by harness preview for an app stage |