opencode-mcp
by aoalejo
README.md
# opencode-mcp
MCP server that lets Claude Code pick an [OpenCode](https://opencode.ai) model and
delegate jobs to the `opencode` CLI (which must already be installed and authenticated:
`opencode auth login`).
## Intended use case
This exists so a Claude Code session can offload work that doesn't need Claude's own
reasoning to a cheap/free model instead, without spending Claude tokens on it — code
review passes, exploratory bug hunts, well-scoped implementation from a written spec,
or any "have someone else look at this" request that doesn't name a specific Anthropic
model. It is **not** a way to run Claude itself more cheaply, and it's not meant for
tasks that genuinely need Claude-level reasoning (architecture decisions, ambiguous
requirements, anything where a wrong answer is costly) — those should stay on Claude.
The tier system (`low`/`mid`/`high`/`max`, see below) exists specifically so the caller
never has to know or care which OpenCode model is currently cheapest-yet-good-enough;
it just asks for an intelligence level and gets whatever the data says best fits it
today.
## Tools
- `opencode_check_go_status` — confirms the OpenCode Go credential is configured, lists its current model lineup, and reports currently-blocked models. Pass `probe:true` to also manually ping the mid/high/max tier models right now (costs a little time/tokens — this is a diagnostic option, not part of the automatic failure-handling below).
- `opencode_refresh_tiers` — recompute the low/mid/high/max tier map from live data (see "Model tiers" below). Pure local computation, effectively free. On-demand only, not routine (it already runs automatically once a day).
- `opencode_unblock_model` — manually clear a model from the failure blocklist (exponential backoff, not a flat block — see "Automatic failure handling" below) and recompute immediately, e.g. right after re-enabling it in the OpenCode dashboard.
- `opencode_start_job` — send a prompt/task to a `tier` (low/mid/high/max) or an explicit `model`+`variant`, in a given directory. Pass `waitMs` to block until it finishes and get the full result back in this same call (recommended — see "No push notifications" below); omit it for fire-and-forget (returns just a `jobId`).
- `opencode_job_status` — check on a job started earlier (works across a server restart or a different process — see "Job state survives a restart" below); pass `waitMs` to block until it finishes.
- `opencode_resume_job` — nudge an existing opencode session to continue instead of starting over, e.g. after a transient hiccup derailed it. See "Resuming a derailed job" below.
- `opencode_list_jobs` — list all jobs started this server session (in-memory only, unlike the two above).
- `opencode_usage_stats` — aggregate tokens/cost/response-chars across *every* job this server has ever delegated (persisted, survives across sessions/processes — unlike `opencode_list_jobs`). See "Usage tracking" below.
- `opencode_cancel_job` — kill a running job.
- `opencode_list_providers` — configured credentials (e.g. OpenCode Zen, OpenCode Go, OpenRouter).
- `opencode_list_models` — list `provider/model` ids, optionally filtered by provider. Only needed when a job requires a model outside the tier map.
- `opencode_model_info` — verbose metadata (cost, context window) for one model.
- `opencode_submit` — queue a batch of work orders for the scheduler daemon; returns order ids immediately, runs nothing in-call. **The way to dispatch parallel work** — see "The work-order queue" below.
- `opencode_wait` — long-poll until listed orders finish (short work only; otherwise watch the sentinel files).
- `opencode_order` / `opencode_batch` — collect results for one order or a whole batch.
- `opencode_queue_status` — order counts, what's running, and each backend pool's limit / KV budget / breaker state.
- `opencode_roster` — the model fleet routing picks from: bench score, context, cost, measured health.
- `opencode_cancel_order` — stop an order or an entire batch.
- `opencode_loop` — implement ONE work package end to end (write, gate, review, decide) in an isolated worktree. See "The work/review loop" below.
- `opencode_loop_status` — check a loop's progress, gate result, surviving claims, and (if escalated) exactly why.
- `opencode_loop_cancel` — stop a running loop; its worktree and branch are left in place.
- `opencode_audit` — fan N forced-read-only reviewers out over uncommitted changes (or a commit range), confidence-ranked and adversarially re-checked. See "Multi-agent orchestration" below.
- `opencode_sweep` — audit an ENTIRE codebase in path-coherent segments, feeding confirmed findings forward so later segments hunt new instances instead of re-reporting known ones. Long-running: returns a `sweepId`, poll it.
- `opencode_sweep_status` — progress, the confirmed-findings ledger, and the path to the markdown report (rewritten after every segment).
- `opencode_investigate` — same read-only fan-out/reconcile shape as `opencode_audit`, but for an arbitrary question instead of a diff.
- `opencode_goal` — sequential passes toward a goal, with real lint/test output fed to each next pass, verified read-only at the end.
- `opencode_job` — runs `opencode_goal` then `opencode_audit` on whatever it produced, and hands both results back untouched.
Jobs shell out to `opencode run --format json`, parsing its newline-delimited JSON
event stream (`text`, `step_finish`, `error`) to assemble the final response text,
token usage, and cost as the process runs.
## Completion detection uses `exit`, not just `close` (`src/jobs.js`)
Node's `close` event on a spawned child only fires once ALL of its stdio file
descriptors are closed — if `opencode run` leaves a descendant process running
(a backgrounded bash command, another MCP server it connected to, an orphaned
watcher) that inherited those pipes, `close` can be delayed by seconds,
minutes, or indefinitely, even though `opencode`'s own process — and the real
work it did (files written, answer produced) — finished long ago. Observed
2026-08-09: a multi-agent orchestration session had jobs whose actual output
files were already written, but `opencode_job_status` kept reporting them as
still running, forcing a workaround of reading the files directly instead of
trusting the job status. Reproduced in isolation: a child that backgrounds a
`sleep` and exits fires `exit` at ~15ms but `close` at ~5000ms — a 5-second
gap from one lingering subprocess, with no ceiling on how long a real one
could hold it.
Fixed by listening to both `exit` and `close`, settling the job on whichever
fires first (`exit` will normally win when this scenario occurs; a `settled`
guard prevents double-finalizing on the completely normal case where both
fire within milliseconds of each other). When the second event does arrive
much later, its gap is logged to stderr — real production evidence of how
often/how badly this happens, not just a synthetic test's word for it.
## No push notifications — but there IS a reliable way to be woken
MCP tool calls are strictly request/response: this server has no way to interrupt
a Claude Code conversation on its own when a background job finishes.
**The scheduler solves this properly — see "The work-order queue" below.** Every
finished work order gets a *sentinel file*, which a single background shell
command can watch (`until [ -f ... ]; do sleep 20; done`); the harness then
notifies the agent when that command exits. The daemon guarantees the file
appears for every terminal outcome, so the watcher cannot hang on work that
quietly died.
The rest of this section describes the older per-job path, still valid for
one-off `opencode_start_job` calls. There, the server genuinely cannot reach
you: unlike Claude Code's native `run_in_background: true` for Bash/Agent —
where the harness pushes a notification the instant the task completes — that
mechanism is specific to the harness's own process tracking and doesn't extend
to arbitrary MCP servers. `jobs.js`'s internal `EventEmitter` ("done") only resolves
a call that's *actively* `await`-ing it (`waitForJob`); it can't reach into a
conversation that already moved on.
Two ways to actually get a result, neither of which involves waiting for a ping
that will never arrive:
- **Block on `waitMs`** (recommended for most jobs): pass `waitMs` to
`opencode_start_job` (or a follow-up `opencode_job_status`) — up to 540000ms
(9 min) — and the call itself won't return until the job finishes, with the
full result (text, tokens, cost) in the same response.
- **Fire-and-forget + manual follow-up**: omit `waitMs` to get just a `jobId`
back immediately, do other work, then call `opencode_job_status({ jobId })`
yourself later. There is no notification to wait for — if you don't check
back, the result just sits there until you do (or the server process exits).
## Job state survives a restart (`src/job-store.js`)
In-memory-only job tracking means the instant this MCP server process
restarts or crashes — or a DIFFERENT process (another Claude Code session)
tries to look up a job it didn't start — `opencode_job_status` returns `No
job with id "..."`, even though the real opencode session and everything it
produced (files written, answer given) are completely intact. Observed
2026-08-09 right after this exact scenario.
Every job's state (model, variant, dir, sessionId, tokens, cost, and the
assembled response text) is checkpointed to disk at
`~/.local/share/opencode-mcp/jobs/<jobId>.json` on every `step_finish` event
and on final completion/failure — frequent enough that a killed process still
leaves recent progress recoverable, not so frequent it's meaningful I/O
overhead. `getJob`/`opencode_job_status`/`opencode_resume_job` all fall back
to this file when a job isn't in the current process's memory. `opencode_list_jobs`
does NOT include disk-only jobs (it only enumerates what THIS process
remembers) — it's for browsing the current session's own work, not a full
history; look a specific job up by id instead if you know it.
## Resuming a derailed job (`opencode_resume_job`)
Sometimes a job goes in circles, loses context, or a transient hiccup (a
network blip, a tool call that failed weirdly) visibly derails it without an
outright process failure — the kind of thing you'd fix by hand in the
opencode TUI by finding the session and typing "hubo un error de red,
continuá desde donde quedaste." `opencode_resume_job` does exactly that
programmatically: it starts a new job continuing the SAME opencode session
(via `--session <id>`, not `--continue`, so it targets a specific session
rather than "whatever was last") with a nudge prompt (a sensible default, or
your own if you know what actually went wrong).
Pass `jobId` (works across a restart/different process, per the above) or
`sessionId` directly. Verified end-to-end, including across a simulated
server restart: a fresh process with zero shared memory recovered a job's
`sessionId` from disk, resumed it, and the model correctly recalled context
from before the "restart."
## Clean hand-off output, not a transcript
`jobSummary(...).text` (what `opencode_job_status`/`opencode_start_job` return) is
built only from `type: "text"` events — tool calls (file reads, bash, skill
loads), step markers, and any reasoning/thinking events are parsed but never
included. This is structural (in `src/jobs.js`'s `handleEvent`), not
prompt-dependent, so it holds regardless of `style`.
On top of that, `opencode_start_job`'s `style` param (default `"handoff"`)
appends a short instruction telling the model to skip preamble/meta-commentary
and return just the deliverable — a caller can pass `style: "verbose"` to get
the model's own narration back for debugging (e.g. "why did it read files it
didn't need to"). In testing this made a bigger difference on models prone to
chatty preambles than on `big-pickle`, which was already fairly direct — treat
it as a nudge, not a guarantee.
## Model tiers (`src/tiers.js` + `src/rank.js` + `src/leaderboard.js`)
Callers pick an intelligence tier (`low`/`mid`/`high`/`max`) instead of memorizing
model names or guessing which one is actually good. The map is **data-driven**,
built by `computeTierMap()` (`src/rank.js`):
1. Pull every `opencode-go/*` model's real per-token cost from
`opencode models opencode-go --verbose` (local, authoritative — no scraping
needed for this axis; the Go plan's advertised "requests per week" chart is
just this cost data divided into a dollar budget, confirmed by cross-check).
2. Scrape `arena.ai/leaderboard/code/webdev` (`src/leaderboard.js`, plain
server-rendered HTML table, no JS execution needed) for each model's
WebDev/code score — matching handles the leaderboard's reasoning-effort
suffixes (`-max`, `-high`, `-xhigh`, dated snapshots), trying an exact id
match first so a real distinct SKU like `qwen3.8-max` isn't mistaken for
`qwen3.8` at variant `max`.
3. Sort all matched models by cost ascending and compute a cost *ceiling* per
tier from the quartile cutoffs (`low`'s ceiling = 25th-percentile cost,
`mid`'s = 50th, `high`'s = 75th, `max` has none). Each tier's pool is
**cumulative** — every candidate at or under its ceiling, not just the ones
in its own quartile.
4. Within a tier's pool, the winner is **not simply the highest score** — it's
the cheapest model within `SCORE_TOLERANCE_PCT` (1%, `rank.js`) of the
pool's best score. A 1676-vs-1668 gap (0.5%) doesn't justify paying 50%
more, so the cheaper one wins; a 1577-vs-1523 gap (3.5%) is treated as a
real quality difference and the higher scorer still wins outright. This
also means a cheap model that's merely "good enough" relative to a tier's
ceiling can win it without being the pool's outright top scorer — e.g.
observed 2026-08-05, `qwen3.8-max` ($2, score 1668) beat `kimi-k3` ($3,
score 1676) for `max` under this rule. Pools nest (low ⊆ mid ⊆ high ⊆ max)
so scores are still monotonically non-decreasing from low to max.
5. Flag a tier `inherited: true` (with `inheritedFrom: "<cheaper tier>"`) when
its final winner is the same model+variant as a cheaper tier's — i.e. one
model was good/cheap enough to win multiple tiers. Informational only.
6. Persist the result (including each tier's full fallback list, not just the
winner) to `tiers.generated.json` (gitignored — regenerate, don't hand-edit).
This is a **pure local computation** — one `opencode models --verbose` call
plus one HTTP fetch of the arena leaderboard, no `opencode run` calls at all.
See "Automatic failure handling" below for how broken models get excluded
without needing to proactively probe every one of them.
**This refresh happens automatically, at most once a day, with no tool call and
no tokens spent describing it** — `opencode_start_job` (and `opencode_check_go_status`,
and server startup) check `tiers.isStale()` and fire the refresh in the
background (fire-and-forget) if the saved map is missing or >24h old. The job
that triggered the check still runs against whatever's on disk *right now*; the
refreshed map is ready for the next call. `opencode_refresh_tiers` still exists
as a manual override for "I need this recomputed right now," not for routine use.
Models the leaderboard has no entry for (e.g. `qwen3.7-plus` as of 2026-08) are
excluded from tiers but reported in `unmatched`, never silently dropped.
**Token/cost discipline:** default to `low` unless the task clearly needs more
reasoning depth — `mid`/`high`/`max` don't cost extra dollars under a Go
subscription, but every job still burns real tokens and wall-clock time. An
explicit `model` (+ optional `variant`) param on `opencode_start_job` overrides
the tier for one-off cases outside the map.
## Automatic failure handling — no proactive probing (`recordJobOutcome` in `index.js`)
Earlier versions of this ranker live-probed every tier's pick with a trivial
prompt before saving, to catch models that score well but are actually
unreachable (region-locked, disabled in the OpenCode dashboard, etc). That
cost real tokens/time on every refresh — small per probe, but recurring, and
it got a false positive: a legitimately slow reasoning model (`kimi-k3/max`,
30-60s for even a 1-word answer) got treated as "broken" by a too-short probe
timeout. Both problems are solved by not probing at all:
- Every `opencode_start_job` tier resolution is tried for real. If it fails
with a genuine error (non-zero exit / an error message — e.g. `deepseek-v4-flash`
returning "requires explicit opt-in" when disabled in the dashboard), that
model+variant is **blocked with exponential backoff** and the tier map is
recomputed immediately to exclude it — this happens whether or not the
caller passed `waitMs`, so even fire-and-forget jobs self-heal the map for
next time.
- **"Still running" past a timeout is never treated as a failure** — only an
actual error is. This is the fix for the `kimi-k3/max` false positive: a
slow-but-working model is never penalized just for being slow.
- If `waitMs` was passed, a hard failure is retried automatically (up to 5
attempts) with the next-best candidate in the same cost pool, within the
*same* `opencode_start_job` call — the caller gets a working result without
needing to notice the failure and retry manually.
- **Backoff, not a flat block** (`BACKOFF_SCHEDULE_MS`, `tiers.js`): 5min for a
first failure, escalating to 30min → 2h → 8h → 24h only if the model keeps
failing on repeated real attempts. A single success clears the failure
history entirely, so the next isolated blip starts back at 5min instead of
compounding. This exists because a flat 24h block (the original design)
meant a brief real outage — observed 2026-08-08, `deepseek-v4-flash` down for
what was probably minutes — kept routing to a pricier fallback (`gpt-5.6-luna`)
for the rest of the day until manually unblocked, visibly spiking that day's
spend for no good reason. To force a block clear sooner regardless of backoff
— e.g. right after re-enabling a model you'd disabled in the OpenCode
dashboard — call `opencode_unblock_model`. Current blocks (with remaining
backoff time) are visible in `opencode_check_go_status`'s `blocked` field.
- Cost is paid **only on real usage, only when something's actually broken**
— not on a schedule, not "just in case." A model that's simply never used
is never checked and never costs anything.
## Notable free/no-extra-cost models seen on this machine
- `opencode/big-pickle` — free on OpenCode Zen (cost: 0).
- `opencode-go/*` — included in the OpenCode Go subscription. Some entries can be
slow, region-restricted, or hang depending on OpenCode's backend that day —
`opencode_cancel_job` exists for exactly that.
## Usage tracking (`src/usage.js`)
Every job, on completion (success or failure), appends one line to
`~/.local/share/opencode-mcp/usage-log.jsonl` — outside the repo, machine-local,
grows forever, same convention as opencode's own `~/.local/share/opencode`. Each
record has tokens, list-price cost, prompt/response character counts, tier, model,
and duration. `opencode_usage_stats` reads it back and aggregates totals + a
per-model breakdown; pass `sinceHours` to scope to recent activity only.
`cost` is OpenCode's own list price for the tokens used — under the Go subscription
(flat-rate) or Zen (free tier) the dollars actually charged is $0 regardless, so the
aggregated total **is** the savings from delegating instead of paying per-token. It
is **not** a comparison to Claude/Anthropic API pricing — there's no reliable way to
know what equivalent work would have cost in a different model's tokenizer, so this
tool doesn't claim to measure that.
This log is separate from (and complements) OpenCode's own `opencode stats
--models`, which aggregates *all* opencode usage on the machine regardless of what
started it — use that for the full-machine picture, use `opencode_usage_stats` to
scope specifically to what this MCP server delegated.
## Pinning one model for a whole session (`OPENCODE_MCP_PIN_MODEL`)
Every `opencode-mcp` server process is tied 1:1 to the Claude Code session
that spawned it, and an env var is fixed for that process's whole lifetime —
so setting `OPENCODE_MCP_PIN_MODEL` (and optionally `OPENCODE_MCP_PIN_VARIANT`)
when registering the server forces literally every `opencode_start_job` call
in that session to one fixed model, bypassing cost/score ranking and the
failure blocklist entirely:
```bash
claude mcp add opencode --scope user \
--env OPENCODE_MCP_PIN_MODEL=opencode-go/deepseek-v4-flash \
--env OPENCODE_MCP_PIN_VARIANT=high \
-- node /path/to/opencode-mcp/src/index.js
```
**The pin is a HARD override — it wins even over an explicit `model` param.**
That's deliberate: the whole point is "this session always uses X, no
exceptions," including the exact case a pin exists for — you forget it's
pinned and ask for something else by name (`model: "opencode-go/kimi-k3"`) or
via `tier`. When the actual request differs from the pin, the response
carries a `warning` field spelling out what was asked for vs what got forced,
so the override is never silent — no `warning` field means nothing was
overridden (including the normal case of a plain `tier` call, which a pin
always "overrides" by design and doesn't warn about).
Useful when you want predictable, consistent model usage for a session
regardless of day-to-day ranking drift or in-flight failures. Whether a pin
is active (and what it's pinned to) is visible in `opencode_check_go_status`'s
`pinnedModel` field. Note this only affects the *session that registers it
this way* — concurrent sessions each run their own server process with their
own env, so this isn't a machine-wide setting.
## Matching the concurrency ceiling to your backend (`OPENCODE_MCP_MAX_CONCURRENCY`)
`opencode_audit`/`opencode_investigate`/`opencode_sweep` fan participants out
through a rolling-window concurrency limiter, not fixed batches — as soon as
any one job finishes, the next queued one starts immediately, rather than
waiting for a whole wave to complete before starting the next. The ceiling on
how many run at once defaults to a conservative `4`, since this server has no
way to know what your actual model backend can serve. If yours can genuinely
handle more — e.g. a local multi-agent-capable server advertising a real
concurrency limit of 16 — set that once at registration instead of passing
`maxConcurrency` on every call:
```bash
claude mcp add opencode --scope user \
--env OPENCODE_MCP_MAX_CONCURRENCY=16 \
-- node /path/to/opencode-mcp/src/index.js
```
The full chain is: **`maxConcurrency` passed on that specific call** → **the
env var** → **4**. Nothing in it is cached at server startup — the env var is
read fresh on every single dispatch, not baked into a frozen default the
moment this process boots. That mostly matters for the ordinary case: pass
`maxConcurrency` directly on any call whenever you want to change the ceiling
right now, no server restart needed either way. The effective fallback is
visible in `opencode_check_go_status`'s `defaultMaxConcurrency` field.
Getting this wrong in either direction has a real cost: too low and you leave
real backend capacity idle for no reason; too high and you get exactly the
failure mode this setting exists to prevent — observed 2026-08-22, a sweep at
`replicas:4` (48 concurrent participants) against a backend that couldn't
actually serve that many ran 5h42m without a single segment completing.
## Transient failure retries (SQLite lock contention)
Aggregation and read-only participant jobs are wrapped in a short
retry-with-backoff (`runOneJob` in `src/orchestrate.js`): if a job fails with
an error matching a known-transient pattern — `database is locked`,
`SQLITE_BUSY`, or a generic "unexpected server error" — it's retried up to 2
more times with a 1.5s backoff before being treated as a real failure. This
exists because opencode's own local session-store database is shared across
every `opencode run` process this server spawns, so a high `maxConcurrency`
against it can produce a transient lock contention failure that has nothing
to do with your actual review logic — observed 2026-08-22 on a
`replicas:4`/`lenses:"all"` (48-participant) single-segment run, where a
tree-aggregation job failed outright with `database is locked` and killed the
whole segment's result before the retry existed; the same run with the retry
in place recovered from one such failure mid-aggregation and completed
normally. This does *not* apply to `opencode_goal`'s sequential edit-making
passes — retrying a job that may have partially edited files carries real
idempotency risk that a pure read-only reconciliation call doesn't have, so
those are left to fail straight through.
## The work-order queue (`src/scheduler/`)
The oldest bug in this server was that concurrency was bounded **per call**.
Each `opencode_audit` / `opencode_sweep` built its own limiter, and
`opencode_start_job` had none at all — so a single orchestrator dispatching a
handful of jobs in one turn could put an unbounded number of processes on one
backend. Against a local model server that crashes past a known request count,
that is not a throughput problem, it is a lost multi-hour run.
`opencode_submit` replaces all of it. You hand the scheduler everything you
want done; it decides what runs when. **You never compute concurrency**, and
you cannot overload a backend by submitting too much — surplus orders wait.
### It survives you
The queue lives in a **daemon**, not in this MCP process. It listens on a unix
socket (`~/.local/share/opencode-mcp/scheduler.sock`), is spawned automatically
on first use, and is detached — its parent becomes init, in its own session.
The MCP server restarting, the agent restarting, Claude Code exiting: none of
them stop the work. Reconnect and the daemon still has your orders, running and
finished alike. This is verified, not assumed: a client was killed mid-flight
with six orders running and all six completed and were collected afterwards by
a different client.
Because the daemon outlives your edits too, it does a **protocol-version
handshake**. A daemon older than its client stops taking new work, finishes what
it has, and exits so the next connect gets a current one — otherwise you spend
an afternoon debugging code that is not the code that is running.
### Admission control is per backend, not one global number
The constraints are physically different and one number cannot express both.
| Pool | Ceiling | Why |
|---|---|---|
| `local-qwen` | 8 hard / 6 default, plus a ~480K KV-token budget | Shared hardware. Measured: 8 concurrent sustains ~36 tok/s each, 10 collapses to ~16.8 (worse in aggregate than running 4), 12 crashes the server. |
| `opencode` (Zen) | learned, starts at 4 | Rate limit, undocumented. |
| `opencode-go` | learned, starts at 4 | Rate limit, undocumented; flat-rate under the subscription. |
Slots alone do not protect a shared KV pool — eight requests at 64K fit in
511K, eight at 262K do not, and counting processes cannot see the difference.
So the local pool also admits against a **token budget**, and every order
carries a `reserveTokens` estimate.
Remote ceilings are **learned** by AIMD: climb one slot every eight successes,
halve on a 429 or a dropped connection. A plain failure — the model ran and
returned something bad — deliberately does *not* shrink the limit, because it
says nothing about capacity.
A burst of connection failures is treated as **one event** (the backend went
down), not N independent ones: the breaker trips, the pool pauses, and after a
cooldown exactly one probe is let through to find out whether it is back.
Retrying all of them immediately is a retry-storm against something already on
the floor.
### Routing: bench, health, and deliberate diversity
You do not pick models. The router picks per order, at the instant a slot opens
— which pool has capacity is not knowable when the batch is queued. It weighs:
- **Arena bench score** for code work (the same ranking behind `tier`).
- **Measured health** from the real usage log: success rate, and *empty-response
rate*. A free model that returns 200 OK with an off-format body is worse than
one that errors, because it enters a swarm as silent noise instead of a
visible failure.
- **Role fit**: an `aggregator` is routed to a big-context model (the fleet has
several free 1M-context ones, which is what lets aggregation leave the local
pool entirely); a `worker` is routed to top bench.
- **Cost: free-only by default.** The roster reaches ~89 models of which ~55 are
metered, several sitting at the top of the bench table. Routing on score alone
quietly bills for a 48-participant swarm. Opting in is per order (`allowPaid`).
**Replica diversity is a hard constraint, and it is a correctness mechanism
rather than a performance one.** Orders sharing a `lens` never run on the same
checkpoint. The swarm treats "2+ reviewers of a lens agree" as corroboration,
which is worth something only if their errors are independent — four replicas on
one model are four correlated opinions. Observed in this repo: twelve reviewers
unanimously confirmed a HIGH-severity bug that did not exist. A duplicate
replica does not merely fail to corroborate, it *manufactures* agreement. So
when the only checkpoint with a free slot is one the lens already used, the
order **waits** rather than taking it.
### Nothing is ever lost
Four distinct ways to lose state, four mechanisms:
| Failure | Detection | Result |
|---|---|---|
| Job killed by a signal | exit signal captured | sentinel + requeue if read-only |
| Job wedged (alive, no output) | 8 min without a checkpoint | killed, sentinel, requeue |
| **Daemon dies** | boot-time reconciliation | orders closed as `orphaned` + sentinel |
| **Backend dies** | breaker | pool paused, orders requeued |
Every terminal transition funnels through one function, which is what makes the
sentinel guarantee hold — there is exactly one place that can end an order, so
exactly one place that has to write the file. Verified by `SIGKILL`ing the
daemon with four orders in flight: the next boot closed all four out with
sentinels and an explanatory message, so nothing waiting on them could hang.
Retries are **not** applied to `worker` orders. Replaying a job that may have
half-applied an edit is worse than reporting the failure.
### Mandatory wait protocol
After `opencode_submit`, do exactly one of these — and never invent a third.
**(A) Preferred, for anything beyond a couple of minutes.** One background shell
command watching the sentinels:
```bash
until [ -f ~/.local/share/opencode-mcp/done/<orderId> ]; do sleep 20; done
```
For a batch, count them:
```bash
until [ "$(ls ~/.local/share/opencode-mcp/done | grep -Fcf /tmp/order-ids)" = "12" ]; do sleep 20; done
```
Then collect with `opencode_batch`. The harness notifies you when the watcher
exits.
**(B) Short work only:** `opencode_wait`, which long-polls and returns the
finished orders directly.
**Never** poll `opencode_queue_status` in a loop — that burns your context on
status text. One watcher per batch, nothing more.
### Tools
| Tool | Purpose |
|---|---|
| `opencode_submit` | Queue a batch. Returns order ids immediately. |
| `opencode_wait` | Long-poll for short work. |
| `opencode_order` / `opencode_batch` | Collect results. |
| `opencode_queue_status` | Counts, what is running, pool limits and breaker state. |
| `opencode_roster` | The fleet: bench, context, cost, measured health. |
| `opencode_cancel_order` | Stop an order or a whole batch (sentinels still written). |
`opencode_queue_status` is for understanding *why* something is queued — a pool
at its limit or a tripped breaker is ordinary backpressure, not a stall.
## The work/review loop (`src/loop/`, `opencode_loop`)
`opencode_submit` dispatches independent work orders. `opencode_loop` is the
layer above it for a SINGLE work package that needs to be built, checked, and
iterated on until it is actually right — not just until something got
written. It runs entirely inside the scheduler daemon, so it survives this
MCP process (and the agent driving it) restarting mid-run, exactly like the
queue underneath it.
### The shape, per iteration
```
A. WORKER writes (edits files directly, in an isolated worktree)
0. GATE proves (the repo's own lint/typecheck/test — see below)
├─ red → raw output straight back to the worker, skip B/C/D entirely
└─ green → continue
B. CONFORMANCE checks (2 reviewers: does the diff match the spec?)
C. REFUTATION tries to break B's claims (3 reviewers, default = "not a defect")
D. ARBITER decides (done | needs-fix | escalate)
```
**Gate 0 runs before a single review token is spent**, because the compiler
is the cheapest and most reliable reviewer there is. Spending five model
calls to learn what `tsc` or a test runner would have said in two seconds is
pure waste — and worse, a reviewer asked to read code that does not build
tends to report the downstream symptom instead of the actual cause. A red
gate sends its raw output straight back to the worker and skips B/C/D for
that iteration entirely.
**Round B is separated from Round C on purpose.** "Did it build the right
thing" and "is what it built correct" are different questions, and a
reviewer asked both at once reliably answers only the second — the spec
silently stops being checked. Round C exists because reviewers told to find
problems will find them whether or not they are real; its whole job is to
try to knock B's claims down, defaulting to "not a defect" unless it can
independently reproduce the failure. A claim needs `upholdThreshold` (default
2) independent UPHELD rulings to survive — and silence is not assent: a
refuter that died or gave an unparseable answer casts no vote either way, so
a round where reviewers failed cannot promote a claim on one opinion.
### Zero votes is not the same as refuted
Caught on a real run, not in review: a genuine SQL syntax error
(`create schema_migrations (...)`, missing the `table` keyword) was correctly
flagged in Round B, then silently discarded — the refutation round mostly
failed to complete (two of three reviewers never finished), and the one
survivor's reply didn't parse into any per-claim ruling. The old adjudication
code treated "0 votes" the same as "refuted by everyone", because both fell
into the same `else` branch. A real, confirmed defect vanished with no trace
beyond a buried `upholdCount: 0 / voteCount: 0`.
A claim with zero votes is now its own outcome — **inconclusive**, distinct
from both upheld and refuted, and deliberately NOT added to the refuted-keys
set (so it stays eligible to be re-raised rather than permanently suppressed).
One retry is attempted, forced onto the local model rather than the free
fleet that just failed to produce a usable round; if it's still inconclusive
after that, the loop escalates with `reason: "refutation-inconclusive"` and
the unreviewed claims attached, rather than letting the arbiter decide on a
partial picture. The arbiter never even runs in that case — the whole point
is not to give it data that looks clean but isn't.
### The arbiter's authority is enforced in code, not just requested in the prompt
- It may return `"done": true` **only if** the gate ran AND passed AND
nothing survived Round C. If it tries to approve anyway — a red gate but an
otherwise-confident model, which happens — the loop overrides it and
escalates instead of trusting the model's own claim of what it checked.
- It may put something under `mustFix` **only if** it is one of the claims
that actually survived Round C, cited with a file and line. It adjudicates;
it does not get to invent new findings in its own round.
- Anything needing a judgement call — a design trade-off, an ambiguous
requirement — goes under `cannotDecide` and **escalates to the caller**.
This is the correct outcome for that kind of question, not a failure of the
loop, and it's how you satisfy "the model decides when it's done" without
asking a small model to make an architecture decision.
### Escalation never means calling a stronger model
No Anthropic model is reachable from this server, at any price, under any
role. Escalating means stopping and handing the caller everything needed to
finish by hand: which claims survived, the gate's actual output, and the
worktree + branch left in place (not deleted — the partial work is usually
the most useful part of an escalated result).
### Isolation: one git worktree per work package
Each loop branches a fresh worktree from `baseRef` so several work packages
can run at once without their diffs colliding. A brand-new worktree has
source but no build state — no `node_modules`, no `.dart_tool`, no `target/`
— which would make the gate fail for purely environmental reasons that look
identical to real ones. `.opencode-loop.json`'s `worktree.shareDirs` symlinks
those directories in from the main checkout instead of reinstalling them per
package; `worktree.setupCommand` is the escape hatch when a dependency tree
genuinely cannot be shared this way.
### Model choice: local by preference, free fleet on failure, never routing back into what just failed
The worker tries the configured local model first — predictable effort,
doesn't touch the throttled free-tier budget. If that fails or times out
(the scheduler's 8-minute wedge-detection heartbeat, same mechanism the queue
already uses), the retry **excludes the model that just failed** and lets the
router pick from the free fleet instead. This exclusion is not optional
decoration: without it, the router is free to route the "fallback" straight
back into the same dead backend it still nominally has capacity for, which is
exactly what happened the first time this was tested against a genuinely
unreachable local server — the fallback wedged for another 8 minutes against
the same target before anyone noticed.
### `.opencode-loop.json` — the gate is per repo, not per call
```jsonc
{
"lintCommand": "npm run lint",
"testCommand": "npm test",
"typecheckCommand": "npx tsc --noEmit",
"gateTimeoutMs": 600000,
"worktree": {
"shareDirs": ["node_modules", ".dart_tool", ".venv", "vendor", "target", "build"],
"setupCommand": null
}
}
```
Without this file, the loop still runs, but the arbiter can **never**
return `done` — an ungated result is reported as unverified, not approved,
because the whole point of Gate 0 is to stop the loop from taking a small
model's word for it.
### Tuning defaults
| Param | Default | Why |
|---|---|---|
| `maxIterations` | 3 | draft + two correction rounds — enough for mechanical fixes (red gate, a clearly cited claim), not enough to burn free-tier budget circling something that needs a human's judgement |
| `conformanceReviewers` | 2 | |
| `refutationReviewers` | 3 | together with conformance, this uses each of the 5 free models roughly once per iteration — deliberate, since the free tier throttles call VOLUME hard |
| `roundTimeoutMs` | 480000 | how long a round may stall (usually a 429/breaker) before finishing on the local model instead — recorded as `degraded`, since several replicas landing on one checkpoint are correlated opinions, not independent ones |
### Waiting for a loop
Same sentinel contract as the queue — `opencode_loop` returns a `loopId` and
a sentinel path immediately:
```bash
until [ -f ~/.local/share/opencode-mcp/done/<loopId> ]; do sleep 20; done
```
Then read the result with `opencode_loop_status`. Never poll it in a loop of
your own.
### Briefing another agent to run a work package through the loop
> You have an MCP server called `opencode` with a work/review loop
> (`opencode_loop`) that implements one work package, checks it against a
> mechanical gate and two rounds of independent review, and only stops when
> both agree — or hands the decision back to you when they can't.
>
> **Before your first call in this repo**, check whether `.opencode-loop.json`
> exists at the repo root. If it doesn't, create it with this repo's real
> lint/test commands (look at `package.json` scripts, or the equivalent for
> this stack) — without it, the loop can run but its arbiter can never
> approve anything, because nothing objective ever verified the result.
>
> **Call `opencode_loop`** with `repoDir`, a short `wpId`, and `spec` — the
> full work-package text, written the way you'd brief a competent engineer.
> Both the worker and every reviewer see `spec` verbatim, so vagueness there
> shows up later as either scope drift or a conformance round that can't tell
> what "correct" means.
>
> **Wait on the sentinel it returns**, in one background shell command — do
> not poll `opencode_loop_status` in a loop of your own:
> ```
> until [ -f <sentinel path> ]; do sleep 20; done
> ```
>
> **Read the result with `opencode_loop_status`.** Three outcomes:
> - `completed` — the gate passed and nothing survived adversarial review.
> The change is in the loop's worktree, on branch `opencode-loop/<wpId>-...`
> — review the diff yourself before merging; the loop verified conformance
> and mechanical correctness, not that the approach was the right one.
> - `escalated` — read `escalation.reason`. `arbiter-cannot-decide` means a
> real judgement call is waiting in `cannotDecide` — that's the loop working
> correctly, not failing. `max-iterations` means it never converged; the
> partial work and the last iteration's surviving claims are usually more
> useful than a clean restart. Either way, the worktree and branch are left
> in place — do not let anything delete them until you've looked.
> - `failed` — something broke before it could even try (bad `repoDir`, git
> worktree setup failed). Check `escalation.detail`.
>
> **You decide what happens to the worktree.** Merge it, cherry-pick from it,
> or hand it back for another `opencode_loop` call with a refined `spec` if
> the escalation was about scope rather than correctness. The loop never
> merges anything itself.
## Proactive local-backend health checks (`src/scheduler/health.js`)
The pool's own circuit breaker (see above) reacts to a request that actually
FAILED. Against a backend that's down, a request doesn't fail fast — it
HANGS: `opencode run` connects, gets nothing back, and sits there until the
scheduler's 8-minute wedge-detection heartbeat kills it. Every routing path
that tries the local model — the router's normal free-vs-local scoring, and
the loop's explicit "try local first, else fall back" attempts — paid that
480-second tax every single time, whether it was the very first thing tried
against a server that had been down for hours or the fifth retry against one
still loading its weights.
The daemon now polls each local backend's `/health` endpoint (vLLM's
convention; falls back to `/v1/models`, which any OpenAI-compatible server
must serve, for one that doesn't implement `/health`) every 20 seconds,
independent of whether any job happens to be in flight. A confirmed-down
backend sets that pool's `externalHealthy: false`, which `canAdmit()` checks
FIRST, before the breaker or slot logic — a pool a health probe just
confirmed is down never gets a request dispatched to it at all, so nothing
ever reaches the 8-minute heartbeat over it. `null` (never checked yet, or
this pool isn't a monitored local backend) is treated as "try it" —
optimistic, not paranoid, since the common case is a healthy server that
simply hasn't been probed at the exact instant something needed it.
This is the single choke point for BOTH routing paths: the router's
`#eligible()` filter already calls `pool.canAdmit()` for free routing, and an
explicit `model` pin (the loop's "try local first" calls) goes through the
same `canAdmit()` check — so `opencode_loop`'s worker/arbiter/degraded-retry
attempts all skip straight to the free fleet the instant local is known down,
with zero wasted wait, rather than each independently discovering the same
outage the slow way.
Current health is visible in `opencode_queue_status`'s `pools[].externalHealthy`
and in the daemon's raw status response; transitions (not every 20s tick) are
logged to `scheduler.log`.
## Multi-agent orchestration: audit / investigate / goal / job (`src/orchestrate.js`)
One model call is one opinion. These four tools spend extra parallel calls —
close to free when `OPENCODE_MCP_PIN_MODEL` points at a local model, since
sending 1 request or 16 costs the same wall-clock time and money — to get a
more reliable answer than a single pass would: many independent read-only
reviewers instead of one, confidence-ranked instead of blindly trusted,
adversarially re-checked instead of taken at face value. `defaultWidth()`
detects whether the resolved model is actually the free local one (or a paid
tier) and scales default participant counts up or down accordingly, so a
paid-tier call doesn't silently balloon in cost just because the code assumes
parallelism is free.
### One-time setup: the `mcp-readonly` opencode agent
Every read-only reviewer/aggregator/verifier across all four tools runs as a
dedicated opencode agent, **`mcp-readonly`**, registered once in
`~/.config/opencode/opencode.jsonc` (a machine-level config file, NOT part of
this repo — each machine running this MCP server needs this added once):
```jsonc
{
"agent": {
"mcp-readonly": {
"mode": "primary",
"description": "Forced read-only agent used by opencode-mcp's audit/investigate/goal-verify commands — cannot edit files, run shell commands, or invoke skills/sub-tasks.",
"permission": {
"read": "allow",
"grep": "allow",
"glob": "allow",
"list": "allow",
"webfetch": "allow",
"websearch": "allow",
"edit": "deny",
"bash": "deny",
"task": "deny",
"skill": "deny",
"todowrite": "deny"
}
}
}
}
```
**`mode` must be `"primary"`, not `"subagent"`.** `opencode run --agent
<name>` silently falls back to the default agent (defeating the whole
read-only guarantee, with only a stderr warning to notice it) if the named
agent isn't a primary one — found this the hard way while testing.
Verify it registered with `opencode agent list` — it should show
`mcp-readonly (primary)`.
### QA lenses (`src/lenses.js`)
Reviewers don't get a vague theme ("look for correctness bugs"), they get one
narrow, mechanically checkable instruction. That distinction matters more than
it sounds: asked for "correctness bugs", a weak local model returns a wall of
plausible prose restating the diff; asked to *"check the EXACT operator against
what the comment says the boundary should be, and trace what happens when the
value is exactly at the boundary"*, it does real work. Narrow lenses also make
N parallel reviewers genuinely diverge instead of producing N near-duplicates.
Six core lenses run by default:
| key | hunts for |
| --- | --- |
| `sign_direction` | gain/loss, credit/debit, from/to swapped or inverted against the documented convention |
| `boundary_offbyone` | `<` vs `<=`, exactly-at-the-boundary cases, loop/slice/pagination arithmetic, inclusive vs exclusive ranges |
| `mutation_ordering` | index-based removal inside a loop, mutation during iteration, symmetric paths handled inconsistently |
| `priority_ordering` | "best match" loops that are actually greedy/local, tie-breaks that silently pick wrong, shadowed rule chains |
| `security` | auth bypass, missing ownership/permission checks, injection, credential leakage, unsafe defaults |
| `performance` | N+1 access, unbounded work, blocking the hot path, repeated recomputation, never-invalidated caches |
Six more are available via `lenses: "all"` or an explicit subset:
`null_empty`, `error_handling`, `concurrency`, `contract_mismatch`,
`state_consistency`, `test_gaps`.
Every lens ends with the same reporting contract — file, location, what the
code does vs should do, a **concrete failing scenario** (specific inputs →
specific wrong output), and a severity — plus an explicit `NO FINDINGS`
sentinel so "found nothing" is distinguishable from "rambled inconclusively".
**Replicas, not a flat count.** `replicas` (default 2, hard minimum 2) gives
*every* selected lens that many independent reviewers. This replaced spreading
a flat participant count round-robin, where some lenses drew 3 reviewers and
others 2 — so "2+ participants agreeing = CONFIRMED" silently meant different
things depending on which lens you got. With uniform replicas, corroboration is
measured against a known denominator. Total participants = lenses × replicas,
clamped to `MAX_PARTICIPANTS`. The legacy `count` param still round-robins if
you'd rather cap total spend than get even coverage.
**Project context.** Every reviewer's prompt is prefixed with a domain/
architecture file — `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, or
`README.md`, auto-detected, or set explicitly via `contextFile`. A reviewer
that doesn't know "distributions count as an expense in this ledger" reports
confident nonsense; a paragraph of domain context removes a whole category of
false positives.
### `opencode_audit` and `opencode_investigate`
Both fan N participants out **in parallel**, all forced onto `mcp-readonly`
(a real permission-engine guarantee, not a prompt asking nicely), then run
them through the same confidence-ranked reconciliation:
1. **Round 0**: N participants' independent findings are reconciled into ONE
report that explicitly notes how many participants corroborated each
finding — 2+ is CONFIRMED, exactly 1 is LOW CONFIDENCE.
2. **Adversarial round(s)** (`depth` times, default 1): a FRESH batch of N
reviewers gets ONLY the current report, framed as "a low-confidence agent
reported this, your job is to determine its falseness" — each
independently tries to refute the low-confidence items using its own
read-only access to the real code. The results are reconciled into
[previous report + all adversarial reviews], promoting survivors to
"adversarially confirmed" and calling out (never silently dropping)
anything refuted. `depth=0` skips this; `depth=2` repeats the whole
round-then-reaggregate step twice for higher-stakes reviews.
`opencode_audit` reviews a git diff — uncommitted changes by default, or
everything since `baseCommit` (a whole branch/PR) when given. If the diff is
too large (~100k+ tokens) to hand every participant, every reviewer gets the
same bounded prefix plus the paths left out (they have read access and can
open those themselves) — see "QA lenses" above for how reviewers are actually
assigned and diversified; this replaced an older per-file partitioning scheme
that broke once `replicas` meant reviewers sharing a lens must see identical
content for their agreement to mean anything.
### Hierarchical aggregation (tree reduction, `groupSize`)
"ONE aggregator" above is a simplification for small N. Both reconciliation
steps (round 0, and each adversarial reaggregation) actually run through a
**tree** of aggregator calls, each combining at most `groupSize` sources
(default 4) — not one aggregator reading every participant's output at once.
This exists because a flat aggregator's prompt scales with participant count
× however verbose each one felt like being. Observed 2026-08-22: 12 lenses ×
2 replicas (24 participants, ~7k chars each) produced a **162,000-character**
aggregator prompt that outran a 5-minute wait entirely — the local model
never got through it, and the whole review silently came back as an empty
report. Two fixes landed together:
- A hard per-source cap (6,000 chars) on what reaches any single aggregator
call — a participant that rambles past this is truncated, not allowed to
starve the rest.
- The tree reduction itself: leaf-level groups of `groupSize` participants
each get reconciled into a partial report IN PARALLEL (they're independent
— this also cuts wall-clock time, not just risk), then those partial
reports are merged in the same way, recursively, until one report remains.
Merge-level prompts are told explicitly to **add up** corroboration counts
across partials rather than trust each partial's count as final — a
finding at 2-of-4 in one partial and 1-of-4 in another is 3-of-8 combined,
which is CONFIRMED overall even though neither partial alone reached 2.
Lower `groupSize` if aggregation still times out or comes back empty at a
high `replicas`/lens count; raise it to trade fewer, larger aggregator calls
for less merge overhead. This also means `opencode_sweep`'s per-segment
reconciliation scales with lens/replica count more gracefully than a flat
aggregator ever could.
`opencode_investigate` is the same shape driven by an arbitrary `prompt`
instead of a diff — use it to have several independent agents look into one
question and get back a reconciled answer, e.g. "does the test suite
actually cover the new drill-down interaction, or just that it renders?"
### `opencode_goal`
**Sequential, not parallel** — these agents actually edit code, and running
them concurrently in the same working tree would corrupt each other's
changes. (An earlier version ran independent parallel attempts judged by a
panel — reverted after real testing showed the judge panel, being the same
weak model, reject genuinely correct candidates outright; see the git
history around `runGoal` if curious.) Pass 1 attempts the goal fresh; each
later pass continues the *previous* pass's own opencode session.
The actual defense against a weak model trusting its own "done!" self-report:
after **every** pass, real `lint`/`test` commands run against `dir` — a
mechanical, ground-truth signal, not another LLM's opinion — and the genuine
pass/fail output gets attached to the *next* pass's prompt. Commands
auto-detect from `dir`'s `package.json` (`scripts.lint`/`scripts.test`) if
not given explicitly; pass `lintCommand`/`testCommand` to override, or
explicit `null` to force-disable one. Once every pass finishes, one final
`mcp-readonly` pass inspects the repo's actual current state (not the
passes' self-reports) and returns a consolidated verification report.
### `opencode_job`
Runs `opencode_goal` to completion, then immediately runs `opencode_audit` on
whatever it left uncommitted, and returns **both results verbatim** — it
does not interpret the QA findings, decide they're serious, or trigger
another goal pass on its own. Deciding what to do with what QA found (fix it,
ignore it, ask the user) is explicitly the caller's job, not this tool's —
matches this whole project's stance of surfacing information rather than
silently resolving it on the caller's behalf.
### `opencode_sweep` — auditing a whole codebase
`opencode_audit` reviews a diff. `opencode_sweep` reviews an entire repository,
which needs three things a diff review doesn't.
**Segmentation.** The repo is split into token-budgeted segments (default ~40k
tokens), packed **by path rather than by size** so a directory stays together.
That's deliberate: bin-packing by size would scatter related files across
segments and destroy exactly the findings that span them — a caller and callee
disagreeing about a contract, two symmetric paths where only one was updated.
A single file over budget gets its own segment rather than being cut
mid-function.
**A segment is a starting point, not a cage.** Reviewers get their sector's
contents inlined, and are explicitly told to follow a flow into any other file
in the repo when tracing it end-to-end is what the lens requires — they have
read access to everything. Findings outside the assigned sector are reported
and flagged as such.
**A findings ledger that feeds forward.** After each segment, the reconciled
report emits a compact machine-readable block that's parsed in plain JS into a
running ledger. Every *later* segment gets that ledger with the instruction:
don't re-report these, but do look for **other instances of the same bug
class** — a confirmed pattern usually repeats. Refuted findings carry forward
too, so a false positive dismissed in segment 3 isn't re-litigated in segment
12. The ledger is capped at the most recent ~40 entries so it can't crowd out
the prompt on a long run.
**Always dry-run first.** `plan: true` spawns nothing and returns the file
list, segment breakdown, and a job estimate:
```
opencode_sweep({ dir: "/path/to/repo", plan: true, lenses: "all", replicas: 4 })
```
Check `fileCount`, `segmentCount`, `estimatedJobs`, and the `skipped` counts
before committing to a run — the estimate is how you find out a sweep would
take six hours *before* it takes six hours.
**Then trial one segment before committing to all of them.** Pass
`maxSegments: 1` with the exact same `lenses`/`replicas`/`maxConcurrency`
you're about to run for real — this exercises the complete pipeline (full
lens fan-out, tree-reduction aggregation, the adversarial round, ledger
emission) at real scale, in a fraction of the wall-clock time. It's how you
find out your `maxConcurrency` is wrong for this backend, or that a lens is
producing garbage, before spending hours discovering it one segment at a
time. Once a single segment behaves the way you expect, drop `maxSegments`
and run the whole thing.
**File selection is language-agnostic and yours to override.** Defaults exclude
vendor/build dirs, lockfiles, minified bundles, and generated-code patterns
(`*.g.*`, `*.pb.*`, `*.generated.*`, `*.freezed.*`); files are additionally
sniffed for `@generated` / "DO NOT EDIT" headers, which catches generated code
in any language that globs would miss. Override with `sourceExtensions`,
`includeGlobs`, and `excludeGlobs`.
**It runs in the background.** A real sweep runs for an hour or more — far past
any MCP client's request timeout — so `opencode_sweep` returns a `sweepId`
immediately and `opencode_sweep_status` polls it. Full state is checkpointed to
`~/.local/share/opencode-mcp/sweeps/<id>.json` and the markdown report at
`<id>.md` is **rewritten after every segment**, so partial results are readable
while it runs and survive the process dying. Segments run strictly sequentially
— parallelising them would break the ledger's whole purpose.
**Polling shows real progress, not just "running."** The active segment's
`phase` field updates live at every meaningful transition — participant N/M
finished, aggregator-tree level 1 group 3/6 done, entering the adversarial
round — not just at segment start/end. A segment sitting at `status: running`
for 40 minutes with `phase` visibly advancing is a healthy sweep; one where
`phase` hasn't changed across several polls is worth investigating (check
whether the model backend itself is actually responding — a network-level
hang looks identical to genuine work from the outside if you're not watching
this field).
**Cancel it if it's not going well.** `opencode_sweep_cancel` stops a running
sweep — before it existed the only way was killing the whole MCP server
process, taking every other tool down with it for the rest of the session. It
checks cooperatively (between segments, and inside the current segment's
concurrency-limited dispatch), so it typically responds within one wave —
bounded by `maxConcurrency` — rather than only at the next segment boundary.
It's an in-process signal, same constraint as `opencode_cancel_job`: it can
only stop a sweep that the server process handling this call is itself
running.
### Briefing another agent to run a sweep
A prompt you can hand to another Claude Code session, verbatim, to have it
sweep an unfamiliar project. It deliberately makes the agent choose the file
filters itself rather than prescribing them — only the agent looking at the
repo knows what's generated, vendored, or irrelevant in it.
> You have an MCP server called `opencode` that can run a whole-codebase QA
> audit using a swarm of read-only agents. Use it on this repository.
>
> **Step 1 — decide what to review.** Look at the repo layout first (top-level
> dirs, the manifest/build file, any `.gitignore`). Then work out which files
> are actually worth reviewing: application source, not generated code, not
> vendored dependencies, not build output, not fixtures/snapshots, not
> committed assets. Whatever this project's stack generates or vendors, exclude
> it.
>
> **Step 2 — dry run.** Call `opencode_sweep` with `plan: true`, `lenses:
> "all"`, `replicas: 4`, and your chosen `sourceExtensions` / `includeGlobs` /
> `excludeGlobs`. It spawns nothing and returns the file list, the segment
> breakdown, and a job estimate. Read the `skipped` counts and the segment
> list: if something important was excluded, or something generated slipped
> through, fix the filters and re-plan. Do not skip this step.
>
> **Step 3 — confirm scale, then trial exactly one segment.** Report the plan
> back to me — file count, segment count, estimated jobs — and say roughly how
> long the full run would take. Then call `opencode_sweep` again WITHOUT
> `plan` but WITH `maxSegments: 1` and the exact same `lenses`/`replicas` you
> intend to use for real. This exercises the complete pipeline (fan-out, tree
> aggregation, the adversarial round, ledger emission) at real scale in a
> fraction of the time — it's how you catch a wrong `maxConcurrency`, a
> misbehaving lens, or a backend that can't actually keep up, before finding
> out three segments into a six-hour run. Poll `opencode_sweep_status` while
> it runs (see below) and confirm the trial segment reaches `completed`, not
> `incomplete` or `failed`, before proceeding.
>
> **Step 4 — tune `maxConcurrency` if the trial showed trouble.** If the trial
> segment came back `incomplete` (reconciliation never finished) or included
> "Unexpected server error" responses, the model backend couldn't keep up with
> the request volume — lower `maxConcurrency` (default 4) and re-trial rather
> than raising `waitMs`, which doesn't address the actual cause. If it
> finished cleanly and you know the backend can genuinely serve more
> concurrent requests, you can raise `maxConcurrency` instead of leaving
> capacity idle.
>
> **Step 5 — run it for real.** Call `opencode_sweep` again without `plan` or
> `maxSegments`, same `lenses`/`replicas`/`maxConcurrency` that worked in the
> trial. It returns a `sweepId` immediately and runs in the background; there
> is NO notification when it finishes. Poll `opencode_sweep_status` with that
> `sweepId` periodically — the active segment's `phase` field updates live
> (e.g. "Reviewing: 30/48 participant(s) finished"), so you can tell real
> progress from a stall without waiting for a segment to complete. The
> `reportPath` it returns points at a markdown report rewritten after every
> segment, so it's readable while the sweep is still going. If something looks
> wrong mid-run, `opencode_sweep_cancel` stops it — you don't have to let a bad
> run finish just because it started.
>
> **Step 6 — report back.** When `status` is `completed`, read the report file
> and summarise: the confirmed findings grouped by severity, which are worth
> acting on now, and which look like false positives to you. Do NOT fix
> anything yet — verify each high-severity finding against the real code
> yourself first and tell me which ones you actually believe, because a
> swarm of small models produces some confident nonsense and the point of your
> pass is to catch it.
>
> Notes: `replicas: 4` means every one of the 12 lenses gets 4 independent
> reviewers per segment — that's the redundancy that makes the confidence
> ranking meaningful, and it's why the job estimate is large. `depth: 1` (the
> default) adds one adversarial round per segment that tries to refute
> single-source findings.
### Try it yourself
```bash
mkdir -p /tmp/opencode-mcp-demo && cd /tmp/opencode-mcp-demo
git init -q && git config user.email "demo@demo.com" && git config user.name "Demo"
cat > calc.js << 'EOF'
function multiply(a, b) {
return a * b;
}
module.exports = { multiply };
EOF
cat > calc.test.js << 'EOF'
const assert = require("assert");
const { multiply, divide } = require("./calc");
assert.strictEqual(multiply(2, 3), 6);
assert.strictEqual(divide(10, 2), 5);
console.log("all tests passed");
EOF
cat > package.json << 'EOF'
{
"name": "opencode-mcp-demo",
"scripts": {
"test": "node calc.test.js",
"lint": "node -e \"require('./calc.js'); console.log('lint ok')\""
}
}
EOF
git add -A && git commit -q -m "initial"
```
Then, from Claude Code (with this MCP server registered — see "Register with
Claude Code" below), ask something like:
> Use opencode_job on /tmp/opencode-mcp-demo with the goal "the test suite in
> calc.test.js is failing — fix calc.js so `npm test` and `npm run lint` both
> pass," then tell me what happened.
Expected: pass 1 adds the missing `divide` function, the lint/test checks
that run right after come back green, and the QA audit that follows finds
nothing wrong with the (correct, minimal) diff. To see the reconciliation
mechanism do real work instead of rubber-stamping, try `opencode_audit`
directly on a deliberately messier diff — introduce an actual bug (e.g. an
inverted comparison or a copy-pasted line) before auditing, and check that it
shows up as CONFIRMED (if more than one lens/reviewer flags it) rather than
buried in a wall of text.
## Install
```bash
npm install
```
## Register with Claude Code
```bash
claude mcp add opencode --scope user -- node /path/to/opencode-mcp/src/index.js
```
Replace `/path/to/opencode-mcp` with wherever you cloned this repo (e.g. run
`pwd` from inside it to get the absolute path).
Takes effect in new Claude Code sessions (an already-running session won't pick up
newly registered servers). This also means **different concurrent sessions can be
running different versions of this server's code** — a session started before a
fix landed keeps running its old behavior until it's restarted. If you see
inconsistent model choices across sessions on the same day, this is the first
thing to check, not necessarily a ranking bug.
## Safety note
`opencode_start_job` accepts an `auto` flag that maps to opencode's `--auto`
(auto-approve all tool permissions). It's off by default; only set it for jobs you
trust to edit files / run commands unattended.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues