Kaynağa Gözat

feat: flatten council dispatch — dynamic councillor subagents with native panes

Replace the hidden council_session tool + CouncilManager dispatch with
dynamic councillor agents registered by buildCouncillorAgents(). The
orchestrator dispatches each councillor via task() at depth 1, giving
each councillor a native TUI pane.

Removes:
- CouncilManager (council/council-manager.ts) and council_session tool
  (tools/council.ts) — ~2400 lines of dead session-spawning plumbing.
- Dead formatCouncillorResults/formatCouncillorPrompt (agents/council.ts).
- Unused config fields: timeout, councillor_execution_mode,
  councillor_retries.

Key implementation details:
- Councillor agents are prefixed councillor-<name> to avoid collisions
  with OpenCode-reserved agent type names (e.g. 'alpha').
- Council agent is now synthesis-only; council_session denied to all.
- buildCouncillorAgents extracted to agents/council-agents.ts.
- Council Mode prompt block instructs orchestrator to dispatch
  councillors in parallel, collect responses, and delegate synthesis
  to the council agent.
- Timeout/retry guidance in prompt: proceed without a councillor after
  3 minutes; retry empty responses once.

Fixes #407
Michael Henke 1 ay önce
ebeveyn
işleme
9a2e9b139e

+ 2 - 4
CONTEXT.md

@@ -14,9 +14,9 @@ A glossary of the terms used in this project's domain. Definitions describe what
 - **Fixer** — Subagent for bounded implementation and execution.
 - **Observer** — Subagent for visual/media analysis (images, PDFs, diagrams). Disabled by default.
 - **Council** — A multi-LLM agent that runs several councillors and synthesizes their views.
-- **Councillor** — A read-only LLM advisor spawned by the council; hidden from @-mention autocomplete. Cannot be disabled.
+- **Councillor** — A read-only LLM advisor dispatched as a subagent by the orchestrator. Each councillor is registered as `councillor-<name>` from the council preset. Not hidden; visible in the TUI as panes.
 - **Agent mode** — SDK classification of an agent: `primary` (orchestrator), `subagent` (specialist), or `all` (council, both user-facing and delegatable).
-- **Protected agent** — An agent that cannot be disabled (orchestrator, councillor).
+- **Protected agent** — An agent that cannot be disabled (orchestrator).
 - **Custom agent** — A user-defined agent supplied via config, distinct from the built-ins.
 - **ACP agent** — An external agent defined via the Agent Communication Protocol, run through `acp_run`.
 - **Display name** — A user-assignable name shown in @-mentions; may differ from the internal agent name.
@@ -26,8 +26,6 @@ A glossary of the terms used in this project's domain. Definitions describe what
 
 - **Consensus** — The synthesized conclusion of a council run, rated `unanimous`, `majority`, or `split`.
 - **Council preset** — A named lineup of councillor configurations used for a council run. Plugin config uses `preset` for the selected agent-override set; council config uses `default_preset` for the selected councillor lineup — the `default_` prefix disambiguates the active selection from the preset list within the council sub-object.
-- **Councillor execution mode** — Whether councillors run `parallel` (default) or `serial`.
-- **Councillor retries** — The number of retries for a councillor that returns an empty response.
 
 ## Multiplexer & Sessions
 

+ 1 - 1
codemap.md

@@ -95,7 +95,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 - cmux-specific readiness, retry, orphan, and cleanup state lives under
   `src/multiplexer/cmux/`; the generic manager delegates cmux events so other
   multiplexer behavior remains on the upstream path.
-- `src/tools/council.ts` delegates into `src/council/`.
+- Council mode is implemented in `src/agents/`; the orchestrator dispatches councillors as subagents and the council agent synthesizes responses.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
 - `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.

+ 0 - 21
oh-my-opencode-slim.schema.json

@@ -1124,31 +1124,10 @@
             }
           }
         },
-        "timeout": {
-          "default": 180000,
-          "type": "number",
-          "minimum": 0
-        },
         "default_preset": {
           "default": "default",
           "type": "string"
         },
-        "councillor_execution_mode": {
-          "default": "parallel",
-          "description": "Execution mode for councillors. \"serial\" runs them one at a time (required for single-model systems). \"parallel\" runs them concurrently (default, faster for multi-model systems).",
-          "type": "string",
-          "enum": [
-            "parallel",
-            "serial"
-          ]
-        },
-        "councillor_retries": {
-          "default": 3,
-          "description": "Number of retry attempts for councillors that return empty responses (e.g. due to provider rate limiting). Default: 3 retries.",
-          "type": "integer",
-          "minimum": 0,
-          "maximum": 5
-        },
         "master": {
           "description": "DEPRECATED - ignored. Council agent synthesizes directly."
         }

+ 5 - 4
src/agents/codemap.md

@@ -19,8 +19,8 @@ Each agent is a **prompt-driven specialist** with a factory function that create
 | **designer** | `createDesignerAgent()` | UI/UX design, review, and implementation | Read/write (read, glob, grep, write, edit) | DEFAULT_MODELS.designer |
 | **fixer** | `createFixerAgent()` | Fast implementation specialist for bounded tasks | Read/write (read, glob, grep, write, edit) | DEFAULT_MODELS.fixer |
 | **observer** | `createObserverAgent()` | Visual analysis specialist (images, PDFs, diagrams) | Read-only (read, glob, grep, ast_grep_search) | DEFAULT_MODELS.observer |
-| **council** | `createCouncilAgent()` | Multi-LLM consensus engine for high-stakes decisions | Read-only + council_session tool | DEFAULT_MODELS.council |
-| **councillor** | `createCouncillorAgent()` | Read-only council advisor (internal use only) | Read-only (read, glob, grep, ast_grep_search) | Inherited from council |
+| **council** | `createCouncilAgent()` | Multi-LLM consensus synthesis from councillor responses | Read-only | DEFAULT_MODELS.council |
+| **councillor** | `createCouncillorAgent()` | Read-only council advisor; registered dynamically per preset seat as `councillor-<name>` | Read-only (read, glob, grep, ast_grep_search) | Inherited from council preset |
 
 ### Configuration System
 
@@ -72,7 +72,8 @@ const displayNameMap = new Map<string, string>();
 // ... populate from orchestrator and all subagents ...
 injectDisplayNames(orchestrator, displayNameMap);
 
-// 5. Return agents array [orchestrator, ...allSubAgents]
+// 5. Inject council-dispatch instructions when dynamic councillors exist
+// 6. Return agents array [orchestrator, ...allSubAgents]
 return [orchestrator, ...allSubAgents];
 ```
 
@@ -163,7 +164,7 @@ The orchestrator's system prompt contains dynamic routing rules that reference a
 - **@designer**: UI/UX design and polish
 - **@fixer**: Bounded implementation tasks
 - **@observer**: Visual/media analysis
-- **@council**: Multi-model consensus for high-stakes decisions
+- **@council**: Multi-model consensus synthesis (orchestrator dispatches councillors directly in flatten mode)
 
 These rules are filtered based on disabled agents and injected into the orchestrator's prompt at startup.
 

+ 46 - 0
src/agents/council-agents.ts

@@ -0,0 +1,46 @@
+import type { PluginConfig } from '../config';
+import { createCouncillorAgent } from './councillor';
+import type { AgentDefinition } from './orchestrator';
+
+const COUNCILLOR_AGENT_PREFIX = 'councillor-';
+
+/**
+ * Build dynamic councillor agents from council config presets.
+ * Each councillor gets its own agent (name + model) so the orchestrator
+ * can task() them with native panes at depth 1 using per-councillor models.
+ * Agent names are prefixed with `councillor-` because raw councillor names
+ * (e.g. "alpha") can collide with OpenCode-reserved agent type names.
+ */
+export function buildCouncillorAgents(
+  config: PluginConfig | undefined,
+  disabled: Set<string>,
+): AgentDefinition[] {
+  const council = config?.council;
+  if (!council) return [];
+
+  const presetName = council.default_preset ?? 'default';
+  const preset = council.presets[presetName];
+  if (!preset) return [];
+
+  const agents: AgentDefinition[] = [];
+  for (const [name, cfg] of Object.entries(preset)) {
+    if (name === 'master') continue;
+    if (disabled.has(name)) continue;
+
+    const agentName = `${COUNCILLOR_AGENT_PREFIX}${name}`;
+    const base = createCouncillorAgent(cfg.model, undefined, cfg.prompt);
+    agents.push({ ...base, name: agentName });
+  }
+
+  return agents;
+}
+
+/**
+ * Return the user-facing councillor seat name for a prefixed agent name.
+ * Inverse of the prefix applied in `buildCouncillorAgents`.
+ */
+export function getCouncillorSeatName(agentName: string): string {
+  return agentName.startsWith(COUNCILLOR_AGENT_PREFIX)
+    ? agentName.slice(COUNCILLOR_AGENT_PREFIX.length)
+    : agentName;
+}

+ 0 - 225
src/agents/council.test.ts

