# @andromarces/agent-loops

> Run a task loop across several CLI coding agents.

Latest version **0.2.2** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install @andromarces/agent-loops
pnpm add @andromarces/agent-loops
yarn add @andromarces/agent-loops
bun add @andromarces/agent-loops
```

Provides the commands `agent-loop`, `agent-loops`, `agent-loop-copilot`.

## Health

**Score 65/100 (B)** — status: active.

Positive: esm support; no vulnerabilities; has provenance; recently updated; high maintenance score.

Warnings: low downloads; no types; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.2.2 |
| Published | 2026-09-24 |
| First published | 2026-09-24 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM |
| Node | >=22 |
| Dependencies | 1 |
| Unpacked size | 132.7 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 0 |
| Maintainers | andromarces |

## Links

- npm: https://www.npmjs.com/package/@andromarces/agent-loops
- Repository: https://github.com/andromarces/agent-loops
- Homepage: https://github.com/andromarces/agent-loops#readme
- Issues: https://github.com/andromarces/agent-loops/issues
- npm.io page: https://npm.io/package/@andromarces/agent-loops

## Dependencies (1)

- [execa](https://npm.io/package/execa.md) ^10.0.1

## Recent versions

- 0.2.2 (latest) — 2026-09-24
- 0.2.1 — 2026-09-24
- 0.2.0 — 2026-09-24

## README

# Agent Loops

Run a task loop across several CLI coding agents using a hybrid orchestrator model.

```text
               +-------------------------------------------------+
               |                  Orchestrator                   |
               | (decides next action: worker, reviewer, finish) |
               +-------------------------------------------------+
                                       ^
                                       | action / result
                                       v
               +-------------------------------------------------+
               |              Deterministic Runtime              |
               | (enforces step budget, timeout, read-only check)|
               +-------------------------------------------------+
                               /                 \
                              /                   \
                             v                     v
                 +-------------------+     +-------------------+
                 |      Worker       |     |     Reviewer      |
                 | (modifies / tests)|     |    (read-only)    |
                 +-------------------+     +-------------------+
```

An LLM orchestrator directs the task by choosing discrete structured actions, while a deterministic Node.js runtime enforces safety invariants, step budgets, process lifecycles, and mutation boundaries.

## Architecture

- **Orchestrator**: Evaluates task status, worker findings, or reviewer feedback, and returns a validated JSON action (`run_worker`, `run_reviewer`, `finish`, `abort`). It never edits files or executes subshells directly.
- **Worker**: Executes the task, modifies files, and runs validation commands in the target repository.
- **Reviewer**: Evaluates the repository state and tests in read-only mode.
- **Deterministic runtime**:
  - Enforces step limits (`--max-steps`, default 20) and timeout boundaries.
  - Spawns agents, manages persistent sessions, and captures process signals (`Ctrl+C` exits 130).
  - Enforces non-mutating safety on reviewer and orchestrator turns using CLI flags and pre/post Git work-tree mutation detection.
  - Recovers from malformed JSON via a single repair turn.
  - Records validated orchestrator actions, child results, one `invocation` event per CLI call with usage when the adapter exposes it, timestamps, exit code, and error when `--transcript` is provided. Raw orchestrator responses are not recorded.

See [Architecture Decision Records](adr/README.md) for background and architectural decisions ([ADR 0001](adr/0001-hybrid-orchestrator-runtime.md), [ADR 0002](adr/0002-harness-neutral-orchestrator-instructions.md)).

## Supported agents

The controller supports these CLI names:

- Claude Code: `claude`
- Codex CLI: `codex`
- Antigravity CLI: `agy` (alias: `antigravity`)
- OpenCode: `opencode` (OpenCode v2, npm `@opencode/cli`)
- GitHub Copilot CLI: `copilot`

Each adapter manages its own persistent session across turns. Model and effort arguments pass through to the CLI on every turn.

The OpenCode adapter passes `--standalone` on every turn. The turn runs against a private server instead of the shared background service, so the run does not depend on a background `opencode` service. Provider variables set on the shared service with `opencode service set env` do not apply to a standalone turn; provide them in the process environment. See [Background service](https://opencode.ai/v2/docs/cli#background-service) in the OpenCode CLI docs.

### OpenCode model default

With `opencode` and no `--<role>-model`, the adapter passes no `--model` argument. OpenCode uses the configured `model` when it is enabled and its provider is available in the project; otherwise it falls back to the newest available supported model. A model already selected for a session takes precedence over the configured default. The resolved model is machine- and project-dependent, so it varies by configuration, authentication, and session history. `opencode session export <id>` names the model that ran.

An explicit `--<role>-model` passes through unchanged, with no variant appended. `--<role>-effort` applies to an explicit model and reaches the CLI as `<model>#<effort>`. An effort without a model is rejected, because the installed CLI accepts a variant only inside `--model provider/model#variant`.

