diff --git a/src/agents/athena/non-interactive-prompt.ts b/src/agents/athena/non-interactive-prompt.ts
index a0d83ae2b..f3f15b769 100644
--- a/src/agents/athena/non-interactive-prompt.ts
+++ b/src/agents/athena/non-interactive-prompt.ts
@@ -20,8 +20,8 @@ background_wait_timeout_ms: {BACKGROUND_WAIT_TIMEOUT_MS}
-Council members are listed at the end of this prompt after config injection.
-Use EXACTLY the subagent_type names listed there.
+Council members are listed in the athena_council tool description.
+Use member names from there when filtering with the members parameter.
@@ -55,14 +55,12 @@ Precedence for ambiguous cases: DIAGNOSE > AUDIT > PLAN > EVALUATE > EXPLAIN > C
- mode: from Step 2
- intent: from Step 3
-#### Step 4.2: For each resolved member, call the task tool with:
-- subagent_type: the exact member name from registered council members
-- run_in_background: true
-- write_output_to_file: true
-- prompt: "Read for your instructions." (path from Step 4.1)
-- load_skills: []
-- description: the member name
-Launch ALL members before collecting results. Track every task_id.
+#### Step 4.2: Call athena_council to launch ALL members at once:
+- prompt_file: the path returned from Step 4.1
+- members: the resolved member names from Step 1 (omit to launch all configured members)
+
+athena_council launches all members in parallel and returns JSON with task IDs.
+Track every task_id from the response for use in Step 5.
### Step 5: Track progress with background_wait.
- Call background_wait(task_ids=[...all task IDs...], timeout={BACKGROUND_WAIT_TIMEOUT_MS}).
@@ -134,11 +132,13 @@ After synthesis, you MUST output EXACTLY this structured format:
}
+IMPORTANT: is your FINAL output. Do NOT output anything after this tag.
+No commentary, no summary, no follow-up text. The closing tag IS the end of your response.
+
Status values:
- "complete": Quorum met, synthesis performed
- "partial": Some members failed but quorum met
- "failed": Quorum not met (<2 successful members)
-
- NEVER use the Question tool — it is unavailable in non-interactive mode.
@@ -148,6 +148,6 @@ Status values:
- ALWAYS auto-select analysis mode from config (Step 2).
- ALWAYS return the structured output.
- Use background_wait for progress tracking and council_finalize for result collection.
-- Preserve confidence caveats (especially single-member claims) in synthesis.
+- After outputting , STOP IMMEDIATELY. No text after the closing tag.
`
diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts
index 2a9cc13f7..5d42c89ad 100644
--- a/src/plugin/tool-registry.ts
+++ b/src/plugin/tool-registry.ts
@@ -30,6 +30,7 @@ import {
createTaskUpdateTool,
createHashlineEditTool,
createPrepareCouncilPromptTool,
+ createAthenaCouncilTool,
} from "../tools"
import { createCouncilFinalize } from "../tools/council-archive"
import { getMainSessionID } from "../features/claude-code-session-state"
@@ -282,6 +283,10 @@ export function createToolRegistry(args: {
...hashlineToolsRecord,
prepare_council_prompt: createPrepareCouncilPromptTool(ctx.directory),
council_finalize: createCouncilFinalize(ctx.directory),
+ athena_council: createAthenaCouncilTool({
+ backgroundManager: managers.backgroundManager,
+ councilConfig: pluginConfig.agents?.athena?.council,
+ }),
}
for (const toolDefinition of Object.values(allTools)) {
diff --git a/src/tools/athena-council/council-launcher.test.ts b/src/tools/athena-council/council-launcher.test.ts
new file mode 100644
index 000000000..b332a2797
--- /dev/null
+++ b/src/tools/athena-council/council-launcher.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it, mock, beforeEach } from "bun:test"
+import { launchCouncilMember } from "./council-launcher"
+import type { CouncilLaunchContext } from "./council-launcher"
+import type { BackgroundManager } from "../../features/background-agent"
+import type { CouncilMemberConfig } from "../../config/schema/athena"
+
+const makeManager = (overrides?: Partial): BackgroundManager =>
+ ({
+ launch: mock(async () => ({
+ id: "task-123",
+ sessionID: undefined,
+ status: "running",
+ })),
+ getTask: mock(() => undefined),
+ ...overrides,
+ }) as unknown as BackgroundManager
+
+const makeContext = (): CouncilLaunchContext => ({
+ parentSessionID: "ses-parent",
+ parentMessageID: "msg-parent",
+ parentAgent: "athena",
+})
+
+describe("launchCouncilMember", () => {
+ describe("#given a valid member with provider/model format", () => {
+ describe("#when launched successfully", () => {
+ let manager: BackgroundManager
+ let result: Awaited>
+
+ beforeEach(async () => {
+ manager = makeManager()
+ const member: CouncilMemberConfig = {
+ name: "Claude Opus",
+ model: "anthropic/claude-opus-4-6",
+ }
+ result = await launchCouncilMember(member, "analyze this", manager, makeContext())
+ })
+
+ it("#then returns the member in the outcome", () => {
+ expect(result.member.name).toBe("Claude Opus")
+ })
+
+ it("#then returns a task with an id", () => {
+ expect(result.task.id).toBe("task-123")
+ })
+
+ it("#then calls manager.launch once", () => {
+ expect(manager.launch).toHaveBeenCalledTimes(1)
+ })
+
+ it("#then passes the correct agent key with Council: prefix", () => {
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.agent).toBe("Council: Claude Opus")
+ })
+
+ it("#then passes the prompt content", () => {
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.prompt).toBe("analyze this")
+ })
+
+ it("#then passes the correct providerID", () => {
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.model.providerID).toBe("anthropic")
+ })
+
+ it("#then passes the correct modelID", () => {
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.model.modelID).toBe("claude-opus-4-6")
+ })
+
+ it("#then passes the parent session ID", () => {
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.parentSessionID).toBe("ses-parent")
+ })
+ })
+ })
+
+ describe("#given a member with a variant", () => {
+ describe("#when launched", () => {
+ it("#then passes the variant through to the model config", async () => {
+ const manager = makeManager()
+ const member: CouncilMemberConfig = {
+ name: "GPT Codex",
+ model: "openai/gpt-5.3-codex",
+ variant: "medium",
+ }
+ await launchCouncilMember(member, "prompt", manager, makeContext())
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.model.variant).toBe("medium")
+ })
+ })
+ })
+
+ describe("#given a member without a variant", () => {
+ describe("#when launched", () => {
+ it("#then does not include variant in the model config", async () => {
+ const manager = makeManager()
+ const member: CouncilMemberConfig = {
+ name: "Gemini Flash",
+ model: "google/gemini-3-flash",
+ }
+ await launchCouncilMember(member, "prompt", manager, makeContext())
+ const launchArgs = (manager.launch as ReturnType).mock.calls[0][0]
+ expect(launchArgs.model.variant).toBeUndefined()
+ })
+ })
+ })
+
+ describe("#given a member with an invalid model format (no slash)", () => {
+ describe("#when launched", () => {
+ it("#then throws an error about invalid model format", async () => {
+ const manager = makeManager()
+ const member: CouncilMemberConfig = {
+ name: "Bad Model",
+ model: "not-a-valid-model",
+ }
+ await expect(launchCouncilMember(member, "prompt", manager, makeContext())).rejects.toThrow(
+ 'Invalid model format: "not-a-valid-model"',
+ )
+ })
+ })
+ })
+
+ describe("#given a member with an empty model string", () => {
+ describe("#when launched", () => {
+ it("#then throws an error about invalid model format", async () => {
+ const manager = makeManager()
+ const member: CouncilMemberConfig = {
+ name: "Empty Model",
+ model: "",
+ }
+ await expect(launchCouncilMember(member, "prompt", manager, makeContext())).rejects.toThrow(
+ "Invalid model format",
+ )
+ })
+ })
+ })
+})
diff --git a/src/tools/athena-council/council-launcher.ts b/src/tools/athena-council/council-launcher.ts
new file mode 100644
index 000000000..ce188844d
--- /dev/null
+++ b/src/tools/athena-council/council-launcher.ts
@@ -0,0 +1,52 @@
+import type { BackgroundManager } from "../../features/background-agent"
+import type { BackgroundTask } from "../../features/background-agent/types"
+import type { CouncilMemberConfig } from "../../config/schema/athena"
+import { parseModelString } from "../delegate-task/model-string-parser"
+import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents"
+
+export interface CouncilLaunchContext {
+ parentSessionID: string
+ parentMessageID: string
+ parentAgent?: string
+}
+
+interface LaunchOutcome {
+ member: CouncilMemberConfig
+ task: BackgroundTask
+}
+
+/**
+ * Launches a single council member as a background task.
+ * The agent key follows the "Council: " pattern used by council-member-agents.ts.
+ */
+export async function launchCouncilMember(
+ member: CouncilMemberConfig,
+ prompt: string,
+ manager: BackgroundManager,
+ context: CouncilLaunchContext,
+): Promise {
+ const parsed = parseModelString(member.model)
+ if (!parsed) {
+ throw new Error(`Invalid model format: "${member.model}" (expected "provider/model-id")`)
+ }
+
+ const agentKey = `${COUNCIL_MEMBER_KEY_PREFIX}${member.name}`
+ const memberName = member.name ?? member.model
+
+ const task = await manager.launch({
+ description: `Council member: ${memberName}`,
+ prompt,
+ agent: agentKey,
+ parentSessionID: context.parentSessionID,
+ parentMessageID: context.parentMessageID,
+ parentAgent: context.parentAgent,
+ writeOutputToFile: true,
+ model: {
+ providerID: parsed.providerID,
+ modelID: parsed.modelID,
+ ...(member.variant ? { variant: member.variant } : {}),
+ },
+ })
+
+ return { member, task }
+}
diff --git a/src/tools/athena-council/index.ts b/src/tools/athena-council/index.ts
new file mode 100644
index 000000000..82fd5a725
--- /dev/null
+++ b/src/tools/athena-council/index.ts
@@ -0,0 +1,2 @@
+export { createAthenaCouncilTool } from "./tools"
+export type { AthenaCouncilToolArgs, AthenaCouncilResult, LaunchedMemberInfo } from "./types"
diff --git a/src/tools/athena-council/tools.test.ts b/src/tools/athena-council/tools.test.ts
new file mode 100644
index 000000000..833db5bc7
--- /dev/null
+++ b/src/tools/athena-council/tools.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, it, mock, beforeEach, afterAll } from "bun:test"
+import { writeFile, mkdir, rm } from "node:fs/promises"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+
+const mockLaunchCouncilMember = mock(async (member: { name: string; model: string }) => ({
+ member,
+ task: { id: `task-${member.name}`, sessionID: undefined, status: "running" },
+}))
+
+mock.module("./council-launcher", () => ({
+ launchCouncilMember: mockLaunchCouncilMember,
+}))
+
+import { createAthenaCouncilTool } from "./tools"
+import type { BackgroundManager } from "../../features/background-agent"
+import type { CouncilConfig } from "../../config/schema/athena"
+
+const TEST_TMP_DIR = join(tmpdir(), "athena-council-test")
+const PROMPT_FILE = join(TEST_TMP_DIR, "test-prompt.md")
+
+const makeManager = (): BackgroundManager =>
+ ({
+ launch: mock(async () => ({ id: "task-123", sessionID: undefined, status: "running" })),
+ getTask: mock((taskId: string) => ({ id: taskId, sessionID: `ses-${taskId}`, status: "running" })),
+ }) as unknown as BackgroundManager
+
+const makeToolContext = () => ({
+ sessionID: "ses-test",
+ messageID: "msg-test",
+ agent: "athena",
+ abort: undefined,
+})
+
+const makeCouncilConfig = (members?: Array<{ name: string; model: string; variant?: string }>): CouncilConfig => ({
+ members: members ?? [
+ { name: "Claude Opus", model: "anthropic/claude-opus-4-6" },
+ { name: "GPT Codex", model: "openai/gpt-5.3-codex" },
+ ],
+ retry_on_fail: 0,
+ retry_failed_if_others_finished: false,
+ cancel_retrying_on_quorum: true,
+ stuck_threshold_seconds: 300,
+ member_max_running_seconds: 600,
+})
+
+describe("createAthenaCouncilTool", () => {
+ beforeEach(async () => {
+ await mkdir(TEST_TMP_DIR, { recursive: true })
+ await writeFile(PROMPT_FILE, "prompt file content", "utf-8")
+ mockLaunchCouncilMember.mockImplementation(async (member: { name: string; model: string }) => ({
+ member,
+ task: { id: `task-${member.name}`, sessionID: undefined, status: "running" },
+ }))
+ })
+
+ afterAll(async () => {
+ await rm(TEST_TMP_DIR, { recursive: true, force: true })
+ })
+
+ describe("#given council is not configured (undefined)", () => {
+ describe("#when execute is called", () => {
+ it("#then returns error message about council not configured", async () => {
+ const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: undefined })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ expect(result).toContain("Council not configured")
+ })
+ })
+ })
+
+ describe("#given council has zero members", () => {
+ describe("#when execute is called", () => {
+ it("#then returns error message about council not configured", async () => {
+ const config = makeCouncilConfig([])
+ const tool = createAthenaCouncilTool({ backgroundManager: makeManager(), councilConfig: config })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ expect(result).toContain("Council not configured")
+ })
+ })
+ })
+
+ describe("#given prompt_file does not exist", () => {
+ describe("#when execute is called", () => {
+ it("#then returns error message about failed file read", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: "/nonexistent/prompt.md" }, makeToolContext())
+ expect(result).toContain("Failed to read prompt file")
+ expect(result).toContain("/nonexistent/prompt.md")
+ })
+ })
+ })
+
+ describe("#given an unknown member name in the members filter", () => {
+ describe("#when execute is called with that unknown name", () => {
+ it("#then returns error listing unknown member and available members", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute(
+ { prompt_file: PROMPT_FILE, members: ["NonExistentMember"] },
+ makeToolContext(),
+ )
+ expect(result).toContain("Unknown council members: NonExistentMember")
+ expect(result).toContain("Available:")
+ })
+ })
+ })
+
+ describe("#given a valid subset of member names in the filter", () => {
+ describe("#when execute is called with one member name", () => {
+ it("#then launches only the specified member", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute(
+ { prompt_file: PROMPT_FILE, members: ["Claude Opus"] },
+ makeToolContext(),
+ )
+ const jsonMatch = result.match(/\{[\s\S]*\}/)
+ expect(jsonMatch).not.toBeNull()
+ const parsed = JSON.parse(jsonMatch![0])
+ expect(parsed.total_requested).toBe(1)
+ expect(parsed.launched).toHaveLength(1)
+ expect(parsed.launched[0].member_name).toBe("Claude Opus")
+ })
+ })
+ })
+
+ describe("#given all members are valid and prompt file is readable", () => {
+ describe("#when execute is called without member filter", () => {
+ it("#then returns JSON with all launched members", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ const jsonMatch = result.match(/\{[\s\S]*\}/)
+ expect(jsonMatch).not.toBeNull()
+ const parsed = JSON.parse(jsonMatch![0])
+ expect(parsed.total_requested).toBe(2)
+ expect(parsed.launched).toHaveLength(2)
+ expect(parsed.failures).toHaveLength(0)
+ })
+
+ it("#then includes task IDs and background_wait instructions in the output", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ expect(result).toContain("background_wait")
+ expect(result).toContain("task_ids=")
+ })
+ })
+ })
+
+ describe("#given some members fail to launch", () => {
+ describe("#when execute is called", () => {
+ beforeEach(() => {
+ let callCount = 0
+ mockLaunchCouncilMember.mockImplementation(async (member: { name: string; model: string }) => {
+ callCount++
+ if (callCount === 1) {
+ return { member, task: { id: `task-${member.name}`, sessionID: undefined, status: "running" } }
+ }
+ throw new Error("Launch failed for member")
+ })
+ })
+
+ it("#then includes successful launches in the result", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ const jsonMatch = result.match(/\{[\s\S]*\}/)
+ expect(jsonMatch).not.toBeNull()
+ const parsed = JSON.parse(jsonMatch![0])
+ expect(parsed.launched).toHaveLength(1)
+ })
+
+ it("#then includes failures in the result", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ const jsonMatch = result.match(/\{[\s\S]*\}/)
+ expect(jsonMatch).not.toBeNull()
+ const parsed = JSON.parse(jsonMatch![0])
+ expect(parsed.failures).toHaveLength(1)
+ expect(parsed.failures[0].error).toContain("Launch failed for member")
+ })
+ })
+ })
+
+ describe("#given all members fail to launch", () => {
+ describe("#when execute is called", () => {
+ beforeEach(() => {
+ mockLaunchCouncilMember.mockImplementation(async () => {
+ throw new Error("All launches failed")
+ })
+ })
+
+ it("#then returns a plain error string (not JSON)", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ expect(result).toContain("All council member launches failed")
+ expect(result).not.toContain('"launched"')
+ })
+
+ it("#then lists each failure in the error message", async () => {
+ const tool = createAthenaCouncilTool({
+ backgroundManager: makeManager(),
+ councilConfig: makeCouncilConfig(),
+ })
+ const result = await tool.execute({ prompt_file: PROMPT_FILE }, makeToolContext())
+ expect(result).toContain("Claude Opus")
+ expect(result).toContain("GPT Codex")
+ })
+ })
+ })
+})
diff --git a/src/tools/athena-council/tools.ts b/src/tools/athena-council/tools.ts
new file mode 100644
index 000000000..d1c85d9ed
--- /dev/null
+++ b/src/tools/athena-council/tools.ts
@@ -0,0 +1,195 @@
+import { tool, type ToolDefinition } from "@opencode-ai/plugin"
+import { readFile } from "node:fs/promises"
+import type { BackgroundManager } from "../../features/background-agent"
+import type { CouncilConfig, CouncilMemberConfig } from "../../config/schema/athena"
+import { launchCouncilMember, type CouncilLaunchContext } from "./council-launcher"
+import type { AthenaCouncilToolArgs, LaunchedMemberInfo, AthenaCouncilResult } from "./types"
+import { log } from "../../shared/logger"
+
+const SESSION_WAIT_INTERVAL_MS = 100
+const SESSION_WAIT_TIMEOUT_MS = 30_000
+
+function buildToolDescription(councilConfig: CouncilConfig | undefined): string {
+ const memberList = councilConfig?.members.length
+ ? councilConfig.members.map((m) => `- ${m.name} (${m.model})`).join("\n")
+ : "No members configured."
+
+ return `Launch all council members in parallel for multi-model analysis.
+
+Takes a prompt file path (from prepare_council_prompt) and launches all specified council members
+as background tasks. Returns an array of task IDs for use with background_wait and background_output.
+
+Available council members:
+${memberList}
+
+Returns JSON with launched task IDs and any launch failures.`
+}
+
+function filterMembers(
+ allMembers: CouncilMemberConfig[],
+ selectedNames: string[] | undefined,
+): { members: CouncilMemberConfig[]; error?: string } {
+ if (!selectedNames || selectedNames.length === 0) {
+ return { members: allMembers }
+ }
+
+ const lookup = new Map()
+ for (const member of allMembers) {
+ lookup.set(member.model.toLowerCase(), member)
+ if (member.name) {
+ lookup.set(member.name.toLowerCase(), member)
+ }
+ }
+
+ const filtered: CouncilMemberConfig[] = []
+ const seen = new Set()
+ const unresolved: string[] = []
+
+ for (const name of selectedNames) {
+ const match = lookup.get(name.toLowerCase())
+ if (!match) {
+ unresolved.push(name)
+ continue
+ }
+ if (!seen.has(match)) {
+ seen.add(match)
+ filtered.push(match)
+ }
+ }
+
+ if (unresolved.length > 0) {
+ const available = allMembers.map((m) => m.name ?? m.model).join(", ")
+ return { members: [], error: `Unknown council members: ${unresolved.join(", ")}. Available: ${available}` }
+ }
+
+ return { members: filtered }
+}
+
+/**
+ * Waits briefly for background sessions to acquire session IDs.
+ * Non-blocking — returns whatever is available within the timeout.
+ */
+async function waitForSessionIds(
+ taskIds: string[],
+ manager: BackgroundManager,
+ abort?: AbortSignal,
+): Promise