Browse Source

feat(council): add multi-LLM council agent with configurable presets (#216)

* feat(council): add multi-LLM council agent with configurable presets

Implements a council agent that dispatches prompts to multiple LLM models
in parallel, then synthesises their responses through a master session.

Core features:
- Council manager with configurable presets and per-model overrides
- Read-only councillor agents spawned as background tasks via tmux
- Master synthesis agent with fresh session per fallback attempt
- Zod-validated council schema with provider/model format enforcement
- Start notification injected into parent session on launch
- Agent-gated council_session tool exposed via MCP

Hardening from review:
- Fresh master session per fallback prevents transcript contamination
- Filter reasoning parts from councillor results before master synthesis
- Stagger councillor launches (250ms) to reduce tmux/api collisions
- Throw on unauthorized tool access instead of returning error string
- Non-null assertions replaced with optional chaining in tests
- Auto-generated schema excluded from biome formatting

Includes: 468 tests (0 fail), typecheck clean, biome lint clean

Addresses: alvinunreal/oh-my-opencode-slim#213

* docs(council): add council agent guide and quick-reference updates

Adds full council.md documentation covering architecture, configuration,
presets, permissions, and troubleshooting. Updates quick-reference.md
with council config tables and MCP defaults for councillor/council-master.

* fix(council): correct councillor stagger for 3+ councillors, fix misleading dedupe comment

* feat(council): add per-preset master override and prevent result re-summarization

Allow each council preset to override the global master's model, variant,
and prompt via a reserved "master" key. The schema transforms preset
entries to separate master overrides from councillor configs at parse time.

Also instructs both orchestrator and council agent to present council
results verbatim — no re-summarization of the master's synthesis.

Includes docs (council.md, quick-reference.md) and 4 new tests for
per-preset master override (model, variant, prompt, no-override fallback).
ReqX 2 weeks ago
parent
commit
cb2271459c

+ 3 - 0
.gitignore

@@ -52,6 +52,9 @@ opencode.jsonc
 
 # Planning docs (not for commit)
 TIMEOUT_PLAN.md
+GOAL.md
+PR-NOTES.md
+REVIEW.md
 
 # Python
 __pycache__/

+ 1 - 1
.slim/cartography.json

@@ -120,4 +120,4 @@
     "src/agents": "56493588858687537daaa3105c0405e0",
     "src/config": "50adc10efa4196609e8cc2105ba8eba2"
   }
-}
+}

+ 15 - 2
AGENTS.md

@@ -64,9 +64,19 @@ bun test -t "test-name-pattern"
 
 ```
 oh-my-opencode-slim/
-├── src/              # TypeScript source files
+├── src/
+│   ├── agents/       # Agent factories (orchestrator, explorer, oracle, etc.)
+│   ├── background/   # Background task management
+│   ├── cli/          # CLI entry point
+│   ├── config/       # Constants, schemas, MCP defaults
+│   ├── council/      # Council manager (multi-LLM session orchestration)
+│   ├── hooks/        # OpenCode lifecycle hooks
+│   ├── mcp/          # MCP server definitions
+│   ├── skills/       # Skill definitions (included in package publish)
+│   ├── tools/        # Tool definitions (background tasks, council, etc.)
+│   └── utils/        # Shared utilities (tmux, session helpers)
 ├── dist/             # Built JavaScript and declarations
-├── node_modules/     # Dependencies
+├── docs/             # User-facing documentation
 ├── biome.json        # Biome configuration
 ├── tsconfig.json     # TypeScript configuration
 └── package.json      # Project manifest and scripts
@@ -230,6 +240,9 @@ OpenCode has a built-in `/review` command that automatically performs comprehens
 - This is an OpenCode plugin - most functionality lives in `src/`
 - The CLI entry point is `src/cli/index.ts`
 - The main plugin export is `src/index.ts`
+- Agent factories are in `src/agents/` — each agent has its own file + optional `.test.ts`
 - Skills are located in `src/skills/` (included in package publish)
 - Background task management is in `src/background/`
+- Council manager (multi-LLM orchestration) is in `src/council/`
 - Tmux utilities are in `src/utils/tmux.ts`
+- 468 tests across 35 files — run `bun test` to verify

+ 3 - 0
biome.json

@@ -1,6 +1,9 @@
 {
   "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
   "assist": { "actions": { "source": { "organizeImports": "on" } } },
+  "files": {
+    "includes": ["**", "!oh-my-opencode-slim.schema.json"]
+  },
   "vcs": {
     "enabled": true,
     "clientKind": "git",

+ 580 - 0
docs/council.md

@@ -0,0 +1,580 @@
+# Council Agent Guide
+
+Multi-LLM consensus system that runs several models in parallel and synthesises their best thinking into one answer.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Quick Setup](#quick-setup)
+- [Configuration](#configuration)
+- [Preset Examples](#preset-examples)
+- [Role Prompts](#role-prompts)
+- [Usage](#usage)
+- [Timeouts & Error Handling](#timeouts--error-handling)
+- [Troubleshooting](#troubleshooting)
+- [Advanced](#advanced)
+
+---
+
+## Overview
+
+The **Council agent** sends your prompt to multiple LLMs (councillors) in parallel, then passes all responses to a **council master** that synthesises the optimal answer. Think of it as asking three experts and having a senior referee pick the best parts.
+
+### Key Benefits
+
+- **Higher confidence** — consensus across models reduces single-model blind spots
+- **Diverse perspectives** — different architectures catch different issues
+- **Graceful degradation** — if the master fails, the best councillor response is returned
+- **Configurable presets** — different council compositions for different tasks
+
+### How It Works
+
+```
+User prompt
+    │
+    ├──────────────┬──────────────┐
+    ▼              ▼              ▼
+ Councillor A  Councillor B  Councillor C
+ (model X)     (model Y)     (model Z)
+🔍 read-only   🔍 read-only   🔍 read-only
+    │              │              │
+    └──────────────┴──────────────┘
+                   │
+                   ▼
+            Council Master
+            (synthesis model)
+            🔒 no tools
+                   │
+                   ▼
+           Synthesised response
+```
+
+---
+
+## Quick Setup
+
+### Step 1: Add Council Configuration
+
+Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "default": {
+        "alpha": { "model": "openai/gpt-5.4-mini" },
+        "beta":  { "model": "google/gemini-3-pro" },
+        "gamma": { "model": "openai/gpt-5.3-codex" }
+      }
+    }
+  }
+}
+```
+
+### Step 2: Use the Council Agent
+
+Talk to the council agent directly:
+
+```
+@council What's the best approach for implementing rate limiting in our API?
+```
+
+Or let the orchestrator delegate when it needs multi-model consensus.
+
+That's it — the council runs, synthesises, and returns one answer.
+
+---
+
+## Configuration
+
+### Council Settings
+
+Configure in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "default_preset": "default",
+    "presets": {
+      "default": { /* councillors */ }
+    },
+    "master_timeout": 300000,
+    "councillors_timeout": 180000
+  }
+}
+```
+
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `master` | object | — | **Required.** Council master configuration (see below) |
+| `master.model` | string | — | **Required.** Model ID in `provider/model` format |
+| `master.variant` | string | — | Optional variant for the master model |
+| `master.prompt` | string | — | Optional guidance for the master's synthesis (see [Role Prompts](#role-prompts)) |
+| `presets` | object | — | **Required.** Named councillor presets (see below) |
+| `default_preset` | string | `"default"` | Which preset to use when none is specified |
+| `master_timeout` | number | `300000` | Master synthesis timeout in ms (5 minutes) |
+| `councillors_timeout` | number | `180000` | Per-councillor timeout in ms (3 minutes) |
+| `master_fallback` | string[] | — | Optional fallback models for the master. Tried in order if the primary model fails or times out |
+
+### Councillor Configuration
+
+Each councillor within a preset:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Model ID in `provider/model` format |
+| `variant` | string | No | Model variant (e.g., `"high"`, `"low"`) |
+| `prompt` | string | No | Role-specific guidance injected into the councillor's user prompt (see [Role Prompts](#role-prompts)) |
+
+### Per-Preset Master Override
+
+Each preset can optionally override the global master's `model`, `variant`, and `prompt` using a reserved `"master"` key:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "fast-review": {
+        "master": { "model": "openai/gpt-5.4" },
+        "alpha": { "model": "openai/gpt-5.4-mini" },
+        "beta":  { "model": "google/gemini-3-pro" }
+      }
+    }
+  }
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `presets.<name>.master.model` | string | No | Overrides the global master model for this preset |
+| `presets.<name>.master.variant` | string | No | Overrides the global master variant for this preset |
+| `presets.<name>.master.prompt` | string | No | Overrides the global master prompt for this preset |
+
+**Merge behaviour:** Each field uses nullish coalescing — if a field is omitted in the preset override, the global value is used. If no `"master"` key exists in the preset, the global master is used as-is.
+
+**Reserved key:** `"master"` inside a preset is reserved for this override and is not treated as a councillor name. Any councillor named `"master"` will be ignored.
+
+### Constraints
+
+- Councillors run as **agent sessions with read-only codebase access** — they can read files, search by name (glob), search by content (grep), search by AST pattern (codesearch), and query the language server (LSP). They cannot modify files, run shell commands, or spawn subagents. This makes council responses grounded in actual code rather than guessing.
+- The council master also runs as an agent session with zero permissions — synthesis is purely analytical.
+- Councillor and council-master agents can be configured (model, temperature, MCPs, skills) via the standard `agents.councillor` and `agents.council-master` preset overrides.
+
+---
+
+## Preset Examples
+
+### 1-Councillor: Second Opinion
+
+Use a single councillor when you want a second model's take without overhead:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "second-opinion": {
+        "reviewer": { "model": "openai/gpt-5.4" }
+      }
+    }
+  }
+}
+```
+
+**When to use:** Quick sanity check from a different model. The master still reviews the single response and can refine it.
+
+### 2-Councillor: Compare & Contrast
+
+Two councillors with different models:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "compare": {
+        "analyst":  { "model": "openai/gpt-5.4" },
+        "creative": { "model": "google/gemini-3-pro" }
+      }
+    }
+  }
+}
+```
+
+**When to use:** Architecture decisions where you want perspectives from two different providers.
+
+### 3-Councillor: Balanced Council
+
+The default setup — three diverse models:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "default": {
+        "alpha": { "model": "openai/gpt-5.4-mini" },
+        "beta":  { "model": "google/gemini-3-pro" },
+        "gamma": { "model": "openai/gpt-5.3-codex" }
+      }
+    }
+  }
+}
+```
+
+**When to use:** General-purpose consensus. Good balance of speed, cost, and diversity.
+
+### N-Councillor: Full Review Board
+
+As many councillors as you need — the system runs them all in parallel:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "full-board": {
+        "alpha":   { "model": "anthropic/claude-opus-4-6" },
+        "bravo":   { "model": "openai/gpt-5.4" },
+        "charlie": { "model": "openai/gpt-5.3-codex" },
+        "delta":   { "model": "google/gemini-3-pro" },
+        "echo":    { "model": "openai/gpt-5.4-mini" }
+      }
+    },
+    "councillors_timeout": 300000
+  }
+}
+```
+
+**When to use:** High-stakes design reviews or complex architectural decisions where maximum model diversity matters. Increase `councillors_timeout` since there are more responses to collect.
+
+### Multiple Presets
+
+Define several presets and choose at invocation time:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "default_preset": "balanced",
+    "presets": {
+      "quick": {
+        "fast": { "model": "openai/gpt-5.4-mini" }
+      },
+      "balanced": {
+        "alpha": { "model": "openai/gpt-5.4-mini" },
+        "beta":  { "model": "google/gemini-3-pro" }
+      },
+      "heavy": {
+        "analyst":   { "model": "anthropic/claude-opus-4-6" },
+        "coder":     { "model": "openai/gpt-5.3-codex" },
+        "reviewer":  { "model": "google/gemini-3-pro" }
+      }
+    }
+  }
+}
+```
+
+**How to select a preset:**
+
+| Caller | How |
+|--------|-----|
+| User via `@council` | The council agent can pass a `preset` argument to the `council_session` tool |
+| Orchestrator delegates | Orchestrator invokes `@council`, which selects the preset |
+| No preset specified | Falls back to `default_preset` (defaults to `"default"`) |
+
+---
+
+### Role Prompts
+
+Both councillors and the master accept an optional `prompt` field that injects role-specific guidance into the user prompt. This lets you steer each participant's behaviour without changing the system prompt.
+
+**Councillor prompt** — prepended to the user prompt before the divider:
+
+```
+<role prompt>
+---
+<user prompt>
+```
+
+**Master prompt** — appended after the synthesis instruction:
+
+```
+<synthesis instruction>
+
+---
+**Master Guidance**:
+<role prompt>
+```
+
+#### Example: Specialised Review Board
+
+Both councillors and the master accept an optional `prompt` field. The master prompt can be set globally (`council.master.prompt`) or per-preset (`presets.<name>.master.prompt`):
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "review-board": {
+        "master": {
+          "prompt": "Prioritise correctness and security over creativity. Flag any risks."
+        },
+        "reviewer": {
+          "model": "openai/gpt-5.4",
+          "prompt": "You are a meticulous code reviewer. Focus on edge cases, error handling, and potential bugs."
+        },
+        "architect": {
+          "model": "google/gemini-3-pro",
+          "prompt": "You are a systems architect. Focus on design patterns, scalability, and maintainability."
+        },
+        "optimiser": {
+          "model": "openai/gpt-5.3-codex",
+          "prompt": "You are a performance specialist. Focus on latency, throughput, and resource usage."
+        }
+      }
+    }
+  }
+}
+```
+
+#### Example: Per-Preset Master Model + Councillor Prompt
+
+Override the master model for a specific preset while customising one councillor's role:
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "fast": {
+        "master": { "model": "openai/gpt-5.4" },
+        "alpha": { "model": "openai/gpt-5.4-mini" },
+        "beta": {
+          "model": "google/gemini-3-pro",
+          "prompt": "Respond as a devil's advocate. Challenge assumptions and find weaknesses."
+        }
+      }
+    }
+  }
+}
+```
+
+Without a `prompt`, the councillor or master uses its default behaviour — no changes to the prompt.
+
+---
+
+## Usage
+
+### Direct Invocation (User)
+
+Talk to the council agent like any other agent:
+
+```
+@council Should we use event sourcing or CRUD for the order service?
+```
+
+The council agent delegates to `council_session` internally and returns the synthesised result.
+
+### Orchestrator Delegation
+
+The orchestrator can delegate to `@council` when it needs multi-model consensus:
+
+```
+This is a high-stakes architectural decision. @council, get consensus on the database migration strategy.
+```
+
+The orchestrator's prompt includes guidance on when to delegate to council:
+
+> **Delegate when:** Critical decisions needing diverse model perspectives • High-stakes architectural choices where consensus reduces risk • Ambiguous problems where multi-model disagreement is informative
+
+### Reading the Output
+
+Council responses include a summary footer:
+
+```
+<synthesised answer>
+
+---
+*Council: 3/3 councillors responded (alpha: gpt-5.4-mini, beta: gemini-3-pro, gamma: gpt-5.3-codex)*
+```
+
+If some councillors failed:
+
+```
+<synthesised answer from available councillors>
+
+---
+*Council: 2/3 councillors responded (alpha: gpt-5.4-mini, beta: gemini-3-pro)*
+```
+
+---
+
+## Timeouts & Error Handling
+
+### Timeout Behaviour
+
+| Timeout | Default | Scope |
+|---------|---------|-------|
+| `councillors_timeout` | 180000 ms (3 min) | Per-councillor — each councillor gets this much time |
+| `master_timeout` | 300000 ms (5 min) | Master synthesis — one timeout for the whole synthesis phase |
+
+Councillors that don't respond in time are marked `timed_out`. The master proceeds with whatever results came back.
+
+### Graceful Degradation
+
+| Scenario | Behaviour |
+|----------|-----------|
+| Some councillors fail | Master synthesises from the survivors |
+| All councillors fail | Returns error immediately — master is never invoked |
+| Master primary model fails | Tries `master_fallback` models in order before degrading |
+| All master models fail | Returns best single councillor response prefixed with `(Degraded — master failed, using <name>'s response)` |
+
+### Master Fallback Chain
+
+The council master can be configured with fallback models. If the primary master model fails (timeout, API error, rate limit), the system tries each fallback in order before degrading to the best councillor response. This uses the same abort-retry pattern as the background task manager.
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "master_fallback": ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
+    "presets": { /* ... */ }
+  }
+}
+```
+
+When not configured, the master uses a single model with no fallback.
+
+---
+
+## Troubleshooting
+
+### Council Not Available
+
+**Problem:** `@council` agent doesn't appear or tool is missing
+
+**Solutions:**
+1. Verify `council` is configured in your plugin config:
+   ```bash
+   cat ~/.config/opencode/oh-my-opencode-slim.json | grep -A 5 '"council"'
+   ```
+2. Ensure `master.model` and at least one preset with one councillor are defined
+3. Restart OpenCode after config changes
+
+### All Councillors Timing Out
+
+**Problem:** "All councillors failed or timed out"
+
+**Solutions:**
+1. **Increase timeout:**
+   ```jsonc
+   { "council": { "councillors_timeout": 300000 } }
+   ```
+2. **Verify model IDs** — models must be in `provider/model` format and available in your OpenCode configuration
+3. **Check provider connectivity** — ensure the model providers are reachable
+
+### Preset Not Found
+
+**Problem:** `Preset "xyz" not found`
+
+**Solutions:**
+1. Check the preset name matches exactly (case-sensitive)
+2. Verify the preset exists under `council.presets` in your config
+3. If not specifying a preset, check `default_preset` points to an existing one
+
+### Subagent Depth Exceeded
+
+**Problem:** "Subagent depth exceeded"
+
+This happens when the council is nested too deep (council calling council, or orchestrator → council → council). The default max depth is 3.
+
+**Solutions:**
+1. Avoid patterns where the orchestrator delegates to council, which then delegates back to orchestrator
+2. Use council as a leaf agent — it should not be chained recursively
+
+---
+
+## Advanced
+
+### Model Selection Strategy
+
+Choose models from **different providers** for maximum perspective diversity:
+
+| Strategy | Example |
+|----------|---------|
+| Diverse providers | OpenAI + Google + Anthropic |
+| Same provider, different tiers | `gpt-5.4` + `gpt-5.4-mini` |
+| Specialised models | Codex (code) + GPT (reasoning) + Gemini (analysis) |
+
+### Cost Considerations
+
+- Each councillor is one agent session → N councillors = N sessions + 1 master session. Councillors may use multiple tool calls within their session (read, grep, etc.), which increases token usage but grounds responses in actual code.
+- Use smaller/faster models as councillors and a stronger model as master, unless you are willing to spend the tokens on parallel frontier models.
+- The 1-councillor preset is the most cost-effective (2 calls total)
+
+### Council Agent Mode
+
+The council agent is registered with `mode: "all"` in the OpenCode SDK, meaning it works as both:
+
+- **Primary agent** — users can talk to it directly via `@council`
+- **Subagent** — the orchestrator can delegate to it
+
+This is intentional: council is useful both as a user-facing tool for deliberate consensus-seeking and as a subagent the orchestrator can invoke for high-stakes decisions.
+
+### Customising Councillor & Master Agents
+
+Councillor and council-master are registered agents, so you can customise them using the standard `agents` override system:
+
+```jsonc
+{
+  "agents": {
+    "councillor": {
+      "model": "openai/gpt-5.4",
+      "temperature": 0.3,
+      "mcps": ["grep_app", "context7"]
+    },
+    "council-master": {
+      "model": "anthropic/claude-opus-4-6",
+      "variant": "high"
+    }
+  }
+}
+```
+
+**Defaults:**
+| Agent | Model | MCPs | Skills | Temperature |
+|-------|-------|------|--------|-------------|
+| `councillor` | `openai/gpt-5.4-mini` | none | none | 0.2 |
+| `council-master` | `openai/gpt-5.4-mini` | none | none | 0.1 |
+
+**Note:** Per-councillor model overrides in the council config (`presets.<name>.<councillor>.model`) take precedence over the agent-level default.
+
+### Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                    Plugin Entry                          │
+│                    (src/index.ts)                        │
+│                                                         │
+│  config.council?                                        │
+│    ├── CouncilManager (session orchestration)           │
+│    ├── council_session tool (agent-gated)               │
+│    ├── SubagentDepthTracker (recursion guard)           │
+│    │                                                     │
+│    └── Agent Sessions                                    │
+│        ├── councillor (read-only, 🔍)                   │
+│        │   └── deny all + allow: read, glob, grep,      │
+│        │       lsp, list, codesearch                     │
+│        └── council-master (zero tools, 🔒)              │
+│            └── deny all + question: deny                 │
+│                                                         │
+│  Agent Registration                                     │
+│    ├── council: mode "all" (user + orchestrator)        │
+│    ├── councillor: mode "subagent", hidden              │
+│    └── council-master: mode "subagent", hidden          │
+└─────────────────────────────────────────────────────────┘
+```

