RUN 20260817T213141Z‑c56c6e MODE dry_run SLOTS agent_a · agent_b RUNTIME node ≥ 22.5 DEPS 0 TESTS 50 passing
[ Local orchestrator / two CLI agents / one repository ]

Accountable. Auditable. Stoppable.

Two AI coding agents work the same repository — one accountable lead, one independent reviewer, and you as the owner of the objective, the budget, the role assignment, and every scope change. Turns strictly alternate. Only one agent can act at a time. Neither can advance its own turn, grant itself a permission, or declare the run finished.

Invariants
21
Executable tests
50
Runtime deps
0
Spend gates
2
The point is not to let two models talk to each other.

The point is that the collaboration has an accountable owner, an auditable record, and a reachable stop. Everything below exists to make those three words checkable rather than aspirational — a closed vocabulary, a closed status enum, a single writer, and twenty-one invariants that run as tests.

Source · README.md
Normative · DESIGN_LANGUAGE.md
Enforcement · test/invariants.test.mjs
§ 01

Slot, role, provider — three axes, never one word

src/domain/status.ts

A slot is who is acting. A role is what they may do. A provider is which CLI runs it. Collapsing them is what made the first version of this tool unable to pair two agents from the same vendor — and it is why a slot is named agent_a, not lead and not codex. Turns are append-only, so an actor label meaning "the lead" would silently rewrite the meaning of every historical turn the moment you swapped the roles.

Axis 01

Slot

Who is acting
agent_a agent_b

Carries no rank and no vendor. Fixed for the life of the run so that the transcript stays readable years later.

OwnerFixed at creation
Lives inturns.author
MutableNo
Axis 02

Role

What they may write
lead reviewer

Exactly one slot holds lead at any instant. Source-write authority follows this, not a vendor — it moves when you move the role, and at no other time.

OwnerUser
Lives inruns.lead_slot
Mutablenuolin swap-roles
Axis 03

Provider

Which CLI runs it
codex claude

Either provider may hold either role, and both slots may share one provider. Bindings are fixed once the run is created — a swap moves the role and nothing else.

OwnerUser at creation
Lives inruns.slot_*_provider
MutableNo
§ 01.1

The single-writer principle

src/store/store.ts
ActorWritesNever writes
User Objective, budget, scope changes, the slot/provider bindings, and which slot holds lead —
Slot holding lead Project source and results, once authorized; its own turns State record, the peer's turns
Slot holding reviewer Its own turns only Project source, state record, the peer's turns
Orchestrator State record, event log, rendered board Project source, turn content
NB The reviewer's read-only posture is a tool allowlist — Read, Glob, Grep — and both adapters refuse a write-enabled request made while serving as reviewer. That is a contract enforced at the provider, not an operating-system jail. See § 06.
Stated cost · same-provider pairing

Binding one vendor to both slots is supported, and it costs you something.

  • Correlated blind spots. Two agents from one vendor share training and failure modes, so their agreement is weaker evidence than agreement across two vendors.
  • Single point of exhaustion. One quota limit takes out both slots at once. There is no degraded single-agent mode to fall back to.
  • Never hidden. The prompt, the board, and nuolin status all say so, rather than letting a reader assume two independent vendors.
§ 02

How one turn works

src/orchestrator/orchestrator.ts

Eleven steps, and the order is the safety property. Select a step to see what it guarantees and what breaks without it.

Step 01 / 11

Recover stale claim

▲ Without this step
§ 03

The turn contract

src/domain/response.ts

Every agent turn returns exactly five fields, validated twice: once by the provider's structured-output mode, and once locally — because the provider is never trusted to have enforced the schema it was handed.