`--<role>-model` and `--<role>-effort` record what the caller requested, not the effective model. For an explicit model, the adapter logs the effective `model#effort` it passes. For an implicit default, the adapter logs that OpenCode selects the model and names none; the OpenCode session metadata records the model that ran.

## Requirements

- Node.js 22 or later
- Git (the target `--cwd` must be inside a Git work tree)
- Installed and authenticated CLI agents

pnpm is required only for development in a clone, not for a registry install.

## Install

Install the CLI globally:

```bash
npm install -g @andromarces/agent-loops
# or
pnpm add -g @andromarces/agent-loops
```

Run it without installing:

```bash
npx @andromarces/agent-loops --help
pnpm dlx @andromarces/agent-loops --help
```

Install from a Git URL instead of the registry:

```bash
npm install -g github:andromarces/agent-loops
```

npm 12 disables git fetches by default. On npm 12, pass `--allow-git=all`:

```bash
npm install -g --allow-git=all github:andromarces/agent-loops
```

The `bin` script keeps its `#!/usr/bin/env node` shebang and executable bit, so macOS and Linux link an executable file. npm generates the `.cmd` and `.ps1` shims on Windows, so `agent-loop` resolves in PowerShell and cmd. An `agent-loops` alias points at the same CLI, so `npx @andromarces/agent-loops` and `pnpm dlx @andromarces/agent-loops` resolve it. The command locates its package files relative to the installed script, not `process.cwd()`, so it works from any directory; `--cwd` selects the work tree.

### From a clone (development)

Development uses pnpm and the repository Git hooks:

```bash
git clone <repository-url>
cd agent-loops
pnpm install
pnpm agent-loop role ...
```

`pnpm agent-loop` runs the CLI entry from the repository root. To expose the `agent-loop` command on `PATH`, add the pnpm global bin directory to `PATH`, then register the `bin` field globally from the repository root:

```bash
pnpm setup   # restart the shell afterwards
pnpm add -g .
```

pnpm v11 removed `pnpm link --global` and keeps global bins under `PNPM_HOME`; `pnpm add -g .` fails with `ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH` until `pnpm setup` puts that directory on `PATH`. Without a global install, call the CLI entry directly and quote the repository path so a path with spaces works:

```bash
node "<repo>/src/cli.mjs" role ...
```

## Usage

```bash
agent-loop \
  --orchestrator codex \
  --worker claude \
  --reviewer agy \
  --task "Implement the change."
```

PowerShell:

```powershell
agent-loop --orchestrator codex --worker claude --reviewer agy --task "Implement the change."
```

### Options

```text
--orchestrator <agent>        Agent that directs the loop. Required.
--worker <agent>              Agent that implements changes. Required.
--reviewer <agent>            Agent that reviews the repository (read-only). Required.
--orchestrator-model <model>  Model passed to the orchestrator CLI. Optional.
--orchestrator-effort <level> Thinking effort passed to the orchestrator CLI. Optional.
--worker-model <model>        Model passed to the worker CLI. Optional.
--worker-effort <level>       Thinking effort passed to the worker CLI. Optional.
--reviewer-model <model>      Model passed to the reviewer CLI. Optional.
--reviewer-effort <level>     Thinking effort passed to the reviewer CLI. Optional.
--cwd <directory>             Working directory for the agents. Must be inside a Git work tree. Defaults to current directory.
--task <text>                 Task description. Required.
--max-steps <count>           Maximum child steps. Defaults to 20.
--timeout <seconds>           Timeout per agent invocation. Defaults to 3600. 0 disables the bound.
--transcript <file>           Record execution transcript to a JSON file.
--verbose                     Enable debug-level lifecycle logging, including snapshot activity.
-h, --help                    Show help.
```

## Interactive child dispatch: `agent-loop role`