+ 61 - 1
docs/quick-reference.md

@@ -98,7 +98,7 @@ The plugin can fail over from one model to the next when a prompt times out or e
 **Important notes:**
 
 - Fallback models must use the `provider/model` format
-- Chains are per agent (`orchestrator`, `oracle`, `designer`, `explorer`, `librarian`, `fixer`)
+- Chains are per agent (`orchestrator`, `oracle`, `designer`, `explorer`, `librarian`, `fixer`, `councillor`, `council-master`)
 - If an agent has no configured chain, only its primary model is used
 - This is documented here because it is easy to miss in the config file
 
@@ -240,6 +240,8 @@ Control which agents can access which MCP servers using per-agent allowlists:
 | `librarian` | `websearch`, `context7`, `grep_app` |
 | `explorer` | none |
 | `fixer` | none |
+| `councillor` | none |
+| `council-master` | none |
 
 ### Configuration & Syntax
 
@@ -326,6 +328,51 @@ You can disable specific MCP servers globally by adding them to the `disabled_mc
 
 > **Detailed Guide:** For complete tmux integration documentation, troubleshooting, and advanced usage, see [Tmux Integration](tmux-integration.md)
 
+### Council Agent
+
+**Multi-model consensus for higher-confidence answers.** The Council agent sends your prompt to multiple LLMs in parallel, then a council master synthesises the best response.
+
+#### Quick Setup
+
+Add council configuration to `oh-my-opencode-slim.json` (or `.jsonc`):
+
+```jsonc
+{
+  "council": {
+    "master": { "model": "anthropic/claude-opus-4-6" },
+    "presets": {
+      "default": {
+        "alpha": { "model": "openai/gpt-5.4-mini" },
+        "beta":  { "model": "google/gemini-3-pro" },
+        "gamma": { "model": "openai/gpt-5.3-codex" }
+      }
+    }
+  }
+}
+```
+
+Then invoke: `@council What's the best approach for rate limiting?`
+
+#### How It Works
+
+1. Prompt is sent to all councillors in parallel as agent sessions with read-only codebase access
+2. Each councillor examines the codebase (read, glob, grep, lsp, codesearch) and provides independent analysis
+3. A council master reviews all responses and synthesises the optimal answer
+4. Result is returned with a summary including model composition (`Council: 3/3 councillors responded (alpha: gpt-5.4-mini, beta: gemini-3-pro, gamma: gpt-5.3-codex)`)
+
+#### Preset Sizes
+
+| Preset | Councillors | Best For |
+|--------|-------------|----------|
+| 1-councillor | 1 | Quick second opinion from a different model |
+| 2-councillor | 2 | Compare & contrast (e.g., analytical vs. creative) |
+| 3-councillor | 3 | General consensus (default) |
+| N-councillor | N | Full review board for high-stakes decisions |
+
+Each councillor has its own model (and optional variant). Define multiple named presets and select at invocation time.
+
+> **Detailed Guide:** For complete council documentation, configuration examples, and troubleshooting, see [Council Agent](council.md)
+
 ### Background Tasks
 
 The plugin provides tools to manage asynchronous work:
@@ -469,3 +516,16 @@ The installer generates this file with the OpenAI preset by default. You can man
 | `tmux.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `tmux.main_pane_size` | number | `60` | Main pane size as percentage (20-80) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally (e.g., `"websearch"`) |
+| `council.master.model` | string | - | **Required if using council.** Model ID for the council master (e.g., `"anthropic/claude-opus-4-6"`) |
+| `council.master.variant` | string | - | Variant for the council master model |
+| `council.master.prompt` | string | - | Optional guidance for the master's synthesis |
+| `council.presets` | object | - | **Required if using council.** Named councillor presets (see [Council Agent](council.md)) |
+| `council.presets.<name>.<councillor>.model` | string | - | Model ID for the councillor |
+| `council.presets.<name>.<councillor>.prompt` | string | - | Optional role guidance for the councillor |
+| `council.presets.<name>.master.model` | string | - | Override the global master model for this preset |
+| `council.presets.<name>.master.variant` | string | - | Override the global master variant for this preset |
+| `council.presets.<name>.master.prompt` | string | - | Override the global master prompt for this preset |
+| `council.default_preset` | string | `"default"` | Which preset to use when none is specified |
+| `council.master_timeout` | number | `300000` | Master synthesis timeout in ms |
+| `council.councillors_timeout` | number | `180000` | Per-councillor timeout in ms |
+| `council.master_fallback` | string[] | — | Optional fallback models for the council master (tried in order on primary failure) |

+ 117 - 9
oh-my-opencode-slim.schema.json

@@ -10,7 +10,11 @@
     },
     "scoringEngineVersion": {
       "type": "string",
-      "enum": ["v1", "v2-shadow", "v2"]
+      "enum": [
+        "v1",
+        "v2-shadow",
+        "v2"
+      ]
     },
     "balanceProviderUsage": {
       "type": "boolean"
@@ -38,7 +42,12 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": ["primary", "fallback1", "fallback2", "fallback3"]
+          "required": [
+            "primary",
+            "fallback1",
+            "fallback2",
+            "fallback3"
+          ]
         },
         "oracle": {
           "type": "object",
@@ -60,7 +69,12 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": ["primary", "fallback1", "fallback2", "fallback3"]
+          "required": [
+            "primary",
+            "fallback1",
+            "fallback2",
+            "fallback3"
+          ]
         },
         "designer": {
           "type": "object",
@@ -82,7 +96,12 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": ["primary", "fallback1", "fallback2", "fallback3"]
+          "required": [
+            "primary",
+            "fallback1",
+            "fallback2",
+            "fallback3"
+          ]
         },
         "explorer": {
           "type": "object",
@@ -104,7 +123,12 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": ["primary", "fallback1", "fallback2", "fallback3"]
+          "required": [
+            "primary",
+            "fallback1",
+            "fallback2",
+            "fallback3"
+          ]
         },
         "librarian": {
           "type": "object",
@@ -126,7 +150,12 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": ["primary", "fallback1", "fallback2", "fallback3"]
+          "required": [
+            "primary",
+            "fallback1",
+            "fallback2",
+            "fallback3"
+          ]
         },
         "fixer": {
           "type": "object",
@@ -148,7 +177,12 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": ["primary", "fallback1", "fallback2", "fallback3"]
+          "required": [
+            "primary",
+            "fallback1",
+            "fallback2",
+            "fallback3"
+          ]
         }
       },
       "required": [
@@ -196,7 +230,9 @@
                             "type": "string"
                           }
                         },
-                        "required": ["id"]
+                        "required": [
+                          "id"
+                        ]
                       }
                     ]
                   }
@@ -257,7 +293,9 @@
                           "type": "string"
                         }
                       },
-                      "required": ["id"]
+                      "required": [
+                        "id"
+                      ]
                     }
                   ]
                 }
@@ -403,6 +441,76 @@
           }
         }
       }
+    },
+    "council": {
+      "type": "object",
+      "properties": {
+        "master": {
+          "type": "object",
+          "properties": {
+            "model": {
+              "type": "string",
+              "pattern": "^[^/\\s]+\\/[^\\s]+$",
+              "description": "Model ID for the council master (e.g. \"anthropic/claude-opus-4-6\")"
+            },
+            "variant": {
+              "type": "string"
+            },
+            "prompt": {
+              "description": "Optional role/guidance injected into the master synthesis prompt",
+              "type": "string"
+            }
+          },
+          "required": [
+            "model"
+          ]
+        },
+        "presets": {
+          "type": "object",
+          "propertyNames": {
+            "type": "string"
+          },
+          "additionalProperties": {
+            "type": "object",
+            "propertyNames": {
+              "type": "string"
+            },
+            "additionalProperties": {
+              "type": "object",
+              "propertyNames": {
+                "type": "string"
+              },
+              "additionalProperties": {}
+            }
+          }
+        },
+        "master_timeout": {
+          "default": 300000,
+          "type": "number",
+          "minimum": 0
+        },
+        "councillors_timeout": {
+          "default": 180000,
+          "type": "number",
+          "minimum": 0
+        },
+        "default_preset": {
+          "default": "default",
+          "type": "string"
+        },
+        "master_fallback": {
+          "description": "Fallback models for the council master. Tried in order if the primary model fails. Example: [\"anthropic/claude-sonnet-4-6\", \"openai/gpt-5.4\"]",
+          "type": "array",
+          "items": {
+            "type": "string",
+            "pattern": "^[^/\\s]+\\/[^\\s]+$"
+          }
+        }
+      },
+      "required": [
+        "master",
+        "presets"
+      ]
     }
   },
   "title": "oh-my-opencode-slim",

+ 84 - 0
src/agents/council-master.test.ts

@@ -0,0 +1,84 @@
+import { describe, expect, test } from 'bun:test';
+import { createCouncilMasterAgent } from './council-master';
+
+describe('createCouncilMasterAgent', () => {
+  test('creates agent with correct name', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    expect(agent.name).toBe('council-master');
+  });
+
+  test('creates agent with correct description', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    expect(agent.description).toContain('Council synthesis engine');
+  });
+
+  test('sets model from argument', () => {
+    const agent = createCouncilMasterAgent('custom-model');
+    expect(agent.config.model).toBe('custom-model');
+  });
+
+  test('sets temperature to 0.1', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    expect(agent.config.temperature).toBe(0.1);
+  });
+
+  test('sets default prompt when no custom prompts provided', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    expect(agent.config.prompt).toContain(
+      'council master responsible for synthesizing',
+    );
+  });
+
+  test('uses custom prompt when provided', () => {
+    const customPrompt = 'You are a custom synthesizer.';
+    const agent = createCouncilMasterAgent('test-model', customPrompt);
+    expect(agent.config.prompt).toBe(customPrompt);
+    expect(agent.config.prompt).not.toContain('council master');
+  });
+
+  test('appends custom append prompt', () => {
+    const customAppendPrompt = 'Additional instructions here.';
+    const agent = createCouncilMasterAgent(
+      'test-model',
+      undefined,
+      customAppendPrompt,
+    );
+    expect(agent.config.prompt).toContain('council master');
+    expect(agent.config.prompt).toContain(customAppendPrompt);
+    expect(agent.config.prompt).toContain('Additional instructions here.');
+  });
+
+  test('custom prompt takes priority over append prompt', () => {
+    const customPrompt = 'Custom prompt only.';
+    const customAppendPrompt = 'Should be ignored.';
+    const agent = createCouncilMasterAgent(
+      'test-model',
+      customPrompt,
+      customAppendPrompt,
+    );
+    expect(agent.config.prompt).toBe(customPrompt);
+    expect(agent.config.prompt).not.toContain(customAppendPrompt);
+  });
+});
+
+describe('council-master permissions', () => {
+  test('denies all with single wildcard deny', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    expect(agent.config.permission).toBeDefined();
+    expect((agent.config.permission as Record<string, string>)['*']).toBe(
+      'deny',
+    );
+  });
+
+  test('denies question explicitly', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    const permission = agent.config.permission as Record<string, string>;
+    expect(permission.question).toBe('deny');
+  });
+
+  test('has exactly 2 permission entries', () => {
+    const agent = createCouncilMasterAgent('test-model');
+    const permission = agent.config.permission as Record<string, string>;
+    expect(Object.keys(permission)).toHaveLength(2);
+  });
+});

+ 70 - 0
src/agents/council-master.ts

@@ -0,0 +1,70 @@
+import { type AgentDefinition, resolvePrompt } from './orchestrator';
+
+/**
+ * Council Master agent — pure synthesis engine.
+ *
+ * The master receives all councillor responses and produces the final
+ * synthesized answer. It has NO tools — synthesis is a text-in/text-out
+ * operation. Councillors already did the research.
+ *
+ * Permission model mirrors OpenCode's built-in compaction/title/summary
+ * agents: deny all.
+ */
+const COUNCIL_MASTER_PROMPT = `You are the council master responsible for \
+synthesizing responses from multiple AI models.
+
+**Role**: Review all councillor responses and create the optimal final answer.
+
+**Process**:
+1. Read the original user prompt
+2. Review each councillor's response carefully
+3. Identify the best elements from each response
+4. Resolve contradictions between councillors
+5. Synthesize a final, optimal response
+
+**Behavior**:
+- Each councillor had read-only access to the codebase — their responses may \
+  reference specific files, functions, and line numbers
+- Clearly explain your reasoning for the chosen approach
+- Be transparent about trade-offs
+- Credit specific insights from individual councillors by name
+- If councillors disagree, explain your resolution
+- Don't just average responses — choose and improve
+
+**Output**:
+- Present the synthesized solution
+- Review, retain, and include relevant code examples, diagrams, and concrete \
+  details from councillor responses
+- Explain your synthesis reasoning
+- Note any remaining uncertainties
+- Acknowledge if consensus was impossible`;
+
+export function createCouncilMasterAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  const prompt = resolvePrompt(
+    COUNCIL_MASTER_PROMPT,
+    customPrompt,
+    customAppendPrompt,
+  );
+
+  return {
+    name: 'council-master',
+    description:
+      'Council synthesis engine. Receives councillor responses and produces the final answer. No tools, pure text synthesis.',
+    config: {
+      model,
+      temperature: 0.1,
+      prompt,
+      // Deny everything — pure synthesis, no tools needed.
+      // Explicit question:deny prevents applyDefaultPermissions from
+      // re-enabling it (it only preserves an existing 'deny' value).
+      permission: {
+        '*': 'deny',
+        question: 'deny',
+      },
+    },
+  };
+}

+ 131 - 0
src/agents/council.ts

@@ -0,0 +1,131 @@
+import { shortModelLabel } from '../utils/session';
+import { type AgentDefinition, resolvePrompt } from './orchestrator';
+
+// NOTE: Councillor and master system prompts live in their respective agent
+// factories (councillor.ts, council-master.ts). The format functions below
+// only structure the USER message content — the agent factory provides the
+// system prompt. This avoids duplicate system prompts (Oracle finding #1/#2).
+
+const COUNCIL_AGENT_PROMPT = `You are the Council agent — a multi-LLM \
+orchestration system that runs consensus across multiple models.
+
+**Tool**: You have access to the \`council_session\` tool.
+
+**When to use**:
+- When invoked by a user with a request
+- When you want multiple expert opinions on a complex problem
+- When higher confidence is needed through model consensus
+
+**Usage**:
+1. Call the \`council_session\` tool with the user's prompt
+2. Optionally specify a preset (default: "default")
+3. Receive the synthesized response from the council master
+4. Present the result to the user
+
+**Behavior**:
+- Delegate requests directly to council_session
+- Don't pre-analyze or filter the prompt
+- Present the synthesized result verbatim — do not re-summarize or condense
+- Briefly explain the consensus if requested`;
+
+export function createCouncilAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  const prompt = resolvePrompt(
+    COUNCIL_AGENT_PROMPT,
+    customPrompt,
+    customAppendPrompt,
+  );
+
+  const definition: AgentDefinition = {
+    name: 'council',
+    description:
+      'Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs',
+    config: {
+      temperature: 0.1,
+      prompt,
+    },
+  };
+
+  // Council's model comes from config override or is resolved at
+  // runtime; only set if a non-empty string is provided.
+  if (model) {
+    definition.config.model = model;
+  }
+
+  return definition;
+}
+
+/**
+ * Build the prompt for a specific councillor session.
+ *
+ * Returns the raw user prompt — the agent factory (councillor.ts) provides
+ * the system prompt with tool-aware instructions. No duplication.
+ *
+ * If a per-councillor prompt override is provided, it is prepended as
+ * role/guidance context before the user's question.
+ */
+export function formatCouncillorPrompt(
+  userPrompt: string,
+  councillorPrompt?: string,
+): string {
+  if (!councillorPrompt) return userPrompt;
+  return `${councillorPrompt}\n\n---\n\n${userPrompt}`;
+}
+
+/**
+ * Build the synthesis prompt for the council master.
+ *
+ * Formats councillor results as structured data — the agent factory
+ * (council-master.ts) provides the system prompt with synthesis instructions.
+ * Returns a special prompt when all councillors failed to produce output.
+ *
+ * @param masterPrompt - Optional per-master guidance appended to the synthesis.
+ */
+export function formatMasterSynthesisPrompt(
+  originalPrompt: string,
+  councillorResults: Array<{
+    name: string;
+    model: string;
+    status: string;
+    result?: string;
+    error?: string;
+  }>,
+  masterPrompt?: string,
+): string {
+  const completedWithResults = councillorResults.filter(
+    (cr) => cr.status === 'completed' && cr.result,
+  );
+
+  const councillorSection = completedWithResults
+    .map((cr) => {
+      const shortModel = shortModelLabel(cr.model);
+      return `**${cr.name}** (${shortModel}):\n${cr.result}`;
+    })
+    .join('\n\n');
+
+  const failedSection = councillorResults
+    .filter((cr) => cr.status !== 'completed')
+    .map((cr) => `**${cr.name}**: ${cr.status} — ${cr.error ?? 'Unknown'}`)
+    .join('\n');
+
+  if (completedWithResults.length === 0) {
+    return `---\n\n**Original Prompt**:\n${originalPrompt}\n\n---\n\n**Councillor Responses**:\nAll councillors failed to produce output. Please generate a response based on the original prompt alone.`;
+  }
+
+  let prompt = `---\n\n**Original Prompt**:\n${originalPrompt}\n\n---\n\n**Councillor Responses**:\n${councillorSection}`;
+
+  if (failedSection) {
+    prompt += `\n\n---\n\n**Failed/Timed-out Councillors**:\n${failedSection}`;
+  }
+
+  prompt += '\n\n---\n\nSynthesize the optimal response based on the above.';
+
+  if (masterPrompt) {
+    prompt += `\n\n---\n\n**Master Guidance**:\n${masterPrompt}`;
+  }
+
+  return prompt;
+}

+ 100 - 0
src/agents/councillor.test.ts

@@ -0,0 +1,100 @@
+import { describe, expect, test } from 'bun:test';
+import { createCouncillorAgent } from './councillor';
+
+describe('createCouncillorAgent', () => {
+  test('creates agent with correct name', () => {
+    const agent = createCouncillorAgent('test-model');
+    expect(agent.name).toBe('councillor');
+  });
+
+  test('creates agent with correct description', () => {
+    const agent = createCouncillorAgent('test-model');
+    expect(agent.description).toContain('Read-only council advisor');
+  });
+
+  test('sets model from argument', () => {
+    const agent = createCouncillorAgent('custom-model');
+    expect(agent.config.model).toBe('custom-model');
+  });
+
+  test('sets temperature to 0.2', () => {
+    const agent = createCouncillorAgent('test-model');
+    expect(agent.config.temperature).toBe(0.2);
+  });
+
+  test('sets default prompt when no custom prompts provided', () => {
+    const agent = createCouncillorAgent('test-model');
+    expect(agent.config.prompt).toContain(
+      'councillor in a multi-model council',
+    );
+  });
+
+  test('uses custom prompt when provided', () => {
+    const customPrompt = 'You are a custom advisor.';
+    const agent = createCouncillorAgent('test-model', customPrompt);
+    expect(agent.config.prompt).toBe(customPrompt);
+    expect(agent.config.prompt).not.toContain('multi-model council');
+  });
+
+  test('appends custom append prompt', () => {
+    const customAppendPrompt = 'Additional instructions here.';
+    const agent = createCouncillorAgent(
+      'test-model',
+      undefined,
+      customAppendPrompt,
+    );
+    expect(agent.config.prompt).toContain('multi-model council');
+    expect(agent.config.prompt).toContain(customAppendPrompt);
+    expect(agent.config.prompt).toContain('Additional instructions here.');
+  });
+
+  test('custom prompt takes priority over append prompt', () => {
+    const customPrompt = 'Custom prompt only.';
+    const customAppendPrompt = 'Should be ignored.';
+    const agent = createCouncillorAgent(
+      'test-model',
+      customPrompt,
+      customAppendPrompt,
+    );
+    expect(agent.config.prompt).toBe(customPrompt);
+    expect(agent.config.prompt).not.toContain(customAppendPrompt);
+  });
+});
+
+describe('councillor permissions', () => {
+  test('denies all by default with wildcard', () => {
+    const agent = createCouncillorAgent('test-model');
+    expect(agent.config.permission).toBeDefined();
+    expect((agent.config.permission as Record<string, string>)['*']).toBe(
+      'deny',
+    );
+  });
+
+  test('denies question explicitly', () => {
+    const agent = createCouncillorAgent('test-model');
+    expect((agent.config.permission as Record<string, string>).question).toBe(
+      'deny',
+    );
+  });
+
+  test('allows read-only tools', () => {
+    const agent = createCouncillorAgent('test-model');
+    const permission = agent.config.permission as Record<string, string>;
+    expect(permission.read).toBe('allow');
+    expect(permission.glob).toBe('allow');
+    expect(permission.grep).toBe('allow');
+  });
+
+  test('allows lsp and list tools', () => {
+    const agent = createCouncillorAgent('test-model');
+    const permission = agent.config.permission as Record<string, string>;
+    expect(permission.lsp).toBe('allow');
+    expect(permission.list).toBe('allow');
+  });
+
+  test('has exactly 8 permission entries', () => {
+    const agent = createCouncillorAgent('test-model');
+    const permission = agent.config.permission as Record<string, string>;
+    expect(Object.keys(permission)).toHaveLength(8);
+  });
+});

+ 82 - 0
src/agents/councillor.ts

@@ -0,0 +1,82 @@
+import { type AgentDefinition, resolvePrompt } from './orchestrator';
+
+/**
+ * Councillor agent — a read-only advisor in the multi-LLM council.
+ *
+ * Councillors are spawned by CouncilManager as agent sessions (visible in
+ * tmux/UI). They have read-only access to the codebase via tools but CANNOT
+ * modify files, run shell commands, or spawn subagents.
+ *
+ * Permission model mirrors OpenCode's built-in `explore` agent:
+ * deny all, then selectively allow read-only tools.
+ *
+ * The per-councillor model is overridden at session creation time via the
+ * `model` field in the prompt body — the agent factory's default model is
+ * just a fallback.
+ */
+const COUNCILLOR_PROMPT = `You are a councillor in a multi-model council.
+
+**Role**: Provide your best independent analysis and solution to the given \
+problem.
+
+**Capabilities**: You have read-only access to the codebase. You can:
+- Read files (read)
+- Search by name patterns (glob)
+- Search by content (grep)
+- Query language server (lsp_diagnostics, lsp_goto_definition, lsp_find_references)
+- Search code patterns (ast_grep_search)
+- Search external docs (if MCPs are configured for this agent)
+
+You CANNOT edit files, write files, run shell commands, or delegate to \
+other agents. You are an advisor, not an implementer.
+
+**Behavior**:
+- **Examine the codebase** before answering — your read access is what makes \
+  council valuable. Don't guess at code you can see.
+- Analyze the problem thoroughly
+- Provide a complete, well-reasoned response
+- Focus on the quality and correctness of your solution
+- Be direct and concise
+- Don't be influenced by what other councillors might say — you won't see \
+  their responses
+
+**Output**:
+- Give your honest assessment
+- Reference specific files and line numbers when relevant
+- Include relevant reasoning
+- State any assumptions clearly
+- Note any uncertainties`;
+
+export function createCouncillorAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  const prompt = resolvePrompt(
+    COUNCILLOR_PROMPT,
+    customPrompt,
+    customAppendPrompt,
+  );
+
+  return {
+    name: 'councillor',
+    description:
+      'Read-only council advisor. Examines codebase and provides independent analysis. Spawned internally by the council system.',
+    config: {
+      model,
+      temperature: 0.2,
+      prompt,
+      // Mirror OpenCode's explore agent: deny all, then allow read-only tools
+      permission: {
+        '*': 'deny',
+        question: 'deny',
+        read: 'allow',
+        glob: 'allow',
+        grep: 'allow',
+        lsp: 'allow',
+        list: 'allow',
+        codesearch: 'allow',
+      },
+    },
+  };
+}

+ 8 - 3
src/agents/index.test.ts

@@ -275,7 +275,12 @@ describe('agent classification', () => {
 
     // Subagents
     for (const name of SUBAGENT_NAMES) {
-      expect(configs[name].mode).toBe('subagent');
+      // Council is a dual-mode agent ("all"), rest are subagents
+      if (name === 'council') {
+        expect(configs[name].mode).toBe('all');
+      } else {
+        expect(configs[name].mode).toBe('subagent');
+      }
     }
   });
 });