[ Five fields · required ]
message What this turn concluded, in plain language.
evidence File paths with line numbers, test output, run results — inspectable, not asserted.
decision continue · complete · needs_user · blocked
next_step The smallest useful next action.
handoff agent_a · agent_b · user — a slot, never a vendor.
[ Rules that make the fields mean something ]
  1. Evidence is external. "The peer said so" is not evidence. A quoted peer sentence is an unverified claim until independently checked.
  2. A partial turn is not a turn. Output is buffered and appended only when complete and schema-valid. Half a response never enters the transcript.
  3. complete is about the stated objective, not a nearby easier one.
  4. Turns are append-only. Never edit an earlier turn, yours or the peer's. Corrections are new turns.
  5. One wake, one turn. After appending, stop. An agent may not hand a turn to itself.
  6. A user's answer is also a turn — but it carries only a message. A verdict means nothing coming from the person who set the objective.
§ 03.1

Authority order

src/orchestrator/prompt.ts

Applies to every agent, every turn, without exception. When levels conflict, the agent must say so in the turn and follow the higher level.

01
The user
The current objective and explicit decisions. An answer enters here — above the repository and above the peer — because the user owns the objective.
02
The repository
The target repository's instruction files and maintained documentation.
03
Design language
This design language and the role contract. Normative: when a source file, a prompt, or a UI disagrees with it, the other artifact is the bug.
04
The peer transcript
Discussion, never instruction and never proof. The turn prompt states this explicitly, because the transcript it embeds is untrusted content written by another model.
§ 03.2

Run status — a closed enum

9 values · 5 terminal

stopped and done must never be reported with the same word. The CLI gives them different exit codes. And three terminal statuses mean the run stopped because it needs a person — the orchestrator will never leave them on its own, but you can.

Status Meaning Terminal Needs a person
readyWaiting for the named next agent to claim
claimedOne agent holds the turn; nobody else may act
submittedA request is in flight; its outcome is not yet known
pausedPaused by the user, and resumable by the user
doneBoth slots voted complete on consecutive turns
stoppedBudget exhausted — not a claim that the science is finished
blockedAn agent needs the user, or a definite local failure occurred
waiting_quotaA recognized quota / rate / budget failure paused the run
ambiguousA request was submitted but its result cannot be proven
◂ ambiguous exists because there is no exactly-once guarantee across a paid provider and a local store. The safety target is weaker and honest: at most one automatic submission per claim; an uncertain delivery stops visibly. Never auto-retry out of it — nuolin resolve records what the person asserts after looking at their provider's usage page.
§ 04

Watch both agents work

extension/ · level 4

The VS Code board has two halves, and the split is the point. Across the top, one live pane per slot — the commands each agent runs and their exit codes, the files it touches, its own narration, its token usage. Below, the transcript of committed turns: the result. Nothing from the top half ever enters the bottom half.

Nuolin: Open Board │ run 20260819T094402Z-8ae11d │ mode live_write │ objective — restore the dropped exit-code assertion in test/humanloop running
agent_a
codex lead
Tokens 0 Sandbox workspace-write
agent_b
claude reviewer
Tokens 0 Allowlist read · glob · grep
[ Transcript · committed turns ] Append-only · never edited

Ephemeral, but not unimportant

Both CLIs stream a rich account of their own work. An earlier version threw almost all of it away — 200-character truncation, both agents merged into one flat log. Streams are now parsed per provider and routed to their own slot's pane.

None of it is evidence

A partial turn is not a turn, and live output never enters the transcript. A run that loses every byte of the top half has lost nothing. That is what makes closing the panel free.

The panel never opens the database

It spawns bin/nuolin.mjs and reads newline-delimited JSON — the same commands you would type, with --json added. A UI holding its own connection would be a second writer of run state.

§ 05

What makes an unattended run legitimate

DESIGN_LANGUAGE.md § 9

All seven, or it is not an unattended run — it is an unsupervised one. "Keep going" is defined as progress to completion or to an explicit block. It never means run forever.

01

Ceilings checked before each call

Turns, wall clock, money. The timing is the point: reaching one costs nothing, because nothing was sent.

02

Per-call timeout + heartbeat

From the owning process, so a dead orchestrator is distinguishable from a slow one.

03

Claim before submit

Always, with the request id recorded before the call. That single ordering is what makes a crash classifiable rather than mysterious.

04

No automatic retry of an uncertain submission

It becomes ambiguous and stops. A human decides whether the call landed.

05

