# Unattended contract (`MULTI_AGENT_UNATTENDED=1`)

<!-- toc -->
- [What the variable means](#what-the-variable-means)
- [The three effects](#the-three-effects)
- [What it does NOT do](#what-it-does-not-do)
- [Who honours it](#who-honours-it)
- [Runner-side operations](#runner-side-operations)
- [The model side already has a contract](#the-model-side-already-has-a-contract)
- [Default behaviour is unchanged, and a gate says so](#default-behaviour-is-unchanged-and-a-gate-says-so)
- [The permission posture](#the-permission-posture)
<!-- /toc -->

## What the variable means

`MULTI_AGENT_UNATTENDED=1` is the operator stating that **nobody is watching
this process**, and it is the only way to state it reliably.

The usual test for that is `[ -t 0 ]`: no terminal, no human. It is wrong in
both directions on a server. A run under `screen`, `tmux`, `ssh -t` or a login
shell HAS a terminal and still has nobody in front of it, so the test says
"ask" and the process waits for an answer that will never come - printing
nothing, exiting never, and looking exactly like slow work. That is the failure
this contract exists to remove.

The variable is opt-in and defaults to absent. Nothing in this file describes
behaviour that changes on a machine that does not set it.

## The three effects

**1. No prompt blocks.** Every shell entry point that asks a question either
resolves it from a documented default or refuses with a reason on stderr and a
non-zero exit. Waiting is never one of the outcomes. Refusing beats hanging:
one of them can be read in a log.

**2. Output is greppable.** Colour is off even when stdout is a terminal.
ANSI escapes in a log file make it unsearchable, and on a server the terminal
that is attached is not the one anyone reads.

**3. A secret is never a prompt.** Credentials arrive through stdin or a file
path, never through an interactive read and never through an environment
variable. An env value is inherited by every child process and is visible to
`ps e` on some systems; a pipe stays in the one process that needs it.

## What it does NOT do

- It does not grant permissions. An unattended run still needs a permission
  posture, which is a separate opt-in (`install --unattended`) and a separate
  doctor check.
- It does not suppress errors. A run that cannot proceed still fails; it just
  fails visibly instead of hanging.
- It does not change any default. With the variable unset, every script below
  behaves exactly as it did before this contract existed.

## Who honours it

Paths below are install-relative: `lib/` and `scripts/` under the host root,
which is `~/.claude`, `~/.copilot` or `~/.codex` depending on the install. A
ref that ships to users must not name a checkout path, because a run happens
in the user's worktree and the checkout is not there.

| Entry point | Without the variable | With `MULTI_AGENT_UNATTENDED=1` |
|---|---|---|
| `lib/ask-choice.sh` | TTY: renders the menu and reads. No TTY: first option, notice on stderr | First option (or `ASK_CHOICE_DEFAULT`), never prompts |
| `scripts/github-ssh-setup.sh` | TTY: three questions. No TTY: refuses, naming `SSH_SETUP_EMAIL` | Refuses the same way even with a terminal |
| `scripts/keychain-save.sh` | TTY: menu + secret prompt. No TTY: refuses, naming `--stdin` / `--json` | Refuses the same way even with a terminal |
| `scripts/phase-banner.sh` | Colour when stdout is a TTY and `TERM != dumb` | Plain text |
| `lib/unattended.mjs`, `lib/unattended.sh` | `isUnattended` false; `gatesActive` follows `state.autopilot` | Both true |
| `scripts/phase0-exit-gate.mjs` | Autopilot-only sources accepted when `state.autopilot` is true, as before | Accepted |
| `scripts/symbol-existence-gate.mjs`, `scripts/open-questions-gate.mjs` | Run only in autopilot mode; otherwise not-applicable, exit 0, nothing written | Run; a missing symbol fails, an open question parks the run |
| `scripts/research-gate.mjs` | Runs only in autopilot mode; otherwise not-applicable, exit 0, nothing written (`--evaluate` and `--recheck` only report) | Runs; records `state.research`, re-opens the parked step or parks the run on the gaps left |
| `scripts/spec-consistency-gate.mjs`, `scripts/analysis-story-tree.mjs` | Outside autopilot mode the consistency report is advisory: stdout only, exit unchanged, nothing written | Run; a gap fails and is recorded (the story tree refuses with exit 4) |
| `scripts/plan-critique-gate.mjs` | Outside autopilot mode no critic is dispatched; the gate, if run, reports on stdout only, exit 0 (a usage error exits 3), nothing written | Runs after the one critique round; an unresolved binding-rule objection or a broken round fails, is recorded and parks the run |
| `scripts/review-decision-gate.mjs` | Outside autopilot mode the decisions are advisory: stdout only, exit 0 (a usage error exits 2), triage and state untouched; `--rebuttal-allowed` exits 0 | Runs; a blocker without two reviewers or a failing test is lowered to important and recorded, a rebuttal round fails, `--rebuttal-allowed` exits 1 |
| `scripts/verify-citations.mjs`, `scripts/plan-coverage-gate.mjs` | Pre-existing check and Section 14 fallback only in autopilot mode | Both run |
| `scripts/gate-ledger.mjs` and the ledger writes of `evidence-gate.mjs`, `test-summary.mjs`, `test-strength.mjs` | Written only in autopilot mode | Each verdict appended to `state.gates[]` |
| `scripts/pre-commit-check.sh` | Secret scan | Secret scan, then a `git commit` needs a passing gate ledger for HEAD |
| `scripts/agent-guard.sh`, `scripts/agent-guard.py` | Attribution and force-push rules; fail-open on an input it cannot judge | Also `scripts/unattended_policy.py`, fail-closed: a command is judged only when it reduces to simple commands joined by `;`, `&&`, `||` and plain pipes between non-interpreter commands - a subshell, group, nested or command-position substitution, unquoted heredoc, background job, shell keyword, interpreter pipe, shell `-c`, inline interpreter program, program file the run wrote or modified, or runtime-built name is refused. On the parseable commands: no push, PR, issue or tracker write, no Keychain read, no fetch outside the allowlist (curl/wget/WebFetch/web_*), no package install or manifest edit, no write into a protected path |
| `scripts/pr-request.mjs` | Not called | Phase 4 writes `pr-request.json` through it instead of pushing |
| The continuous-mode runner | Not run | Sets the variable on every child it launches, and refuses to launch (`blocked-guard-missing`) unless `agent-guard.sh` is registered in the user or managed settings on the `Bash`, `Edit|Write|NotebookEdit` and `WebFetch|mcp__multi-agent-toolkit__.*` PreToolUse matchers with the installer's exact command, and unless the permission profile file exists; passes that file with `--settings` |
| `scripts/autopilot-publish.mjs` | Not called | Runner-side: verifies the request, re-runs the stack's build and tests itself, pushes from a clean staging repository and opens the draft PR, with the variable removed from every process it starts |
| `scripts/scaffold-gate.mjs` | The skeleton or story check runs and prints its verdict; the verdict reaches the gate ledger only in autopilot mode | The check runs, and the verdict is recorded as `scaffold/skeleton` or `scaffold/story` through `gate-ledger.mjs` |
| `scripts/launch-request.mjs` | `register-repo` adds a checkout to the launch repositories | `register-repo` refuses with exit 3 |
| `scripts/phone-devices.mjs` | `add`, `list`, `revoke`, `launch on\|off` change or print the phone device registry | `list` only; `add`, `revoke` and `launch` refuse with exit 3, and the guard blocks them by name (`features/phone-api.md`) |

Anything not in this table does not read the variable. That is deliberate: a
list that is true beats a claim of coverage that is not. The verification gates,
and the split between "gates active" and "unattended", are described in
`features/unattended-gates.md`; the security side (the guard, the PR request, the
profile, the machine setup) in `features/unattended-security.md`.

## Runner-side operations

These run in the runner process, never in a child, so none of them reads the
variable or is visible to a session. They are listed here because they are part
of what unattended mode does on a machine. Detail:
`features/autopilot-operations.md`.

| Operation | What the runner does | Default |
|---|---|---|
| Sleep lock | Holds `caffeinate -s -w <runner pid>` (AC only) from the launch to the end of the tick, and kills the pid it spawned on every exit path | On |
| Awake agent | A separate launchd agent, `com.multi-agent.autopilot-awake`, runs `/usr/bin/caffeinate -s -i` (plus `-d` with `awake.display`) from `autopilot-on` to `autopilot-off`, so ticks keep firing between runs | On (`awake.enabled`); display may sleep |
| Credential probe | `autopilot-arming.mjs probe` at `autopilot-on`, `check --probe` on every tick for the item's source: `gh auth status`, `credential-store.sh probe <key>`. A missing, unmapped or locked credential refuses; no value is read into the runner | On |
| Parallel cap | `maxParallelAgents` caps the supervised run plus parked runs busy again | Unlimited: today's one-at-a-time claim, unchanged |
| Cleanup report | `gc-abandoned.sh --only` over worktrees the runner created, minus any whose item is parked, once per local day, to `gc-report-<date>.json` / `.md` | Dry run; `gc.autoDelete` adds `--yes` |
| Daily digest | Items, outcomes, PRs, parked items and cost for the day, sent through `reportChannels` after the outbound gate | Off (`digest.enabled`) |

## The model side already has a contract

The prompt-level question - what an agent does when it would call
`AskUserQuestion` and no one can answer - is `multi-agent-refs/picker-contract.md`, section
"Autopilot / non-interactive contract", and it predates this file. It resolves
from the remembered choice first, then the documented default, and records
which rule fired so the run stays readable afterwards.

The two are different layers and should not be merged. The picker contract
governs a model deciding; this file governs a process waiting. A run on a
server needs both, and only one of them can be enforced by a gate.

## Default behaviour is unchanged, and a gate says so

`smoke-unattended-profile.sh` (a maintainer gate, not shipped) asserts both directions. With the variable set,
each entry point above resolves or refuses under a hard timeout. With it unset,
each one produces byte-identical output to the behaviour it had before - the
assertion that matters most, because the whole point is that a local
interactive machine is not affected by any of this.

## The permission posture

Everything above concerns a process that would otherwise WAIT. There is a second
way an unattended run stops, and it does not wait at all.

autopilot spawns its child with `--permission-prompts none`. That stops Claude
Code from ASKING; it grants nothing. The tools the child then calls still have
to be allowed, and on a fresh machine they are not - so the run stops at the
first tool call, with no prompt anywhere for a person to answer. From the
outside it is indistinguishable from a queue with nothing to do.

`install --unattended` writes the profile that closes it:

```bash
npx @mmerterden/multi-agent-pipeline install --unattended --dry-run          # show it
npx @mmerterden/multi-agent-pipeline install --unattended                    # write it
npx @mmerterden/multi-agent-pipeline install --unattended-sandbox            # and the sandbox block
```

The profile is `schemas/unattended-profile.json`, written to
`~/.claude/multi-agent-unattended.settings.json` and passed to each run with
`claude --settings`; `~/.claude/settings.json` is not changed. It holds `permissions.defaultMode:
"dontAsk"` (a call that would prompt is denied, not left waiting), a narrow
allow list, deny rules (merge, push, `~/.ssh`, `~/.aws`, `~/.gnupg`,
`~/Library/Keychains`, the toolkit's `research_*` tools, the installed pipeline,
CI workflows), and the toolkit's `MCP_TOOLKIT_URL_POLICY=strict` and
`MCP_TOOLKIT_INDEX_DENY`. The sandbox block is opt-in on top.

Three properties, each one a promise to somebody who did not ask for this:

1. A DEFAULT install writes no permissions at all. Widening a permission set is
   not something an installer does to a person who wanted files copied.
2. The profile is printed in full, with a reason per line, BEFORE anything is
   written. "The installer changed my permissions" must never be a thing
   discovered afterwards.
3. It is additive and idempotent over its own file, and leaves the attended
   posture alone. An entry is "already there" only when that exact rule is
   present, so a narrower rule is kept alongside and never stands in for the
   profile's entry. A `defaultMode` or env value already in the file is kept. A
   whole-`Bash` allow in `settings.json` still reaches the run, because
   permission lists merge; it is reported, never removed. A profile an earlier
   install wrote into `settings.json` is moved out, printed first. A second run
   changes nothing, and a profile file that does not parse is refused rather
   than overwritten.

The permission list is the second line, not the boundary. `Bash(node *)` lets a
script do anything node can, and a Bash deny rule does not stop `sh -c`. The
guard hook is the enforcement the run cannot switch off, and a separate macOS
user is the boundary: `features/unattended-security.md`. `doctor
--profile=server` reports `unattended-permissions` against the same file the
writer applies, so the two cannot disagree.

`smoke-unattended-install-profile.sh` asserts all of it, including that a plain
install still writes nothing.