@@ -292,9 +297,9 @@ describe('createAgents', () => {
     expect(names).toContain('fixer');
   });
 
-  test('creates exactly 6 agents (1 primary + 5 subagents)', () => {
+  test('creates exactly 9 agents (1 primary + 8 subagents)', () => {
     const agents = createAgents();
-    expect(agents.length).toBe(6);
+    expect(agents.length).toBe(9);
   });
 });
 

+ 22 - 2
src/agents/index.ts

@@ -10,6 +10,9 @@ import {
 } from '../config';
 import { getAgentMcpList } from '../config/agent-mcps';
 
+import { createCouncilAgent } from './council';
+import { createCouncilMasterAgent } from './council-master';
+import { createCouncillorAgent } from './councillor';
 import { createDesignerAgent } from './designer';
 import { createExplorerAgent } from './explorer';
 import { createFixerAgent } from './fixer';
@@ -56,6 +59,9 @@ function applyOverrides(
  * Apply default permissions to an agent.
  * Sets 'question' permission to 'allow' and includes skill permission presets.
  * If configuredSkills is provided, it honors that list instead of defaults.
+ *
+ * Note: If the agent already explicitly sets question to 'deny', that is
+ * respected (e.g. councillor and council-master should not ask questions).
  */
 function applyDefaultPermissions(
   agent: AgentDefinition,
@@ -72,9 +78,12 @@ function applyDefaultPermissions(
     configuredSkills,
   );
 
+  // Respect explicit deny on question (councillor, council-master)
+  const questionPerm = existing.question === 'deny' ? 'deny' : 'allow';
+
   agent.config.permission = {
     ...existing,
-    question: 'allow',
+    question: questionPerm,
     // Apply skill permissions as nested object under 'skill' key
     skill: {
       ...(typeof existing.skill === 'object' ? existing.skill : {}),
@@ -99,6 +108,9 @@ const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
   oracle: createOracleAgent,
   designer: createDesignerAgent,
   fixer: createFixerAgent,
+  council: createCouncilAgent,
+  councillor: createCouncillorAgent,
+  'council-master': createCouncilMasterAgent,
 };
 
 // Public API
@@ -191,7 +203,15 @@ export function getAgentConfigs(
       };
 
       // Apply classification-based visibility and mode
-      if (isSubagent(a.name)) {
+      if (a.name === 'council') {
+        // Council is callable both as a primary agent (user-facing)
+        // and as a subagent (orchestrator can delegate to it)
+        sdkConfig.mode = 'all';
+      } else if (a.name === 'councillor' || a.name === 'council-master') {
+        // Internal agents — subagent mode, hidden from @ autocomplete
+        sdkConfig.mode = 'subagent';
+        sdkConfig.hidden = true;
+      } else if (isSubagent(a.name)) {
         sdkConfig.mode = 'subagent';
       } else if (a.name === 'orchestrator') {
         sdkConfig.mode = 'primary';

+ 28 - 7
src/agents/orchestrator.ts

@@ -8,6 +8,21 @@ export interface AgentDefinition {
   _modelArray?: Array<{ id: string; variant?: string }>;
 }
 
+/**
+ * Resolve agent prompt from base/custom/append inputs.
+ * If customPrompt is provided, it replaces the base entirely.
+ * Otherwise, customAppendPrompt is appended to the base.
+ */
+export function resolvePrompt(
+  base: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): string {
+  if (customPrompt) return customPrompt;
+  if (customAppendPrompt) return `${base}\n\n${customAppendPrompt}`;
+  return base;
+}
+
 const ORCHESTRATOR_PROMPT = `<Role>
 You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
 </Role>
@@ -51,6 +66,14 @@ You are an AI coding orchestrator that optimizes for quality, speed, cost, and r
 - **Parallelization:** 3+ independent tasks → spawn multiple @fixers. 1-2 simple tasks → do yourself.
 - **Rule of thumb:** Explaining > doing? → yourself. Can split to parallel streams? → multiple @fixers.
 
+@council
+- Role: Multi-LLM consensus engine for high-confidence answers
+- Capabilities: Runs multiple models in parallel, synthesizes their responses via a council master
+- **Delegate when:** Critical decisions needing diverse model perspectives • High-stakes architectural choices where consensus reduces risk • Ambiguous problems where multi-model disagreement is informative • Security-sensitive design reviews
+- **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Single-model answer is sufficient • Routine implementation work
+- **Result handling:** Present the council's synthesized response verbatim. Do not re-summarize — the council master has already produced the final answer.
+- **Rule of thumb:** Need second/third opinions from different models? → @council. One good answer enough? → yourself.
+
 </Agents>
 
 <Workflow>
@@ -147,13 +170,11 @@ export function createOrchestratorAgent(
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = ORCHESTRATOR_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${ORCHESTRATOR_PROMPT}\n\n${customAppendPrompt}`;
-  }
+  const prompt = resolvePrompt(
+    ORCHESTRATOR_PROMPT,
+    customPrompt,
+    customAppendPrompt,
+  );
 
   const definition: AgentDefinition = {
     name: 'orchestrator',

+ 2 - 0
src/background/background-manager.test.ts

@@ -1340,6 +1340,7 @@ describe('BackgroundTaskManager', () => {
         'oracle',
         'designer',
         'fixer',
+        'council',
       ]);
 
       // Fixer -> empty (leaf node)
@@ -1399,6 +1400,7 @@ describe('BackgroundTaskManager', () => {
         'oracle',
         'designer',
         'fixer',
+        'council',
       ]);
     });
   });

+ 38 - 92
src/background/background-manager.ts

@@ -26,35 +26,16 @@ import {
   resolveAgentVariant,
 } from '../utils';
 import { log } from '../utils/logger';
-
-type PromptBody = {
-  messageID?: string;
-  model?: { providerID: string; modelID: string };
-  agent?: string;
-  noReply?: boolean;
-  system?: string;
-  tools?: { [key: string]: boolean };
-  parts: Array<{ type: 'text'; text: string }>;
-  variant?: string;
-};
+import {
+  extractSessionResult,
+  type PromptBody,
+  parseModelReference,
+  promptWithTimeout,
+} from '../utils/session';
+import { SubagentDepthTracker } from './subagent-depth';
 
 type OpencodeClient = PluginInput['client'];
 
-function parseModelReference(model: string): {
-  providerID: string;
-  modelID: string;
-} | null {
-  const slashIndex = model.indexOf('/');
-  if (slashIndex <= 0 || slashIndex >= model.length - 1) {
-    return null;
-  }
-
-  return {
-    providerID: model.slice(0, slashIndex),
-    modelID: model.slice(slashIndex + 1),
-  };
-}
-
 /**
  * Represents a background task running in an isolated session.
  * Tasks are tracked from creation through completion or failure.
@@ -99,6 +80,7 @@ export class BackgroundTaskManager {
   private tasksBySessionId = new Map<string, string>();
   // Track which agent type owns each session for delegation permission checks
   private agentBySessionId = new Map<string, string>();
+  private depthTracker: SubagentDepthTracker;
   private client: OpencodeClient;
   private directory: string;
   private tmuxEnabled: boolean;
@@ -129,6 +111,7 @@ export class BackgroundTaskManager {
       maxConcurrentStarts: 10,
     };
     this.maxConcurrentStarts = this.backgroundConfig.maxConcurrentStarts;
+    this.depthTracker = new SubagentDepthTracker();
   }
 
   /**
@@ -263,46 +246,6 @@ export class BackgroundTaskManager {
     return chain;
   }
 
-  private async promptWithTimeout(
-    args: Parameters<OpencodeClient['session']['prompt']>[0],
-    timeoutMs: number,
-  ): Promise<void> {
-    // No timeout when fallback disabled (timeoutMs = 0)
-    if (timeoutMs <= 0) {
-      await this.client.session.prompt(args);
-      return;
-    }
-
-    const sessionId = args.path.id;
-    let timer: ReturnType<typeof setTimeout> | undefined;
-
-    try {
-      // Attach a no-op .catch() so that when the timeout fires and
-      // session.abort() causes the prompt to reject after the race has
-      // already settled, the late rejection does not become unhandled
-      // (which would crash the process in Node ≥15 / Bun).
-      const promptPromise = this.client.session.prompt(args);
-      promptPromise.catch(() => {});
-
-      await Promise.race([
-        promptPromise,
-        new Promise<never>((_, reject) => {
-          timer = setTimeout(() => {
-            // Abort the running prompt so the session is no longer busy.
-            // Without this, session.prompt() continues running server-side
-            // and blocks subsequent fallback attempts on the same session.
-            this.client.session
-              .abort({ path: { id: sessionId } })
-              .catch(() => {});
-            reject(new Error(`Prompt timed out after ${timeoutMs}ms`));
-          }, timeoutMs);
-        }),
-      ]);
-    } finally {
-      clearTimeout(timer);
-    }
-  }
-
   /**
    * Calculate tool permissions for a spawned agent based on its own delegation rules.
    * Agents that cannot delegate (leaf nodes) get delegation tools disabled entirely,
@@ -343,6 +286,18 @@ export class BackgroundTaskManager {
     }
 
     try {
+      // Check subagent spawn depth BEFORE creating session
+      const parentDepth = this.depthTracker.getDepth(task.parentSessionId);
+      if (parentDepth + 1 > this.depthTracker.maxDepth) {
+        log('[background-manager] spawn blocked: max depth exceeded', {
+          parentSessionId: task.parentSessionId,
+          parentDepth,
+          maxDepth: this.depthTracker.maxDepth,
+        });
+        this.completeTask(task, 'failed', 'Subagent depth exceeded');
+        return;
+      }
+
       // Create session
       const session = await this.client.session.create({
         body: {
@@ -362,6 +317,9 @@ export class BackgroundTaskManager {
       this.agentBySessionId.set(session.data.id, task.agent);
       task.status = 'running';
 
+      // Register depth after session creation succeeds
+      this.depthTracker.registerChild(task.parentSessionId, session.data.id);
+
       // Give TmuxSessionManager time to spawn the pane
       if (this.tmuxEnabled) {
         await new Promise((r) => setTimeout(r, 500));
@@ -417,7 +375,8 @@ export class BackgroundTaskManager {
             );
           }
 
-          await this.promptWithTimeout(
+          await promptWithTimeout(
+            this.client,
             {
               path: { id: sessionId },
               body,
@@ -527,6 +486,7 @@ export class BackgroundTaskManager {
       // Clean up session tracking
       this.tasksBySessionId.delete(sessionId);
       this.agentBySessionId.delete(sessionId);
+      this.depthTracker.cleanup(sessionId);
 
       // Resolve any waiting callers
       const resolver = this.completionResolvers.get(taskId);
@@ -548,33 +508,11 @@ export class BackgroundTaskManager {
     if (!task.sessionId) return;
 
     try {
-      const messagesResult = await this.client.session.messages({
-        path: { id: task.sessionId },
-      });
-      const messages = (messagesResult.data ?? []) as Array<{
-        info?: { role: string };
-        parts?: Array<{ type: string; text?: string }>;
-      }>;
-      const assistantMessages = messages.filter(
-        (m) => m.info?.role === 'assistant',
+      const responseText = await extractSessionResult(
+        this.client,
+        task.sessionId,
       );
 
-      const extractedContent: string[] = [];
-      for (const message of assistantMessages) {
-        for (const part of message.parts ?? []) {
-          if (
-            (part.type === 'text' || part.type === 'reasoning') &&
-            part.text
-          ) {
-            extractedContent.push(part.text);
-          }
-        }
-      }
-
-      const responseText = extractedContent
-        .filter((t) => t.length > 0)
-        .join('\n\n');
-
       if (responseText) {
         this.completeTask(task, 'completed', responseText);
       } else {
@@ -790,5 +728,13 @@ export class BackgroundTaskManager {
     this.tasks.clear();
     this.tasksBySessionId.clear();
     this.agentBySessionId.clear();
+    this.depthTracker.cleanupAll();
+  }
+
+  /**
+   * Get the depth tracker instance for use by other managers.
+   */
+  getDepthTracker(): SubagentDepthTracker {
+    return this.depthTracker;
   }
 }

+ 1 - 0
src/background/index.ts

@@ -3,4 +3,5 @@ export {
   BackgroundTaskManager,
   type LaunchOptions,
 } from './background-manager';
+export { SubagentDepthTracker } from './subagent-depth';
 export { TmuxSessionManager } from './tmux-session-manager';

+ 187 - 0
src/background/subagent-depth.test.ts