@@ -1,225 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { formatCouncillorPrompt, formatCouncillorResults } from './council';
-
-describe('formatCouncillorResults', () => {
-  const originalPrompt =
-    'What is the best way to implement a REST API in TypeScript?';
-
-  test('formats completed councillor results correctly', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'completed',
-        result: 'Use Express.js with TypeScript interfaces for type safety.',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'completed',
-        result:
-          'Consider Fastify for better performance and built-in type validation.',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**Original Prompt**:');
-    expect(formatted).toContain(originalPrompt);
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain('**beta** (gpt-5.6):');
-    expect(formatted).toContain(
-      'Use Express.js with TypeScript interfaces for type safety.',
-    );
-    expect(formatted).toContain(
-      'Consider Fastify for better performance and built-in type validation.',
-    );
-    expect(formatted).toContain('**Councillor Responses**:');
-    expect(formatted).toContain(
-      'You MUST follow the Synthesis Process steps before producing output',
-    );
-    expect(formatted).toContain(
-      'consensus confidence rating (unanimous, majority, or split)',
-    );
-    expect(formatted).not.toContain('**Failed/Timed-out Councillors**:');
-  });
-
-  test('includes failed councillors section when some fail', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'completed',
-        result: 'Use Express.js with TypeScript interfaces for type safety.',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'timed_out',
-        error: 'Request timed out after 180000ms',
-      },
-      {
-        name: 'gamma',
-        model: 'google/gemini-pro',
-        status: 'failed',
-        error: 'Provider returned empty response',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**Councillor Responses**:');
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain(
-      'Use Express.js with TypeScript interfaces for type safety.',
-    );
-    expect(formatted).toContain('**Failed/Timed-out Councillors**:');
-    expect(formatted).toContain(
-      '**beta**: timed_out - Request timed out after 180000ms',
-    );
-    expect(formatted).toContain(
-      '**gamma**: failed - Provider returned empty response',
-    );
-    expect(formatted).not.toContain('**beta** (gpt-5.6):');
-    expect(formatted).not.toContain('**gamma** (gemini-pro):');
-  });
-
-  test('returns fallback message when all councillors fail', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'timeout',
-        error: 'Request timed out',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'error',
-        error: 'Provider error',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**Original Prompt**:');
-    expect(formatted).toContain(originalPrompt);
-    expect(formatted).toContain('**Councillor Responses**:');
-    expect(formatted).toContain('All councillors failed to produce output:');
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain('**beta** (gpt-5.6):');
-    expect(formatted).toContain('Request timed out');
-    expect(formatted).toContain('Provider error');
-  });
-
-  test('handles councillors with result but completed status', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'completed',
-        result: 'Valid response',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'completed',
-        result: 'Another valid response',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain('Valid response');
-    expect(formatted).toContain('**beta** (gpt-5.6):');
-    expect(formatted).toContain('Another valid response');
-    expect(formatted).toContain('review each councillor response individually');
-  });
-});
-
-describe('formatCouncillorPrompt', () => {
-  const userPrompt = 'How do I implement async/await in TypeScript?';
-
-  test('returns user prompt unchanged when no councillor prompt is provided', () => {
-    const formatted = formatCouncillorPrompt(userPrompt);
-    expect(formatted).toBe(userPrompt);
-  });
-
-  test('prepends councillor prompt with separator when provided', () => {
-    const councillorPrompt =
-      'You are a TypeScript expert. Focus on practical examples.';
-    const formatted = formatCouncillorPrompt(userPrompt, councillorPrompt);
-
-    expect(formatted).toContain(councillorPrompt);
-    expect(formatted).toContain(userPrompt);
-    expect(formatted).toContain('---');
-    expect(formatted).toMatch(
-      new RegExp(
-        `^${councillorPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}\\n\\n---\\n\\n${userPrompt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
-      ),
-    );
-  });
-
-  test('handles multiline councillor prompt', () => {
-    const councillorPrompt =
-      'You are an expert.\nFocus on clarity.\nProvide code examples.';
-    const formatted = formatCouncillorPrompt(userPrompt, councillorPrompt);
-
-    expect(formatted).toContain(councillorPrompt);
-    expect(formatted).toContain(userPrompt);
-    expect(formatted).toContain('---');
-    expect(formatted).toMatch(
-      new RegExp(
-        `^${councillorPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}\\n\\n---\\n\\n${userPrompt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
-      ),
-    );
-  });
-
-  test('handles empty councillor prompt', () => {
-    const formatted = formatCouncillorPrompt(userPrompt, '');
-    expect(formatted).toBe(userPrompt);
-  });
-
-  test('handles multiline user prompt with councillor prompt', () => {
-    const councillorPrompt = 'You are an expert.';
-    const multilineUserPrompt = 'Line 1\nLine 2\nLine 3';
-    const formatted = formatCouncillorPrompt(
-      multilineUserPrompt,
-      councillorPrompt,
-    );
-
-    expect(formatted).toContain(councillorPrompt);
-    expect(formatted).toContain(multilineUserPrompt);
-    expect(formatted).toContain('---');
-    expect(formatted).toMatch(
-      new RegExp(
-        `^${councillorPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}\\n\\n---\\n\\n${multilineUserPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}$`,
-      ),
-    );
-  });
-});

+ 24 - 122
src/agents/council.ts

@@ -1,31 +1,19 @@
 import { READONLY_FILE_OPERATIONS_RULES } from '../config';
-import { shortModelLabel } from '../utils/session';
 import { type AgentDefinition, resolvePrompt } from './orchestrator';
 import { createReadOnlyAgentPermission } from './permissions';
 
 // NOTE: Councillor system prompts live in the councillor agent factory.
-// The format functions below only structure the USER message content - the
-// agent factory provides the system prompt.
+// The council agent synthesizes councillor responses passed by the orchestrator.
 
-const COUNCIL_AGENT_PROMPT = `You are the Council agent - a multi-LLM \
-orchestration system that runs consensus across multiple models.
+const COUNCIL_AGENT_PROMPT = `You are the Council agent - a \
+synthesizer for multi-model consensus.
 
-**Tool**: You have access to the \`council_session\` tool. You also have read-only codebase inspection tools. You do not have write, edit, shell, or subagent-delegation tools.
+**Role**: You receive raw responses from multiple councillors (different models) and synthesize them into a structured council report. You do NOT dispatch councillors yourself - the orchestrator handles dispatch and provides the councillor results.
 
-**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 (omit to use the configured default)
-3. Receive the councillor responses formatted for synthesis
-4. Follow the Synthesis Process below
-5. Present the result to the user
+**Tools**: You have read-only codebase inspection tools. You do not have write, edit, shell, or task tools.
 
 **Synthesis Process** (MANDATORY - follow in order):
-1. Read the original user prompt
+1. Read the original user prompt (provided in the context)
 2. Review each councillor's response individually - note each councillor's \
 key insight and unique contribution by name
 3. Identify agreements and contradictions between councillors
@@ -34,13 +22,9 @@ key insight and unique contribution by name
 6. Format output per the Required Output Format below
 
 **Behavior**:
-- Delegate requests directly to council_session
-- Don't pre-analyze or filter the prompt before calling council_session
 - Credit specific insights from individual councillors using their names
 - If councillors disagree, explain why you chose one approach over another
 - Do not omit per-councillor details from the final response
-- Do not collapse the output into only a final summary
-- Be transparent about trade-offs when different approaches have valid pros/cons
 - Don't just average responses - choose the best approach and improve upon it
 
 ${READONLY_FILE_OPERATIONS_RULES}
@@ -53,23 +37,23 @@ Provide the best synthesized answer. Integrate the strongest points from the \
 councillors, resolve disagreements, and give the user a clear final \
 recommendation or answer. Include relevant code examples and concrete details.
 
-## Councillor Details
-Include each councillor's response separately.
-
-Use each councillor name exactly as provided in the tool result.
-
-Format each councillor like:
-
-### <councillor name>
-<that councillor's response>
-
-If a councillor failed or timed out, include that status briefly.
+## Per-Councillor Details
+For each councillor, show:
+- Their key insight, idea, or recommendation (using their exact name)
+- Their confidence level (if expressed)
+- Notable points of agreement/disagreement with other councillors
 
 ## Council Summary
-Summarize where councillors agreed, where they disagreed, why you chose the \
-final answer, and any remaining uncertainty. Include a consensus confidence \
-rating: unanimous, majority, or split.`;
+- **Consensus Level**: unanimous | majority | split (pick one)
+- **Agreed Points**: what all councillors agreed on
+- **Disagreements**: where councillors differed and your resolution
+- **Recommended Action**: what to do next`;
 
+/**
+ * Create the council agent definition.
+ * The council agent synthesizes councillor responses into a structured report.
+ * It does not dispatch councillors — the orchestrator handles that.
+ */
 export function createCouncilAgent(
   model: string,
   customPrompt?: string,
@@ -81,103 +65,21 @@ export function createCouncilAgent(
     customAppendPrompt,
   );
 
-  const definition: AgentDefinition = {
+  return {
     name: 'council',
+    displayName: 'Council',
     description:
-      'Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs',
+      'Multi-model consensus agent that synthesizes viewpoints from council members to make informed decisions with higher confidence than single models',
     config: {
+      model,
       temperature: 0.1,
       prompt,
       permission: {
         ...createReadOnlyAgentPermission(),
-        council_session: 'allow',
       },
     },
   };
 
   // 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}`;
-}
-
-/**
- * Format councillor results for the council agent to synthesize.
- *
- * Formats councillor results as structured data that the council agent
- * (which called the tool) will receive as the tool response. The council
- * agent's system prompt contains synthesis instructions.
- * Returns a special message when all councillors failed to produce output.
- */
-export function formatCouncillorResults(
-  originalPrompt: string,
-  councillorResults: Array<{
-    name: string;
-    model: string;
-    status: string;
-    result?: string;
-    error?: 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');
-
-  // Defensive guard: caller (runCouncil) short-circuits when all fail,
-  // but this function may be reused in other contexts.
-  if (completedWithResults.length === 0) {
-    const errorDetails = councillorResults
-      .map(
-        (cr) =>
-          `**${cr.name}** (${shortModelLabel(cr.model)}): ${cr.status} - ${
-            cr.error ?? 'Unknown'
-          }`,
-      )
-      .join('\n');
-
-    return `---\n\n**Original Prompt**:\n${originalPrompt}\n\n---\n\n**Councillor Responses**:\nAll councillors failed to produce output:\n${errorDetails}\n\nPlease 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\nYou MUST follow the Synthesis Process steps before producing output: review each councillor response individually, then produce the required output with a synthesized Council Response, per-councillor details using their exact names, and a Council Summary with consensus confidence rating (unanimous, majority, or split).';
-
-  return prompt;
 }

+ 0 - 4
src/agents/custom.test.ts

@@ -495,10 +495,6 @@ describe('permission edge cases', () => {
     expect(
       (orchestrator?.config.permission as Record<string, unknown>)?.question,
     ).toBeDefined();
-    expect(
-      (orchestrator?.config.permission as Record<string, unknown>)
-        ?.council_session,
-    ).toBeDefined();
     expect(
       (orchestrator?.config.permission as Record<string, unknown>)?.cancel_task,
     ).toBeDefined();

+ 21 - 48
src/agents/index.test.ts

@@ -182,15 +182,6 @@ describe('orchestrator agent', () => {
     ).toBe('allow');
   });
 
-  test('orchestrator is denied access to council_session', () => {
-    const agents = createAgents();
-    const orchestrator = agents.find((a) => a.name === 'orchestrator');
-    expect(
-      (orchestrator as { config: { permission: Record<string, unknown> } })
-        .config.permission.council_session,
-    ).toBe('deny');
-  });
-
   test('orchestrator is allowed to invoke cancel_task', () => {
     const agents = createAgents();
     const orchestrator = agents.find((a) => a.name === 'orchestrator');
@@ -338,42 +329,10 @@ describe('skill permissions', () => {
 });
 
 describe('tool permissions', () => {
-  test('council agent is allowed to invoke council_session', () => {
-    const agents = createAgents({
-      council: councilConfig(),
-    });
-    const council = agents.find((a) => a.name === 'council');
-    expect(
-      (council as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('allow');
-  });
-
-  test('oracle is denied access to council_session', () => {
-    const agents = createAgents();
-    const oracle = agents.find((a) => a.name === 'oracle');
-    expect(
-      (oracle as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('deny');
-  });
-
-  test('explorer is denied access to council_session', () => {
-    const agents = createAgents();
-    const explorer = agents.find((a) => a.name === 'explorer');
-    expect(
-      (explorer as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('deny');
-  });
-
-  test('councillor is denied access to council_session', () => {
-    const agents = createAgents();
-    const councillor = agents.find((a) => a.name === 'councillor');
-    expect(
-      (councillor as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('deny');
+  test('dynamic councillor agents are prefixed to avoid reserved agent type names', () => {
+    const agents = createAgents({ council: councilConfig() });
+    expect(agents.some((a) => a.name === 'councillor-alpha')).toBe(true);
+    expect(agents.some((a) => a.name === 'alpha')).toBe(false);
   });
 
   test('oracle is denied access to cancel_task', () => {
@@ -403,7 +362,7 @@ describe('tool permissions', () => {
     ).toBe('deny');
   });
 
-  test('council agent is read-only except council_session', () => {
+  test('council agent is fully read-only (synthesis-only, no council_session)', () => {
     const agents = createAgents({
       council: councilConfig(),
     });
@@ -414,7 +373,6 @@ describe('tool permissions', () => {
     expect(permission.glob).toBe('allow');
     expect(permission.grep).toBe('allow');
     expect(permission.ast_grep_search).toBe('allow');
-    expect(permission.council_session).toBe('allow');
     expect(permission.bash).toBe('deny');
     expect(permission.edit).toBe('deny');
     expect(permission.write).toBe('deny');
@@ -433,7 +391,6 @@ describe('tool permissions', () => {
     expect(permission.read).toBe('allow');
     expect(permission.glob).toBe('allow');
     expect(permission.grep).toBe('allow');
-    expect(permission.council_session).toBe('deny');
     expect(permission.bash).toBe('deny');
     expect(permission.edit).toBe('deny');
     expect(permission.write).toBe('deny');
@@ -443,6 +400,22 @@ describe('tool permissions', () => {
   });
 });
 
+test('orchestrator prompt includes Council Mode block when councillors exist', () => {
+  const agents = createAgents({ council: councilConfig() });
+  const orchestrator = agents.find((a) => a.name === 'orchestrator');
+  const prompt = orchestrator?.config.prompt as string;
+  expect(prompt).toContain('## Council Mode');
+  expect(prompt).toContain("task(subagent_type='councillor-alpha'");
+  expect(prompt).toContain('proceed without it');
+});
+
+test('orchestrator prompt excludes Council Mode when no councillors', () => {
+  const agents = createAgents();
+  const orchestrator = agents.find((a) => a.name === 'orchestrator');
+  const prompt = orchestrator?.config.prompt as string;
+  expect(prompt).not.toContain('## Council Mode');
+});
+
 describe('isSubagent type guard', () => {
   test('returns true for valid subagent names', () => {
     expect(isSubagent('explorer')).toBe(true);

+ 19 - 9
src/agents/index.ts

@@ -17,6 +17,7 @@ import {
 import { getAgentMcpList } from '../config/agent-mcps';
 
 import { createCouncilAgent } from './council';
+import { buildCouncillorAgents, getCouncillorSeatName } from './council-agents';
 import { createCouncillorAgent } from './councillor';
 import { createDesignerAgent } from './designer';
 import { createExplorerAgent } from './explorer';
@@ -38,7 +39,6 @@ type AgentFactory = (
   customAppendPrompt?: string,
 ) => AgentDefinition;
 
-const COUNCIL_TOOL_ALLOWED_AGENTS = new Set(['council']);
 const CANCEL_TASK_ALLOWED_AGENTS = new Set(['orchestrator']);
 const SAFE_AGENT_ALIAS_RE = /^[a-z][a-z0-9_-]*$/i;
 
@@ -296,13 +296,6 @@ function applyDefaultPermissions(
 
   // Respect explicit deny on question (councillor)
   const questionPerm = existing.question === 'deny' ? 'deny' : 'allow';
-  // Councillors are denied council_session so they cannot spawn nested
-  // councils — this permission denial is now the recursion guard (the
-  // plugin's SubagentDepthTracker was removed; OpenCode's native
-  // subagent_depth covers TaskTool-based recursion for other subagents).
-  const councilSessionPerm = COUNCIL_TOOL_ALLOWED_AGENTS.has(agent.name)
-    ? (existing.council_session ?? 'allow')
-    : 'deny';
   const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name)
     ? (existing.cancel_task ?? 'allow')
     : 'deny';
@@ -310,7 +303,6 @@ function applyDefaultPermissions(
   agent.config.permission = {
     ...existing,
     question: questionPerm,
-    council_session: councilSessionPerm,
     cancel_task: cancelTaskPerm,
     // Apply skill permissions as nested object under 'skill' key
     skill: {
@@ -514,10 +506,16 @@ export function createAgents(
     return agent;
   });
 
+  // Build dynamic councillor agents from council config (flatten mode).
+  // Each councillor becomes a dispatchable subagent with its own model,
+  // so the orchestrator can task() them with native panes at depth 1.
+  const councillorAgents = buildCouncillorAgents(config, disabled);
+
   const allSubAgents = [
     ...builtInSubAgents,
     ...customSubAgents,
     ...acpSubAgents,
+    ...councillorAgents,
   ];
 
   // 3. Create Orchestrator (with its own overrides and custom prompts)
@@ -535,6 +533,7 @@ export function createAgents(
     undefined,
     undefined,
     disabled,
+    councillorAgents.length > 0 ? ['council'] : undefined,
   );
 
   const inlineOrchestratorPrompt = orchestratorOverride?.prompt;
@@ -650,6 +649,17 @@ export function createAgents(
     updatedPrompt = `${updatedPrompt}\n\n${rewrittenAcps.join('\n\n')}`;
   }
 
+  // Inject council-dispatch block if dynamic councillors exist (flatten mode)
+  if (councillorAgents.length > 0) {
+    const dispatchList = councillorAgents
+      .map(
+        (a: AgentDefinition) =>
+          `   - task(subagent_type='${a.name}', description='Councillor ${getCouncillorSeatName(a.name)} on <brief topic>', prompt=<user's question>)`,
+      )
+      .join('\n');
+    updatedPrompt = `${updatedPrompt}\n\n## Council Mode\n\nWhen you need to run a council or the user asks for consensus/multiple opinions, use this procedure INSTEAD of delegating to @council:\n\n1. Dispatch the user's question to each councillor in PARALLEL via task():\n${dispatchList}\n2. Collect ALL councillor responses. If any councillor returns empty or does not respond within 3 minutes, proceed without it — do not wait indefinitely. If a councillor's response is empty, retry that councillor once before continuing.\n3. Call task(subagent_type='council', description='Synthesize council report') with a prompt that includes the original user question AND all councillor responses, formatted so each councillor's seat name and response is clearly separated. Skip any councillor that still returned empty after retry.\n4. Present the council's synthesized report.\n\nThis ensures each councillor runs with its own model and the council agent synthesizes the full multi-model consensus.`;
+  }
+
   orchestrator.config.prompt = updatedPrompt;
 
   return [orchestrator, ...allSubAgents];

+ 10 - 2
src/agents/orchestrator.ts

@@ -115,10 +115,14 @@ const PARALLEL_DELEGATION_EXAMPLES = [
  * @param disabledAgents - Set of disabled agent names to exclude from the prompt
  * @returns The complete orchestrator prompt string
  */
-export function buildOrchestratorPrompt(disabledAgents?: Set<string>): string {
+export function buildOrchestratorPrompt(
+  disabledAgents?: Set<string>,
+  excludeDescriptions?: string[],
+): string {
   // Filter agent descriptions
   const enabledAgents = Object.entries(AGENT_DESCRIPTIONS)
     .filter(([name]) => !disabledAgents?.has(name))
+    .filter(([name]) => !excludeDescriptions?.includes(name))
     .map(([, desc]) => desc)
     .join('\n\n');
 
@@ -270,8 +274,12 @@ export function createOrchestratorAgent(
   customPrompt?: string,
   customAppendPrompt?: string,
   disabledAgents?: Set<string>,
+  excludeDescriptions?: string[],
 ): AgentDefinition {
-  const basePrompt = buildOrchestratorPrompt(disabledAgents);
+  const basePrompt = buildOrchestratorPrompt(
+    disabledAgents,
+    excludeDescriptions,
+  );
   const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
 
   const definition: AgentDefinition = {

+ 0 - 3
src/config/codemap.md

@@ -176,10 +176,7 @@ This allows consumers to import directly from `src/config` rather than individua
 
 ### CouncilConfig
 - `presets`: Named council presets (map of presetName → CouncillorConfig[])
-- `timeout`: Council execution timeout in ms
 - `default_preset`: Default preset name to use
-- `councillor_execution_mode`: "parallel" or "serial" execution
-- `councillor_retries`: Number of retry attempts for empty responses
 
 ### MultiplexerConfig
 - `type`: "auto", "tmux", "zellij", or "none"

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

@@ -59,8 +59,6 @@ describe('CouncillorConfigSchema', () => {
     if (result.success) {
       // Deprecated fields are stripped but reported via _deprecated
       expect(result.data._deprecated).toEqual(['master']);
-      // Core fields still work normally
-      expect(result.data.timeout).toBe(180000);
       expect(Object.keys(result.data.presets.default)).toEqual(['alpha']);
       // Legacy master.model is extracted for backward-compat fallback
       expect(result.data._legacyMasterModel).toBe('anthropic/claude-opus-4-6');
@@ -305,8 +303,6 @@ describe('CouncilConfigSchema', () => {
     expect(result.success).toBe(true);
 
     if (result.success) {
-      // Check defaults are filled in
-      expect(result.data.timeout).toBe(180000);
       expect(result.data.default_preset).toBe('default');
     }
   });
@@ -325,7 +321,6 @@ describe('CouncilConfigSchema', () => {
     expect(result.success).toBe(true);
 
     if (result.success) {
-      expect(result.data.timeout).toBe(180000);
       expect(result.data.default_preset).toBe('custom');
     }
   });
@@ -337,38 +332,6 @@ describe('CouncilConfigSchema', () => {
     expect(result.success).toBe(false);
   });
 
-  test('rejects invalid timeout (negative)', () => {
-    const badConfig = {
-      presets: {
-        default: {
-          alpha: { model: 'openai/gpt-5.6-luna' },
-        },
-      },
-      timeout: -1000,
-    };
-
-    const result = CouncilConfigSchema.safeParse(badConfig);
-    expect(result.success).toBe(false);
-  });
-
-  test('accepts zero timeout values (no timeout)', () => {
-    const config = {
-      presets: {
-        default: {
-          alpha: { model: 'openai/gpt-5.6-luna' },
-        },
-      },
-      timeout: 0,
-    };
-
-    const result = CouncilConfigSchema.safeParse(config);
-    expect(result.success).toBe(true);
-
-    if (result.success) {
-      expect(result.data.timeout).toBe(0);
-    }
-  });
-
   test('rejects missing presets', () => {
     const badConfig = {
       master: {

+ 1 - 52
src/config/council-schema.ts

@@ -131,19 +131,6 @@ export const CouncilPresetSchema = z
 
 export type CouncilPreset = z.infer<typeof CouncilPresetSchema>;
 
-/**
- * Execution mode for councillors.
- * - parallel: Run all councillors concurrently (default, fastest for multi-model systems)
- * - serial: Run councillors one at a time (required for single-model systems to avoid conflicts)
- */
-export const CouncillorExecutionModeSchema = z
-  .enum(['parallel', 'serial'])
-  .default('parallel')
-  .describe(
-    'Execution mode for councillors. Use "serial" for single-model systems to avoid conflicts. ' +
-      'Use "parallel" for multi-model systems for faster execution.',
-  );
-
 /**
  * Top-level council configuration.
  *
@@ -157,9 +144,7 @@ export const CouncillorExecutionModeSchema = z
  *         "beta":  { "model": "openai/gpt-5.3-codex" },
  *         "gamma": { "model": "google/gemini-3-pro" }
  *       }
- *     },
- *     "timeout": 180000,
- *     "councillor_execution_mode": "serial"
+ *     }
  *   }
  * }
  * ```
@@ -167,21 +152,7 @@ export const CouncillorExecutionModeSchema = z
 export const CouncilConfigSchema = z
   .object({
     presets: z.record(z.string(), CouncilPresetSchema),
-    timeout: z.number().min(0).default(180000),
     default_preset: z.string().default('default'),
-    councillor_execution_mode: CouncillorExecutionModeSchema.describe(
-      'Execution mode for councillors. "serial" runs them one at a time (required for single-model systems). "parallel" runs them concurrently (default, faster for multi-model systems).',
-    ),
-    councillor_retries: z
-      .number()
-      .int()
-      .min(0)
-      .max(5)
-      .default(3)
-      .describe(
-        'Number of retry attempts for councillors that return empty responses ' +
-          '(e.g. due to provider rate limiting). Default: 3 retries.',
-      ),
     // Deprecated fields - accepted for backward compatibility but ignored.
     // The council agent now synthesizes directly; no separate master session.
     // Uses permissive schemas since the values are discarded - strict
@@ -209,32 +180,10 @@ export const CouncilConfigSchema = z
 
     return {
       presets: data.presets,
-      timeout: data.timeout,
       default_preset: data.default_preset,
-      councillor_execution_mode: data.councillor_execution_mode,
-      councillor_retries: data.councillor_retries,
       _deprecated: deprecated.length > 0 ? deprecated : undefined,
       _legacyMasterModel: legacyMasterModel,
     };
   });
 
 export type CouncilConfig = z.infer<typeof CouncilConfigSchema>;
-export type CouncillorExecutionMode = z.infer<
-  typeof CouncillorExecutionModeSchema
->;
-
-/**
- * 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;
-  }>;
-}

+ 0 - 168
src/council/codemap.md

@@ -1,168 +0,0 @@
-# src/council/
-
-## Responsibility
-Orchestrates multi-LLM council sessions by spawning parallel councillor agents, collecting their results, and formatting them for synthesis by the council agent. Implements the **Council Pattern** to aggregate diverse model perspectives for higher-quality decision making and complex task resolution.
-
-## Design
-
-### Core Abstraction: CouncilManager
-- **Singleton**: One instance per plugin session manages the entire council lifecycle
-- **Strategy Pattern**: Configurable execution modes (`parallel` vs `serial`) for councillor orchestration
-- **Retry Pattern**: Automatic retry on empty responses with configurable limits
-
-### Key Components
-
-| Component | Purpose | Type |
-|-----------|---------|------|
-| `CouncilManager` | Main orchestrator class | Class |
-| `runCouncil()` | Entry point for council sessions | Method |
-| `runCouncillors()` | Parallel/serial councillor execution | Method |
-| `runAgentSession()` | Single councillor lifecycle management | Method |
-| `runCouncillorWithRetry()` | Retry logic for councillors | Method |
-
-### Configuration Schema
-- **Presets**: Named configurations mapping councillor names to their models and prompts
-- **Timeout**: Global timeout for all councillor sessions (default: 180s)
-- **Execution Mode**: Parallel (default) or serial execution of councillors
-- **Retry Policy**: Number of retries for empty responses (default: 3)
-
-### Councillor Lifecycle
-1. **Spawn**: Create child session for each councillor with advisory-only tools
-2. **Prompt**: Send formatted prompt with restricted tool access (no file edits, writes, etc.)
-3. **Timeout**: Enforce session timeout with graceful abortion
-4. **Extract**: Retrieve result from session
-5. **Cleanup**: Abort session and release resources
-
-## Flow
-
-### Session Initiation
-```
-┌─────────────────────────────────────────────────────────────┐
-│                    CouncilManager                       │
-│  (parentSessionId, prompt, presetName)                 │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼
-┌─────────────────────────────────────────────────────────────┐
-│                    runCouncil()                       │
-│  - Resolve preset (default or named)                  │
-│  - Validate councillor configuration                   │
-│  - Notify parent session (immediate feedback)           │
-│  - Launch councillors (parallel/serial)                 │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼
-┌─────────────────────────────────────────────────────────────┐
-│                   runCouncillors()                     │
-│  - For each councillor config:                         │
-│    - Spawn child session (session.create)               │
- │    - Send prompt with restricted tools                 │
-│    - Extract result (extractSessionResult)              │
-│    - Cleanup session (session.abort)                  │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼
-┌─────────────────────────────────────────────────────────────┐
-│                 runAgentSession()                      │
-│  - Create session with parentID                        │
- │  - Send prompt (promptWithTimeout)                     │
-│  - Extract result with reasoning disabled               │
-│  - Abort session on completion/cleanup                 │
-└─────────────────────────────────────────────────────────────┘
-```
-
-### Parallel Execution (Default)
-- All councillors launched concurrently with staggered starts (250ms intervals)
-- Results collected via `Promise.allSettled()`
-- Timeout applies to entire council session, not individual councillors
-
-### Serial Execution (Configurable)
-- Councillors executed sequentially in defined order
-- Each councillor inherits parent session timeout
-- Useful for ordered deliberation or resource-constrained environments
-
-### Error Handling & Retries
-1. **Empty responses**: Retry up to `maxRetries` times (provider rate-limiting)
-2. **Timeouts**: Immediate failure, no retry
-3. **Session failures**: Mark as failed, continue with other councillors
-
-## Integration
-
-### Dependencies
-- **Config**: `PluginConfig` from `../config` (council presets, timeouts)
-- **Agents**: `formatCouncillorPrompt()`, `formatCouncillorResults()` from `../agents/council`
-- **Session**: `extractSessionResult()`, `promptWithTimeout()` from `../utils/session`
-- **Logger**: `log()` from `../utils/logger`
-- **Client**: `OpencodeClient` from `@opencode-ai/plugin` (session management)
-
-### Consumers
-- **Main Plugin**: `src/index.ts` - orchestrates council sessions for complex tasks
-- **Council Agent**: Receives formatted results via `formatCouncillorResults()` for synthesis
-- **Skills**: Can invoke council sessions for multi-model consensus on decisions
-
-### Configuration Example (from `../config/plugin-config.ts`)
-```typescript
-council: {
-  default_preset: 'default',
-  timeout: 180000, // 3 minutes
-  councillor_execution_mode: 'parallel',
-  councillor_retries: 3,
-  presets: {
-    default: {
-      architect: { model: 'gpt-4', prompt: 'Think like a software architect' },
-      critic: { model: 'claude-3', prompt: 'Critique the architect\'s plan' },
-      implementer: { model: 'gpt-4', prompt: 'Implement the solution' },
-    },
-  },
-}
-```
-
-### Environment Variables & Fallbacks
-- **Directory**: Inherited from plugin context (`ctx.directory`)
-- **TMUX Enabled**: Controls pane staggering and spawn delays
-- **Fallback**: `retry_on_empty` controls whether to retry empty responses
-
-## Key Behaviors
-
-### Tool Restrictions for Councillors
-Councillors operate with **advisory-only** tool access:
-- ❌ `task` - Cannot spawn new subagents
-- ❌ `question` - Cannot ask user questions
-- ❌ `edit`, `write`, `apply_patch` - Cannot modify files
-- ❌ `ast_grep_replace`, `bash` - Cannot execute commands
-- ✅ `read` - Can read files for analysis
-
-This ensures councillors provide guidance without side effects.
-
-### Notifications
-- Sends immediate feedback to parent session on council start
-- Message format: `⎔ Council starting - ${count} councillors launching - ctrl+x ↓ to watch`
-
-## Performance Considerations
-
-- **Parallel execution**: Optimal for most cases, maximizes throughput
-- **Staggered starts**: Reduces tmux pane creation contention (250ms intervals)
-- **Timeout alignment**: Single timeout for entire council avoids cascading delays
-- **Resource cleanup**: Guaranteed session abortion in `finally` block prevents leaks
-
-## Error Scenarios & Recovery
-
-| Scenario | Behavior | Recovery |
-|----------|----------|----------|
-| No council config | Return error immediately | User must configure council in plugin config |
-| Invalid preset | Return error with available presets | User selects valid preset or uses default |
-| Empty preset | Return error about no councillors | User adds councillors to preset |
-| All councillors fail | Return error with all failures | Investigate model availability or prompts |
-| Timeout | Mark timed_out status | Increase timeout or reduce council size |
-| Provider rate-limiting | Retry up to maxRetries | Automatic recovery |
-
-## Testing Points
-
-- Preset resolution (default vs named)
-- Parallel vs serial execution modes
-- Retry logic for empty responses
-- Tool restrictions enforcement
-- Session lifecycle (create → prompt → extract → abort)
-- Timeout behavior
-- Error propagation and formatting
-- Councillor result formatting for synthesis

+ 0 - 959
src/council/council-manager.test.ts

@@ -1,959 +0,0 @@
-import { describe, expect, mock, test } from 'bun:test';
-import type { PluginConfig } from '../config';
-import { CouncilConfigSchema } from '../config/council-schema';
-import { CouncilManager } from './council-manager';
-
-function createMockContext(overrides?: {
-  sessionCreateResult?:
-    | (() => { data?: { id?: string } })
-    | {
-        data?: { id?: string };
-      };
-  sessionMessagesResult?: {
-    data?: Array<{
-      info?: { role: string };
-      parts?: Array<{ type: string; text?: string }>;
-    }>;
-  };
-  promptImpl?: (args: unknown) => Promise<unknown>;
-}) {
-  let callCount = 0;
-  return {
-    client: {
-      session: {
-        create: mock(async () => {
-          callCount++;
-          const overrideResult = overrides?.sessionCreateResult;
-          if (typeof overrideResult === 'function') {
-            return overrideResult();
-          }
-          return (
-            overrideResult ?? {
-              data: { id: `test-session-${callCount}` },
-            }
-          );
-        }),
-        messages: mock(
-          async () => overrides?.sessionMessagesResult ?? { data: [] },
-        ),
-        prompt: mock(async (args: unknown) => {
-          if (overrides?.promptImpl) {
-            return await overrides.promptImpl(args);
-          }
-          return {};
-        }),
-        abort: mock(async () => ({})),
-      },
-    },
-    directory: '/tmp/test',
-  } as any;
-}
-
-function createTestCouncilConfig(overrides?: {
-  presets?: Record<string, Record<string, { model: string; variant?: string }>>;
-  default_preset?: string;
-  timeout?: number;
-}): PluginConfig {
-  const councilConfig = CouncilConfigSchema.parse({
-    presets: overrides?.presets ?? {
-      default: {
-        alpha: { model: 'openai/gpt-5.6-luna' },
-        beta: { model: 'openai/gpt-5.3-codex' },
-      },
-    },
-    default_preset: overrides?.default_preset,
-    timeout: overrides?.timeout,
-  });
-
-  return { council: councilConfig } as any;
-}
-
-describe('CouncilManager', () => {
-  describe('constructor', () => {
-    test('creates manager without config', () => {
-      const ctx = createMockContext();
-      const manager = new CouncilManager(ctx, undefined);
-      expect(manager).toBeDefined();
-    });
-
-    test('creates manager with plugin config', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Councillor response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.result).toBeDefined();
-      expect(result.councillorResults).toHaveLength(2);
-
-      // Check all councillors completed
-      expect(
-        result.councillorResults.every((r) => r.status === 'completed'),
-      ).toBe(true);
-    });
-
-    test('returns error when all councillors fail', async () => {
-      const ctx = createMockContext({
-        sessionCreateResult: () => ({ data: {} }), // Missing ID triggers failure
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('All councillors failed or timed out');
-      expect(result.councillorResults).toHaveLength(2);
-      expect(result.councillorResults.every((r) => r.status === 'failed')).toBe(
-        true,
-      );
-    });
-
-    test('uses default_preset when presetName is undefined', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Councillor response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig({
-        presets: {
-          default: {
-            alpha: { model: 'openai/gpt-5.6-luna' },
-          },
-          custom: {
-            beta: { model: 'openai/gpt-5.3-codex' },
-          },
-        },
-        default_preset: 'custom',
-      });
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].name).toBe('beta');
-    });
-
-    test('handles mixed councillor success/failure', async () => {
-      let createCallCount = 0;
-      const ctx = createMockContext({
-        sessionCreateResult: () => {
-          createCallCount++;
-          // First councillor succeeds, second fails
-          if (createCallCount === 1) {
-            return { data: { id: 'councillor-success' } };
-          }
-          if (createCallCount === 2) {
-            return { data: {} }; // Missing ID = failure
-          }
-          return { data: { id: `session-${createCallCount}` } };
-        },
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Successful response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              councillor1: { model: 'openai/gpt-5.6-luna' },
-              councillor2: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(2);
-
-      // Check that one completed and one failed (order not guaranteed)
-      const completedCount = result.councillorResults.filter(
-        (r) => r.status === 'completed',
-      ).length;
-      const failedCount = result.councillorResults.filter(
-        (r) => r.status === 'failed',
-      ).length;
-
-      expect(completedCount).toBe(1);
-      expect(failedCount).toBe(1);
-    });
-
-    test('uses custom timeouts from config', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-            custom: {
-              beta: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-          default_preset: 'custom',
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-    });
-
-    test('handles councillor timeout', async () => {
-      let sessionCount = 0;
-      const ctx = createMockContext({
-        sessionCreateResult: () => {
-          sessionCount++;
-          return { data: { id: `session-${sessionCount}` } };
-        },
-        promptImpl: async (args: any) => {
-          // First councillor times out, second succeeds
-          const sessionId = args.path?.id;
-          if (sessionId === 'session-1') {
-            // Simulate timeout
-            throw new Error('Prompt timed out after 180000ms');
-          }
-          return {};
-        },
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Success' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              timeout: { model: 'openai/gpt-5.6-luna' },
-              success: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(2);
-
-      const timeoutResult = result.councillorResults.find(
-        (r) => r.name === 'timeout',
-      );
-      const successResult = result.councillorResults.find(
-        (r) => r.name === 'success',
-      );
-
-      expect(timeoutResult?.status).toBe('timed_out');
-      expect(timeoutResult?.error).toContain('timed out');
-      expect(successResult?.status).toBe('completed');
-    });
-
-    test('passes variant to councillor sessions', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna', variant: 'low' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-session-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [{ body?: { variant?: string; agent?: string } }]
-      >;
-      // Find the councillor call by agent field (notification may be at [0])
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall).toBeDefined();
-      expect(councillorCall?.[0].body?.variant).toBe('low');
-    });
-
-    test('always aborts councillor sessions after completion', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-              beta: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-session-id');
-
-      // Should abort 2 councillors
-      expect(ctx.client.session.abort).toHaveBeenCalledTimes(2);
-    });
-
-    test('handles councillor with invalid model format', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              badmodel: { model: 'invalid-model-no-slash' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('All councillors failed or timed out');
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('failed');
-      expect(result.councillorResults[0].error).toContain(
-        'Invalid model format',
-      );
-    });
-
-    test('extracts text and reasoning content from councillor responses', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [
-                { type: 'reasoning', text: 'I am thinking...' },
-                { type: 'text', text: 'Final answer.' },
-              ],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      // Councillors filter out reasoning parts to avoid bloating the synthesis
-      expect(result.councillorResults[0].result).not.toContain(
-        'I am thinking...',
-      );
-      expect(result.councillorResults[0].result).toContain('Final answer.');
-    });
-
-    test('handles concurrent council sessions with different presets', async () => {
-      const ctx = createMockContext({
-        sessionCreateResult: () => ({ data: { id: 'session-1' } }),
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const defaultConfig = createTestCouncilConfig({
-        presets: {
-          default: {
-            alpha: { model: 'openai/gpt-5.6-luna' },
-          },
-          fast: {
-            beta: { model: 'openai/gpt-5.3-codex' },
-          },
-        },
-      });
-      const manager1 = new CouncilManager(ctx, defaultConfig, undefined);
-      const manager2 = new CouncilManager(ctx, defaultConfig, undefined);
-
-      const [result1, result2] = await Promise.all([
-        manager1.runCouncil('test prompt 1', 'default', 'parent-1'),
-        manager2.runCouncil('test prompt 2', 'fast', 'parent-2'),
-      ]);
-
-      expect(result1.success).toBe(true);
-      expect(result2.success).toBe(true);
-      expect(result1.councillorResults[0].name).toBe('alpha');
-      expect(result2.councillorResults[0].name).toBe('beta');
-    });
-
-    test('handles empty preset gracefully', async () => {
-      const ctx = createMockContext();
-      const config = createTestCouncilConfig({
-        presets: {
-          empty: {},
-        },
-      });
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        'empty',
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toContain(
-        'Preset "empty" has no councillors configured',
-      );
-      expect(result.councillorResults).toHaveLength(0);
-    });
-
-    test('returns available presets when invalid preset name given', async () => {
-      const ctx = createMockContext();
-      const config = createTestCouncilConfig({
-        presets: {
-          default: {
-            alpha: { model: 'openai/gpt-5.6-luna' },
-          },
-          roled: {
-            beta: { model: 'openai/gpt-5.3-codex' },
-          },
-        },
-      });
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        'architect',
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toContain('Preset "architect" does not exist');
-      expect(result.error).toContain('Omit the preset parameter');
-      expect(result.error).toContain('default, roled');
-      expect(result.councillorResults).toHaveLength(0);
-    });
-
-    test('passes agent field in councillor prompt body', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [{ body?: { agent?: string } }]
-      >;
-      // Find councillor call by agent (notification may interleave)
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall).toBeDefined();
-    });
-
-    test('disables mutating and delegation tools in councillor prompt body', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [{ body?: { agent?: string; tools?: Record<string, boolean> } }]
-      >;
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall?.[0].body?.tools).toEqual({
-        task: false,
-        question: false,
-        edit: false,
-        write: false,
-        apply_patch: false,
-        ast_grep_replace: false,
-        bash: false,
-      });
-    });
-
-    test('creates session with model label in title', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const createCalls = ctx.client.session.create.mock.calls as Array<
-        [{ body?: { title?: string } }]
-      >;
-      // Councillor title: "Council alpha (gpt-5.6-luna)"
-      expect(createCalls[0][0].body?.title).toBe(
-        'Council alpha (gpt-5.6-luna)',
-      );
-    });
-
-    test('passes councillor prompt to councillor session', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response with role guidance' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: {
-                model: 'openai/gpt-5.6-luna',
-                prompt: 'You are a meticulous reviewer focused on edge cases.',
-              },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [
-          {
-            body?: {
-              parts?: Array<{ type: string; text?: string }>;
-              agent?: string;
-            };
-          },
-        ]
-      >;
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall).toBeDefined();
-      const promptText = councillorCall?.[0]?.body?.parts?.[0]?.text;
-      expect(promptText).toContain('test prompt');
-      expect(promptText).toContain(
-        'You are a meticulous reviewer focused on edge cases.',
-      );
-    });
-
-    test('works without any prompt overrides (backward compatible)', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(true);
-      // Verify no prompt contamination - councillor gets raw prompt
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [
-          {
-            body?: {
-              parts?: Array<{ type: string; text?: string }>;
-              agent?: string;
-            };
-          },
-        ]
-      >;
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      // Without prompt override, councillor gets just the raw user prompt
-      expect(councillorCall?.[0]?.body?.parts?.[0]?.text).toBe('test prompt');
-    });
-
-    test('retries councillor on empty response', async () => {
-      const ctx = createMockContext({
-        promptImpl: async () => ({}),
-      });
-
-      // Track messages call count and return empty first, then success
-      let councillorMessagesCallCount = 0;
-      const originalMessages = ctx.client.session.messages;
-      ctx.client.session.messages = mock(async (args) => {
-        // First call (first councillor attempt): empty response
-        // Second call (councillor retry): success
-        councillorMessagesCallCount++;
-        if (councillorMessagesCallCount === 1) {
-          return {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: '' }],
-              },
-            ],
-          };
-        }
-        if (councillorMessagesCallCount === 2) {
-          return {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Success' }],
-              },
-            ],
-          };
-        }
-        // Any other calls: use original
-        return originalMessages(args);
-      });
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 1,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(true);
-      // First two messages calls are for councillor (empty + success)
-      expect(councillorMessagesCallCount).toBeGreaterThanOrEqual(2);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('completed');
-      expect(result.councillorResults[0].result).toBe('Success');
-    });
-
-    test('does not retry councillor on non-empty failure (timeout)', async () => {
-      let messagesCallCount = 0;
-      const ctx = createMockContext({
-        promptImpl: async () => {
-          // Simulate timeout error
-          throw new Error('Prompt timed out after 180000ms');
-        },
-      });
-
-      // Override messages to track calls (won't be reached due to timeout)
-      ctx.client.session.messages = mock(async () => {
-        messagesCallCount++;
-        return {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Success' }],
-            },
-          ],
-        };
-      });
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 2,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      // No retry on timeout - messages should not be called
-      expect(messagesCallCount).toBe(0);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('timed_out');
-      expect(result.councillorResults[0].error).toContain('timed out');
-    });
-
-    test('falls back to next model in councillor chain on failure', async () => {
-      let sessionCount = 0;
-      const ctx = createMockContext({
-        sessionCreateResult: () => {
-          sessionCount++;
-          return { data: { id: `session-${sessionCount}` } };
-        },
-        promptImpl: async (args: any) => {
-          // First model (session-1) fails; second model (session-2) succeeds.
-          if (args.path?.id === 'session-1') {
-            throw new Error('Prompt timed out after 180000ms');
-          }
-          return {};
-        },
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Fallback success' }],
-            },
-          ],
-        },
-      });
-
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: {
-                model: ['openai/gpt-5.6-luna', 'openai/gpt-5.3-codex'],
-              },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('completed');
-      expect(result.councillorResults[0].result).toBe('Fallback success');
-      // Reported model reflects the fallback that actually responded.
-      expect(result.councillorResults[0].model).toBe('openai/gpt-5.3-codex');
-    });
-
-    test('exhausts councillor retries and returns failure', async () => {
-      const ctx = createMockContext({
-        promptImpl: async () => ({}),
-      });
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 1,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('All councillors failed or timed out');
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('failed');
-      expect(result.councillorResults[0].error).toContain(
-        'Empty response from provider',
-      );
-    });
-
-    test('returns empty councillor result when retry_on_empty is false', async () => {
-      const ctx = createMockContext({
-        promptImpl: async () => ({}),
-      });
-
-      // Always return empty response
-      ctx.client.session.messages = mock(async () => ({
-        data: [
-          {
-            info: { role: 'assistant' },
-            parts: [{ type: 'text', text: '' }],
-          },
-        ],
-      }));
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 1,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-        fallback: {
-          retry_on_empty: false,
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      // With retry_on_empty: false, empty response is accepted as completed
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('completed');
-      expect(result.councillorResults[0].result).toBe('');
-      // Council succeeds because empty is accepted as valid response
-      // The formatted result contains the message about all councillors failing
-      expect(result.success).toBe(true);
-      expect(result.result).toContain(
-        'All councillors failed to produce output',
-      );
-      expect(result.result).toContain('test prompt');
-    });
-  });
-});

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

@@ -1,457 +0,0 @@
-/**
- * Council Manager
- *
- * Orchestrates multi-LLM council sessions: launches councillors in
- * parallel and collects their results for the council agent to synthesize.
- */
-
-import type { PluginInput } from '@opencode-ai/plugin';
-import {
-  formatCouncillorPrompt,
-  formatCouncillorResults,
-} from '../agents/council';
-import type { PluginConfig } from '../config';
-import {
-  COUNCILLOR_STAGGER_MS,
-  TMUX_SPAWN_DELAY_MS,
-} from '../config/constants';
-import type { CouncillorConfig, CouncilResult } from '../config/council-schema';
-import { normalizeCouncillorModels } from '../utils/councillor-models';
-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 tmuxEnabled: boolean;
-  private deprecatedFields?: string[];
-  private legacyMasterModel?: string;
-
-  constructor(ctx: PluginInput, config?: PluginConfig, tmuxEnabled = false) {
-    this.client = ctx.client;
-    this.directory = ctx.directory;
-    this.config = config;
-    this.deprecatedFields = config?.council?._deprecated;
-    this.legacyMasterModel = config?.council?._legacyMasterModel;
-    this.tmuxEnabled = tmuxEnabled;
-  }
-
-  /** Return deprecated config fields detected during parsing (for tool warnings). */
-  getDeprecatedFields(): string[] | undefined {
-    return this.deprecatedFields;
-  }
-
-  /** Return the legacy master.model if it was used as fallback. */
-  getLegacyMasterModel(): string | undefined {
-    return this.legacyMasterModel;
-  }
-
-  /**
-   * Run a full council session.
-   *
-   * 1. Look up the preset
-   * 2. Launch all councillors in parallel
-   * 3. Collect results (respecting timeout)
-   * 4. Return formatted councillor results for synthesis
-   */
-  async runCouncil(
-    prompt: string,
-    presetName: string | undefined,
-    parentSessionId: string,
-  ): Promise<CouncilResult> {
-    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) {
-      const available = Object.keys(councilConfig.presets).join(', ');
-      log(`[council-manager] Preset "${resolvedPreset}" not found`);
-      return {
-        success: false,
-        error: `Preset "${resolvedPreset}" does not exist. Omit the preset parameter to use the default, or call again with one of: ${available}`,
-        councillorResults: [],
-      };
-    }
-
-    if (Object.keys(preset).length === 0) {
-      log(`[council-manager] Preset "${resolvedPreset}" has no councillors`);
-      return {
-        success: false,
-        error: `Preset "${resolvedPreset}" has no councillors configured. Note: the reserved key "master" is ignored - use councillor names as keys`,
-        councillorResults: [],
-      };
-    }
-
-    const timeout = councilConfig.timeout ?? 180000;
-    const executionMode = councilConfig.councillor_execution_mode ?? 'parallel';
-    const maxRetries = councilConfig.councillor_retries ?? 3;
-
-    const councillorCount = Object.keys(preset).length;
-
-    log(`[council-manager] Starting council with preset "${resolvedPreset}"`, {
-      councillors: Object.keys(preset),
-    });
-
-    // 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),
-        });
-      },
-    );
-
-    // Run councillors (parallel or serial based on config)
-    const councillorResults = await this.runCouncillors(
-      prompt,
-      preset,
-      parentSessionId,
-      timeout,
-      executionMode,
-      maxRetries,
-    );
-
-    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,
-      };
-    }
-
-    // Format councillor results for the council agent to synthesize
-    const formattedCouncillorResults = formatCouncillorResults(
-      prompt,
-      councillorResults,
-    );
-
-    log('[council-manager] Council completed successfully');
-
-    return {
-      success: true,
-      result: formattedCouncillorResults,
-      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
-  // -------------------------------------------------------------------------
-
-  /**
-   * Run a single agent session: create → register → prompt → extract → cleanup.
-   */
-  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.tmuxEnabled) {
-        await new Promise((r) => setTimeout(r, TMUX_SPAWN_DELAY_MS));
-      }
-
-      // Councillors are advisory only: disable delegation, questions, and known mutating
-      // tools even if host defaults would otherwise expose them.
-      const body: PromptBody = {
-        agent: options.agent,
-        model: modelRef,
-        tools: {
-          task: false,
-          question: false,
-          edit: false,
-          write: false,
-          apply_patch: false,
-          ast_grep_replace: false,
-          bash: 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 extraction = await extractSessionResult(this.client, sessionId, {
-        includeReasoning: options.includeReasoning,
-      });
-
-      if (extraction.empty) {
-        const retryOnEmpty = this.config?.fallback?.retry_on_empty ?? true;
-        if (retryOnEmpty) {
-          throw new Error('Empty response from provider');
-        }
-      }
-
-      return extraction.text;
-    } finally {
-      if (sessionId) {
-        this.client.session.abort({ path: { id: sessionId } }).catch(() => {});
-      }
-    }
-  }
-
-  // -------------------------------------------------------------------------
-  // Phase 1: Councillors
-  // -------------------------------------------------------------------------
-
-  private async runCouncillors(
-    prompt: string,
-    councillors: Record<string, CouncillorConfig>,
-    parentSessionId: string,
-    timeout: number,
-    executionMode: 'parallel' | 'serial' = 'parallel',
-    maxRetries: number,
-  ): Promise<CouncilResult['councillorResults']> {
-    const entries = Object.entries(councillors);
-    const results: Array<{
-      name: string;
-      model: string;
-      status: 'completed' | 'failed' | 'timed_out';
-      result?: string;
-      error?: string;
-    }> = [];
-
-    if (executionMode === 'serial') {
-      // Serial execution: run each councillor one at a time
-      for (const [name, config] of entries) {
-        results.push(
-          await this.runCouncillorWithRetry(
-            name,
-            config,
-            prompt,
-            parentSessionId,
-            timeout,
-            maxRetries,
-          ),
-        );
-      }
-    } else {
-      // Parallel execution (default): run all councillors concurrently
-      const promises = entries.map(([name, config], index) =>
-        (async () => {
-          // Stagger launches only when multiplexer panes can be created.
-          // Outside tmux/zellij this delay only adds latency with no benefit.
-          if (this.tmuxEnabled && index > 0) {
-            await new Promise((r) =>
-              setTimeout(r, index * COUNCILLOR_STAGGER_MS),
-            );
-          }
-
-          return this.runCouncillorWithRetry(
-            name,
-            config,
-            prompt,
-            parentSessionId,
-            timeout,
-            maxRetries,
-          );
-        })(),
-      );
-
-      const settled = await Promise.allSettled(promises);
-
-      for (let index = 0; index < settled.length; index++) {
-        const result = settled[index];
-        const [name, cfg] = entries[index];
-
-        if (result.status === 'fulfilled') {
-          results.push(result.value);
-        } else {
-          results.push({
-            name,
-            model: cfg.model,
-            status: 'failed' as const,
-            error:
-              result.reason instanceof Error
-                ? result.reason.message
-                : String(result.reason),
-          });
-        }
-      }
-    }
-
-    return results;
-  }
-
-  /**
-   * Run a single councillor across its configured model chain.
-   *
-   * For each model in the chain, empty responses are retried up to
-   * `maxRetries` times (providers that silently rate-limit). Any other
-   * failure or timeout advances to the next model in the chain. The
-   * councillor only fails once every model has been exhausted; the reported
-   * `model` and `error` reflect the last model tried.
-   */
-  private async runCouncillorWithRetry(
-    name: string,
-    config: CouncillorConfig,
-    prompt: string,
-    parentSessionId: string,
-    timeout: number,
-    maxRetries: number,
-  ): Promise<{
-    name: string;
-    model: string;
-    status: 'completed' | 'failed' | 'timed_out';
-    result?: string;
-    error?: string;
-  }> {
-    // Prefer the normalized chain from the schema transform. When configs are
-    // built without the transform (e.g. tests), derive it from the raw model.
-    const models =
-      config.models ?? normalizeCouncillorModels(config.model, config.variant);
-    const totalAttempts = 1 + maxRetries;
-
-    let lastModel = models[0].id;
-    let lastStatus: 'failed' | 'timed_out' = 'failed';
-    let lastError = `Councillor "${name}": no model responded`;
-
-    for (let modelIndex = 0; modelIndex < models.length; modelIndex++) {
-      const entry = models[modelIndex];
-      const modelLabel = shortModelLabel(entry.id);
-      lastModel = entry.id;
-
-      for (let attempt = 1; attempt <= totalAttempts; attempt++) {
-        if (attempt > 1) {
-          log(
-            `[council-manager] Retrying councillor "${name}" (${modelLabel}), attempt ${attempt}/${totalAttempts}`,
-          );
-        } else if (modelIndex > 0) {
-          log(
-            `[council-manager] Councillor "${name}" falling back to ${modelLabel} (model ${modelIndex + 1}/${models.length})`,
-          );
-        }
-
-        try {
-          const result = await this.runAgentSession({
-            parentSessionId,
-            title: `Council ${name} (${modelLabel})`,
-            agent: 'councillor',
-            model: entry.id,
-            promptText: formatCouncillorPrompt(prompt, config.prompt),
-            variant: entry.variant,
-            timeout,
-            includeReasoning: false,
-          });
-
-          return {
-            name,
-            model: entry.id,
-            status: 'completed' as const,
-            result,
-          };
-        } catch (error) {
-          const msg = error instanceof Error ? error.message : String(error);
-          lastStatus = msg.includes('timed out') ? 'timed_out' : 'failed';
-          lastError = `Councillor "${name}": ${msg}`;
-
-          // Retry the same model only on empty responses (silent rate-limit);
-          // any other error moves on to the next model in the chain.
-          const isEmptyResponse = msg.includes('Empty response from provider');
-          if (!(attempt < totalAttempts && isEmptyResponse)) break;
-        }
-      }
-    }
-
-    return {
-      name,
-      model: lastModel,
-      status: lastStatus,
-      error: lastError,
-    };
-  }
-}

+ 0 - 1
src/council/index.ts

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

+ 0 - 12
src/index.ts

@@ -23,7 +23,6 @@ import {
   setActiveRuntimePreset,
 } from './config/runtime-preset';
 import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
-import { CouncilManager } from './council';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
@@ -55,7 +54,6 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
-  createCouncilTool,
   createPresetManager,
   createWebfetchTool,
 } from './tools';
@@ -171,7 +169,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
   let companionManager: CompanionManager;
-  let councilTools: ReturnType<typeof createCouncilTool>;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
@@ -251,14 +248,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       startAvailabilityCheck(multiplexerConfig);
     }
 
-    // Initialize council tools (only when council is configured)
-    councilTools = config.council
-      ? createCouncilTool(
-          ctx,
-          new CouncilManager(ctx, config, multiplexerEnabled),
-        )
-      : {};
-
     mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
     acpRunTools =
       Object.keys(config.acpAgents ?? {}).length > 0
@@ -420,7 +409,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
 
     tools = {
-      ...councilTools,
       ...cancelTaskTools,
       ...acpRunTools,
       webfetch,

+ 11 - 30
src/tools/codemap.md

@@ -4,7 +4,7 @@
 
 Centralized tool factory and registry for the OpenCode plugin system. This directory defines all executable tools exposed to OpenCode agents, including:
 
-- **Agent orchestration tools**: Multi-LLM council sessions, task cancellation, and ACP agent execution
+- **Agent orchestration tools**: Multi-LLM council synthesis, task cancellation, and ACP agent execution
 - **Code intelligence tools**: AST-grep pattern matching and transformation across languages
 - **Web capabilities**: Smart web fetching with caching and secondary model processing
 - **Runtime configuration**: Preset management for dynamic agent configuration switching
@@ -26,7 +26,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 
 | Tool Family | Purpose | Key Components |
 |------------|---------|----------------|
-| **Council** | Multi-LLM consensus orchestration | `council.ts`, `council-manager.ts` |
+| **Council** | Multi-LLM consensus synthesis (orchestrator dispatches councillors as subagents) | `agents/council.ts`, `agents/index.ts` |
 | **Task Management** | Background task lifecycle control | `cancel-task.ts`, `background-job-board.ts` |
 | **ACP Integration** | External agent protocol execution | `acp-run.ts`, ACP client implementation |
 | **Code Intelligence** | AST-based code manipulation | `ast-grep/` directory, `tools.ts` |
@@ -35,7 +35,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 
 ### Security & Validation
 
-- **Agent Restrictions**: Tools validate calling agent identity (e.g., `council_session` only callable by `council` agent)
+- **Agent Restrictions**: Tools validate calling agent identity and configured permissions
 - **Permission Prompts**: Web fetching and ACP tools require explicit user permission via `ctx.ask()`
 - **Timeout Controls**: Configurable timeouts prevent unbounded execution
 - **Input Sanitization**: Zod schemas validate all tool arguments
@@ -68,19 +68,6 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
    └─> OpenCode presents result to agent
 ```
 
-### Council Session Flow (Multi-LLM Orchestration)
-
-```
-1. Agent invokes council_session tool
-   ├─> Validates calling agent is 'council'
-   ├─> Receives prompt and optional preset
-   ├─> Delegates to CouncilManager.runCouncil()
-   │   ├─> Spawns parallel councillor sessions
-   │   ├─> Collects formatted responses
-   │   └─> Synthesizes final output with model composition footer
-   └─> Returns consensus result to agent
-```
-
 ### Task Cancellation Flow
 
 ```
@@ -139,7 +126,8 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
   - `getToolDefinitions()` - Composes tool set for plugin initialization
   
 - **Agents** (`src/agents/`):
-  - Council agent uses `council_session` tool
+  - Orchestrator dispatches councillors as subagents
+  - Council agent synthesizes councillor responses
   - Individual agents use `acp_run` tool for specialized tasks
   - All agents use `ast_grep_search`/`ast_grep_replace` for code manipulation
 
@@ -152,7 +140,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 | Dependency | Purpose |
 |------------|---------|
 | `@opencode-ai/plugin` | Tool schema and execution framework |
-| `CouncilManager` (`src/council/`) | Multi-LLM orchestration engine |
+| `Council Config` (`src/config/council-schema.ts`) | Councillor model/preset definitions |
 | `BackgroundJobBoard` (`src/utils/`) | Background task tracking and cleanup |
 | `Config System` (`src/config/`) | ACP agent configurations and presets |
 | `TUI State` (`src/tui-state.ts`) | Preset visualization in terminal UI |
@@ -162,10 +150,6 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 ### Cross-Module Data Flow
 
 ```
-Tools Layer → Council Layer
-├─ council_session tool → CouncilManager.runCouncil()
-└─> Returns consensus result with model composition footer
-
 Tools Layer → Background Layer
 ├─ cancel_task tool → BackgroundJobBoard.resolve() → abortSessionWithTimeout()
 └─> Returns cancellation status
@@ -210,9 +194,6 @@ export { ast_grep_replace, ast_grep_search } from './ast-grep';
 // Task management
 export { createCancelTaskTool } from './cancel-task';
 
-// Council orchestration
-export { createCouncilTool } from './council';
-
 // Preset management
 export type { PresetManager } from './preset-manager';
 export { createPresetManager } from './preset-manager';
@@ -229,10 +210,10 @@ export { createWebfetchTool } from './smartfetch';
 - Each agent requires: `command`, `args`, `cwd`, `permissionMode`
 - Supports: `ask` (prompt user), `reject` (auto-deny), `allow` (auto-approve)
 
-#### Council Sessions (council.ts)
+#### Council Sessions
 - Configured via council presets in plugin config
-- Requires council agent to be registered in OpenCode
-- Supports preset-specific councillor configurations
+- Orchestrator dispatches each councillor as a prefixed subagent (`councillor-<name>`)
+- Council agent synthesizes responses into a consensus report
 
 #### Presets (preset-manager.ts)
 - Defined in plugin config under `presets` field
@@ -253,7 +234,7 @@ export { createWebfetchTool } from './smartfetch';
 
 - **Unit Tests**: Individual tool factories tested in `*.test.ts` files
 - **Integration Tests**: Tools tested with mock dependencies and OpenCode context
-- **E2E Tests**: Council and ACP tools tested with real external services
+- **E2E Tests**: ACP tools tested with real external services
 - **Binary Tests**: AST-grep CLI availability and functionality verified
 
 ## Performance Considerations
@@ -261,7 +242,7 @@ export { createWebfetchTool } from './smartfetch';
 - **Binary Downloads**: AST-grep CLI downloaded once and cached
 - **Network Caching**: Webfetch results cached to avoid redundant requests
 - **Timeout Enforcement**: Prevents unbounded execution of external tools
-- **Parallel Execution**: Council sessions run councillors in parallel
+- **Parallel Execution**: Councillors run as parallel subagent tasks
 
 ## Security Considerations
 

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

@@ -1,570 +0,0 @@
-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,
-      };
-    }),
-    getDeprecatedFields: mock(() => undefined),
-  } 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');
-    });
-
-    test('preset description does not hardcode the "default" preset', () => {
-      const ctx = createMockPluginContext();
-      const councilManager = createMockCouncilManager();
-      const tools = createCouncilTool(ctx, councilManager);
-
-      const description = (tools.council_session.args.preset as any)
-        .description;
-      expect(description).toBeDefined();
-      expect(description).not.toContain('(default: "default")');
-      expect(description).toContain('configured default');
-    });
-  });
-
-  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.6-luna',
-            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 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');
-        }),
-        getDeprecatedFields: mock(() => undefined),
-      } 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('blocks orchestrator agent 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: 'orchestrator',
-        } as any),
-      ).rejects.toThrow(
-        'Council sessions can only be invoked by the council agent',
-      );
-      expect(councilManager.runCouncil).not.toHaveBeenCalled();
-    });
-
-    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 the council agent',
-      );
-      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');
-    });
-
-    test('includes deprecation warning when deprecated config fields detected', async () => {
-      const ctx = createMockPluginContext();
-      const councilManager = {
-        runCouncil: mock(async () => ({
-          success: true,
-          result: 'Synthesized response',
-          councillorResults: [
-            {
-              name: 'alpha',
-              model: 'test/model',
-              status: 'completed',
-              result: 'Response',
-            },
-          ],
-        })),
-        getDeprecatedFields: mock(() => ['master', 'master_timeout']),
-        getLegacyMasterModel: mock(() => undefined),
-      } as unknown as CouncilManager;
-      const tools = createCouncilTool(ctx, councilManager);
-
-      const result = await tools.council_session.execute({ prompt: 'Test' }, {
-        sessionID: 'test',
-      } as any);
-
-      expect(result).toContain('Config warning');
-      expect(result).toContain('`council.master`');
-      expect(result).toContain('`council.master_timeout`');
-      // master with no legacy model → both treated as ignored
-      expect(result).toContain('deprecated and ignored');
-    });
-
-    test('includes fallback warning when legacy master.model is used', async () => {
-      const ctx = createMockPluginContext();
-      const councilManager = {
-        runCouncil: mock(async () => ({
-          success: true,
-          result: 'Synthesized response',
-          councillorResults: [
-            {
-              name: 'alpha',
-              model: 'test/model',
-              status: 'completed',
-              result: 'Response',
-            },
-          ],
-        })),
-        getDeprecatedFields: mock(() => ['master', 'master_timeout']),
-        getLegacyMasterModel: mock(() => 'anthropic/claude-opus-4-6'),
-      } as unknown as CouncilManager;
-      const tools = createCouncilTool(ctx, councilManager);
-
-      const result = await tools.council_session.execute({ prompt: 'Test' }, {
-        sessionID: 'test',
-      } as any);
-
-      expect(result).toContain('Config warning');
-      expect(result).toContain('`council.master`');
-      // master with legacy model → fallback warning
-      expect(result).toContain('fallback for the council agent');
-      // master_timeout is still "ignored"
-      expect(result).toContain('deprecated and ignored');
-    });
-  });
-});

+ 0 - 128
src/tools/council.ts

@@ -1,128 +0,0 @@
-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.6-luna, β: 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 →
- * formatted results returned to the council agent for 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 and returns their formatted responses for you to synthesize.
-
-Returns the councillor responses with a summary footer.`,
-    args: {
-      prompt: z.string().describe('The prompt to send to all councillors'),
-      preset: z
-        .string()
-        .optional()
-        .describe(
-          'Council preset to use. Omit to use the configured 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 the council agent can invoke council sessions.
-      // If agent is missing from context, allow through (backward compatible).
-      const allowedAgents = ['council'];
-      const callingAgent = (toolContext as { agent?: string }).agent;
-      if (callingAgent && !allowedAgents.includes(callingAgent)) {
-        throw new Error(
-          `Council sessions can only be invoked by the council agent. 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) {
-        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})*`;
-
-      // Warn about deprecated config fields if detected
-      const deprecated = councilManager.getDeprecatedFields();
-      if (deprecated && deprecated.length > 0) {
-        const legacyMasterModel = councilManager.getLegacyMasterModel();
-        const hasMaster = deprecated.includes('master');
-        const trulyIgnored =
-          hasMaster && !legacyMasterModel
-            ? deprecated // master has no model → treat as ignored too
-            : deprecated.filter((f) => f !== 'master');
-        const parts: string[] = [];
-        if (hasMaster && legacyMasterModel) {
-          parts.push(
-            `\`council.master\` is deprecated and will be removed in a future version. Its \`model\` is currently used as a fallback for the council agent - add a \`council\` entry to your preset to make this explicit.`,
-          );
-        }
-        if (trulyIgnored.length > 0) {
-          parts.push(
-            `${trulyIgnored.map((f) => `\`council.${f}\``).join(', ')} ${
-              trulyIgnored.length === 1 ? 'is' : 'are'
-            } deprecated and ignored - remove ${
-              trulyIgnored.length === 1 ? 'it' : 'them'
-            } from your config.`,
-          );
-        }
-        output += `\n⚠ Config warning: ${parts.join(' ')}`;
-      }
-
-      return output;
-    },
-  });
-
-  return { council_session };
-}

+ 0 - 1
src/tools/index.ts

@@ -2,7 +2,6 @@
 export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
-export { createCouncilTool } from './council';
 export type { PresetManager } from './preset-manager';
 export { createPresetManager } from './preset-manager';
 export { createWebfetchTool } from './smartfetch';