An interactive parent session (Claude Code, Codex, or any harness with shell access) can dispatch one child turn without spawning a headless orchestrator:

```bash
# First call initializes the run state and dispatches the worker.
agent-loop role dispatch \
  --role worker \
  --cwd /path/to/work-tree \
  --task "Implement the change." \
  --mode work-first \
  --parent-session "$CLAUDE_SESSION_ID" \
  --worker claude --reviewer agy \
  --prompt-file ./prompt.txt

# Later calls read the configuration from the state file.
agent-loop role dispatch --role reviewer --cwd /path/to/work-tree --prompt-file ./review.txt
```

Operations: `dispatch` (default), `finish`, `abort`.

- The run state lives at a fixed path derived from the resolved `--cwd` (`<os tmpdir>/agent-loops/runs/<sha256 of cwd, shortened>/state.json`, with `state.lock` beside it). There is no `--state` flag; `AGENT_LOOP_RUNS_ROOT` overrides the root for tests only.
- The init call writes a session index entry at `<root>/sessions/<parent-session>` pointing at the state file, so a parent guard hook (#57) can look the run up by session id even when `--cwd` is a different work tree. A later init call from the same session overwrites the entry.
- Prompts come from stdin by default, or `--prompt-file`. `finish` reads the five-key summary as JSON on stdin; `abort` takes `--reason`.
- The state file records `task`, `mode`, `cwd`, `parentSession`, `maxSteps`, `timeout`, `stepsUsed`, `lifecycle`, `roles.{worker,reviewer}.{kind,model,effort,sessionId}` (`roles.worker` is null in `review-only`), `lastDispatch`, and `lastResult`, plus `summary` or `reason` when terminal and `resumeDecision` when a maintainer resumed an interrupted run. Updates are atomic (temp file plus rename); exclusive access uses `state.lock` with a stale-lock check on the owner pid.
- Lifecycle values: `active`, `dispatched`, `interrupted`, `halted`, `finished`, `aborted` (terminal: `halted`, `finished`, `aborted`). A turn interrupted between the CLI start and the state write leaves `dispatched` with a dead lock owner; the first call after the crash marks it `interrupted`, exits non-zero, and never repeats the turn, even with `--resume-interrupted`. From `interrupted`, only `abort` or an explicit `dispatch --resume-interrupted` is accepted.
- The lock is fail-closed on ambiguity: a contender that finds a lock it cannot read (created moments ago, content not yet written) exits non-zero and never removes it; only an unparseable lock older than a grace window, or one whose recorded pid is dead, is treated as stale.
- The reviewer turn runs under the same `withMutationCheck` as the headless loop: a detected mutation or snapshot error is fatal, keeps the charged step, and sets `halted`. No further dispatch is possible; the next run needs a new init call, which archives the halted file as `state.<timestamp>.json`.
- `mode: review-only` rejects `--role worker` as a hard guard and does not require `--worker` at init. `finish` is completion of the requested work, not code acceptance: it is accepted from `active` in any mode, and the five-key summary carries the reviewer verdict and unresolved findings.
- The reviewer is required to end with one explicit `Verdict:` line (`accept` or `reject`, parsed case-insensitively) inside its closing block. The verdict word alone, the word closed by a sentence period (`reject.`), or the word followed by a separator and a trailing clause (`reject — the state does not pass`) parses to that word, unless the clause names either verdict as a whole word. Any other malformed value, including a missing line or a line that names both verdicts, yields `verdict: unknown`; process success never implies acceptance.
- `--transcript <file>` appends one JSON line per `invocation` and `result` event, in the same shape as the headless mode, accumulating across calls.

Stdout carries exactly one JSON envelope; all logs go to stderr:

```json
{
  "role": "reviewer",
  "status": "ok",
  "report": { "conclusion": "...", "why": "...", "blockers": "..." },
  "verdict": "accept"
}
```

`report` is parsed from the closing block every child turn must end with. When parsing fails, `report` is null and `raw` carries the tail of the response. `status: "error"` carries `error`, and every error path still prints one JSON object. The subcommand launches no orchestrator model and accepts no `--orchestrator` flags.

## Interactive orchestrator: harness entry points

An interactive parent session runs the same role rules through the subcommand,
driven by `docs/orchestrator-instructions.md`. One harness-neutral instruction
file defines the role; each supported harness gets a thin entry point that
includes it rather than copying it. Role activation never goes into `AGENTS.md`
or `CLAUDE.md`, because dispatched children read those files; activation
happens only through explicit invocation.

| Harness         | Entry point                          | Invocation                                    |
| --------------- | ------------------------------------ | --------------------------------------------- |
| Claude Code     | `.claude/skills/agent-loop/SKILL.md` | `/agent-loop <task and role settings>`        |
| OpenCode        | `.opencode/plugins/parent-guard.ts`  | `/agent-loop <task and role settings>`        |
| Codex CLI       | `.agents/skills/agent-loop/SKILL.md` | `$agent-loop <task and role settings>`        |
| Copilot CLI     | `src/entrypoints/copilot.mjs`        | `agent-loop-copilot <task and role settings>` |
| Antigravity CLI | universal fallback (below)           | first prompt references the file              |

- The Claude Code skill sets `disable-model-invocation: true`, so only the
  maintainer activates it with `/agent-loop`, and it omits `context: fork`, so
  the skill runs in the current session. Its body passes `${CLAUDE_SESSION_ID}`
  as `--parent-session` on the init dispatch call.
- The OpenCode entry point is a local plugin (`.opencode/plugins/parent-guard.ts`),
  loaded automatically from `.opencode/plugins/`. Stored command templates expose
  no session id, so the plugin registers the `/agent-loop` command itself: its
  executor reads `CommandInvocation.sessionID` and carries that id into the
  orchestrator prompt, and the init dispatch call passes it as
  `--parent-session`.
- The Codex CLI skill (`.agents/skills/agent-loop/SKILL.md`) activates only through
  `$agent-loop`. It passes `CODEX_THREAD_ID` as `--parent-session`. Use
  `$env:CODEX_THREAD_ID` in PowerShell and `$CODEX_THREAD_ID` in POSIX shells.
  In PowerShell, pass `--cwd $worktree` after setting
  `$worktree = (Get-Location).Path`.
  The CLI rejects an empty `--parent-session` before it initializes a run.
  This environment variable is an undocumented dependency and can change on
  upgrade. When it is absent, do not start a guarded run. Run `/hooks` once to
  review and trust the repository hook. Do not use
  `--dangerously-bypass-hook-trust` for normal use.
- The Copilot CLI entry point is `src/entrypoints/copilot.mjs`, exposed as
  `agent-loop-copilot`. It mints a UUID, starts
  `copilot --session-id <uuid> --add-dir <docs dir> --interactive <prompt>`, and includes the
  instruction file, task, and same id for `--parent-session` in the first
  prompt. The current CLI documentation exposes no custom command-template
  session-id placeholder, so the launcher is the native entry point.
- Universal fallback (Antigravity): reference
  `docs/orchestrator-instructions.md` in the first prompt and follow it. A
  native entry point is added only after that harness documents an explicit
  extension mechanism.
- On Copilot CLI, `.github/hooks/parent-guard.json` registers PascalCase `PreToolUse`, so the payload carries
  `session_id` and `tool_name`; the hook prints the flat `permissionDecision` object that Copilot CLI consumes.
  Repository hooks require a trusted folder. GitHub documents the `.github/hooks/*.json` path for Windows, macOS, and
  Linux, and a live probe on Windows with Copilot CLI 1.0.87-0 confirmed the hook loaded and reported `tool_name:
Write`; no macOS runtime was available for this change. Copilot also loads `.claude/settings.json` as repository
  settings and sets `CLAUDE_PROJECT_DIR` to the repository root, so the Claude hook runs the same guard there and
  Copilot honors the Claude `hookSpecificOutput.permissionDecision` response. Every Copilot `Edit`/`Write` therefore
  runs two parent guards: the Claude-compat one and the `.github/hooks` one. Both deny the registered parent and both
  stay silent for any other session. The `.github/hooks` hook stays as the documented, version-stable path.
- The parent-edit guard (#57) reads `--parent-session` from the state index
  (see Parent guard below). For any run without `--parent-session`, the parent
  stays unguarded.

## Parent guard: hard read-only for the parent session

The parent rule ("the orchestrator never edits files") is prompt-only, so a drifting parent session can still edit. Four harnesses add a hard guard for the file-edit tools: Claude Code through a `PreToolUse` hook in `.claude/settings.json`, Codex CLI through a `PreToolUse` hook in `.codex/hooks.json`, OpenCode through a `permission` `evaluate` plugin hook, and GitHub Copilot CLI through a repository `PreToolUse` hook in `.github/hooks/parent-guard.json`. All share the decision logic in `src/hook/decision.mjs`, so they deny and release under the same rule.

- Matchers: Claude Code uses
  `Edit|Write|MultiEdit|NotebookEdit`. Codex CLI uses
  `apply_patch`, which its hook input reports as `tool_name: "apply_patch"`.
  Copilot CLI uses `Edit|Write`, which covers the current built-in edit and create
  tools. `Bash` stays allowed because the parent needs it to run `agent-loop role`;
  a shell-based edit bypasses all guards. Full enforcement needs a harness that
  exposes only orchestration tools.
- The hook (`src/hook/parent-guard.mjs`) reads the hook input on stdin and uses only `session_id`. It resolves the state file through the session index written by the init call, never through the hook `cwd`, so a parent whose run targets a different `--cwd` stays guarded wherever it edits. No environment variable is required at parent start-up; the harness entry points (#56) pass their session id as `--parent-session` on init.
- Deny only when the hook `session_id` equals `parentSession` in the registered state and the lifecycle is non-terminal (`active`, `dispatched`, `interrupted`). The guard releases only on `finish`, `abort`, or a `halted` state; during `interrupted` it stays engaged, and `dispatch --resume-interrupted` keeps it engaged because the resumed run is non-terminal again.
- Everything else allows: a worker dispatched by `role` in the same cwd (a different session id), a second interactive session in the same cwd, a state without `parentSession`, and a missing or corrupt index entry or state file. The guard fails open by design: it supplements the prompt-only rule, so an unknown record never blocks a tool call.
- Without a state file the hook does one absent-file read, prints nothing, and exits 0; the normal permission flow applies. The deny reason names orchestrator mode and points at `role dispatch` / `finish` / `abort`.
- The Claude hook is registered as a shell-form `command` with no `args`, the form both Claude Code and Copilot's Claude-compatible settings loader execute through a shell. An exec-form `command` (`node` + script `args`) fails under Copilot, which ignores the Claude `args` field and runs `node` with the hook payload on stdin; `node` then exits 1 and Copilot fail-closes the tool call. With the shell form, Copilot imports the guard, which exits 0 with no output for every session that is not the registered parent.
- On OpenCode, `.opencode/plugins/parent-guard.ts` registers a `permission` `evaluate` hook. It reads `PermissionEvaluation.sessionID`, resolves the state through the same session index, and sets `effect: "deny"` with the same reason under the same rule. A live probe against OpenCode v0.0.0-dev-19933 showed the `edit`, `write`, and `apply_patch` tools all raise the `edit` action, so the guard's action set (one set entry, `edit`) covers every built-in file-edit tool; a tool served by an MCP server raises its own action name and passes the guard. `shell` raises a different action and stays allowed, as `Bash` does on Claude Code. The guard exists only while the plugin is loaded, so a session that disables it stays unguarded.
- The plugin runs inside the OpenCode server process, so it resolves `AGENT_LOOP_RUNS_ROOT` from that process's environment; the Claude Code hook inherits the parent shell's environment instead. The override is test-only, but using it outside tests would point the plugin and the `agent-loop` CLI at different roots and disable the guard silently.

### Pre-tool hook availability by harness

Surveyed 2026-09-21 against current vendor docs, current binaries, and the installed Copilot CLI 1.0.87-0 binary. A session-keyed guard needs both a pre-tool hook and a documented way for the parent to learn its own session id at init time; the guard ships only where both exist.

| Harness            | Pre-tool hook                                                                                                                          | Guard                     |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| Claude Code        | `PreToolUse`, deny supported, `session_id` in input                                                                                    | Implemented (this repo)   |
| Codex CLI          | `PreToolUse`, deny supported, `session_id` in input; entry point depends on undocumented `CODEX_THREAD_ID`                             | Implemented (best effort) |
| Antigravity CLI    | `PreToolUse` hooks (workspace or global `hooks.json`), `conversationId` in input                                                       | Not implemented           |
| GitHub Copilot CLI | PascalCase `PreToolUse`, deny supported, `session_id` and `tool_name` in input; repository `.github/hooks/*.json` loads in current CLI | Implemented (this repo)   |
| OpenCode           | `permission` `evaluate` plugin hook can set `deny`; event carries `PermissionEvaluation.sessionID`                                     | Implemented (this repo)   |

Codex CLI now uses its `PreToolUse` hook with the session id in hook input. Its
entry point relies on `CODEX_THREAD_ID` from the shell environment, which is not
documented and can break on upgrade. The guard fails open when the variable is
absent or unusable. Antigravity still lacks a documented parent-session channel.
Copilot uses the documented session-keyed launcher and repository hook described
above. OpenCode has both: a command reads `CommandInvocation.sessionID`, and the
permission hook reads `PermissionEvaluation.sessionID`.

The guard path blocked `apply_patch` on Codex CLI 0.156.0-alpha.14 for this
Windows check. This is a known-good runtime, not a stable minimum version.
Codex hook denial was not enforced in CLI 0.133.0 or Desktop 0.138.0-alpha.7.
See [openai/codex#27833](https://github.com/openai/codex/issues/27833). Verify
that the installed Codex version blocks `apply_patch` before relying on this
guard. The hook is a best-effort guardrail, not a complete enforcement boundary.

The full skill-to-edit check passed on Codex CLI 0.157.0-alpha.1 on Windows: the
`$agent-loop` skill registered the Codex thread as the parent, `apply_patch`
returned `GUARD_DENY_REASON`, `role abort` set the lifecycle to `aborted`, and
`apply_patch` then succeeded. The probe ran with
`sandbox_mode = "danger-full-access"`; the `role finish` release path is covered
by `tests/hook/parent-guard.test.mjs`. The `workspace-write` leg stays
unverified on Windows: Codex's unelevated Windows sandbox blocks the child
`git` spawn (`EPERM`) before run initialization
([openai/codex#37415](https://github.com/openai/codex/issues/37415)). Run that
leg on macOS or Linux, or under the elevated Windows sandbox.

## Reviewer safety

Reviewer and orchestrator turns run in read-only mode to prevent unintended repository mutations.

### Read-only CLI flags

| CLI        | Read-only invocation flag     | Flag effect                                                                                                                        | Role-model subagent fan-out                                              | Evidence                              |
| ---------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------- |
| `claude`   | `--permission-mode plan`      | Plan mode blocks file edits.                                                                                                       | Yes; Explore and Plan subagents run on the role model. Adapter disables. | Claude docs; issue #46 smoke test     |
| `codex`    | `-c sandbox_mode="read-only"` | Passes read-only sandbox mode on new and resumed sessions.                                                                         | Yes; `spawn_agent` subagents inherit the parent model and effort.        | Codex rollout transcript              |
| `agy`      | `--mode plan`                 | Plan mode disables file edits.                                                                                                     | Yes; `invoke_subagent` subagents inherit the parent model by default.    | CLI `stream-json` step                |
| `opencode` | `--agent plan`                | The plan agent blocks edits; a global permissions allow can cancel it. Adapter denies `edit` per turn. Shell writes stay possible. | Yes; the `subagent` tool inherits the session model. Adapter denies it.  | `run --format json` tool call; A/B    |
| `copilot`  | `--deny-tool write`           | Denies write/edit tools. External permissions may still permit shell writes.                                                       | No; subagents run on an agent-definition default model.                  | `--output-format json` subagent event |

#### Read-only subagent fan-out

Every adapter can fan out from a read-only turn to child subagents. Probes on 2026-09-23 measured each installed CLI.

- `claude`: Plan mode delegates research to the built-in Explore and Plan subagents. They inherit the role model (Explore is capped at Opus on the Claude API). The adapter sets `CLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS=1` on read-only turns only, so plan mode reads files directly. Worker turns stay unchanged. The variable disables only the built-in Explore and Plan subagents; the general-purpose and custom subagents stay available. Requires Claude Code v2.1.198 or later; older versions ignore the variable.
- `codex`: A read-only `exec` turn kept the collaboration tools. On codex-cli 0.156.1 the session rollout recorded a `spawn_agent` call and its subagent reply, while `--json` collapsed the spawn into a `collab_tool_call`. The `<multi_agent_role>` developer message and the `spawn_agent` tool description state that subagents inherit the parent model and reasoning effort. No switch disables it: `--disable multi_agent`, `--disable multi_agent_v2`, `-c features.multi_agent=false`, `-c agents.max_depth=0`, and `--ignore-user-config` each still spawned a subagent. A read-only codex turn can therefore cost the parent model a multiple of what the transcript suggests.
- `agy`: On Antigravity CLI 1.2.8, a plan-mode turn ran a `research` subagent through `invoke_subagent`, visible as a `step_type: "subagent"` step in `--output-format stream-json`. The binary defaults a subagent `model` to `inherit`, the parent model. No CLI flag or setting disables it.
- `opencode`: On OpenCode v0.0.0-dev-20030, a plan turn ran an `explore` subagent through the `subagent` tool, visible as a `tool_use` event in `run --format json`. The `explore` and `general` agents set no model, so they inherit the session model. The adapter sets `OPENCODE_CONFIG_CONTENT` with `subagent` and `edit` denies on read-only turns only; an A/B run through the adapter showed the `subagent` tool call without the deny and no such call with it. OpenCode merges that per-turn document after the user config, so the `edit` deny removes the edit and write tools even when a global permissions allow resolves after the plan agent's own `edit` deny (issue #107, probed on v0.0.0-dev-20033). The tools are absent entirely, so a read-only turn cannot save plan files under `~/.opencode/plan/*` either. Shell stays available for read-only commands such as `git diff`; the adapter does not deny `shell`, so a read-only turn can still write through shell. A user-set `OPENCODE_CONFIG_CONTENT` is replaced on read-only turns, so permission rules belong in `opencode.json`. Worker turns stay unchanged.
- `copilot`: On Copilot CLI 1.0.89-0, a read-only turn ran an `explore` subagent through `task` and a `search-subagent` through `search_code_subagent`, visible in `--output-format json`. The `subagent.started` event carried `modelSelectionSource: "agent_definition_default"`, and the subagent ran on `gpt-5.6-luna` while the parent ran on `gpt-5.4`. Copilot subagents do not inherit the role model, so a read-only turn does not multiply the role-model budget; the subagent still runs and still costs money on its own model. No switch is reliable: `--excluded-tools task` removed `task`, and the model then used `search_code_subagent`.

Per-model usage cannot separate parent tokens from subagent tokens on the same model (#47). Fan-out above rests on tool-call traces and the controlled A/B probe, not on token totals.

### Mutation detection

Before and after every read-only turn (reviewer and orchestrator), the runtime takes a snapshot of the Git work tree, index, and `HEAD`.
If any modified, added, or deleted tracked or untracked file, index change, or commit is detected, the run aborts immediately with a fatal `MutationError` (exit code 1).

**No-revert rule**: Detected modifications are left intact in the work tree so the user can inspect what the agent did.

Known limits:

- Ignored files (matching `.gitignore`) are not tracked.
- Mutations reverted within the same turn are not detected.
- Only paths within `--cwd` are monitored.

## Transcript

When `--transcript <file>` is specified, a JSON transcript is written upon process exit (except when argv parsing fails).

The transcript records each validated orchestrator action, each child result, and one `invocation` event per CLI call, all with timestamps, plus the final exit code and error. It does not record raw orchestrator responses.

An `invocation` event exists for every CLI call: orchestrator attempts, orchestrator repair turns, and child turns, with `status` `ok` or `error`. When the adapter exposes usage, the event carries a `usage` object. The Claude and Copilot adapters map it from the CLI result:

- `models`: the per-model usage map keyed by model id. The Claude CLI exposes it; the Copilot CLI does not.
- `mainLoop`: the top-level `usage` field. Copilot exposes a session-cumulative `result.usage` object here, with no token counts and possible `codeChanges.filesModified` paths. Claude exposes its main-loop usage here.
- `totalCostUsd`: `total_cost_usd`. The CLI must expose it for this key to exist; Copilot does not.

Do not sum Copilot `mainLoop` values across invocation events. Its usage is cumulative for the session, not per turn.

The OpenCode adapter maps usage from the `opencode run --standalone --format json` stream. A step that ends with tool calls emits a `step_finish` part carrying `tokens` (`input`, `output`, `reasoning`, `cache.read`, `cache.write`) and `cost`; the adapter sums both across steps:

- `mainLoop`: the summed `tokens` object.
- `totalCostUsd`: the summed `cost`.

A failed turn keeps the usage its completed steps reported, the same as the Claude adapter. No event names the model, so `models` is omitted. Usage was inspected against OpenCode `v0.0.0-dev-19933`; in that version only steps that end with tool calls emit a `step_finish`, so a text-only turn, and the closing text step of a tool-using turn, contribute no usage.

The Antigravity adapter maps `mainLoop` from the CLI `usage` field. Antigravity reports no cost and no per-model breakdown.

The Codex adapter maps `turn.completed.usage` to `mainLoop`. The map contains input, cached input, cache-write input, output, and reasoning-output token counts. The token counts are cumulative for the thread, not per turn. Codex reports no cost or per-model usage.

Other adapters emit `invocation` events without `usage` until their CLI output is mapped. Per-model usage shows which models ran inside a turn. It cannot separate parent tokens from subagent tokens on the same model.

```json
{
  "task": "...",
  "cwd": "...",
  "options": { "maxSteps": 20, "timeout": 3600 },
  "roles": {
    "orchestrator": { "kind": "codex", "model": null, "effort": null, "sessionId": "..." },
    "worker": { "kind": "claude", "model": "...", "effort": "...", "sessionId": "..." },
    "reviewer": { "kind": "agy", "model": null, "effort": null, "sessionId": "..." }
  },
  "events": [
    { "type": "invocation", "at": "...", "stepsUsed": 0, "role": "orchestrator", "status": "ok", "usage": { ... } },
    { "type": "action", "at": "...", "stepsUsed": 0, "action": { ... } },
    { "type": "invocation", "at": "...", "stepsUsed": 1, "role": "worker", "status": "ok", "usage": { ... } },
    { "type": "result", "at": "...", "stepsUsed": 1, "role": "worker", "result": { ... } }
  ],
  "exitCode": 0,
  "error": null
}
```

## Exit codes

| Code | Meaning                                                                                                                                                                                                    |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0    | Orchestrator returned `finish` with valid 5-part summary.                                                                                                                                                  |
| 1    | Orchestrator returned `abort`, orchestrator CLI failure or timeout, mutation detected, or controller error. A child timeout is not fatal: the orchestrator receives it as an error result and can recover. |
| 2    | Step limit reached (`--max-steps`) with work remaining.                                                                                                                                                    |
| 130  | Interrupted by `Ctrl+C` (active children killed).                                                                                                                                                          |

## Development

```bash
pnpm fmt         # Format files with oxfmt
pnpm fmt:check   # Check formatting
pnpm lint        # Lint files with oxlint
pnpm test        # Run Vitest test suite
```

## Releasing

The release workflow stages a version on a published GitHub release or a manual dispatch, authenticated by OIDC trusted publishing. No npm token is stored.

npm requires the package to exist before a trusted publisher can be configured, so the first version is published once from a checkout:

```bash
npm login
npm publish
```

Then add a trusted publisher on npmjs.com: package settings, **Trusted publishing**, **GitHub Actions**, organization or user `andromarces`, repository `agent-loops`, workflow filename `release.yml`, allowed actions `npm stage publish`.

For later releases, bump the version, then publish a GitHub release with tag `v<version>`. The tag must match the `package.json` version. The workflow runs `npm stage publish` and prints a stage id. Review and approve the staged version with 2FA:

```bash
npm stage list
npm stage view <stage-id>
npm stage approve <stage-id>
```

A manual dispatch must run on the release tag, for example `gh workflow run release.yml --ref v<version>`. A dispatch on a branch is rejected, because npm records provenance from the run ref, not the checked-out commit.

The version goes live only after approval. Staged publishing needs npm 11.15.0 or later and 2FA on the account.

## Manual smoke test

Run against real CLI agents in a temporary Git repository:

```bash
# Prepare scratch repo
mkdir /tmp/smoke-repo && cd /tmp/smoke-repo
git init
git commit --allow-empty -m "init"

# Run smoke test
agent-loop \
  --orchestrator codex \
  --worker claude \
  --reviewer agy \
  --max-steps 4 \
  --timeout 600 \
  --transcript ./run.json \
  --task "Add a README line that names the project."
```

## Future additions

Features considered for future development once the hybrid loop stabilizes:

- Orchestrator-addressable roles (allowing dynamic registration of additional named specialist roles)
- Per-role extra CLI arguments and flags
- GitHub pull request mode
- Configurable validation commands and automated gates
- Persistent controller state and session resume across process restarts
- Streaming transcript logs and usage metadata

---
_Source: https://npm.io/package/@andromarces/agent-loops · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