@@ -0,0 +1,187 @@
+import { describe, expect, test } from 'bun:test';
+import { SubagentDepthTracker } from './subagent-depth';
+
+describe('SubagentDepthTracker', () => {
+  describe('constructor', () => {
+    test('uses DEFAULT_MAX_SUBAGENT_DEPTH (3) by default', () => {
+      const tracker = new SubagentDepthTracker();
+      expect(tracker).toBeDefined();
+    });
+
+    test('accepts custom max depth', () => {
+      const tracker = new SubagentDepthTracker(5);
+      expect(tracker).toBeDefined();
+    });
+  });
+
+  describe('getDepth', () => {
+    test('returns 0 for untracked sessions (root sessions)', () => {
+      const tracker = new SubagentDepthTracker();
+      expect(tracker.getDepth('root-session')).toBe(0);
+      expect(tracker.getDepth('untracked-session')).toBe(0);
+    });
+
+    test('returns tracked depth for registered sessions', () => {
+      const tracker = new SubagentDepthTracker();
+      tracker.registerChild('root-session', 'child-session');
+      expect(tracker.getDepth('child-session')).toBe(1);
+    });
+  });
+
+  describe('registerChild', () => {
+    test('tracks depth correctly (parent=0, child=1, grandchild=2)', () => {
+      const tracker = new SubagentDepthTracker();
+
+      // Root has depth 0 (untracked)
+      expect(tracker.getDepth('root')).toBe(0);
+
+      // Child of root has depth 1
+      const allowed1 = tracker.registerChild('root', 'child1');
+      expect(allowed1).toBe(true);
+      expect(tracker.getDepth('child1')).toBe(1);
+
+      // Grandchild has depth 2
+      const allowed2 = tracker.registerChild('child1', 'grandchild');
+      expect(allowed2).toBe(true);
+      expect(tracker.getDepth('grandchild')).toBe(2);
+    });
+
+    test('returns false when max depth exceeded (depth 4 > max 3)', () => {
+      const tracker = new SubagentDepthTracker(3);
+
+      const root = 'root';
+      const child1 = 'child1';
+      const child2 = 'child2';
+      const child3 = 'child3';
+      const child4 = 'child4';
+
+      // Depth 1
+      expect(tracker.registerChild(root, child1)).toBe(true);
+
+      // Depth 2
+      expect(tracker.registerChild(child1, child2)).toBe(true);
+
+      // Depth 3 (max allowed)
+      expect(tracker.registerChild(child2, child3)).toBe(true);
+
+      // Depth 4 (exceeds max)
+      expect(tracker.registerChild(child3, child4)).toBe(false);
+    });
+
+    test('tracks across multiple branches independently', () => {
+      const tracker = new SubagentDepthTracker();
+
+      const root = 'root';
+      const branch1Child = 'branch1-child';
+      const branch2Child = 'branch2-child';
+      const branch1Grandchild = 'branch1-grandchild';
+      const branch2Grandchild = 'branch2-grandchild';
+
+      // Branch 1
+      tracker.registerChild(root, branch1Child);
+      tracker.registerChild(branch1Child, branch1Grandchild);
+
+      // Branch 2
+      tracker.registerChild(root, branch2Child);
+      tracker.registerChild(branch2Child, branch2Grandchild);
+
+      // Both branches track independently
+      expect(tracker.getDepth(branch1Child)).toBe(1);
+      expect(tracker.getDepth(branch2Child)).toBe(1);
+      expect(tracker.getDepth(branch1Grandchild)).toBe(2);
+      expect(tracker.getDepth(branch2Grandchild)).toBe(2);
+    });
+
+    test('does not re-register existing session', () => {
+      const tracker = new SubagentDepthTracker();
+
+      const root = 'root';
+      const child = 'child';
+
+      tracker.registerChild(root, child);
+      expect(tracker.getDepth(child)).toBe(1);
+
+      // Re-register should not change depth
+      tracker.registerChild(root, child);
+      expect(tracker.getDepth(child)).toBe(1);
+    });
+
+    test('updates depth if child is re-registered from different parent', () => {
+      const tracker = new SubagentDepthTracker();
+
+      const root = 'root';
+      const child1 = 'child1';
+      const child2 = 'child2';
+      const grandchild = 'grandchild';
+
+      // Register grandchild from child1 (depth 2)
+      tracker.registerChild(root, child1);
+      tracker.registerChild(child1, grandchild);
+      expect(tracker.getDepth(grandchild)).toBe(2);
+
+      // Re-register grandchild from child2 (depth 1)
+      tracker.registerChild(root, child2);
+      tracker.registerChild(child2, grandchild);
+      expect(tracker.getDepth(grandchild)).toBe(2);
+    });
+  });
+
+  describe('cleanup', () => {
+    test('removes a specific session', () => {
+      const tracker = new SubagentDepthTracker();
+
+      const root = 'root';
+      const child1 = 'child1';
+      const child2 = 'child2';
+
+      tracker.registerChild(root, child1);
+      tracker.registerChild(root, child2);
+
+      expect(tracker.getDepth(child1)).toBe(1);
+      expect(tracker.getDepth(child2)).toBe(1);
+
+      tracker.cleanup(child1);
+
+      expect(tracker.getDepth(child1)).toBe(0); // Back to default
+      expect(tracker.getDepth(child2)).toBe(1); // Still tracked
+    });
+
+    test('does not throw when cleaning up untracked session', () => {
+      const tracker = new SubagentDepthTracker();
+
+      expect(() => tracker.cleanup('untracked')).not.toThrow();
+    });
+  });
+
+  describe('cleanupAll', () => {
+    test('removes all sessions', () => {
+      const tracker = new SubagentDepthTracker();
+
+      const root = 'root';
+      const child1 = 'child1';
+      const child2 = 'child2';
+      const grandchild = 'grandchild';
+
+      tracker.registerChild(root, child1);
+      tracker.registerChild(root, child2);
+      tracker.registerChild(child1, grandchild);
+
+      expect(tracker.getDepth(child1)).toBe(1);
+      expect(tracker.getDepth(child2)).toBe(1);
+      expect(tracker.getDepth(grandchild)).toBe(2);
+
+      tracker.cleanupAll();
+
+      // All sessions back to default depth 0
+      expect(tracker.getDepth(child1)).toBe(0);
+      expect(tracker.getDepth(child2)).toBe(0);
+      expect(tracker.getDepth(grandchild)).toBe(0);
+    });
+
+    test('does not throw when called on empty tracker', () => {
+      const tracker = new SubagentDepthTracker();
+
+      expect(() => tracker.cleanupAll()).not.toThrow();
+    });
+  });
+});

+ 75 - 0
src/background/subagent-depth.ts

@@ -0,0 +1,75 @@
+import { DEFAULT_MAX_SUBAGENT_DEPTH } from '../config';
+import { log } from '../utils/logger';
+
+/**
+ * Tracks subagent spawn depth to prevent excessive nesting.
+ *
+ * Depth 0 = root session (user's main conversation)
+ * Depth 1 = agent spawned by root (e.g., explorer, council)
+ * Depth 2 = agent spawned by depth-1 agent (e.g., councillor spawned by council)
+ * Depth 3 = agent spawned by depth-2 agent (max depth by default)
+ *
+ * When max depth is exceeded, the spawn is blocked.
+ */
+export class SubagentDepthTracker {
+  private depthBySession = new Map<string, number>();
+  private readonly _maxDepth: number;
+
+  constructor(maxDepth: number = DEFAULT_MAX_SUBAGENT_DEPTH) {
+    this._maxDepth = maxDepth;
+  }
+
+  /** Maximum allowed depth. */
+  get maxDepth(): number {
+    return this._maxDepth;
+  }
+
+  /**
+   * Get the current depth of a session.
+   * Root sessions (not tracked) have depth 0.
+   */
+  getDepth(sessionId: string): number {
+    return this.depthBySession.get(sessionId) ?? 0;
+  }
+
+  /**
+   * Register a child session and check if the spawn is allowed.
+   * @returns true if allowed, false if max depth exceeded
+   */
+  registerChild(parentSessionId: string, childSessionId: string): boolean {
+    const parentDepth = this.getDepth(parentSessionId);
+    const childDepth = parentDepth + 1;
+
+    if (childDepth > this.maxDepth) {
+      log('[subagent-depth] spawn blocked: max depth exceeded', {
+        parentSessionId,
+        parentDepth,
+        childDepth,
+        maxDepth: this.maxDepth,
+      });
+      return false;
+    }
+
+    this.depthBySession.set(childSessionId, childDepth);
+    log('[subagent-depth] child registered', {
+      parentSessionId,
+      childSessionId,
+      childDepth,
+    });
+    return true;
+  }
+
+  /**
+   * Clean up session tracking when a session is deleted.
+   */
+  cleanup(sessionId: string): void {
+    this.depthBySession.delete(sessionId);
+  }
+
+  /**
+   * Clean up all tracking data.
+   */
+  cleanupAll(): void {
+    this.depthBySession.clear();
+  }
+}

+ 3 - 0
src/config/agent-mcps.ts

