Multi-model adversarial review plugin for opencode
Sends the same review prompt to multiple LLMs in parallel, then aggregates their responses into a single structured synthesis. Designed for adversarial code review workflows where diverse model perspectives catch issues that a single model misses.
A single reviewer model has blind spots. By fanning out the same prompt to 2+ models from different providers (e.g., GPT-5.5 + Claude Opus), you get:
- Broader coverage — different models catch different classes of issues
- Reduced false confidence — disagreement between models surfaces genuine ambiguity
- Structured output — an aggregator synthesizes responses without injecting its own verdict
If fewer than 2 reviewers respond (timeouts, errors), the tool returns an error string so the calling agent can gracefully fall back to a single reviewer.
Good use cases:
- Pre-merge code review where catching subtle bugs justifies the extra latency
- Architecture and design document review
- Security-sensitive changes where multiple perspectives reduce risk
- Any review where you want structured disagreement, not just a single opinion
Not ideal for:
- Quick formatting or lint-only checks (overkill for the latency cost)
- Real-time interactive workflows where sub-second response matters
- Tasks where all models would give identical answers (e.g., simple factual lookups)
npm install @skwid138/opencode-councilRequires @opencode-ai/plugin ^1.15.0 as a peer dependency.
Add the plugin to your opencode.json. The only required council option is a models array with at least two entries; the plugin injects bundled reviewer and aggregator agents automatically when you do not specify your own.
Minimal config:
{
"plugin": [
[
"@skwid138/opencode-council",
{
"council": {
"models": [
{ "providerID": "openai", "modelID": "gpt-5.5" },
{ "providerID": "github-copilot", "modelID": "claude-opus-4.6" }
]
}
}
]
]
}Full config with all options:
{
"plugin": [
[
"@skwid138/opencode-council",
{
"council": {
"models": [
{ "providerID": "openai", "modelID": "gpt-5.5" },
{ "providerID": "github-copilot", "modelID": "claude-opus-4.6" },
{ "providerID": "google", "modelID": "gemini-3-pro" }
],
"quorum": 2,
"reviewer": "my-reviewer",
"aggregator": "my-aggregator",
"debug": false,
"reviewer_temperature": 0.3,
"reviewer_permission": {
"bash": {
"*": "allow",
"sudo *": "deny",
"git push --force*": "deny"
},
"external_directory": {
"~/code/*": "allow",
"~/secrets/*": "deny"
}
},
"aggregator_permission": { "*": "deny" },
"aggregator_model": { "providerID": "openai", "modelID": "gpt-5.5" },
"timeouts": {
"councillor_ms": 270000,
"aggregator_ms": 120000,
"quorum_grace_ms": 10000,
"hard_cap_ms": 430000
}
}
}
]
]
}Note: reviewer_temperature is shown for option reference; it only affects the injected bundled reviewer, so omit reviewer or define temperature on your custom reviewer agent if you use one.
| Field | Description |
|---|---|
council.models |
Array of 2+ model configs (providerID + modelID) to fan out to |
| Field | Default | Description |
|---|---|---|
council.reviewer |
council-plugin-reviewer |
Name of the opencode agent to use as each reviewer |
council.aggregator |
council-plugin-aggregator |
Name of the opencode agent to use as the aggregator |
council.debug |
false |
Enable structured debug logs through ctx.client.app.log() |
council.reviewer_temperature |
null (uses bundled default 0.3) |
Optional temperature for the injected bundled reviewer agent. Must be a finite number from 0 to 2. Ignored when council.reviewer names a custom agent. |
council.reviewer_permission |
Catch-all bash/external_directory allows plus workspace inherited rules |
Extra session-level reviewer rules. Values may be flat ("bash": "deny") or nested pattern maps ("bash": { "sudo *": "deny" }). Use only allow or deny; ask entries are stripped. |
council.aggregator_permission |
none | Optional session-level aggregator rules. No workspace inheritance or catch-all allows are applied to the aggregator. Use only allow or deny; ask entries are stripped. |
council.aggregator_model |
First available model | Specific model for the aggregator agent |
council.quorum |
models.length |
Number of successful councillor responses required before the aggregator can run. When quorum < models.length, the aggregator starts as soon as quorum is reached and pending councillors are aborted (after an optional grace window). Must be an integer in [2, models.length]. Default preserves wait-for-all behavior. |
council.timeouts.councillor_ms |
270000 (4.5 min) |
Timeout for each reviewer request |
council.timeouts.aggregator_ms |
120000 (2 min) |
Timeout for the aggregation step. This starts after the councillor phase completes, so the aggregator gets a fresh clock. |
council.timeouts.quorum_grace_ms |
0 |
Optional grace window in milliseconds after quorum is reached, during which stragglers can still join before the laggard abort sweep. Set to 0 (default) to abort pending councillors immediately on quorum. |
council.timeouts.hard_cap_ms |
computed (420000 with defaults) |
Absolute maximum wall time for the entire operation. By default this is councillor_ms + aggregator_ms + quorum_grace_ms + 30000. |
Council review uses three timeout layers:
- Each councillor request gets
councillor_ms. - The aggregator gets a fresh
aggregator_msclock after the councillor phase completes. - When
quorum < models.length, an optional layer kicks in: oncequorumcouncillors succeed, the orchestrator waits up toquorum_grace_msfor stragglers to join, then aborts any still-pending councillor sessions before invoking the aggregator.
The outer hard_cap_ms is a safety net around the whole operation. When omitted, it is computed from the resolved phase timeouts plus a 30-second buffer: councillor_ms + aggregator_ms + quorum_grace_ms + 30000. With defaults (grace = 0), that is 270000 + 120000 + 0 + 30000 = 420000 (7 minutes).
If you explicitly set hard_cap_ms, the plugin honors it exactly. If the explicit hard cap is smaller than the computed phase budget, the plugin emits a structured warning but does not shrink the inner phase timeouts.
Structured debug logging is resolved once when the plugin initializes, then the
same logger is reused for every council_review invocation. It can be enabled
from any of three sources:
- Set
COUNCIL_DEBUG=1in the environment before starting opencode. - Set top-level plugin option
"debug": true. - Set
"debug": truein the plugin'scouncilconfig.
Logs are emitted through opencode's app logger as ctx.client.app.log({ body: { service: "council-plugin", level, message, extra } }). Debug logs include councillor request start/completion, aggregator start/end, timeout events, and hard-cap triggers. Warnings, including stripped ask permissions, deprecated timeout keys, and undersized explicit hard caps, also use the same structured logger.
If reviewer or aggregator is omitted, the plugin injects hidden mode: "subagent" bundled agents into config.agent during opencode startup:
council-plugin-reviewer— an adversarial reviewer that tiers findings as Must Address, Should Address, or Unrelated Observations and returns APPROVE / REVISE / REJECT.council-plugin-aggregator— a structural aggregator that deduplicates reviewer responses by agreement level without issuing its own verdict.
Bundled agent temperatures are set explicitly: the reviewer defaults to 0.3 for some council diversity, and the aggregator uses 0 for deterministic synthesis. Set council.reviewer_temperature to override the injected bundled reviewer temperature; the aggregator temperature is not configurable. If you specify a custom reviewer, define any desired temperature on that agent instead.
If you specify reviewer or aggregator, that name must reference an opencode agent defined in your config (~/.config/opencode/agent/ or .opencode/agent/). Custom agents fully replace the corresponding bundled agent.
Reviewer — An adversarial review agent. Its job is to find problems with the code or plan it receives. Design its system prompt for critical analysis, not helpfulness. The same reviewer agent is used for all models in the models array; model diversity comes from the fan-out, not from multiple agent definitions.
Aggregator — A synthesis agent. Its job is to structurally combine multiple reviewer responses into a unified summary. The plugin runs the aggregator prompt with all tools disabled, so its prompt should focus on structural synthesis rather than independent analysis.
reviewer_permission and aggregator_permission accept the same nested shape as opencode permission maps:
{
"reviewer_permission": {
"read": "allow",
"bash": {
"*": "allow",
"sudo *": "deny",
"git push --force*": "deny"
},
"external_directory": {
"~/code/*": "allow",
"~/secrets/*": "deny"
}
}
}Flat strings become a * pattern. Nested objects are emitted in object key order; opencode uses last-match-wins permission evaluation, so put broad patterns first and narrower patterns later.
Reviewer overrides are appended after the plugin's catch-all allows and after inherited workspace bash / external_directory rules, so they can tighten reviewer sessions by adding later deny rules. Allow-only overrides do not narrow access; because the workaround starts with broad allows, use explicit deny rules for restrictions.
Aggregator overrides are used only when explicitly configured. The aggregator never inherits workspace permissions and never receives the reviewer catch-all allows.
For working examples of reviewer and aggregator agent definitions, see:
- config-opencode — personal opencode config with council agents
- ai-dev-bootstrap-mac — similar agent setup in a bootstrap config
- Fan out — The review prompt is sent to each configured model in parallel, each running as the specified
revieweragent in its own child session. - Quorum — As soon as
quorumreviewers respond successfully (default: all of them), the orchestrator stops waiting on the rest. Ifquorum_grace_ms > 0, a grace window lets stragglers join before pending sessions are aborted. - Gate — At least 2 successful responses are required overall. Otherwise, an error is returned for fallback handling.
- Aggregate — Successful responses are passed to the
aggregatoragent, which performs structural synthesis without issuing its own verdict. The aggregator prompt is byte-identical to the pre-quorum behavior when no councillors were aborted. - Return — The aggregated result is returned to the calling agent.
The runtime is split into focused internal modules under src/:
index.ts— plugin registration, config hook, tool handler, public re-exports, default exportconfig.ts— council option parsing, defaults, timeout constants, config warningspermissions.ts— permission override normalization and child-session ruleset constructionsession.ts— child-session creation, parent-directory lookup, prompt/response extractioncouncillor.ts— reviewer request lifecycleaggregator.ts— aggregator prompt formatting and synthesis session lifecycleorchestrator.ts— per-review fan-out, success threshold, hard-cap handlingtimeout.ts— timeout race helper and formattinglogging.ts— structured logger factory and shared log helperstypes.ts— shared types, bundled agent constants, and type guardsprompts.ts— bundled reviewer and aggregator prompts/permissions
The plugin registers a single tool:
| Parameter | Type | Description |
|---|---|---|
prompt |
string |
The complete review prompt to send to each reviewer |
Returns: The aggregator's structural synthesis, or an error string if fewer than 2 reviewers succeeded (timeouts, failures, or quorum-aborts all count against the success threshold).
A typical aggregated response:
┌─────────────────────────────────────────────────┐
│ Council Review — 2 of 2 reviewers responded │
├─────────────────────────────────────────────────┤
│ │
│ VERDICT: REVISE │
│ │
│ ▸ Must Address (agreed by 2/2): │
│ • Missing null check on `user.preferences` │
│ before destructuring (line 42) │
│ • SQL query uses string interpolation — │
│ switch to parameterized query │
│ │
│ ▸ Should Address (1/2 flagged): │
│ • Function `processData` exceeds 80 lines — │
│ consider extracting validation logic │
│ │
│ ▸ Unrelated Observations: │
│ • Unused import `lodash` on line 3 │
│ │
└─────────────────────────────────────────────────┘
Output format depends on the aggregator agent. The bundled aggregator produces Markdown with tiered findings.
Install dependencies:
npm installRun tests:
npm testRun tests with coverage:
npm run test:coverageType-check:
npm run typecheckBuild the package:
npm run buildThe build emits ESM artifacts and declaration files into dist/.
This repo uses Conventional Commits. A local
husky hook runs
commitlint on every commit, and PR titles are validated
by GitHub Actions (.github/workflows/pr-title.yml) since this repo squash-merges
with the PR title as the commit subject.
Allowed types: feat, fix, perf, refactor (patch), docs, chore, ci,
style, test, build, revert.
Notes:
- Use
git commit --no-verifyto bypass the hook in unusual cases (e.g. WIP commits you'll squash later). - A
BREAKING CHANGE:footer in the commit body triggers a major release regardless of the type prefix — use deliberately. - The conventional prefix governs the release type, but the human is responsible for
matching prefix to actual change (a
feat:whose diff is a README typo will still publish a minor release).
- Bundled reviewer and aggregator permissions are defined on the injected agents and contain no
askvalues. - Reviewer child sessions always receive session-level catch-all allows for
bashandexternal_directory, then inherited workspacebash/external_directoryallowanddenyrules, then explicitreviewer_permissionoverrides. - The reviewer catch-all
bashallow is an intentional security trade-off for the anomalyco/opencode#28037 workaround. It prevents unanswerable child-session permission prompts from hanging the TUI. Tighten custom reviewer sessions by adding laterdenyrules inreviewer_permission. external_directoryhas no catch-all deny. The session starts with a catch-all allow to neutralizeask, then applies inherited workspace denies and explicit reviewer denies.- Explicit permission overrides are applied at session level and should use only
allowordenyvalues. Anyaskentries are stripped with a warning because child sessions cannot prompt interactively. - The aggregator prompt runs with all tools disabled as defense-in-depth.
- The aggregator never receives workspace permission inheritance or reviewer catch-all allows. If you need aggregator session rules, set
aggregator_permissionexplicitly. - Sessions are aborted after use; server-side TTL handles edge cases.
Status: Workaround implemented; upstream fix pending Tracking: anomalyco/opencode#28037
When a councillor session triggers a bash command not on the allow-list, the opencode TUI displays a permission prompt that cannot be resolved — clicking Allow/Reject does nothing, and the session hangs indefinitely.
Root cause: The opencode server uses separate in-memory permission service instances for the plugin SDK client vs. the TUI listener. Permission replies from either surface land on a different instance than the one holding the pending request, so they're silently dropped.
Workaround (already implemented): Bundled agents are injected with permissions that contain no ask values. Reviewer child sessions receive a session-level ruleset with catch-all allow entries for bash and external_directory, followed by inherited workspace allow / deny rules and explicit reviewer overrides. Councillors never trigger ask prompts; configure later deny patterns in reviewer_permission to restrict risky commands or paths.
Implications for consumers:
- Bundled agents work without requiring you to create reviewer or aggregator agents
- Custom reviewer sessions read your parent workspace
opencode.jsonpermission.bashandpermission.external_directoryrules automatically - Commands and external paths are broadly allowed by the workaround unless workspace rules or explicit reviewer overrides deny them later
- If you need stricter custom reviewer behavior, define
reviewer_permissionwith explicit deny patterns such as"sudo *": "deny"or"~/secrets/*": "deny" - If you add new bash or external-directory rules to your config, councillor sessions pick them up on next invocation
- Once the upstream bug is fixed, this workaround becomes redundant but harmless
MIT