Autonomous runs
apps/core/src/autonomous/ implements freecode night. For how to use it, see
overnight runs. The design lives in
docs/specs/2026-09-28-overnight-runs.md, and its §8 is the implementation
record for Phases 0–5, all built on 2026-09-28. That spec replaces Phases 1–5 of
2026-08-10-autonomous-runs-design.md. Only that older spec’s Phase 0 (types.ts,
budget.ts, run-store.ts) is still used.
Shape
cli/commands/night.ts yargs surface, duration/time parsing
cli/commands/night-run.ts composition root: preflight, boot, wire deps, exit summary
│
▼
autonomous/orchestrator.ts runNight(deps, limits) — PURE over injected git/clock/iteration
│ per iteration
▼
autonomous/iteration.ts one fresh session via runEffect, AgentLoopConfig.unattended
autonomous/prompt.ts the iteration prompt (a USER message, never system content)
tools/finish-iteration.ts how an iteration ends
autonomous/envelope.ts answers permission asks without a human
autonomous/sandbox.ts bubblewrap plan for unattended bash / --verify / shells
│
▼
autonomous/git.ts commit / reset / worktree / diffstat
autonomous/night-store.ts notes.md, decisions.jsonl, iterations.jsonl (secret-scrubbed)
autonomous/run-store.ts manifest.json (atomic), FREECODE_RUNS_HOME
autonomous/quota-wait.ts planQuotaWait — pure wait policy
autonomous/inhibit.ts one killable systemd-inhibit / caffeinate child
autonomous/report.ts pure fold → report.md
autonomous/night-ops.ts status / list / stop / crash reconciliation
autonomous/supervisor.ts detached / scheduled worker launchorchestrator.ts makes every decision and does no IO of its own. Git, the
clock, sleeping, the iteration runner, --verify and the stop flag are all
passed in, so orchestrator.test.ts can drive a whole night on a fake clock.
night-run.ts contains no policy. It only wires the real implementations
together.
The one seam in the agent loop
The loop knows about unattended runs through exactly one field:
AgentLoopConfig.unattended (UnattendedContext). When it is set:
- Permission asks go to
decideUnattendedAskinstead of the bus. - Tools can see it via
ToolContext.unattended.questionanswers itself and records the ask.bashwraps its command in the sandbox plan. finish_iterationis added to the provider’s tool list byunattendedToolDefs.getToolDefsfilters it out, so attended sessions never see it.- The run ends once
unattended.finishis set, after the current tool batch.
The envelope’s deny rules are added to the session’s DENY tier via
PermissionSettingsManager.addSessionDeny. Deny beats allow, so a user’s
Bash(git:*) allow cannot widen them. deniedSegment / normalizeSegment
split chained commands and strip env prefixes and program paths before
matching. That fix lives in the envelope, not in permission/rules.ts, because
docs/DECISIONS.md records that allow-rule prefix matching deliberately refuses
compound commands, and the envelope is the only caller whose rules are all
denials. The three bypasses this closes were found by evals/night.jsonl.
Failure evidence (Phase 0)
agent/recovery/manager.ts classifies failures:
QuotaExhaustedErrorcarriesscope(window/credits/auth/unknown) andresetAt.quotaResetAtreads every common reset-header spelling and takes the soonest future one.classifyLoopFailurefillsLoopResult.failureat all five loop exits, including the three that reportsuccess: true.model.errorrollout events carryrateLimitHeaders(names and values only, no body).
A question with no frontend subscribed is rejected at once rather than
waiting the 30-minute prompt timeout.
Quota waits
planQuotaWait is a pure function. The outcome for each input:
| Input | Plan |
|---|---|
credits | abort (permanent_error) |
unknown after 3 probes | abort (permanent_error) |
| stated reset in the future | wait until reset + 60s |
| no usable reset | probe after 1, 2, 4, …, max 30 min |
wait would exceed maxWaitMs | abort (wait_budget) |
wait would cross --until | abort (deadline) |
A single wait is capped at 24h. Both budgets are checked against what the wait
would cost, before it starts. A wait retries the same iteration number and
does not count towards maxConsecutiveFailures. sleepUntil in night-run.ts
re-checks the clock every 60s, for two reasons: a single setTimeout overflows
past 2^31 ms, and a suspended machine resumes to a timer that never fired.
Stop reasons
objective_met, max_iterations, deadline, budget_usd,
consecutive_failures, permanent_error, wait_budget, interrupted,
cancelled. An iteration can fail with no_finish, no_op, turn_cap,
stuck, timeout, commit_failed, verify_failed, provider, quota or
auth. Each iteration is capped at MAX_TURNS_PER_ITERATION (60) and
ITERATION_TIMEOUT_MS (30 min).
Storage and the report
~/.freecode/runs/<id>/ holds manifest.json, notes.md,
iterations.jsonl, decisions.jsonl, report.md and, for detached runs,
worker.log. scrubFields redacts each string field before it is
serialised. An earlier version scrubbed the finished JSON line, which produced
invalid JSON, so the reader skipped it and a redacted needs_human item
disappeared from the report.
report.ts is a pure fold over the manifest and both logs. That is how
night report can regenerate the report for a run that crashed. Crash
detection is pidAlive() (kill(pid, 0), where EPERM counts as alive),
reconciled when the manifest is read and then saved. It applies to running
and pending manifests. A finished run is never re-judged. night stop sets
cancelRequested in the manifest and never sends a signal, because killing a
process mid-write is how manifests get corrupted.
Detached worker and sandbox (Phase 5)
launchDetachedNight writes a pending manifest before spawning, so a fast
worker’s live manifest is never overwritten by the parent’s provisional one.
It then re-runs the current invocation (currentInvocation() handles both
script and single-file binaries) as a detached process, with stdout and stderr
sent to worker.log. Three environment variables hand the run over:
FREECODE_NIGHT_WORKER=1, FREECODE_NIGHT_RUN_ID, and
FREECODE_NIGHT_SCHEDULED_FOR. A worker always runs in a dedicated worktree. A
scheduled worker inhibits sleep while waiting for its start time and polls the
manifest for cancelRequested every second.
sandboxPlan builds the bubblewrap command:
bwrap --die-with-parent --new-session --unshare-{pid,ipc,uts,cgroup-try}
--ro-bind / / --tmpfs $HOME --tmpfs /tmp
[--ro-bind <main .git> <main .git>] # worktree runs only
--bind <tree> <tree>
--setenv HOME /tmp --proc /proc --dev /dev --chdir <cwd>
/bin/bash -c <command>sandboxEnvironment drops variables whose names match
KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH. bubblewrapAvailable
checks by running a minimal sandbox, not bwrap --version: --version succeeds
even when the kernel forbids the user namespace that a real sandbox needs. The
network is shared on purpose.
A worktree’s .git is a file that points at <main>/.git/worktrees/<name>.
That directory is usually under the hidden $HOME, so worktreeGitDir()
resolves the repository’s common git dir and mounts it read-only. Without that
mount, every git command inside a detached run failed with “not a git
repository”.
The process directory must be the run tree. The agent loop gives every tool
cwd: process.cwd() (agent/loop.ts). night-run.ts therefore calls
process.chdir() into the worktree once it exists. Before this fix, bash and
relative file paths resolved against the user’s own checkout. The sandbox
refused all of those calls, but with --no-sandbox they would have run there.
The first live detached run found both problems.
Frontends
IPC night.list | report | stop (server.ts) only read what a separate night
process left on disk. They start none of the backend, so reading a finished run
never spins up providers or MCP servers. night.list counts needsHuman in
core, so that each frontend doesn’t decide for itself what “needs you” means.
The TUI’s /night is apps/tui/src/components/night-panel.ts.
Tests
orchestrator.test.ts, quota-wait.test.ts, envelope.test.ts,
git.test.ts (a real repo in a tmpdir), night-store.test.ts,
report.test.ts, night-ops.test.ts, sandbox.test.ts,
supervisor.test.ts, tools/finish-iteration.test.ts,
cli/commands/night.test.ts, and the TUI’s night-panel.test.ts. The paid
eval suite is evals/night.jsonl (5 cases, unattended: true). Its
2026-09-28 run on MiniMax-M3 scored 5/5, with finish_iteration called in
14/15 trials.
Known gaps are listed on the user guide.