@@ -14,6 +14,9 @@ export const DEFAULT_AGENT_MCPS: Record<AgentName, string[]> = {
   librarian: ['websearch', 'context7', 'grep_app'],
   explorer: [],
   fixer: [],
+  council: [],
+  councillor: [],
+  'council-master': [],
 };
 
 /**

+ 30 - 1
src/config/constants.ts

@@ -10,6 +10,9 @@ export const SUBAGENT_NAMES = [
   'oracle',
   'designer',
   'fixer',
+  'council',
+  'councillor',
+  'council-master',
 ] as const;
 
 export const ORCHESTRATOR_NAME = 'orchestrator' as const;
@@ -25,13 +28,27 @@ export type AgentName = (typeof ALL_AGENT_NAMES)[number];
 // designer: can spawn explorer (for research during design)
 // explorer/librarian/oracle: cannot spawn any subagents (leaf nodes)
 // Unknown agent types not listed here default to explorer-only access
+// Which agents each agent type can spawn via background_task tool.
+// councillor and council-master are internal — only CouncilManager spawns them.
+export const ORCHESTRATABLE_AGENTS = [
+  'explorer',
+  'librarian',
+  'oracle',
+  'designer',
+  'fixer',
+  'council',
+] as const;
+
 export const SUBAGENT_DELEGATION_RULES: Record<AgentName, readonly string[]> = {
-  orchestrator: SUBAGENT_NAMES,
+  orchestrator: ORCHESTRATABLE_AGENTS,
   fixer: [],
   designer: [],
   explorer: [],
   librarian: [],
   oracle: [],
+  council: [],
+  councillor: [],
+  'council-master': [],
 };
 
 // Default models for each agent
@@ -43,6 +60,9 @@ export const DEFAULT_MODELS: Record<AgentName, string | undefined> = {
   explorer: 'openai/gpt-5.4-mini',
   designer: 'openai/gpt-5.4-mini',
   fixer: 'openai/gpt-5.4-mini',
+  council: 'openai/gpt-5.4-mini',
+  councillor: 'openai/gpt-5.4-mini',
+  'council-master': 'openai/gpt-5.4-mini',
 };
 
 // Polling configuration
@@ -55,5 +75,14 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
 export const MAX_POLL_TIME_MS = 5 * 60 * 1000; // 5 minutes
 export const FALLBACK_FAILOVER_TIMEOUT_MS = 15_000;
 
+// Subagent depth limits
+export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
+
+// Tmux pane spawn delay (ms) — gives TmuxSessionManager time to create pane
+export const TMUX_SPAWN_DELAY_MS = 500;
+
+// Stagger delay (ms) between parallel councillor launches to avoid tmux collisions
+export const COUNCILLOR_STAGGER_MS = 250;
+
 // Polling stability
 export const STABLE_POLLS_THRESHOLD = 3;

+ 464 - 0
src/config/council-schema.test.ts

@@ -0,0 +1,464 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  CouncilConfigSchema,
+  type CouncillorConfig,
+  CouncillorConfigSchema,
+  type CouncilMasterConfig,
+  CouncilMasterConfigSchema,
+  CouncilPresetSchema,
+  PresetMasterOverrideSchema,
+} from './council-schema';
+
+describe('CouncillorConfigSchema', () => {
+  test('validates config with model and optional variant', () => {
+    const goodConfig: CouncillorConfig = {
+      model: 'openai/gpt-5.4-mini',
+      variant: 'low',
+    };
+
+    const result = CouncillorConfigSchema.safeParse(goodConfig);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data).toEqual(goodConfig);
+    }
+  });
+
+  test('validates config with only required model field', () => {
+    const minimalConfig: CouncillorConfig = {
+      model: 'openai/gpt-5.4-mini',
+    };
+
+    const result = CouncillorConfigSchema.safeParse(minimalConfig);
+    expect(result.success).toBe(true);
+  });
+
+  test('rejects missing model', () => {
+    const badConfig = {
+      variant: 'low',
+    };
+
+    const result = CouncillorConfigSchema.safeParse(badConfig);
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects empty model string', () => {
+    const config = {
+      model: '',
+    };
+
+    const result = CouncillorConfigSchema.safeParse(config);
+    expect(result.success).toBe(false);
+  });
+
+  test('accepts optional prompt field', () => {
+    const config: CouncillorConfig = {
+      model: 'openai/gpt-5.4-mini',
+      prompt: 'Focus on security implications and edge cases.',
+    };
+
+    const result = CouncillorConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.prompt).toBe(
+        'Focus on security implications and edge cases.',
+      );
+    }
+  });
+
+  test('prompt is optional and defaults to undefined', () => {
+    const config: CouncillorConfig = {
+      model: 'openai/gpt-5.4-mini',
+    };
+
+    const result = CouncillorConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.prompt).toBeUndefined();
+    }
+  });
+});
+
+describe('CouncilMasterConfigSchema', () => {
+  test('validates good config', () => {
+    const goodConfig: CouncilMasterConfig = {
+      model: 'anthropic/claude-opus-4-6',
+      variant: 'high',
+    };
+
+    const result = CouncilMasterConfigSchema.safeParse(goodConfig);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data).toEqual(goodConfig);
+    }
+  });
+
+  test('validates config with only required model field', () => {
+    const minimalConfig: CouncilMasterConfig = {
+      model: 'anthropic/claude-opus-4-6',
+    };
+
+    const result = CouncilMasterConfigSchema.safeParse(minimalConfig);
+    expect(result.success).toBe(true);
+  });
+
+  test('rejects missing model', () => {
+    const badConfig = {
+      variant: 'high',
+    };
+
+    const result = CouncilMasterConfigSchema.safeParse(badConfig);
+    expect(result.success).toBe(false);
+  });
+
+  test('accepts optional prompt field', () => {
+    const config: CouncilMasterConfig = {
+      model: 'anthropic/claude-opus-4-6',
+      prompt: 'Prioritize correctness over creativity. When in doubt, flag it.',
+    };
+
+    const result = CouncilMasterConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.prompt).toBe(
+        'Prioritize correctness over creativity. When in doubt, flag it.',
+      );
+    }
+  });
+
+  test('prompt defaults to undefined when not provided', () => {
+    const config: CouncilMasterConfig = {
+      model: 'anthropic/claude-opus-4-6',
+    };
+
+    const result = CouncilMasterConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.prompt).toBeUndefined();
+    }
+  });
+});
+
+describe('CouncilPresetSchema', () => {
+  test('validates a named preset with multiple councillors', () => {
+    const raw = {
+      alpha: {
+        model: 'openai/gpt-5.4-mini',
+      },
+      beta: {
+        model: 'openai/gpt-5.3-codex',
+        variant: 'low',
+      },
+      gamma: {
+        model: 'google/gemini-3-pro',
+      },
+    };
+
+    const result = CouncilPresetSchema.safeParse(raw);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(Object.keys(result.data.councillors)).toEqual([
+        'alpha',
+        'beta',
+        'gamma',
+      ]);
+    }
+  });
+
+  test('accepts preset with single councillor', () => {
+    const raw = {
+      solo: {
+        model: 'openai/gpt-5.4-mini',
+      },
+    };
+
+    const result = CouncilPresetSchema.safeParse(raw);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(Object.keys(result.data.councillors)).toEqual(['solo']);
+    }
+  });
+
+  test('accepts empty preset (no councillors)', () => {
+    const raw = {};
+
+    const result = CouncilPresetSchema.safeParse(raw);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.councillors).toEqual({});
+    }
+  });
+
+  test('separates master key from councillors', () => {
+    const raw = {
+      master: { model: 'openai/gpt-5.4', prompt: 'Override prompt.' },
+      alpha: { model: 'openai/gpt-5.4-mini' },
+      beta: { model: 'google/gemini-3-pro' },
+    };
+
+    const result = CouncilPresetSchema.safeParse(raw);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(Object.keys(result.data.councillors)).toEqual(['alpha', 'beta']);
+      expect(result.data.master).toEqual({
+        model: 'openai/gpt-5.4',
+        prompt: 'Override prompt.',
+      });
+    }
+  });
+
+  test('preset without master key has no master override', () => {
+    const raw = {
+      alpha: { model: 'openai/gpt-5.4-mini' },
+    };
+
+    const result = CouncilPresetSchema.safeParse(raw);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(Object.keys(result.data.councillors)).toEqual(['alpha']);
+      expect(result.data.master).toBeUndefined();
+    }
+  });
+
+  test('rejects invalid master override in preset', () => {
+    const raw = {
+      master: { model: 'invalid-no-slash' },
+      alpha: { model: 'openai/gpt-5.4-mini' },
+    };
+
+    const result = CouncilPresetSchema.safeParse(raw);
+    expect(result.success).toBe(false);
+  });
+});
+
+describe('CouncilConfigSchema', () => {
+  test('validates complete config with defaults', () => {
+    const config = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+          beta: { model: 'openai/gpt-5.3-codex' },
+          gamma: { model: 'google/gemini-3-pro' },
+        },
+      },
+    };
+
+    const result = CouncilConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+
+    if (result.success) {
+      // Check defaults are filled in
+      expect(result.data.master_timeout).toBe(300000);
+      expect(result.data.councillors_timeout).toBe(180000);
+      expect(result.data.default_preset).toBe('default');
+    }
+  });
+
+  test('fills in defaults for optional fields', () => {
+    const config = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+      presets: {
+        custom: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+        },
+      },
+      default_preset: 'custom',
+    };
+
+    const result = CouncilConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+
+    if (result.success) {
+      expect(result.data.master_timeout).toBe(300000);
+      expect(result.data.councillors_timeout).toBe(180000);
+      expect(result.data.default_preset).toBe('custom');
+    }
+  });
+
+  test('rejects missing master config', () => {
+    const badConfig = {
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+        },
+      },
+    };
+
+    const result = CouncilConfigSchema.safeParse(badConfig);
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects missing presets', () => {
+    const badConfig = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+    };
+
+    const result = CouncilConfigSchema.safeParse(badConfig);
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects invalid master_timeout (negative)', () => {
+    const badConfig = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+        },
+      },
+      master_timeout: -1000,
+    };
+
+    const result = CouncilConfigSchema.safeParse(badConfig);
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects invalid councillors_timeout (negative)', () => {
+    const badConfig = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+        },
+      },
+      councillors_timeout: -1000,
+    };
+
+    const result = CouncilConfigSchema.safeParse(badConfig);
+    expect(result.success).toBe(false);
+  });
+
+  test('accepts zero timeout values (no timeout)', () => {
+    const config = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+        },
+      },
+      master_timeout: 0,
+      councillors_timeout: 0,
+    };
+
+    const result = CouncilConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+
+    if (result.success) {
+      expect(result.data.master_timeout).toBe(0);
+      expect(result.data.councillors_timeout).toBe(0);
+    }
+  });
+
+  test('accepts multiple presets', () => {
+    const config = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+      },
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+          beta: { model: 'openai/gpt-5.3-codex' },
+        },
+        fast: {
+          quick: { model: 'openai/gpt-5.4-mini', variant: 'low' },
+        },
+        thorough: {
+          detailed1: {
+            model: 'anthropic/claude-opus-4-6',
+            prompt: 'Provide detailed analysis with citations.',
+          },
+          detailed2: { model: 'openai/gpt-5.4' },
+        },
+      },
+    };
+
+    const result = CouncilConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+
+    if (result.success) {
+      // Verify prompt is preserved (not silently stripped)
+      const thoroughPreset = result.data.presets.thorough;
+      expect(thoroughPreset.councillors.detailed1.prompt).toBe(
+        'Provide detailed analysis with citations.',
+      );
+      // Verify prompt is undefined when not set
+      expect(thoroughPreset.councillors.detailed2.prompt).toBeUndefined();
+    }
+  });
+
+  test('accepts master with prompt', () => {
+    const config = {
+      master: {
+        model: 'anthropic/claude-opus-4-6',
+        prompt: 'Prioritize correctness over creativity.',
+      },
+      presets: {
+        default: {
+          alpha: { model: 'openai/gpt-5.4-mini' },
+        },
+      },
+    };
+
+    const result = CouncilConfigSchema.safeParse(config);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.master.prompt).toBe(
+        'Prioritize correctness over creativity.',
+      );
+    }
+  });
+});
+
+describe('PresetMasterOverrideSchema', () => {
+  test('accepts empty override (all fields optional)', () => {
+    const result = PresetMasterOverrideSchema.safeParse({});
+    expect(result.success).toBe(true);
+  });
+
+  test('accepts full override with model, variant, and prompt', () => {
+    const override = {
+      model: 'openai/gpt-5.4',
+      variant: 'high',
+      prompt: 'Be extra thorough.',
+    };
+    const result = PresetMasterOverrideSchema.safeParse(override);
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.model).toBe('openai/gpt-5.4');
+      expect(result.data.variant).toBe('high');
+      expect(result.data.prompt).toBe('Be extra thorough.');
+    }
+  });
+
+  test('accepts partial override with only model', () => {
+    const result = PresetMasterOverrideSchema.safeParse({
+      model: 'anthropic/claude-sonnet-4-6',
+    });
+    expect(result.success).toBe(true);
+  });
+
+  test('accepts partial override with only prompt', () => {
+    const result = PresetMasterOverrideSchema.safeParse({
+      prompt: 'Focus on security.',
+    });
+    expect(result.success).toBe(true);
+  });
+
+  test('rejects invalid model format in override', () => {
+    const result = PresetMasterOverrideSchema.safeParse({
+      model: 'invalid-no-slash',
+    });
+    expect(result.success).toBe(false);
+  });
+});

+ 207 - 0
src/config/council-schema.ts

@@ -0,0 +1,207 @@
+import { z } from 'zod';
+
+/**
+ * Validates model IDs in "provider/model" format.
+ * Inlined here to avoid circular dependency with schema.ts.
+ */
+const ModelIdSchema = z
+  .string()
+  .regex(
+    /^[^/\s]+\/[^\s]+$/,
+    'Expected provider/model format (e.g. "openai/gpt-5.4-mini")',
+  );
+
+/**
+ * Configuration for a single councillor within a preset.
+ * Each councillor is an independent LLM that processes the same prompt.
+ *
+ * Councillors run as agent sessions with read-only codebase access
+ * (read, glob, grep, lsp, list). They can examine the codebase but
+ * cannot modify files or spawn subagents.
+ */
+export const CouncillorConfigSchema = z.object({
+  model: ModelIdSchema.describe(
+    'Model ID in provider/model format (e.g. "openai/gpt-5.4-mini")',
+  ),
+  variant: z.string().optional(),
+  prompt: z
+    .string()
+    .optional()
+    .describe(
+      'Optional role/guidance injected into the councillor user prompt',
+    ),
+});
+
+export type CouncillorConfig = z.infer<typeof CouncillorConfigSchema>;
+
+/**
+ * Per-preset master override. All fields are optional — any field
+ * provided here overrides the global `council.master` for this preset.
+ * Fields not provided fall back to the global master config.
+ */
+export const PresetMasterOverrideSchema = z.object({
+  model: ModelIdSchema.optional().describe(
+    'Override the master model for this preset',
+  ),
+  variant: z
+    .string()
+    .optional()
+    .describe('Override the master variant for this preset'),
+  prompt: z
+    .string()
+    .optional()
+    .describe('Override the master synthesis guidance for this preset'),
+});
+
+export type PresetMasterOverride = z.infer<typeof PresetMasterOverrideSchema>;
+
+/**
+ * A named preset grouping several councillors with an optional master override.
+ *
+ * The reserved key `"master"` provides per-preset overrides for the council
+ * master (model, variant, prompt). All other keys are treated as councillor
+ * names mapping to councillor configs.
+ *
+ * After parsing, the preset resolves to:
+ * `{ councillors: Record<string, CouncillorConfig>, master?: PresetMasterOverride }`
+ */
+export const CouncilPresetSchema = z
+  .record(z.string(), z.record(z.string(), z.unknown()))
+  .transform((entries, ctx) => {
+    const councillors: Record<string, CouncillorConfig> = {};
+    let masterOverride: PresetMasterOverride | undefined;
+
+    for (const [key, raw] of Object.entries(entries)) {
+      if (key === 'master') {
+        const parsed = PresetMasterOverrideSchema.safeParse(raw);
+        if (!parsed.success) {
+          ctx.addIssue(
+            `Invalid master override in preset: ${parsed.error.issues.map((i) => i.message).join(', ')}`,
+          );
+          return z.NEVER;
+        }
+        masterOverride = parsed.data;
+      } else {
+        const parsed = CouncillorConfigSchema.safeParse(raw);
+        if (!parsed.success) {
+          ctx.addIssue(
+            `Invalid councillor "${key}": ${parsed.error.issues.map((i) => i.message).join(', ')}`,
+          );
+          return z.NEVER;
+        }
+        councillors[key] = parsed.data;
+      }
+    }
+
+    return { councillors, master: masterOverride };
+  });
+
+export type CouncilPreset = z.infer<typeof CouncilPresetSchema>;
+
+/**
+ * Council Master configuration.
+ * The master receives all councillor responses and produces the final synthesis.
+ *
+ * Note: The master runs as a council-master agent session with zero
+ * permissions (deny all). Synthesis is a text-in/text-out operation —
+ * no tools or MCPs are needed.
+ */
+export const CouncilMasterConfigSchema = z.object({
+  model: ModelIdSchema.describe(
+    'Model ID for the council master (e.g. "anthropic/claude-opus-4-6")',
+  ),
+  variant: z.string().optional(),
+  prompt: z
+    .string()
+    .optional()
+    .describe(
+      'Optional role/guidance injected into the master synthesis prompt',
+    ),
+});
+
+export type CouncilMasterConfig = z.infer<typeof CouncilMasterConfigSchema>;
+
+/**
+ * Top-level council configuration.
+ *
+ * Example JSONC:
+ * ```jsonc
+ * {
+ *   "council": {
+ *     "master": { "model": "anthropic/claude-opus-4-6" },
+ *     "presets": {
+ *       "default": {
+ *         "alpha": { "model": "openai/gpt-5.4-mini" },
+ *         "beta":  { "model": "openai/gpt-5.3-codex" },
+ *         "gamma": { "model": "google/gemini-3-pro" }
+ *       }
+ *     },
+ *     "master_timeout": 300000,
+ *     "councillors_timeout": 180000
+ *   }
+ * }
+ * ```
+ */
+export const CouncilConfigSchema = z.object({
+  master: CouncilMasterConfigSchema,
+  presets: z.record(z.string(), CouncilPresetSchema),
+  master_timeout: z.number().min(0).default(300000),
+  councillors_timeout: z.number().min(0).default(180000),
+  default_preset: z.string().default('default'),
+  master_fallback: z
+    .array(ModelIdSchema)
+    .optional()
+    .transform((val) => {
+      if (!val) return val;
+      const unique = [...new Set(val)];
+      if (unique.length !== val.length) {
+        // Silently deduplicate — no validation error is raised for
+        // duplicate entries; duplicates are removed transparently.
+        return unique;
+      }
+      return val;
+    })
+    .describe(
+      'Fallback models for the council master. Tried in order if the primary model fails. ' +
+        'Example: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"]',
+    ),
+});
+
+export type CouncilConfig = z.infer<typeof CouncilConfigSchema>;
+
+/**
+ * A sensible default council configuration that users can copy into their
+ * opencode.jsonc. Provides a 3-councillor preset using common models.
+ *
+ * Users should replace models with ones they have access to.
+ *
+ * ```jsonc
+ * "council": DEFAULT_COUNCIL_CONFIG
+ * ```
+ */
+export const DEFAULT_COUNCIL_CONFIG: z.input<typeof CouncilConfigSchema> = {
+  master: { model: 'anthropic/claude-opus-4-6' },
+  presets: {
+    default: {
+      alpha: { model: 'openai/gpt-5.4-mini' },
+      beta: { model: 'openai/gpt-5.3-codex' },
+      gamma: { model: 'google/gemini-3-pro' },
+    },
+  },
+};
+
+/**
+ * Result of a council session.
+ */
+export interface CouncilResult {
+  success: boolean;
+  result?: string;
+  error?: string;
+  councillorResults: Array<{
+    name: string;
+    model: string;
+    status: 'completed' | 'failed' | 'timed_out';
+    result?: string;
+    error?: string;
+  }>;
+}

+ 1 - 0
src/config/index.ts

@@ -1,4 +1,5 @@
 export * from './constants';
+export * from './council-schema';
 export { loadAgentPrompt, loadPluginConfig } from './loader';
 export * from './schema';
 export { getAgentOverride } from './utils';

+ 3 - 1
src/config/schema.ts

@@ -1,4 +1,5 @@
 import { z } from 'zod';
