Agent Loops
Run a task loop across several CLI coding agents using a hybrid orchestrator model.
+-------------------------------------------------+
| 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+Cexits 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
invocationevent per CLI call with usage when the adapter exposes it, timestamps, exit code, and error when--transcriptis provided. Raw orchestrator responses are not recorded.
- Enforces step limits (
See Architecture Decision Records for background and architectural decisions (ADR 0001, ADR 0002).
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 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
--cwdmust 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:
npm install -g @andromarces/agent-loops
# or
pnpm add -g @andromarces/agent-loops
Run it without installing:
npx @andromarces/agent-loops --help
pnpm dlx @andromarces/agent-loops --help
Install from a Git URL instead of the registry:
npm install -g github:andromarces/agent-loops
npm 12 disables git fetches by default. On npm 12, pass --allow-git=all:
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:
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:
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:
node "<repo>/src/cli.mjs" role ...
Usage
agent-loop \
--orchestrator codex \
--worker claude \
--reviewer agy \
--task "Implement the change."
PowerShell:
agent-loop --orchestrator codex --worker claude --reviewer agy --task "Implement the change."
Options
--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:
# 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, withstate.lockbeside it). There is no--stateflag;AGENT_LOOP_RUNS_ROOToverrides 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--cwdis a different work tree. A later init call from the same session overwrites the entry. - Prompts come from stdin by default, or
--prompt-file.finishreads the five-key summary as JSON on stdin;aborttakes--reason. - The state file records
task,mode,cwd,parentSession,maxSteps,timeout,stepsUsed,lifecycle,roles.{worker,reviewer}.{kind,model,effort,sessionId}(roles.workeris null inreview-only),lastDispatch, andlastResult, plussummaryorreasonwhen terminal andresumeDecisionwhen a maintainer resumed an interrupted run. Updates are atomic (temp file plus rename); exclusive access usesstate.lockwith 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 leavesdispatchedwith a dead lock owner; the first call after the crash marks itinterrupted, exits non-zero, and never repeats the turn, even with--resume-interrupted. Frominterrupted, onlyabortor an explicitdispatch --resume-interruptedis 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
withMutationCheckas the headless loop: a detected mutation or snapshot error is fatal, keeps the charged step, and setshalted. No further dispatch is possible; the next run needs a new init call, which archives the halted file asstate.<timestamp>.json. mode: review-onlyrejects--role workeras a hard guard and does not require--workerat init.finishis completion of the requested work, not code acceptance: it is accepted fromactivein 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 (acceptorreject, 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, yieldsverdict: unknown; process success never implies acceptance. --transcript <file>appends one JSON line perinvocationandresultevent, in the same shape as the headless mode, accumulating across calls.
Stdout carries exactly one JSON envelope; all logs go to stderr:
{
"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 omitscontext: fork, so the skill runs in the current session. Its body passes${CLAUDE_SESSION_ID}as--parent-sessionon 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-loopcommand itself: its executor readsCommandInvocation.sessionIDand 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 passesCODEX_THREAD_IDas--parent-session. Use$env:CODEX_THREAD_IDin PowerShell and$CODEX_THREAD_IDin POSIX shells. In PowerShell, pass--cwd $worktreeafter setting$worktree = (Get-Location).Path. The CLI rejects an empty--parent-sessionbefore 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/hooksonce to review and trust the repository hook. Do not use--dangerously-bypass-hook-trustfor normal use. - The Copilot CLI entry point is
src/entrypoints/copilot.mjs, exposed asagent-loop-copilot. It mints a UUID, startscopilot --session-id <uuid> --add-dir <docs dir> --interactive <prompt>, and includes the instruction file, task, and same id for--parent-sessionin 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.mdin 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.jsonregisters PascalCasePreToolUse, so the payload carriessession_idandtool_name; the hook prints the flatpermissionDecisionobject that Copilot CLI consumes. Repository hooks require a trusted folder. GitHub documents the.github/hooks/*.jsonpath for Windows, macOS, and Linux, and a live probe on Windows with Copilot CLI 1.0.87-0 confirmed the hook loaded and reportedtool_name: Write; no macOS runtime was available for this change. Copilot also loads.claude/settings.jsonas repository settings and setsCLAUDE_PROJECT_DIRto the repository root, so the Claude hook runs the same guard there and Copilot honors the ClaudehookSpecificOutput.permissionDecisionresponse. Every CopilotEdit/Writetherefore runs two parent guards: the Claude-compat one and the.github/hooksone. Both deny the registered parent and both stay silent for any other session. The.github/hookshook stays as the documented, version-stable path. - The parent-edit guard (#57) reads
--parent-sessionfrom 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 usesapply_patch, which its hook input reports astool_name: "apply_patch". Copilot CLI usesEdit|Write, which covers the current built-in edit and create tools.Bashstays allowed because the parent needs it to runagent-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 onlysession_id. It resolves the state file through the session index written by the init call, never through the hookcwd, so a parent whose run targets a different--cwdstays guarded wherever it edits. No environment variable is required at parent start-up; the harness entry points (#56) pass their session id as--parent-sessionon init. - Deny only when the hook
session_idequalsparentSessionin the registered state and the lifecycle is non-terminal (active,dispatched,interrupted). The guard releases only onfinish,abort, or ahaltedstate; duringinterruptedit stays engaged, anddispatch --resume-interruptedkeeps it engaged because the resumed run is non-terminal again. - Everything else allows: a worker dispatched by
rolein the same cwd (a different session id), a second interactive session in the same cwd, a state withoutparentSession, 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
commandwith noargs, the form both Claude Code and Copilot's Claude-compatible settings loader execute through a shell. An exec-formcommand(node+ scriptargs) fails under Copilot, which ignores the Claudeargsfield and runsnodewith the hook payload on stdin;nodethen 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.tsregisters apermissionevaluatehook. It readsPermissionEvaluation.sessionID, resolves the state through the same session index, and setseffect: "deny"with the same reason under the same rule. A live probe against OpenCode v0.0.0-dev-19933 showed theedit,write, andapply_patchtools all raise theeditaction, 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.shellraises a different action and stays allowed, asBashdoes 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_ROOTfrom 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 theagent-loopCLI 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. 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). 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 setsCLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS=1on 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-onlyexecturn kept the collaboration tools. On codex-cli 0.156.1 the session rollout recorded aspawn_agentcall and its subagent reply, while--jsoncollapsed the spawn into acollab_tool_call. The<multi_agent_role>developer message and thespawn_agenttool 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-configeach 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 aresearchsubagent throughinvoke_subagent, visible as astep_type: "subagent"step in--output-format stream-json. The binary defaults a subagentmodeltoinherit, the parent model. No CLI flag or setting disables it.opencode: On OpenCode v0.0.0-dev-20030, a plan turn ran anexploresubagent through thesubagenttool, visible as atool_useevent inrun --format json. Theexploreandgeneralagents set no model, so they inherit the session model. The adapter setsOPENCODE_CONFIG_CONTENTwithsubagentandeditdenies on read-only turns only; an A/B run through the adapter showed thesubagenttool call without the deny and no such call with it. OpenCode merges that per-turn document after the user config, so theeditdeny removes the edit and write tools even when a global permissions allow resolves after the plan agent's owneditdeny (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 asgit diff; the adapter does not denyshell, so a read-only turn can still write through shell. A user-setOPENCODE_CONFIG_CONTENTis replaced on read-only turns, so permission rules belong inopencode.json. Worker turns stay unchanged.copilot: On Copilot CLI 1.0.89-0, a read-only turn ran anexploresubagent throughtaskand asearch-subagentthroughsearch_code_subagent, visible in--output-format json. Thesubagent.startedevent carriedmodelSelectionSource: "agent_definition_default", and the subagent ran ongpt-5.6-lunawhile the parent ran ongpt-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 taskremovedtask, and the model then usedsearch_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
--cwdare 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-levelusagefield. Copilot exposes a session-cumulativeresult.usageobject here, with no token counts and possiblecodeChanges.filesModifiedpaths. 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 summedtokensobject.totalCostUsd: the summedcost.
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.
{
"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
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:
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:
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:
# 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