Recovery of an abandoned claim

Pre-submission only. A claim abandoned after submission is never recovered — it is escalated.

06

Pause, resume, and an immediate stop

Reachable by the user at any non-terminal moment, and never invoked by an agent.

07

A complete event log

Past-tense facts, one per line, never overwritten. The board is a view of it, never a source of truth.

+ Two gates

A live mode at creation and a flag at run time

--mode live_read_only or live_write, then --allow-model-calls. A forgotten flag cannot silently spend money.

Not on the list · on purpose

Three things that look like controls and are not

  • Mid-call spend ceilings. A ceiling that fires while a call is in flight does not prevent spending — the money is already gone. It only discards what was bought with it, turning a turn that would have succeeded into a total loss. Off by default.
  • Interactive approval prompts. A provider that escalates a command to a human mid-turn has nobody to escalate to: the orchestrator is unattended by construction. A prompt nobody will see is not a control, so approval policies are set to never ask and the boundary is the sandbox plus the ceilings.
  • An operating-system jail. The reviewer's allowlist and the lead's read-only sandbox are provider-level mechanisms. Bans on Git, package managers, and external services are contractual. If prompt-level restriction is not enough for your use case, add an OS or exec-policy layer before running unattended.
§ 05.1

Degraded modes are defined, not improvised

DESIGN_LANGUAGE.md § 10
SituationBehavior
Reviewer exhaustedThe lead may continue reversible preparation. Every such turn is marked UNREVIEWED. No dual-agent completion may be declared.
Lead exhaustedThe reviewer keeps reviewing existing material. It does not inherit source-write authority — you may hand it the lead role explicitly, and it is never inherited by default.
Submission outcome unknownEnter ambiguous. Stop. No automatic retry.
Provider CLI missingblocked with a local failure, before any claim is spent.
Both slots on one provider, and it failsBoth agents are down at once. There is no degraded single-agent mode. Persist state and stop.
Both unavailablePersist state and stop.
§ 05.2

Resolving disagreement — route by kind, not by seniority

DESIGN_LANGUAGE.md § 11
Kind of disagreementResolution
FactRead the code, run the test, look at the result.
MethodEach side proposes the smallest decisive experiment; run the cheaper one.
StyleLead decides, records the decision, moves on.
Scope or interpretationEscalate to the user via needs_user.
Non-convergingRecord both positions and stop. A recorded open disagreement is a better output than a fabricated agreement.
◂ Neither agent may propose itself as the better lead. A role change is a user decision reached through needs_user — never a conclusion an agent draws about its own competence.
§ 06

Twenty-one invariants, and they run

test/invariants.test.mjs
◼ Executable · not aspirational

Each assertion below maps to a case in the test suite. Fifty tests run without calling a model or spending a cent — but dry runs are not a substitute for live ones, and this project says so out loud in § 07.

§ 07

Field report — what live runs actually found

Read-only · write · cross-vendor
Run 01 · live_read_only

Cross-vendor, and it paid for itself

Codex leading, Claude reviewing, three turns. Both agents voted complete and the run ended in joint completion — settling what dry_run never could: both structured-output paths work, and a real model handles the vendor-neutral slot names without confusion, citing its peer by slot in prose.

Asked to name the most important unresolved uncertainty in src/, the pair agreed on a real one: a generic adapter rejection after markSubmitted fell through to the retry loop and issued a second paid submission for the same claim. Their predicted failure reproduced exactly. Twenty-six dry-run tests had passed over that path.

Turns
3
Reported spend
$0.12
Bugs found
1
Run 02 · live_write

A lead edited source and ran the tests

Against a throwaway copy of this repository: a Codex lead edited three source files and a test, ran npm test itself, and the pair reached done with 29 passing tests, under the reviewer's read-only watch the whole way.

Getting there took two provider corrections no dry run could have surfaced. Codex's default on-request approval policy silently denies every write in an unattended run; and on Windows the workspace mounts read-only unless windows.sandbox=elevated is restored after --ignore-user-config discards it.

Files edited
4
Tests green
29
Provider fixes
2
Run 03 · both providers as lead

