@andromarces/agent-loops 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andro Marces
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,461 @@
1
+ # Agent Loops
2
+
3
+ Run a task loop across several CLI coding agents using a hybrid orchestrator model.
4
+
5
+ ```text
6
+ +-------------------------------------------------+
7
+ | Orchestrator |
8
+ | (decides next action: worker, reviewer, finish) |
9
+ +-------------------------------------------------+
10
+ ^
11
+ | action / result
12
+ v
13
+ +-------------------------------------------------+
14
+ | Deterministic Runtime |
15
+ | (enforces step budget, timeout, read-only check)|
16
+ +-------------------------------------------------+
17
+ / \
18
+ / \
19
+ v v
20
+ +-------------------+ +-------------------+
21
+ | Worker | | Reviewer |
22
+ | (modifies / tests)| | (read-only) |
23
+ +-------------------+ +-------------------+
24
+ ```
25
+
26
+ 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.
27
+
28
+ ## Architecture
29
+
30
+ - **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.
31
+ - **Worker**: Executes the task, modifies files, and runs validation commands in the target repository.
32
+ - **Reviewer**: Evaluates the repository state and tests in read-only mode.
33
+ - **Deterministic runtime**:
34
+ - Enforces step limits (`--max-steps`, default 20) and timeout boundaries.
35
+ - Spawns agents, manages persistent sessions, and captures process signals (`Ctrl+C` exits 130).
36
+ - Enforces non-mutating safety on reviewer and orchestrator turns using CLI flags and pre/post Git work-tree mutation detection.
37
+ - Recovers from malformed JSON via a single repair turn.
38
+ - 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.
39
+
40
+ 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)).
41
+
42
+ ## Supported agents
43
+
44
+ The controller supports these CLI names:
45
+
46
+ - Claude Code: `claude`
47
+ - Codex CLI: `codex`
48
+ - Antigravity CLI: `agy` (alias: `antigravity`)
49
+ - OpenCode: `opencode` (OpenCode v2, npm `@opencode/cli`)
50
+ - GitHub Copilot CLI: `copilot`
51
+
52
+ Each adapter manages its own persistent session across turns. Model and effort arguments pass through to the CLI on every turn.
53
+
54
+ 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.
55
+
56
+ ### OpenCode model default
57
+
58
+ 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.
59
+
60
+ 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`.
61
+
62
+ `--<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.
63
+
64
+ ## Requirements
65
+
66
+ - Node.js 22 or later
67
+ - Git (the target `--cwd` must be inside a Git work tree)
68
+ - Installed and authenticated CLI agents
69
+
70
+ pnpm is required only for development in a clone, not for a registry install.
71
+
72
+ ## Install
73
+
74
+ Install the CLI globally:
75
+
76
+ ```bash
77
+ npm install -g @andromarces/agent-loops
78
+ # or
79
+ pnpm add -g @andromarces/agent-loops
80
+ ```
81
+
82
+ Run it without installing:
83
+
84
+ ```bash
85
+ npx @andromarces/agent-loops --help
86
+ pnpm dlx @andromarces/agent-loops --help
87
+ ```
88
+
89
+ Install from a Git URL instead of the registry:
90
+
91
+ ```bash
92
+ npm install -g github:andromarces/agent-loops
93
+ ```
94
+
95
+ npm 12 disables git fetches by default. On npm 12, pass `--allow-git=all`:
96
+
97
+ ```bash
98
+ npm install -g --allow-git=all github:andromarces/agent-loops
99
+ ```
100
+
101
+ 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.
102
+
103
+ ### From a clone (development)
104
+
105
+ Development uses pnpm and the repository Git hooks:
106
+
107
+ ```bash
108
+ git clone <repository-url>
109
+ cd agent-loops
110
+ pnpm install
111
+ pnpm agent-loop role ...
112
+ ```
113
+
114
+ `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:
115
+
116
+ ```bash
117
+ pnpm setup # restart the shell afterwards
118
+ pnpm add -g .
119
+ ```
120
+
121
+ 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:
122
+
123
+ ```bash
124
+ node "<repo>/src/cli.mjs" role ...
125
+ ```
126
+
127
+ ## Usage
128
+
129
+ ```bash
130
+ agent-loop \
131
+ --orchestrator codex \
132
+ --worker claude \
133
+ --reviewer agy \
134
+ --task "Implement the change."
135
+ ```
136
+
137
+ PowerShell:
138
+
139
+ ```powershell
140
+ agent-loop --orchestrator codex --worker claude --reviewer agy --task "Implement the change."
141
+ ```
142
+
143
+ ### Options
144
+
145
+ ```text
146
+ --orchestrator <agent> Agent that directs the loop. Required.
147
+ --worker <agent> Agent that implements changes. Required.
148
+ --reviewer <agent> Agent that reviews the repository (read-only). Required.
149
+ --orchestrator-model <model> Model passed to the orchestrator CLI. Optional.
150
+ --orchestrator-effort <level> Thinking effort passed to the orchestrator CLI. Optional.
151
+ --worker-model <model> Model passed to the worker CLI. Optional.
152
+ --worker-effort <level> Thinking effort passed to the worker CLI. Optional.
153
+ --reviewer-model <model> Model passed to the reviewer CLI. Optional.
154
+ --reviewer-effort <level> Thinking effort passed to the reviewer CLI. Optional.
155
+ --cwd <directory> Working directory for the agents. Must be inside a Git work tree. Defaults to current directory.
156
+ --task <text> Task description. Required.
157
+ --max-steps <count> Maximum child steps. Defaults to 20.
158
+ --timeout <seconds> Timeout per agent invocation. Defaults to 3600. 0 disables the bound.
159
+ --transcript <file> Record execution transcript to a JSON file.
160
+ --verbose Enable debug-level lifecycle logging, including snapshot activity.
161
+ -h, --help Show help.
162
+ ```
163
+
164
+ ## Interactive child dispatch: `agent-loop role`
165
+
166
+ An interactive parent session (Claude Code, Codex, or any harness with shell access) can dispatch one child turn without spawning a headless orchestrator:
167
+
168
+ ```bash
169
+ # First call initializes the run state and dispatches the worker.
170
+ agent-loop role dispatch \
171
+ --role worker \
172
+ --cwd /path/to/work-tree \
173
+ --task "Implement the change." \
174
+ --mode work-first \
175
+ --parent-session "$CLAUDE_SESSION_ID" \
176
+ --worker claude --reviewer agy \
177
+ --prompt-file ./prompt.txt
178
+
179
+ # Later calls read the configuration from the state file.
180
+ agent-loop role dispatch --role reviewer --cwd /path/to/work-tree --prompt-file ./review.txt
181
+ ```
182
+
183
+ Operations: `dispatch` (default), `finish`, `abort`.
184
+
185
+ - 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.
186
+ - 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.
187
+ - Prompts come from stdin by default, or `--prompt-file`. `finish` reads the five-key summary as JSON on stdin; `abort` takes `--reason`.
188
+ - 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.
189
+ - 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.
190
+ - 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.
191
+ - 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`.
192
+ - `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.
193
+ - 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.
194
+ - `--transcript <file>` appends one JSON line per `invocation` and `result` event, in the same shape as the headless mode, accumulating across calls.
195
+
196
+ Stdout carries exactly one JSON envelope; all logs go to stderr:
197
+
198
+ ```json
199
+ {
200
+ "role": "reviewer",
201
+ "status": "ok",
202
+ "report": { "conclusion": "...", "why": "...", "blockers": "..." },
203
+ "verdict": "accept"
204
+ }
205
+ ```
206
+
207
+ `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.
208
+
209
+ ## Interactive orchestrator: harness entry points
210
+
211
+ An interactive parent session runs the same role rules through the subcommand,
212
+ driven by `docs/orchestrator-instructions.md`. One harness-neutral instruction
213
+ file defines the role; each supported harness gets a thin entry point that
214
+ includes it rather than copying it. Role activation never goes into `AGENTS.md`
215
+ or `CLAUDE.md`, because dispatched children read those files; activation
216
+ happens only through explicit invocation.
217
+
218
+ | Harness | Entry point | Invocation |
219
+ | --------------- | ------------------------------------ | --------------------------------------------- |
220
+ | Claude Code | `.claude/skills/agent-loop/SKILL.md` | `/agent-loop <task and role settings>` |
221
+ | OpenCode | `.opencode/plugins/parent-guard.ts` | `/agent-loop <task and role settings>` |
222
+ | Codex CLI | `.agents/skills/agent-loop/SKILL.md` | `$agent-loop <task and role settings>` |
223
+ | Copilot CLI | `src/entrypoints/copilot.mjs` | `agent-loop-copilot <task and role settings>` |
224
+ | Antigravity CLI | universal fallback (below) | first prompt references the file |
225
+
226
+ - The Claude Code skill sets `disable-model-invocation: true`, so only the
227
+ maintainer activates it with `/agent-loop`, and it omits `context: fork`, so
228
+ the skill runs in the current session. Its body passes `${CLAUDE_SESSION_ID}`
229
+ as `--parent-session` on the init dispatch call.
230
+ - The OpenCode entry point is a local plugin (`.opencode/plugins/parent-guard.ts`),
231
+ loaded automatically from `.opencode/plugins/`. Stored command templates expose
232
+ no session id, so the plugin registers the `/agent-loop` command itself: its
233
+ executor reads `CommandInvocation.sessionID` and carries that id into the
234
+ orchestrator prompt, and the init dispatch call passes it as
235
+ `--parent-session`.
236
+ - The Codex CLI skill (`.agents/skills/agent-loop/SKILL.md`) activates only through
237
+ `$agent-loop`. It passes `CODEX_THREAD_ID` as `--parent-session`. Use
238
+ `$env:CODEX_THREAD_ID` in PowerShell and `$CODEX_THREAD_ID` in POSIX shells.
239
+ In PowerShell, pass `--cwd $worktree` after setting
240
+ `$worktree = (Get-Location).Path`.
241
+ The CLI rejects an empty `--parent-session` before it initializes a run.
242
+ This environment variable is an undocumented dependency and can change on
243
+ upgrade. When it is absent, do not start a guarded run. Run `/hooks` once to
244
+ review and trust the repository hook. Do not use
245
+ `--dangerously-bypass-hook-trust` for normal use.
246
+ - The Copilot CLI entry point is `src/entrypoints/copilot.mjs`, exposed as
247
+ `agent-loop-copilot`. It mints a UUID, starts
248
+ `copilot --session-id <uuid> --interactive <prompt>`, and includes the
249
+ instruction file, task, and same id for `--parent-session` in the first
250
+ prompt. The current CLI documentation exposes no custom command-template
251
+ session-id placeholder, so the launcher is the native entry point.
252
+ - Universal fallback (Antigravity): reference
253
+ `docs/orchestrator-instructions.md` in the first prompt and follow it. A
254
+ native entry point is added only after that harness documents an explicit
255
+ extension mechanism.
256
+ - On Copilot CLI, `.github/hooks/parent-guard.json` registers PascalCase `PreToolUse`, so the payload carries
257
+ `session_id` and `tool_name`; the hook prints the flat `permissionDecision` object that Copilot CLI consumes.
258
+ Repository hooks require a trusted folder. GitHub documents the `.github/hooks/*.json` path for Windows, macOS, and
259
+ Linux, and a live probe on Windows with Copilot CLI 1.0.87-0 confirmed the hook loaded and reported `tool_name:
260
+ Write`; no macOS runtime was available for this change. Copilot also loads `.claude/settings.json` as repository
261
+ settings and sets `CLAUDE_PROJECT_DIR` to the repository root, so the Claude hook runs the same guard there and
262
+ Copilot honors the Claude `hookSpecificOutput.permissionDecision` response. Every Copilot `Edit`/`Write` therefore
263
+ runs two parent guards: the Claude-compat one and the `.github/hooks` one. Both deny the registered parent and both
264
+ stay silent for any other session. The `.github/hooks` hook stays as the documented, version-stable path.
265
+ - The parent-edit guard (#57) reads `--parent-session` from the state index
266
+ (see Parent guard below). For any run without `--parent-session`, the parent
267
+ stays unguarded.
268
+
269
+ ## Parent guard: hard read-only for the parent session
270
+
271
+ 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.
272
+
273
+ - Matchers: Claude Code uses
274
+ `Edit|Write|MultiEdit|NotebookEdit`. Codex CLI uses
275
+ `apply_patch`, which its hook input reports as `tool_name: "apply_patch"`.
276
+ Copilot CLI uses `Edit|Write`, which covers the current built-in edit and create
277
+ tools. `Bash` stays allowed because the parent needs it to run `agent-loop role`;
278
+ a shell-based edit bypasses all guards. Full enforcement needs a harness that
279
+ exposes only orchestration tools.
280
+ - 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.
281
+ - 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.
282
+ - 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.
283
+ - 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`.
284
+ - 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.
285
+ - 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.
286
+ - 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.
287
+
288
+ ### Pre-tool hook availability by harness
289
+
290
+ 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.
291
+
292
+ | Harness | Pre-tool hook | Guard |
293
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
294
+ | Claude Code | `PreToolUse`, deny supported, `session_id` in input | Implemented (this repo) |
295
+ | Codex CLI | `PreToolUse`, deny supported, `session_id` in input; entry point depends on undocumented `CODEX_THREAD_ID` | Implemented (best effort) |
296
+ | Antigravity CLI | `PreToolUse` hooks (workspace or global `hooks.json`), `conversationId` in input | Not implemented |
297
+ | 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) |
298
+ | OpenCode | `permission` `evaluate` plugin hook can set `deny`; event carries `PermissionEvaluation.sessionID` | Implemented (this repo) |
299
+
300
+ Codex CLI now uses its `PreToolUse` hook with the session id in hook input. Its
301
+ entry point relies on `CODEX_THREAD_ID` from the shell environment, which is not
302
+ documented and can break on upgrade. The guard fails open when the variable is
303
+ absent or unusable. Antigravity still lacks a documented parent-session channel.
304
+ Copilot uses the documented session-keyed launcher and repository hook described
305
+ above. OpenCode has both: a command reads `CommandInvocation.sessionID`, and the
306
+ permission hook reads `PermissionEvaluation.sessionID`.
307
+
308
+ The guard path blocked `apply_patch` on Codex CLI 0.156.0-alpha.14 for this
309
+ Windows check. This is a known-good runtime, not a stable minimum version.
310
+ Codex hook denial was not enforced in CLI 0.133.0 or Desktop 0.138.0-alpha.7.
311
+ See [openai/codex#27833](https://github.com/openai/codex/issues/27833). Verify
312
+ that the installed Codex version blocks `apply_patch` before relying on this
313
+ guard. The hook is a best-effort guardrail, not a complete enforcement boundary.
314
+
315
+ The full skill-to-edit check passed on Codex CLI 0.157.0-alpha.1 on Windows: the
316
+ `$agent-loop` skill registered the Codex thread as the parent, `apply_patch`
317
+ returned `GUARD_DENY_REASON`, `role abort` set the lifecycle to `aborted`, and
318
+ `apply_patch` then succeeded. The probe ran with
319
+ `sandbox_mode = "danger-full-access"`; the `role finish` release path is covered
320
+ by `tests/hook/parent-guard.test.mjs`. The `workspace-write` leg stays
321
+ unverified on Windows: Codex's unelevated Windows sandbox blocks the child
322
+ `git` spawn (`EPERM`) before run initialization
323
+ ([openai/codex#37415](https://github.com/openai/codex/issues/37415)). Run that
324
+ leg on macOS or Linux, or under the elevated Windows sandbox.
325
+
326
+ ## Reviewer safety
327
+
328
+ Reviewer and orchestrator turns run in read-only mode to prevent unintended repository mutations.
329
+
330
+ ### Read-only CLI flags
331
+
332
+ | CLI | Read-only invocation flag | Flag effect | Role-model subagent fan-out | Evidence |
333
+ | ---------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------- |
334
+ | `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 |
335
+ | `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 |
336
+ | `agy` | `--mode plan` | Plan mode disables file edits. | Yes; `invoke_subagent` subagents inherit the parent model by default. | CLI `stream-json` step |
337
+ | `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 |
338
+ | `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 |
339
+
340
+ #### Read-only subagent fan-out
341
+
342
+ Every adapter can fan out from a read-only turn to child subagents. Probes on 2026-09-23 measured each installed CLI.
343
+
344
+ - `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.
345
+ - `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.
346
+ - `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.
347
+ - `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.
348
+ - `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`.
349
+
350
+ 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.
351
+
352
+ ### Mutation detection
353
+
354
+ Before and after every read-only turn (reviewer and orchestrator), the runtime takes a snapshot of the Git work tree, index, and `HEAD`.
355
+ 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).
356
+
357
+ **No-revert rule**: Detected modifications are left intact in the work tree so the user can inspect what the agent did.
358
+
359
+ Known limits:
360
+
361
+ - Ignored files (matching `.gitignore`) are not tracked.
362
+ - Mutations reverted within the same turn are not detected.
363
+ - Only paths within `--cwd` are monitored.
364
+
365
+ ## Transcript
366
+
367
+ When `--transcript <file>` is specified, a JSON transcript is written upon process exit (except when argv parsing fails).
368
+
369
+ 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.
370
+
371
+ 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:
372
+
373
+ - `models`: the per-model usage map keyed by model id. The Claude CLI exposes it; the Copilot CLI does not.
374
+ - `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.
375
+ - `totalCostUsd`: `total_cost_usd`. The CLI must expose it for this key to exist; Copilot does not.
376
+
377
+ Do not sum Copilot `mainLoop` values across invocation events. Its usage is cumulative for the session, not per turn.
378
+
379
+ 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:
380
+
381
+ - `mainLoop`: the summed `tokens` object.
382
+ - `totalCostUsd`: the summed `cost`.
383
+
384
+ 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.
385
+
386
+ The Antigravity adapter maps `mainLoop` from the CLI `usage` field. Antigravity reports no cost and no per-model breakdown.
387
+
388
+ 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.
389
+
390
+ 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.
391
+
392
+ ```json
393
+ {
394
+ "task": "...",
395
+ "cwd": "...",
396
+ "options": { "maxSteps": 20, "timeout": 3600 },
397
+ "roles": {
398
+ "orchestrator": { "kind": "codex", "model": null, "effort": null, "sessionId": "..." },
399
+ "worker": { "kind": "claude", "model": "...", "effort": "...", "sessionId": "..." },
400
+ "reviewer": { "kind": "agy", "model": null, "effort": null, "sessionId": "..." }
401
+ },
402
+ "events": [
403
+ { "type": "invocation", "at": "...", "stepsUsed": 0, "role": "orchestrator", "status": "ok", "usage": { ... } },
404
+ { "type": "action", "at": "...", "stepsUsed": 0, "action": { ... } },
405
+ { "type": "invocation", "at": "...", "stepsUsed": 1, "role": "worker", "status": "ok", "usage": { ... } },
406
+ { "type": "result", "at": "...", "stepsUsed": 1, "role": "worker", "result": { ... } }
407
+ ],
408
+ "exitCode": 0,
409
+ "error": null
410
+ }
411
+ ```
412
+
413
+ ## Exit codes
414
+
415
+ | Code | Meaning |
416
+ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
417
+ | 0 | Orchestrator returned `finish` with valid 5-part summary. |
418
+ | 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. |
419
+ | 2 | Step limit reached (`--max-steps`) with work remaining. |
420
+ | 130 | Interrupted by `Ctrl+C` (active children killed). |
421
+
422
+ ## Development
423
+
424
+ ```bash
425
+ pnpm fmt # Format files with oxfmt
426
+ pnpm fmt:check # Check formatting
427
+ pnpm lint # Lint files with oxlint
428
+ pnpm test # Run Vitest test suite
429
+ ```
430
+
431
+ ## Manual smoke test
432
+
433
+ Run against real CLI agents in a temporary Git repository:
434
+
435
+ ```bash
436
+ # Prepare scratch repo
437
+ mkdir /tmp/smoke-repo && cd /tmp/smoke-repo
438
+ git init
439
+ git commit --allow-empty -m "init"
440
+
441
+ # Run smoke test
442
+ agent-loop \
443
+ --orchestrator codex \
444
+ --worker claude \
445
+ --reviewer agy \
446
+ --max-steps 4 \
447
+ --timeout 600 \
448
+ --transcript ./run.json \
449
+ --task "Add a README line that names the project."
450
+ ```
451
+
452
+ ## Future additions
453
+
454
+ Features considered for future development once the hybrid loop stabilizes:
455
+
456
+ - Orchestrator-addressable roles (allowing dynamic registration of additional named specialist roles)
457
+ - Per-role extra CLI arguments and flags
458
+ - GitHub pull request mode
459
+ - Configurable validation commands and automated gates
460
+ - Persistent controller state and session resume across process restarts
461
+ - Streaming transcript logs and usage metadata