+import { CouncilConfigSchema } from './council-schema';
 
 const FALLBACK_AGENT_NAMES = [
   'orchestrator',
@@ -18,7 +19,7 @@ const MANUAL_AGENT_NAMES = [
   'fixer',
 ] as const;
 
-const ProviderModelIdSchema = z
+export const ProviderModelIdSchema = z
   .string()
   .regex(
     /^[^/\s]+\/[^\s]+$/,
@@ -161,6 +162,7 @@ export const PluginConfigSchema = z.object({
   tmux: TmuxConfigSchema.optional(),
   background: BackgroundTaskConfigSchema.optional(),
   fallback: FailoverConfigSchema.optional(),
+  council: CouncilConfigSchema.optional(),
 });
 
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;

File diff suppressed because it is too large
+ 1262 - 0
src/council/council-manager.test.ts


+ 472 - 0
src/council/council-manager.ts

@@ -0,0 +1,472 @@
+/**
+ * Council Manager
+ *
+ * Orchestrates multi-LLM council sessions: launches councillors in
+ * parallel, collects results, then runs the council master for synthesis.
+ */
+
+import type { PluginInput } from '@opencode-ai/plugin';
+import {
+  formatCouncillorPrompt,
+  formatMasterSynthesisPrompt,
+} from '../agents/council';
+import type { SubagentDepthTracker } from '../background/subagent-depth';
+import type { PluginConfig } from '../config';
+import {
+  COUNCILLOR_STAGGER_MS,
+  TMUX_SPAWN_DELAY_MS,
+} from '../config/constants';
+import type {
+  CouncilConfig,
+  CouncillorConfig,
+  CouncilResult,
+  PresetMasterOverride,
+} from '../config/council-schema';
+import { log } from '../utils/logger';
+import {
+  extractSessionResult,
+  type PromptBody,
+  parseModelReference,
+  promptWithTimeout,
+  shortModelLabel,
+} from '../utils/session';
+
+type OpencodeClient = PluginInput['client'];
+
+// ---------------------------------------------------------------------------
+// CouncilManager
+// ---------------------------------------------------------------------------
+
+export class CouncilManager {
+  private client: OpencodeClient;
+  private directory: string;
+  private config?: PluginConfig;
+  private depthTracker?: SubagentDepthTracker;
+  private tmuxEnabled: boolean;
+
+  constructor(
+    ctx: PluginInput,
+    config?: PluginConfig,
+    depthTracker?: SubagentDepthTracker,
+    tmuxEnabled = false,
+  ) {
+    this.client = ctx.client;
+    this.directory = ctx.directory;
+    this.config = config;
+    this.depthTracker = depthTracker;
+    this.tmuxEnabled = tmuxEnabled;
+  }
+
+  /**
+   * Run a full council session.
+   *
+   * 1. Look up the preset
+   * 2. Launch all councillors in parallel
+   * 3. Collect results (respecting timeout)
+   * 4. Run master synthesis
+   * 5. Return combined result
+   */
+  async runCouncil(
+    prompt: string,
+    presetName: string | undefined,
+    parentSessionId: string,
+  ): Promise<CouncilResult> {
+    // Check depth limit before starting councillors
+    if (this.depthTracker) {
+      const parentDepth = this.depthTracker.getDepth(parentSessionId);
+      if (parentDepth + 1 > this.depthTracker.maxDepth) {
+        log('[council-manager] spawn blocked: max depth exceeded', {
+          parentSessionId,
+          parentDepth,
+          maxDepth: this.depthTracker.maxDepth,
+        });
+        return {
+          success: false,
+          error: 'Subagent depth exceeded',
+          councillorResults: [],
+        };
+      }
+    }
+
+    const councilConfig = this.config?.council;
+    if (!councilConfig) {
+      log('[council-manager] Council configuration not found');
+      return {
+        success: false,
+        error: 'Council not configured',
+        councillorResults: [],
+      };
+    }
+
+    const resolvedPreset =
+      presetName ?? councilConfig.default_preset ?? 'default';
+    const preset = councilConfig.presets[resolvedPreset];
+
+    if (!preset) {
+      log(`[council-manager] Preset "${resolvedPreset}" not found`);
+      return {
+        success: false,
+        error: `Preset "${resolvedPreset}" not found`,
+        councillorResults: [],
+      };
+    }
+
+    if (Object.keys(preset.councillors).length === 0) {
+      log(`[council-manager] Preset "${resolvedPreset}" has no councillors`);
+      return {
+        success: false,
+        error: `Preset "${resolvedPreset}" has no councillors configured`,
+        councillorResults: [],
+      };
+    }
+
+    const councillorsTimeout = councilConfig.councillors_timeout ?? 180000;
+    const masterTimeout = councilConfig.master_timeout ?? 300000;
+
+    const councillorCount = Object.keys(preset.councillors).length;
+
+    log(`[council-manager] Starting council with preset "${resolvedPreset}"`, {
+      councillors: Object.keys(preset.councillors),
+    });
+
+    // Notify parent session that council is starting
+    this.sendStartNotification(parentSessionId, councillorCount).catch(
+      (err) => {
+        log('[council-manager] Failed to send start notification', {
+          error: err instanceof Error ? err.message : String(err),
+        });
+      },
+    );
+
+    // Phase 1: Run councillors in parallel
+    const councillorResults = await this.runCouncillors(
+      prompt,
+      preset.councillors,
+      parentSessionId,
+      councillorsTimeout,
+    );
+
+    const completedCount = councillorResults.filter(
+      (r) => r.status === 'completed',
+    ).length;
+
+    log(
+      `[council-manager] Councillors completed: ${completedCount}/${councillorResults.length}`,
+    );
+
+    if (completedCount === 0) {
+      return {
+        success: false,
+        error: 'All councillors failed or timed out',
+        councillorResults,
+      };
+    }
+
+    // Phase 2: Master synthesis
+    const masterResult = await this.runMaster(
+      prompt,
+      councillorResults,
+      councilConfig,
+      parentSessionId,
+      masterTimeout,
+      preset.master,
+    );
+
+    if (!masterResult.success) {
+      log('[council-manager] Master failed', {
+        error: masterResult.error,
+      });
+
+      // Graceful degradation: return best single councillor result
+      const bestResult = councillorResults.find(
+        (r) => r.status === 'completed' && r.result,
+      );
+      return {
+        success: false,
+        error: masterResult.error ?? 'Council master failed',
+        result: bestResult?.result
+          ? `(Degraded — master failed, using ${bestResult.name}'s response)\n\n${bestResult.result}`
+          : undefined,
+        councillorResults,
+      };
+    }
+
+    log('[council-manager] Council completed successfully');
+
+    return {
+      success: true,
+      result: masterResult.result,
+      councillorResults,
+    };
+  }
+
+  // -------------------------------------------------------------------------
+  // Parent session notification
+  // -------------------------------------------------------------------------
+
+  /**
+   * Inject a start notification into the parent session so the user
+   * sees immediate feedback while councillors are spinning up.
+   */
+  private async sendStartNotification(
+    parentSessionId: string,
+    councillorCount: number,
+  ): Promise<void> {
+    const message = [
+      `⎔ Council starting — ${councillorCount} councillors launching — ctrl+x ↓ to watch`,
+      '',
+      '[system status: continue without acknowledging this notification]',
+    ].join('\n');
+    await this.client.session.prompt({
+      path: { id: parentSessionId },
+      body: {
+        noReply: true,
+        parts: [{ type: 'text', text: message }],
+      },
+    });
+  }
+
+  // -------------------------------------------------------------------------
+  // Shared session lifecycle (councillors + master both use this)
+  // -------------------------------------------------------------------------
+
+  /**
+   * Run a single agent session: create → register → prompt → extract → cleanup.
+   * Both councillors and the master follow this identical lifecycle.
+   */
+  private async runAgentSession(options: {
+    parentSessionId: string;
+    title: string;
+    agent: string;
+    model: string;
+    promptText: string;
+    variant?: string;
+    timeout: number;
+    includeReasoning?: boolean;
+  }): Promise<string> {
+    const modelRef = parseModelReference(options.model);
+    if (!modelRef) {
+      throw new Error(`Invalid model format: ${options.model}`);
+    }
+
+    let sessionId: string | undefined;
+
+    try {
+      const session = await this.client.session.create({
+        body: {
+          parentID: options.parentSessionId,
+          title: options.title,
+        },
+        query: { directory: this.directory },
+      });
+
+      if (!session.data?.id) {
+        throw new Error('Failed to create session');
+      }
+
+      sessionId = session.data.id;
+
+      if (this.depthTracker) {
+        const registered = this.depthTracker.registerChild(
+          options.parentSessionId,
+          sessionId,
+        );
+        if (!registered) {
+          throw new Error('Subagent depth exceeded');
+        }
+      }
+
+      if (this.tmuxEnabled) {
+        await new Promise((r) => setTimeout(r, TMUX_SPAWN_DELAY_MS));
+      }
+
+      const body: PromptBody = {
+        agent: options.agent,
+        model: modelRef,
+        tools: { background_task: false, task: false },
+        parts: [{ type: 'text', text: options.promptText }],
+      };
+
+      if (options.variant) {
+        body.variant = options.variant;
+      }
+
+      await promptWithTimeout(
+        this.client,
+        {
+          path: { id: sessionId },
+          body,
+          query: { directory: this.directory },
+        },
+        options.timeout,
+      );
+
+      const result = await extractSessionResult(this.client, sessionId, {
+        includeReasoning: options.includeReasoning,
+      });
+
+      return result || '(No output)';
+    } finally {
+      if (sessionId) {
+        this.client.session.abort({ path: { id: sessionId } }).catch(() => {});
+        if (this.depthTracker) {
+          this.depthTracker.cleanup(sessionId);
+        }
+      }
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // Phase 1: Councillors
+  // -------------------------------------------------------------------------
+
+  private async runCouncillors(
+    prompt: string,
+    councillors: Record<string, CouncillorConfig>,
+    parentSessionId: string,
+    timeout: number,
+  ): Promise<CouncilResult['councillorResults']> {
+    const entries = Object.entries(councillors);
+    const promises = entries.map(([name, config], index) =>
+      (async () => {
+        // Stagger launches to avoid tmux split-window collisions
+        if (index > 0) {
+          await new Promise((r) =>
+            setTimeout(r, index * COUNCILLOR_STAGGER_MS),
+          );
+        }
+
+        const modelLabel = shortModelLabel(config.model);
+
+        try {
+          const result = await this.runAgentSession({
+            parentSessionId,
+            title: `Council ${name} (${modelLabel})`,
+            agent: 'councillor',
+            model: config.model,
+            promptText: formatCouncillorPrompt(prompt, config.prompt),
+            variant: config.variant,
+            timeout,
+            includeReasoning: false,
+          });
+
+          return {
+            name,
+            model: config.model,
+            status: 'completed' as const,
+            result,
+          };
+        } catch (error) {
+          const msg = error instanceof Error ? error.message : String(error);
+
+          return {
+            name,
+            model: config.model,
+            status: msg.includes('timed out')
+              ? ('timed_out' as const)
+              : ('failed' as const),
+            error: `Councillor "${name}": ${msg}`,
+          };
+        }
+      })(),
+    );
+
+    const settled = await Promise.allSettled(promises);
+
+    return settled.map((result, index) => {
+      const [name, cfg] = entries[index];
+
+      if (result.status === 'fulfilled') {
+        return {
+          name,
+          model: cfg.model,
+          status: result.value.status,
+          result: result.value.result,
+          error: result.value.error,
+        };
+      }
+
+      return {
+        name,
+        model: cfg.model,
+        status: 'failed' as const,
+        error:
+          result.reason instanceof Error
+            ? result.reason.message
+            : String(result.reason),
+      };
+    });
+  }
+
+  // -------------------------------------------------------------------------
+  // Phase 2: Master Synthesis
+  // -------------------------------------------------------------------------
+
+  private async runMaster(
+    prompt: string,
+    councillorResults: CouncilResult['councillorResults'],
+    councilConfig: CouncilConfig,
+    parentSessionId: string,
+    timeout: number,
+    presetMasterOverride?: PresetMasterOverride,
+  ): Promise<{ success: boolean; result?: string; error?: string }> {
+    const masterConfig = councilConfig.master;
+    const fallbackModels = councilConfig.master_fallback ?? [];
+
+    // Merge per-preset master override with global config
+    const effectiveModel = presetMasterOverride?.model ?? masterConfig.model;
+    const effectiveVariant =
+      presetMasterOverride?.variant ?? masterConfig.variant;
+    const effectivePrompt = presetMasterOverride?.prompt ?? masterConfig.prompt;
+
+    // Build ordered list of models to try (primary first, then fallbacks)
+    const attemptModels = [effectiveModel, ...fallbackModels];
+
+    // Build synthesis prompt (data only — agent factory provides system prompt)
+    const synthesisPrompt = formatMasterSynthesisPrompt(
+      prompt,
+      councillorResults,
+      effectivePrompt,
+    );
+
+    // Try each model in order — fresh session per attempt prevents
+    // transcript contamination and respects session lifecycle.
+    const errors: string[] = [];
+
+    for (let i = 0; i < attemptModels.length; i++) {
+      const model = attemptModels[i];
+      const currentLabel = shortModelLabel(model);
+
+      try {
+        if (i > 0) {
+          log(
+            `[council-manager] master fallback ${i}/${attemptModels.length - 1}: ${currentLabel}`,
+          );
+        }
+
+        const result = await this.runAgentSession({
+          parentSessionId,
+          title: `Council Master (${currentLabel})`,
+          agent: 'council-master',
+          model,
+          promptText: synthesisPrompt,
+          variant: effectiveVariant,
+          timeout,
+        });
+
+        return { success: true, result };
+      } catch (error) {
+        const msg = error instanceof Error ? error.message : String(error);
+        errors.push(`${currentLabel}: ${msg}`);
+
+        log(`[council-manager] master model failed: ${currentLabel} — ${msg}`);
+      }
+    }
+
+    // All models failed
+    return {
+      success: false,
+      error: `All master models failed. ${errors.join(' | ')}`,
+    };
+  }
+}

+ 1 - 0
src/council/index.ts

@@ -0,0 +1 @@
+export { CouncilManager } from './council-manager';

+ 17 - 0
src/index.ts

@@ -3,6 +3,7 @@ import { createAgents, getAgentConfigs } from './agents';
 import { BackgroundTaskManager, TmuxSessionManager } from './background';
 import { loadPluginConfig, type TmuxConfig } from './config';
 import { parseList } from './config/agent-mcps';
+import { CouncilManager } from './council';
 import {
   createAutoUpdateCheckerHook,
   createChatHeadersHook,
@@ -17,6 +18,7 @@ import {
   ast_grep_replace,
   ast_grep_search,
   createBackgroundTools,
+  createCouncilTool,
   lsp_diagnostics,
   lsp_find_references,
   lsp_goto_definition,
@@ -94,6 +96,20 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     tmuxConfig,
     config,
   );
+
+  // Initialize council tools (only when council is configured)
+  const councilTools = config.council
+    ? createCouncilTool(
+        ctx,
+        new CouncilManager(
+          ctx,
+          config,
+          backgroundManager.getDepthTracker(),
+          tmuxConfig.enabled,
+        ),
+      )
+    : {};
+
   const mcps = createBuiltinMcps(config.disabled_mcps);
 
   // Initialize TmuxSessionManager to handle OpenCode's built-in Task tool sessions
@@ -133,6 +149,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     tool: {
       ...backgroundTools,
+      ...councilTools,
       lsp_goto_definition,
       lsp_find_references,
       lsp_diagnostics,

+ 519 - 0
src/tools/council.test.ts

@@ -0,0 +1,519 @@
+import { describe, expect, mock, test } from 'bun:test';
+import type { CouncilResult } from '../config/council-schema';
+import type { CouncilManager } from '../council/council-manager';
+import { createCouncilTool } from './council';
+
+function createMockPluginContext() {
+  return {
+    client: {
+      session: {
+        create: mock(async () => ({})),
+        messages: mock(async () => ({})),
+        prompt: mock(async () => ({})),
+        abort: mock(async () => ({})),
+      },
+    },
+    directory: '/tmp/test',
+  } as any;
+}
+
+// Test mocks can omit 'model' field — it's filled by the manager, not the test
+type TestCouncillorResult = {
+  name: string;
+  model?: string;
+  status: 'completed' | 'failed' | 'timed_out';
+  result?: string;
+  error?: string;
+};
+
+function createMockCouncilManager(
+  results: {
+    success?: boolean;
+    result?: string;
+    error?: string;
+    councillorResults?: TestCouncillorResult[];
+  } = {},
+) {
+  const councillorResults: CouncilResult['councillorResults'] = (
+    results.councillorResults ?? [
+      { name: 'alpha', status: 'completed', result: 'Alpha response' },
+      { name: 'beta', status: 'completed', result: 'Beta response' },
+    ]
+  ).map((cr) => ({
+    model: 'test/model',
+    ...cr,
+  }));
+
+  const mockManager = {
+    runCouncil: mock(async (): Promise<CouncilResult> => {
+      return {
+        success: results.success ?? true,
+        result: 'result' in results ? results.result : 'Synthesized response',
+        error: results.error,
+        councillorResults,
+      };
+    }),
+  } as unknown as CouncilManager;
+
+  return mockManager;
+}
+
+describe('council_session tool', () => {
+  describe('tool definition', () => {
+    test('creates council_session tool', () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      expect(tools).toBeDefined();
+      expect(tools.council_session).toBeDefined();
+      expect(tools.council_session.description).toBeDefined();
+      expect(tools.council_session.args).toBeDefined();
+    });
+
+    test('has correct tool description', () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      expect(tools.council_session.description).toContain('multi-LLM');
+      expect(tools.council_session.description).toContain('consensus');
+      expect(tools.council_session.description).toContain('councillors');
+    });
+
+    test('defines required prompt argument', () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      expect(tools.council_session.args.prompt).toBeDefined();
+      expect(tools.council_session.args).toHaveProperty('prompt');
+    });
+
+    test('defines optional preset argument', () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      expect(tools.council_session.args.preset).toBeDefined();
+      expect(tools.council_session.args).toHaveProperty('preset');
+    });
+  });
+
+  describe('execute', () => {
+    test('calls councilManager.runCouncil with correct arguments', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const _result = await tools.council_session.execute(
+        {
+          prompt: 'Test prompt',
+          preset: 'custom',
+        },
+        { sessionID: 'test-session-123' } as any,
+      );
+
+      expect(councilManager.runCouncil).toHaveBeenCalledTimes(1);
+      expect(councilManager.runCouncil).toHaveBeenCalledWith(
+        'Test prompt',
+        'custom',
+        'test-session-123',
+      );
+    });
+
+    test('uses default preset when not specified', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await tools.council_session.execute({ prompt: 'Test prompt' }, {
+        sessionID: 'test-session-123',
+      } as any);
+
+      expect(councilManager.runCouncil).toHaveBeenCalledWith(
+        'Test prompt',
+        undefined,
+        'test-session-123',
+      );
+    });
+
+    test('returns successful council result with output', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Synthesized answer from council',
+        councillorResults: [
+          {
+            name: 'alpha',
+            model: 'openai/gpt-5.4-mini',
+            status: 'completed',
+            result: 'Alpha says yes',
+          },
+          {
+            name: 'beta',
+            model: 'google/gemini-3-pro',
+            status: 'completed',
+            result: 'Beta says no',
+          },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute(
+        { prompt: 'Test prompt' },
+        { sessionID: 'test-session' } as any,
+      );
+
+      expect(result).toContain('Synthesized answer from council');
+      expect(result).toContain('Council: 2/2 councillors responded');
+    });
+
+    test('appends councillor summary to successful result', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Main answer',
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'A' },
+          { name: 'beta', status: 'completed', result: 'B' },
+          { name: 'gamma', status: 'completed', result: 'G' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(result).toContain('Main answer');
+      expect(result).toContain('Council: 3/3 councillors responded');
+      expect(result).toMatch(/---\s*\*Council:/);
+    });
+
+    test('handles mixed councillor success/failure in summary', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Answer',
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'A' },
+          { name: 'beta', status: 'failed', error: 'Error' },
+          { name: 'gamma', status: 'completed', result: 'G' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      // Summary should only count completed councillors
+      expect(result).toContain('Council: 2/3 councillors responded');
+    });
+
+    test('handles all councillors failing', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: false,
+        error: 'All councillors failed',
+        result: undefined,
+        councillorResults: [
+          { name: 'alpha', status: 'failed', error: 'Failed' },
+          { name: 'beta', status: 'timed_out', error: 'Timeout' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(result).toContain('Council session failed');
+      expect(result).toContain('All councillors failed');
+    });
+
+    test('handles council master failure with degraded result', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: false,
+        error: 'Master synthesis failed',
+        result:
+          "(Degraded — master failed, using alpha's response)\n\nBest answer",
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'Best answer' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(result).toContain('Degraded');
+      expect(result).toContain('Best answer');
+      expect(result).toContain('1/1 councillors responded');
+      expect(result).toContain('degraded');
+    });
+
+    test('handles case when result is undefined', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: undefined,
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'A' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      // Tool uses result ?? '(No output)', so it should show (No output)
+      // But the mock manager is returning undefined in the outer object
+      // The tool actually gets the result from the returned object
+      expect(result).toContain('Council: 1/1 councillors responded');
+    });
+
+    test('converts prompt to string', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await tools.council_session.execute({ prompt: 12345 as any }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(councilManager.runCouncil).toHaveBeenCalledWith(
+        '12345',
+        undefined,
+        'test',
+      );
+    });
+
+    test('handles preset as non-string (falls back to undefined)', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await tools.council_session.execute(
+        { preset: 123 as any, prompt: 'Test' },
+        { sessionID: 'test' } as any,
+      );
+
+      expect(councilManager.runCouncil).toHaveBeenCalledWith(
+        'Test',
+        undefined,
+        'test',
+      );
+    });
+  });
+
+  describe('error handling', () => {
+    test('throws error when toolContext is missing', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await expect(
+        tools.council_session.execute({ prompt: 'Test' }, undefined as any),
+      ).rejects.toThrow('Invalid toolContext');
+    });
+
+    test('throws error when toolContext is not object', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await expect(
+        tools.council_session.execute({ prompt: 'Test' }, 'invalid' as any),
+      ).rejects.toThrow('Invalid toolContext');
+    });
+
+    test('throws error when toolContext is missing sessionID', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await expect(
+        tools.council_session.execute({ prompt: 'Test' }, {} as any),
+      ).rejects.toThrow('Invalid toolContext');
+    });
+
+    test('handles CouncilManager throwing exception', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = {
+        runCouncil: mock(async () => {
+          throw new Error('Council manager crashed');
+        }),
+      } as unknown as CouncilManager;
+      const tools = createCouncilTool(ctx, councilManager);
+
+      await expect(
+        tools.council_session.execute({ prompt: 'Test' }, {
+          sessionID: 'test',
+        } as any),
+      ).rejects.toThrow('Council manager crashed');
+    });
+  });
+
+  describe('agent guard', () => {
+    test('allows council agent to invoke council session', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Synthesised answer',
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'A' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+        agent: 'council',
+      } as any);
+
+      expect(result).toContain('Synthesised answer');
+      expect(councilManager.runCouncil).toHaveBeenCalledTimes(1);
+    });
+
+    test('allows orchestrator agent to invoke council session', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Synthesised answer',
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'A' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+        agent: 'orchestrator',
+      } as any);
+
+      expect(result).toContain('Synthesised answer');
+    });
+
+    test('blocks disallowed agents from invoking council session', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager();
+      const tools = createCouncilTool(ctx, councilManager);
+
+      expect(
+        tools.council_session.execute({ prompt: 'Test' }, {
+          sessionID: 'test',
+          agent: 'explorer',
+        } as any),
+      ).rejects.toThrow(
+        'Council sessions can only be invoked by council or orchestrator agents',
+      );
+      expect(councilManager.runCouncil).not.toHaveBeenCalled();
+    });
+
+    test('allows undefined agent (backward compatible)', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Synthesised answer',
+        councillorResults: [
+          { name: 'alpha', status: 'completed', result: 'A' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(result).toContain('Synthesised answer');
+      expect(councilManager.runCouncil).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('edge cases', () => {
+    test('handles empty councillor results', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: false,
+        error: 'No councillors',
+        result: undefined,
+        councillorResults: [],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      // When success is false, tool returns error message without summary
+      expect(result).toContain('Council session failed');
+      expect(result).toContain('No councillors');
+    });
+
+    test('handles all councillors timed out', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: false,
+        error: 'All timed out',
+        result: undefined,
+        councillorResults: [
+          { name: 'alpha', status: 'timed_out', error: 'Timeout' },
+          { name: 'beta', status: 'timed_out', error: 'Timeout' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      // When success is false, tool returns error message without summary
+      expect(result).toContain('Council session failed');
+      expect(result).toContain('All timed out');
+    });
+
+    test('handles single successful councillor', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Single result',
+        councillorResults: [
+          { name: 'solo', status: 'completed', result: 'Solo answer' },
+        ],
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(result).toContain('Single result');
+      expect(result).toContain('Council: 1/1 councillors responded');
+    });
+
+    test('handles many councillors', async () => {
+      const ctx = createMockPluginContext();
+      const councilManager = createMockCouncilManager({
+        success: true,
+        result: 'Multi result',
+        councillorResults: Array.from({ length: 10 }, (_, i) => ({
+          name: `councillor${i}`,
+          status: 'completed',
+          result: `Response ${i}`,
+        })),
+      });
+      const tools = createCouncilTool(ctx, councilManager);
+
+      const result = await tools.council_session.execute({ prompt: 'Test' }, {
+        sessionID: 'test',
+      } as any);
+
+      expect(result).toContain('Council: 10/10 councillors responded');
+    });
+  });
+});