Money the ceiling could not see

Both providers have now led a live write run, each editing source and running the suite under the other's review. The second cost more than the first and found the sharper bug: a call can be charged and then fail before producing a turn, and the run was recording that spend as zero.

Money the ceiling cannot see is money it cannot stop. Spend is now counted whether or not a turn survives, and shown separately as spend without a turn — which is invariant 19, added because a live run demanded it.

Leads proven
2 / 2
New invariant
19
Regressions
9b · 9c
Known gaps · read before spending

Three things to know before you spend

  • The Codex adapter reports no per-call cost (costReported: false), so the money ceiling is only enforced on the Claude side. --max-turns and --max-minutes are the ceilings that actually bind.
  • On a subscription account (ChatGPT / Claude Max) the reported dollar figure is an equivalent-cost estimate rather than a bill. Treat it as a signal, not an invoice.
  • stopped never means the work is finished. It means the budget ran out. Report it as such — the CLI even gives it a different exit code.
§ 07.1

Maturity ladder — one capability per level

Do not skip a level
L0
Manual dual-extension mode: a shared append-only session file, one turn per wake, the user moves the handoff.
Prototype · research repo
L1
Local watcher: alternation, claims, timeouts, budget caps, terminal statuses.
Prototype · PowerShell
L2
A single orchestrator process driving both agents; state and event log outside Markdown.
Built here
L2.5
Slot / role / provider separated: either provider may hold either role, both slots may share one provider, and the user may exchange the roles mid-run.
Built · proven dry
L3
The lead slot gains source-write authority for one run; the reviewer stays read-only.
Exercised live · both providers
L4
VS Code UI: one pane per slot, the shared board and current handoff between them, with start / pause / resume / swap-roles / budget controls.
Built · not yet live
§ 08

The command surface

src/cli/main.ts
CommandWhat it doesActor
nuolin newCreate a run: objective, target repo, mode, budget, and the slot/provider bindingsuser
nuolin runDrive the run to a terminal status (--once for a single turn)user
nuolin statusCurrent status, next actor, turn count, spenduser
nuolin boardRe-render the Markdown board and print its pathuser
nuolin eventsThe append-only audit trailuser
nuolin watchStream JSON snapshots until the run reaches a terminal statususer
nuolin swap-rolesExchange lead and reviewer between the two slotsuser only
nuolin answerAppend your reply as a turn and hand the turn back to the slot that asked — legal in blockeduser only
nuolin retryRe-offer the same turn to the same slot after a limit resets — legal in waiting_quotauser only
nuolin resolveRecord your assertion about whether the lost call landed, then continue — legal in ambiguoususer only
nuolin pause / resumeHold the run without ending ituser only
nuolin stopEnd the run deliberately, from any non-terminal statususer only
nuolin listRecent runsuser
nuolin doctorWhich provider CLIs are installed, usable, and where they came fromuser
nuolin schemaThe JSON Schema every turn must satisfyuser
Exit 0

done

The run finished. Both slots voted complete.

Exit 1

A human is needed

blocked · waiting_quota · ambiguous

Exit 3

Healthy but incomplete

stopped at a ceiling, or paused.

Rule

Three verbs belong to you alone

answer, retry, resolve. No agent and no orchestrator may invoke them — and none of them raises a ceiling.

§ 09

Quickstart

PowerShell · Node ≥ 22.5

The first run is free: dry_run exercises the entire protocol without calling a model. Expect agent_a → agent_b → agent_a → agent_b → stopped, a rendered board under .nuolin/boards/, and a complete event log.

install.ps1
npm install
npm run build
npm test          # 50 tests, no model is called, no quota is spent
▸ Requires Node 22.5+ for node:sqlite. The nuolin launcher handles the experimental flag for you, so you never have to remember it. There are zero runtime dependencies — bin/ plus dist/ is the whole program.

Stop is a
feature.

Every run has hard ceilings on turns, wall-clock time, and money, checked before each call. An uncertain submission halts visibly instead of retrying and paying twice. Nothing here optimises for the loop running longer.