Slot
Carries no rank and no vendor. Fixed for the life of the run so that the transcript stays readable years later.
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.
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.
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.
Carries no rank and no vendor. Fixed for the life of the run so that the transcript stays readable years later.
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.
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.
| Actor | Writes | Never 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 |
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.
nuolin status all say so, rather than letting a reader assume two independent vendors.Eleven steps, and the order is the safety property. Select a step to see what it guarantees and what breaks without it.
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.
continue · complete · needs_user · blocked
agent_a · agent_b · user — a slot, never a vendor.
complete is about the stated objective, not a nearby easier one.Applies to every agent, every turn, without exception. When levels conflict, the agent must say so in the turn and follow the higher level.
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 |
|---|---|---|---|
| ready | Waiting for the named next agent to claim | ||
| claimed | One agent holds the turn; nobody else may act | ||
| submitted | A request is in flight; its outcome is not yet known | ||
| paused | Paused by the user, and resumable by the user | ||
| done | Both slots voted complete on consecutive turns | ||
| stopped | Budget exhausted — not a claim that the science is finished | ||
| blocked | An agent needs the user, or a definite local failure occurred | ||
| waiting_quota | A recognized quota / rate / budget failure paused the run | ||
| ambiguous | A 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.
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.
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.
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.
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.
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.
Turns, wall clock, money. The timing is the point: reaching one costs nothing, because nothing was sent.
From the owning process, so a dead orchestrator is distinguishable from a slow one.
Always, with the request id recorded before the call. That single ordering is what makes a crash classifiable rather than mysterious.
It becomes ambiguous and stops. A human decides whether the
call landed.
Pre-submission only. A claim abandoned after submission is never recovered — it is escalated.
Reachable by the user at any non-terminal moment, and never invoked by an agent.
Past-tense facts, one per line, never overwritten. The board is a view of it, never a source of truth.
--mode live_read_only or live_write,
then --allow-model-calls. A forgotten flag cannot silently
spend money.
| Situation | Behavior |
|---|---|
| Reviewer exhausted | The lead may continue reversible preparation. Every such turn is marked UNREVIEWED. No dual-agent completion may be declared. |
| Lead exhausted | The 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 unknown | Enter ambiguous. Stop. No automatic retry. |
| Provider CLI missing | blocked with a local failure, before any claim is spent. |
| Both slots on one provider, and it fails | Both agents are down at once. There is no degraded single-agent mode. Persist state and stop. |
| Both unavailable | Persist state and stop. |
| Kind of disagreement | Resolution |
|---|---|
| Fact | Read the code, run the test, look at the result. |
| Method | Each side proposes the smallest decisive experiment; run the cheaper one. |
| Style | Lead decides, records the decision, moves on. |
| Scope or interpretation | Escalate to the user via needs_user. |
| Non-converging | Record both positions and stop. A recorded open disagreement is a better output than a fabricated agreement. |
needs_user — never a conclusion an agent draws
about its own competence.
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.
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.
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.
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.
costReported: false), so the money ceiling is only enforced on the Claude side. --max-turns and --max-minutes are the ceilings that actually bind.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.| Command | What it does | Actor |
|---|---|---|
| nuolin new | Create a run: objective, target repo, mode, budget, and the slot/provider bindings | user |
| nuolin run | Drive the run to a terminal status (--once for a single turn) | user |
| nuolin status | Current status, next actor, turn count, spend | user |
| nuolin board | Re-render the Markdown board and print its path | user |
| nuolin events | The append-only audit trail | user |
| nuolin watch | Stream JSON snapshots until the run reaches a terminal status | user |
| nuolin swap-roles | Exchange lead and reviewer between the two slots | user only |
| nuolin answer | Append your reply as a turn and hand the turn back to the slot that asked — legal in blocked | user only |
| nuolin retry | Re-offer the same turn to the same slot after a limit resets — legal in waiting_quota | user only |
| nuolin resolve | Record your assertion about whether the lost call landed, then continue — legal in ambiguous | user only |
| nuolin pause / resume | Hold the run without ending it | user only |
| nuolin stop | End the run deliberately, from any non-terminal status | user only |
| nuolin list | Recent runs | user |
| nuolin doctor | Which provider CLIs are installed, usable, and where they came from | user |
| nuolin schema | The JSON Schema every turn must satisfy | user |
The run finished. Both slots voted complete.
blocked · waiting_quota · ambiguous
stopped at a ceiling, or paused.
answer, retry, resolve.
No agent and no orchestrator may invoke them — and none of them raises a ceiling.
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.
npm install
npm run build
npm test # 50 tests, no model is called, no quota is spent
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.
node bin/nuolin.mjs doctor
# explicit wins over everything
$env:NUOLIN_CODEX_PATH = "C:\path\to\codex.exe"
doctor reports where each binary came from, because
that matters. codex is looked up in three places, in order:
NUOLIN_CODEX_PATH, then PATH, then the
bundle inside an installed VS Code ChatGPT extension. If
NUOLIN_CODEX_PATH is set and wrong, resolution fails loudly rather
than quietly finding some other binary. ~/.codex/.sandbox-bin/
is deliberately not probed — it holds the binary without the helpers it spawns, so a run
through it starts, spends quota, and then fails every tool call.
node bin/nuolin.mjs new --repo C:\path\to\your\project --max-turns 4 `
--objective "Prove the two agents alternate and the run stops at the ceiling."
node bin/nuolin.mjs run
agent_a → agent_b → agent_a → agent_b → stopped
.nuolin/boards/ and the
full event log is one nuolin events away. Note the terminal status:
stopped, not done — the ceiling was
reached, which is not a claim that the work is finished.
# the default: Codex leads, Claude reviews
node bin/nuolin.mjs new --repo . --objective "..."
# reversed: Claude leads, Codex reviews
node bin/nuolin.mjs new --repo . --lead claude --reviewer codex --objective "..."
# one vendor, two models — supported, and weaker evidence
node bin/nuolin.mjs new --repo . --lead claude --lead-model opus `
--reviewer claude --reviewer-model sonnet --objective "..."
# let the reviewer open the run instead of the lead
node bin/nuolin.mjs new --repo . --first reviewer --objective "..."
# hand the pen to the other slot, mid-run
node bin/nuolin.mjs swap-roles --reason "hand the pen to the other slot"
ready or
paused — never with a turn in flight, because that turn was built
from a prompt and an allowlist derived from the role current when it was claimed. And it
moves the role only: provider bindings and whose turn it is both stay put.
node bin/nuolin.mjs new --repo C:\path\to\your\project `
--mode live_read_only --max-turns 4 --max-usd 2.00 `
--objective "Identify the single most important unresolved uncertainty in src/, and agree on the smallest decisive next check. Do not modify files."
node bin/nuolin.mjs run --allow-model-calls
--mode at creation, and --allow-model-calls
at run time. A forgotten flag cannot silently spend money. Swap
live_read_only for live_write to grant
the lead slot source authority for one run.
# develop against this checkout — press F5, then "Nuolin: Open Board"
npm run build
cd extension; npm install; npm run build
# or install it properly
npm run package # build, bundle the orchestrator, produce a .vsix
code --install-extension nuolin-vscode-0.1.0.vsix
nuolin/ — possible only because there are no runtime dependencies —
so an installed extension works in any folder with nothing to configure. The trade-off
is worth naming: a bundled copy is a snapshot. Editing this repository
does not change an installed extension until you repackage. Point
nuolin.launcherPath at a checkout when you want the two to move
together.
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.