+ 110 - 0
src/tools/council.ts

@@ -0,0 +1,110 @@
+import {
+  type PluginInput,
+  type ToolDefinition,
+  tool,
+} from '@opencode-ai/plugin';
+import type { CouncilManager } from '../council/council-manager';
+import { shortModelLabel } from '../utils/session';
+
+const z = tool.schema;
+
+/**
+ * Formats the model composition string for the council footer.
+ * Shows short model labels per councillor: "α: gpt-5.4-mini, β: gemini-3-pro"
+ */
+function formatModelComposition(
+  councillorResults: Array<{ name: string; model: string }>,
+): string {
+  return councillorResults
+    .map((cr) => {
+      const shortModel = shortModelLabel(cr.model ?? '');
+      return `${cr.name}: ${shortModel}`;
+    })
+    .join(', ');
+}
+
+/**
+ * Creates the council_session tool for multi-LLM orchestration.
+ *
+ * This tool triggers a full council session: parallel councillors →
+ * master synthesis. Available to the council agent.
+ */
+export function createCouncilTool(
+  _ctx: PluginInput,
+  councilManager: CouncilManager,
+): Record<string, ToolDefinition> {
+  const council_session = tool({
+    description: `Launch a multi-LLM council session for consensus-based analysis.
+
+Sends the prompt to multiple models (councillors) in parallel, then a council master synthesizes the best response.
+
+Returns the synthesized result with councillor summary.`,
+    args: {
+      prompt: z.string().describe('The prompt to send to all councillors'),
+      preset: z
+        .string()
+        .optional()
+        .describe(
+          'Council preset to use (default: "default"). Must match a preset in the council config.',
+        ),
+    },
+    async execute(args, toolContext) {
+      if (
+        !toolContext ||
+        typeof toolContext !== 'object' ||
+        !('sessionID' in toolContext)
+      ) {
+        throw new Error('Invalid toolContext: missing sessionID');
+      }
+
+      // Guard: Only council and orchestrator agents can invoke council sessions.
+      // If agent is missing from context, allow through (backward compatible).
+      const allowedAgents = ['council', 'orchestrator'];
+      const callingAgent = (toolContext as { agent?: string }).agent;
+      if (callingAgent && !allowedAgents.includes(callingAgent)) {
+        throw new Error(
+          `Council sessions can only be invoked by council or orchestrator agents. Current agent: ${callingAgent}`,
+        );
+      }
+
+      const prompt = String(args.prompt);
+      const preset = typeof args.preset === 'string' ? args.preset : undefined;
+      const parentSessionId = (toolContext as { sessionID: string }).sessionID;
+
+      const result = await councilManager.runCouncil(
+        prompt,
+        preset,
+        parentSessionId,
+      );
+
+      if (!result.success) {
+        if (result.result) {
+          // Graceful degradation — master failed, return best councillor
+          const completed = result.councillorResults.filter(
+            (cr) => cr.status === 'completed',
+          ).length;
+          const total = result.councillorResults.length;
+          const composition = formatModelComposition(result.councillorResults);
+
+          return `${result.result}\n\n---\n*Council: ${completed}/${total} councillors responded (${composition}) — degraded*`;
+        }
+        return `Council session failed: ${result.error}`;
+      }
+
+      let output = result.result ?? '(No output)';
+
+      // Append councillor summary for transparency
+      const completed = result.councillorResults.filter(
+        (cr) => cr.status === 'completed',
+      ).length;
+      const total = result.councillorResults.length;
+      const composition = formatModelComposition(result.councillorResults);
+
+      output += `\n\n---\n*Council: ${completed}/${total} councillors responded (${composition})*`;
+
+      return output;
+    },
+  });
+
+  return { council_session };
+}

+ 1 - 0
src/tools/index.ts

@@ -1,6 +1,7 @@
 // AST-grep tools
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createBackgroundTools } from './background';
+export { createCouncilTool } from './council';
 export {
   lsp_diagnostics,
   lsp_find_references,

+ 1 - 0
src/utils/index.ts

@@ -3,5 +3,6 @@ export * from './env';
 export * from './internal-initiator';
 export { log } from './logger';
 export * from './polling';
+export * from './session';
 export * from './tmux';
 export { extractZip } from './zip-extractor';

+ 125 - 0
src/utils/session.ts

@@ -0,0 +1,125 @@
+/**
+ * Shared session utilities for council and background managers.
+ */
+
+import type { PluginInput } from '@opencode-ai/plugin';
+
+type OpencodeClient = PluginInput['client'];
+
+/**
+ * Extract the short model label from a "provider/model" string.
+ * E.g. "openai/gpt-5.4-mini" → "gpt-5.4-mini"
+ */
+export function shortModelLabel(model: string): string {
+  return model.split('/').pop() ?? model;
+}
+
+export type PromptBody = {
+  messageID?: string;
+  model?: { providerID: string; modelID: string };
+  agent?: string;
+  noReply?: boolean;
+  system?: string;
+  tools?: { [key: string]: boolean };
+  parts: Array<{ type: 'text'; text: string }>;
+  variant?: string;
+};
+
+/**
+ * Parse a model reference string into provider and model IDs.
+ * @param model - Model string in format "provider/model"
+ * @returns Object with providerID and modelID, or null if invalid
+ */
+export function parseModelReference(
+  model: string,
+): { providerID: string; modelID: string } | null {
+  const slashIndex = model.indexOf('/');
+  if (slashIndex <= 0 || slashIndex >= model.length - 1) {
+    return null;
+  }
+  return {
+    providerID: model.slice(0, slashIndex),
+    modelID: model.slice(slashIndex + 1),
+  };
+}
+
+/**
+ * Send a prompt to a session with optional timeout.
+ * If timeout is exceeded, the session is aborted and an error is thrown.
+ * @param client - OpenCode client instance
+ * @param args - Arguments for session.prompt()
+ * @param timeoutMs - Timeout in milliseconds (0 = no timeout)
+ * @throws Error if timeout is exceeded
+ */
+export async function promptWithTimeout(
+  client: OpencodeClient,
+  args: Parameters<OpencodeClient['session']['prompt']>[0],
+  timeoutMs: number,
+): Promise<void> {
+  if (timeoutMs <= 0) {
+    await client.session.prompt(args);
+    return;
+  }
+
+  const sessionId = args.path.id;
+  let timer: ReturnType<typeof setTimeout> | undefined;
+
+  try {
+    const promptPromise = client.session.prompt(args);
+    promptPromise.catch(() => {});
+
+    await Promise.race([
+      promptPromise,
+      new Promise<never>((_, reject) => {
+        timer = setTimeout(() => {
+          client.session.abort({ path: { id: sessionId } }).catch(() => {});
+          reject(new Error(`Prompt timed out after ${timeoutMs}ms`));
+        }, timeoutMs);
+      }),
+    ]);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+/**
+ * Extract the result text from a session.
+ * Collects all assistant messages and concatenates their text parts.
+ * @param client - OpenCode client instance
+ * @param sessionId - Session ID to extract from
+ * @param options - Optional: `includeReasoning` (default true) controls whether
+ *                  reasoning/chain-of-thought parts are included.
+ * @returns Concatenated text from all assistant messages
+ */
+export async function extractSessionResult(
+  client: OpencodeClient,
+  sessionId: string,
+  options?: { includeReasoning?: boolean },
+): Promise<string> {
+  const includeReasoning = options?.includeReasoning ?? true;
+
+  const messagesResult = await client.session.messages({
+    path: { id: sessionId },
+  });
+  const messages = (messagesResult.data ?? []) as Array<{
+    info?: { role: string };
+    parts?: Array<{ type: string; text?: string }>;
+  }>;
+  const assistantMessages = messages.filter(
+    (m) => m.info?.role === 'assistant',
+  );
+
+  const extractedContent: string[] = [];
+  for (const message of assistantMessages) {
+    for (const part of message.parts ?? []) {
+      const allowed = includeReasoning
+        ? part.type === 'text' || part.type === 'reasoning'
+        : part.type === 'text';
+      if (allowed && part.text) {
+        extractedContent.push(part.text);
+      }
+    }
+  }
+
+  return extractedContent.filter((t) => t.length > 0).join('\n\n');
+}