/** * aicouncil — a multi-model deliberation extension for oh-my-pi (omp). * * Implements the Karpathy llm-council flow on top of omp's provider layer, so * every seat runs on your existing subscriptions (Anthropic OAuth, Codex OAuth, * Antigravity OAuth, ...) — no extra API keys. * * Stage 1 OPINIONS — every member answers the question independently, in parallel * Stage 2 REVIEW — every member ranks the anonymized answers (A/B/C/...) * Stage 3 SYNTHESIS — the chairman reads answers + reviews and writes the final verdict * * Usage: * /council convene the council * /council models list model IDs you are authenticated for * /council roster show the resolved seats + chairman * /council live open the live agent-communication graph * /council last open the latest deliberation dashboard * /council add [nick] seat a new member (persisted to council.json) * /council remove unseat a member * /council chair [nick] set the chairman * * The main agent also gets a `council` tool (read-only approval), so you can just * say "ask the council whether ..." mid-session. * * Config (later overrides earlier): `council.json` next to this file → * `~/.omp/agent/council.json` → `/.omp/council.json`. * See DEFAULT_CONFIG below for the schema and defaults. */ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { completeSimple } from "@oh-my-pi/pi-ai"; import type { AssistantMessage, Effort, Model, UserMessage } from "@oh-my-pi/pi-ai"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@oh-my-pi/pi-coding-agent"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- interface MemberConfig { /** "provider/model-id" — run `/council models` for the exact IDs you have. */ model: string; /** Display name in transcripts. Defaults to the model id. */ nick?: string; /** Optional per-member reasoning effort: minimal|low|medium|high|xhigh|max */ effort?: string; } interface CouncilConfig { members: MemberConfig[]; /** "provider/model-id" of the synthesizing chairman. */ chairman: string; chairmanNick?: string; chairmanEffort?: string; /** Reasoning effort for members without their own `effort`. */ effort?: string; maxTokensOpinion: number; maxTokensReview: number; maxTokensSynthesis: number; /** Per-model-call timeout. */ timeoutMs: number; /** Prepend recent session conversation to the question so the council has context. */ includeSessionContext: boolean; /** Char budget for that context slice. */ contextCharBudget: number; /** Write the full deliberation to /.omp/council/. */ writeTranscript: boolean; /** Also expose the council as a tool the main agent can call. */ exposeTool: boolean; /** Serve a live communication graph (glowing agent-to-agent lines) and open it when a run starts. */ liveGraph: boolean; } /** * Default roster: Fable 5 + Opus 5 (one Anthropic login covers both), Codex, * and Gemini via Antigravity. Opus 5 chairs. * * The model IDs below are best guesses — run `/council models` after * `/login`-ing each provider and fix any seat that reports "unresolved" * in `/council roster`. Fuzzy matching handles most drift (e.g. * "anthropic/opus" finds claude-opus-*). */ const DEFAULT_CONFIG: CouncilConfig = { members: [ { model: "anthropic/claude-fable-5", nick: "Fable 5" }, { model: "anthropic/claude-opus-5", nick: "Opus 5" }, { model: "openai-codex/gpt-5-codex", nick: "Codex" }, { model: "google-antigravity/gemini-3-pro", nick: "Gemini" }, ], chairman: "anthropic/claude-opus-5", chairmanNick: "Opus 5", chairmanEffort: "high", effort: "medium", maxTokensOpinion: 3000, maxTokensReview: 1500, maxTokensSynthesis: 4000, timeoutMs: 240_000, includeSessionContext: true, contextCharBudget: 24_000, writeTranscript: true, exposeTool: true, liveGraph: true, }; function loadConfig(cwd: string): CouncilConfig { const merged: CouncilConfig = { ...DEFAULT_CONFIG }; const here = (() => { try { return join(fileURLToPath(import.meta.url), ".."); } catch { return undefined; } })(); // Later entries override earlier ones: packaged defaults < user config < project config. const candidates = [ here ? join(here, "council.json") : undefined, userConfigPath(), join(cwd, ".omp", "council.json"), ]; for (const path of candidates) { if (!path || !existsSync(path)) continue; try { const raw = JSON.parse(readFileSync(path, "utf-8")) as Partial; Object.assign(merged, raw); } catch (error) { // Bad JSON should not brick the extension; surface it later via roster. configLoadError = `${path}: ${error instanceof Error ? error.message : String(error)}`; } } return merged; } let configLoadError: string | undefined; const VERSION = "0.4.3"; // --------------------------------------------------------------------------- // Roster mutation (/council add | remove | chair) — persisted to council.json // --------------------------------------------------------------------------- /** User-level config path — honors omp's PI_CODING_AGENT_DIR (profiles) like core does. */ function userConfigPath(): string { const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".omp", "agent"); return join(agentDir, "council.json"); } /** * Where a roster change should be written: the project config if it already * owns that key (it would override anything we write user-level), else the * user config. */ function pickWriteTarget(cwd: string, key: "members" | "chairman"): string { const projectPath = join(cwd, ".omp", "council.json"); if (existsSync(projectPath)) { try { const json = JSON.parse(readFileSync(projectPath, "utf-8")) as Record; if (json[key] !== undefined) return projectPath; } catch { // unreadable project config — fall through to user config } } return userConfigPath(); } function updateConfigFile(path: string, mutate: (json: Record) => void): void { let json: Record = {}; if (existsSync(path)) { try { json = JSON.parse(readFileSync(path, "utf-8")) as Record; } catch (error) { throw new Error(`${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}) — fix it before using roster commands`); } } mutate(json); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(json, null, 2)}\n`); } // --------------------------------------------------------------------------- // Model resolution + side-calls // --------------------------------------------------------------------------- interface Seat { nick: string; spec: string; model: Model | undefined; effort?: string; } /** Resolve "provider/id" exactly, then fall back to token containment over authenticated models. */ function resolveModel(ctx: ExtensionContext, spec: string): Model | undefined { const direct = ctx.models.resolve(spec); if (direct) return direct; const all = ctx.models.list(); const slash = spec.indexOf("/"); const provider = slash > 0 ? spec.slice(0, slash).toLowerCase() : undefined; const idPart = (slash > 0 ? spec.slice(slash + 1) : spec).toLowerCase(); const tokens = idPart.split(/[^a-z0-9.]+/).filter(Boolean); if (tokens.length === 0) return undefined; const candidates = all.filter( m => (!provider || m.provider.toLowerCase() === provider) && tokens.every(t => m.id.toLowerCase().includes(t)), ); // Shortest matching id ≈ the base model (avoids -thinking/-preview variants). candidates.sort((a, b) => a.id.length - b.id.length); return candidates[0]; } function resolveSeats(ctx: ExtensionContext, config: CouncilConfig): { members: Seat[]; chairman: Seat } { const members = config.members.map(m => ({ nick: m.nick ?? m.model, spec: m.model, model: resolveModel(ctx, m.model), effort: m.effort ?? config.effort, })); const chairman: Seat = { nick: config.chairmanNick ?? config.chairman, spec: config.chairman, model: resolveModel(ctx, config.chairman), effort: config.chairmanEffort ?? config.effort, }; return { members, chairman }; } interface CallResult { text: string; error?: string; inputTokens?: number; outputTokens?: number; elapsedMs: number; } /** * Resolve request auth against whatever surface this omp/pi build exposes. * The extension-facing registry API has drifted across versions * (getApiKeyAndHeaders → getApiKey/getProviderHeaders → resolver), so we * capability-detect instead of assuming one method exists. */ type ResolvedAuth = { ok: true; apiKey: unknown; headers?: Record } | { ok: false; error: string }; async function resolveAuth(ctx: ExtensionContext, model: Model): Promise { const reg = ctx.modelRegistry as unknown as { getApiKeyAndHeaders?: (m: Model) => Promise<{ ok: boolean; apiKey?: string; headers?: Record; error?: string }>; getApiKey?: (m: Model, sessionId?: string) => Promise; getProviderHeaders?: (provider: string) => Record | undefined; resolver?: (m: Model, sessionId?: string) => unknown; }; const headers = (() => { try { return typeof reg?.getProviderHeaders === "function" ? reg.getProviderHeaders(model.provider) : undefined; } catch { return undefined; } })(); try { if (typeof reg?.getApiKeyAndHeaders === "function") { const auth = await reg.getApiKeyAndHeaders(model); if (auth?.ok && auth.apiKey) return { ok: true, apiKey: auth.apiKey, headers: auth.headers ?? headers }; return { ok: false, error: auth?.error ?? `no credentials for ${model.provider} — try /login ${model.provider}` }; } if (typeof reg?.getApiKey === "function") { const key = await reg.getApiKey(model); if (key) return { ok: true, apiKey: key, headers }; return { ok: false, error: `no credentials for ${model.provider} — try /login ${model.provider}` }; } if (typeof reg?.resolver === "function") { return { ok: true, apiKey: reg.resolver(model), headers }; } return { ok: false, error: "this omp/pi build exposes no known auth API on ctx.modelRegistry — run /council doctor and report the output at github.com/mynenikoteshwarrao/aicouncil/issues", }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } /** One tool-less side-call. Never throws — errors come back in `.error`. */ async function callModel( ctx: ExtensionContext, seat: Seat, systemPrompt: string, userText: string, maxTokens: number, timeoutMs: number, outerSignal?: AbortSignal, ): Promise { const started = Date.now(); const model = seat.model; if (!model) return { text: "", error: `model "${seat.spec}" not resolved`, elapsedMs: 0 }; const auth = await resolveAuth(ctx, model); if (!auth.ok) return { text: "", error: auth.error, elapsedMs: Date.now() - started }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); const onOuterAbort = () => controller.abort(outerSignal?.reason); outerSignal?.addEventListener("abort", onOuterAbort, { once: true }); if (outerSignal?.aborted) controller.abort(outerSignal.reason); try { const message: UserMessage = { role: "user", content: userText, timestamp: Date.now() }; const response: AssistantMessage = await completeSimple( model, { systemPrompt: [systemPrompt], messages: [message], tools: [] }, { // string or ApiKeyResolver depending on which registry surface answered apiKey: auth.apiKey as string, headers: auth.headers, signal: controller.signal, maxTokens, reasoning: seat.effort as Effort | undefined, }, ); const elapsedMs = Date.now() - started; if (response.stopReason === "error" || response.stopReason === "aborted") { return { text: "", error: response.errorMessage ?? `stopReason=${response.stopReason}`, elapsedMs, }; } let text = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join("\n") .trim(); if (!text) { // Reasoning models occasionally emit only thinking blocks; salvage those. text = response.content .filter((c): c is { type: "thinking"; thinking: string } => c.type === "thinking") .map(c => c.thinking) .join("\n") .trim(); } if (!text) return { text: "", error: "empty response", elapsedMs }; return { text, inputTokens: response.usage?.input, outputTokens: response.usage?.output, elapsedMs, }; } catch (error) { return { text: "", error: error instanceof Error ? error.message : String(error), elapsedMs: Date.now() - started, }; } finally { clearTimeout(timer); outerSignal?.removeEventListener("abort", onOuterAbort); } } // --------------------------------------------------------------------------- // Session context // --------------------------------------------------------------------------- /** Recent user/assistant text from the session, newest-last, capped by charBudget. */ function collectSessionContext(ctx: ExtensionContext, charBudget: number): string | undefined { const lines: string[] = []; let used = 0; const entries = ctx.sessionManager.getEntries(); for (let i = entries.length - 1; i >= 0 && used < charBudget; i--) { const entry = entries[i]; if (!entry || entry.type !== "message") continue; const msg = entry.message; let text = ""; if (msg.role === "user") { text = typeof msg.content === "string" ? msg.content : msg.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join("\n"); } else if (msg.role === "assistant") { text = msg.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join("\n"); } else { continue; } text = text.trim(); if (!text) continue; const line = `${msg.role === "user" ? "User" : "Assistant"}: ${text}`; used += line.length; lines.push(line.length > charBudget ? `${line.slice(0, charBudget)}…` : line); } if (lines.length === 0) return undefined; lines.reverse(); return lines.join("\n\n"); } // --------------------------------------------------------------------------- // Prompts // --------------------------------------------------------------------------- const LETTERS = "ABCDEFGHIJKLMNOP"; function opinionSystemPrompt(nick: string, n: number): string { return [ `You are ${nick}, one of ${n} independent members of an AI council convened to answer a question.`, `Give YOUR best answer. Rules:`, `- Be direct and concrete. Commit to positions; if something is uncertain, flag it in one line rather than hedging everything.`, `- If the question involves code or a technical decision, be precise and show the key reasoning or snippet.`, `- No preamble, no "as an AI". Answer as a sharp domain expert would.`, ].join("\n"); } function reviewSystemPrompt(nick: string, n: number): string { return [ `You are ${nick}, a member of an AI council. The question and ${n} anonymized responses follow (one may be your own — you do not know which).`, `Evaluate each response for accuracy, insight, and completeness. Be adversarial: hunt for factual errors, weak reasoning, and missed considerations. Praise only what earns it.`, `For each response give 1-3 sentences of assessment.`, `Then end with EXACTLY one final line in this format (best first):`, `RANKING: A > B > C`, ].join("\n"); } function chairmanSystemPrompt(nick: string): string { return [ `You are ${nick}, chairman of an AI council. You have the question, the members' independent answers (anonymized), and their peer reviews of each other.`, `Write the council's FINAL answer:`, `- Synthesize the strongest, correct material. Weigh arguments by substance, not popularity.`, `- Where members disagreed, name the disagreement explicitly and make the call yourself, with reasoning. Do not average positions into mush — a false consensus is worse than an honest split.`, `- Correct any factual errors the reviews exposed.`, `- Structure: the answer itself first; then, if relevant, a short "Where the council split" section.`, ].join("\n"); } function questionBlock(question: string, sessionContext: string | undefined): string { const parts: string[] = []; if (sessionContext) { parts.push(`\nRecent conversation for background (may or may not be relevant):\n\n${sessionContext}\n\n`); } parts.push(`Question for the council:\n\n${question}`); return parts.join("\n"); } // --------------------------------------------------------------------------- // Ranking aggregation (Borda count over parsed RANKING lines) // --------------------------------------------------------------------------- function parseRanking(text: string, n: number): number[] | undefined { const matches = [...text.matchAll(/RANKING:\s*([A-Z](?:\s*>\s*[A-Z])*)/gi)]; const last = matches[matches.length - 1]; if (!last?.[1]) return undefined; const order = last[1] .split(">") .map(s => LETTERS.indexOf(s.trim().toUpperCase())) .filter(i => i >= 0 && i < n); return new Set(order).size >= 2 ? [...new Set(order)] : undefined; } /** Returns average Borda points per seat index (higher = better), or undefined if nothing parsed. */ function aggregateRankings(reviews: string[], n: number): number[] | undefined { const points = new Array(n).fill(0); let voters = 0; for (const review of reviews) { const order = parseRanking(review, n); if (!order) continue; voters++; order.forEach((seatIdx, pos) => { points[seatIdx] = (points[seatIdx] ?? 0) + (n - pos); }); } if (voters === 0) return undefined; return points.map(p => p / voters); } // --------------------------------------------------------------------------- // Deliberation dashboard (self-contained HTML, one per run) // --------------------------------------------------------------------------- export interface SeatRecord { letter: string; nick: string; model: string; answer?: string; answerMs?: number; answerTokens?: number; review?: string; reviewMs?: number; /** Rank order this seat gave (array of seat indices, best first), if parsed. */ rankingGiven?: number[]; error?: string; } export interface DeliberationRecord { question: string; startedAtIso: string; durationMs: number; chairman: { nick: string; model: string; error?: string; ms?: number }; seats: SeatRecord[]; /** Average Borda points per seat index, if any rankings parsed. */ borda?: number[]; verdict?: string; failures: string[]; settings: { effort?: string; chairmanEffort?: string; timeoutMs: number; contextIncluded: boolean }; } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } /** Tiny markdown renderer for answer bodies — headers, fences, inline code, bold, lists. */ function mdToHtml(md: string): string { const fences: string[] = []; let s = md.replace(/```([\s\S]*?)```/g, (_m, code: string) => { fences.push(`
${escapeHtml(code.replace(/^[a-z]*\n/, ""))}
`); return `${fences.length - 1}`; }); s = escapeHtml(s); s = s.replace(/`([^`\n]+)`/g, "$1"); s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1"); s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,;:!?]|$)/gm, "$1$2"); s = s.replace(/^######? (.+)$/gm, "
$1
").replace(/^#{1,4} (.+)$/gm, "

$1

"); s = s.replace(/^[-*] (.+)$/gm, "
  • $1
  • ").replace(/(
  • [\s\S]*?<\/li>)(?!\s*
  • )/g, "
      $1
    "); s = s .split(/\n{2,}/) .map(p => (/^\s*<(h4|h5|ul|pre)/.test(p) ? p : `

    ${p.replace(/\n/g, "
    ")}

    `)) .join("\n"); return s.replace(/(\d+)/g, (_m, i: string) => fences[Number(i)]!); } function fmtMs(ms: number | undefined): string { if (ms === undefined) return "–"; return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; } /** Categorical slots (validated reference palette; color follows the seat, fixed order). */ const SEAT_COLORS_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]; const SEAT_COLORS_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"]; export function renderDashboardHtml(rec: DeliberationRecord): string { const n = rec.seats.length; const answered = rec.seats.filter(s => s.answer); const reviewed = rec.seats.filter(s => s.review); const seatVars = rec.seats .map((_s, i) => `--seat-${i}: ${SEAT_COLORS_LIGHT[i % 8]}; --seat-${i}-d: ${SEAT_COLORS_DARK[i % 8]};`) .join(" "); // Borda leaderboard, sorted by score, colors stay with the seat entity. const maxScore = rec.borda ? Math.max(...rec.borda, 0.001) : 0; const leaderboard = rec.borda ? rec.seats .map((s, i) => ({ s, i, score: rec.borda![i] ?? 0 })) .filter(e => e.s.answer) .sort((a, b) => b.score - a.score) .map( e => `
    ${escapeHtml(e.s.nick)} ${e.score.toFixed(1)}
    `, ) .join("") : `

    No parseable rankings this run.

    `; // Reviewer × subject rank matrix. rank 1 = strongest ordinal step. const ordinal = ["#86b6ef", "#9ec5f4", "#b7d3f6", "#cde2fb", "#e7f0fc", "#f1f6fd", "#f6f9fe", "#fafcff"]; const matrixHead = answered.map(s => `${s.letter}`).join(""); const matrixRows = reviewed .map(r => { const cells = answered .map(subject => { const subjectIdx = rec.seats.indexOf(subject); const pos = r.rankingGiven?.indexOf(subjectIdx); if (pos === undefined || pos < 0) return `–`; const bg = ordinal[Math.min(pos, ordinal.length - 1)]!; return `${pos + 1}`; }) .join(""); const ri = rec.seats.indexOf(r); return `${escapeHtml(r.nick)}${cells}`; }) .join(""); const seatCards = rec.seats .map((s, i) => { const status = s.answer ? `✓ answered · ${fmtMs(s.answerMs)}${s.answerTokens ? ` · ${s.answerTokens} tok` : ""}` : `✗ ${escapeHtml(s.error ?? "no answer")}`; const review = s.review ? `
    Peer review (${fmtMs(s.reviewMs)})
    ${mdToHtml(s.review)}
    ` : ""; const answer = s.answer ? `
    Opinion — Seat ${s.letter}
    ${mdToHtml(s.answer)}
    ` : ""; return `

    ${escapeHtml(s.nick)} Seat ${s.letter}

    ${escapeHtml(s.model)}

    ${status}

    ${answer}${review}
    `; }) .join(""); return ` Council — ${escapeHtml(rec.question.slice(0, 60))}

    🏛️ Council deliberation

    ${escapeHtml(rec.startedAtIso)} · total ${fmtMs(rec.durationMs)} · effort ${escapeHtml(rec.settings.effort ?? "default")} · chair effort ${escapeHtml(rec.settings.chairmanEffort ?? "default")} · timeout ${Math.round(rec.settings.timeoutMs / 1000)}s · session context ${rec.settings.contextIncluded ? "on" : "off"}

    Q: ${escapeHtml(rec.question)}

    1 · Opinions ${answered.length}/${n} answered 2 · Anonymized review ${reviewed.length} reviewers 3 · Synthesis ${escapeHtml(rec.chairman.nick)}${rec.chairman.error ? " ✗" : " ✓"}

    Verdict — chaired by ${escapeHtml(rec.chairman.nick)} ${escapeHtml(rec.chairman.model)}

    ${rec.verdict ? mdToHtml(rec.verdict) : `

    ✗ Chairman failed: ${escapeHtml(rec.chairman.error ?? "unknown")}

    `}

    Peer ranking — Borda average (higher is better)

    ${leaderboard} ${matrixRows ? `

    Who ranked whom (1 = best)

    ${matrixHead}${matrixRows}
    reviewer ↓
    ` : ""}
    ${seatCards}
    ${rec.failures.length ? `

    ⚠ Issues

      ${rec.failures.map(f => `
    • ${escapeHtml(f)}
    • `).join("")}
    ` : ""}
    generated by aicouncil · the letters are what reviewers saw; identities were revealed only after ranking
    `; } // --------------------------------------------------------------------------- // Live communication graph — agents as nodes, glowing lines while they talk. // Served from inside omp (Bun.serve) with SSE; auto-opens on run start. // --------------------------------------------------------------------------- export function liveGraphPageHtml(demoEvents?: [number, Record][]): string { const demo = demoEvents ? `` : ""; return ` aicouncil — live ${demo}

    🏛️ aicouncil live

    1 · Opinions2 · Anonymized review3 · Synthesis
    waiting for a council to convene… run /council <question> in omp
    `; } interface LiveServerHandle { port: number; push: (ev: Record) => void; stop: () => void; } let liveServer: LiveServerHandle | undefined; let liveHistory: Record[] = []; let liveOpened = false; /** Start (or reuse) the SSE server on a random localhost port. Returns undefined off-Bun. */ function ensureLiveServer(): LiveServerHandle | undefined { if (liveServer) return liveServer; const BunRt = (globalThis as { Bun?: { serve?: (opts: unknown) => { port: number; stop: (force?: boolean) => void } } }).Bun; if (!BunRt?.serve) return undefined; const encoder = new TextEncoder(); const clients = new Set>(); try { const server = BunRt.serve({ port: 0, fetch: (req: Request) => { const path = new URL(req.url).pathname; if (path === "/") { return new Response(liveGraphPageHtml(), { headers: { "content-type": "text/html; charset=utf-8" } }); } if (path === "/events") { let ctrl: ReadableStreamDefaultController | undefined; const stream = new ReadableStream({ start(c) { ctrl = c; clients.add(c); for (const ev of liveHistory) c.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`)); }, cancel() { if (ctrl) clients.delete(ctrl); }, }); return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, }); } return new Response("not found", { status: 404 }); }, }); liveServer = { port: server.port, push: ev => { liveHistory = ev.type === "run_start" ? [ev] : [...liveHistory, ev]; const data = encoder.encode(`data: ${JSON.stringify(ev)}\n\n`); for (const c of [...clients]) { try { c.enqueue(data); } catch { clients.delete(c); } } }, stop: () => { try { server.stop(true); } catch { // already down } liveServer = undefined; }, }; return liveServer; } catch { return undefined; } } function livePush(ev: Record): void { liveServer?.push(ev); } /** Open a file or URL in the default browser/app, best-effort. */ async function openExternal(pi: ExtensionAPI | undefined, target: string): Promise { if (!pi) return false; const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"; try { await pi.exec(opener, [target]); return true; } catch { return false; } } let piRef: ExtensionAPI | undefined; // --------------------------------------------------------------------------- // The council run // --------------------------------------------------------------------------- interface CouncilRunResult { verdict: string; summaryMarkdown: string; transcriptPath?: string; dashboardPath?: string; failures: string[]; } async function runCouncil( ctx: ExtensionContext, question: string, signal?: AbortSignal, onProgress?: (status: string) => void, ): Promise { const config = loadConfig(ctx.cwd); const { members, chairman } = resolveSeats(ctx, config); const failures: string[] = []; const startedAt = Date.now(); // Live TUI widget: per-seat status through the three stages. const seatStage = new Map(); const renderWidget = ctx.hasUI ? () => { const lines = [`🏛️ council · ${question.slice(0, 60)}${question.length > 60 ? "…" : ""}`]; for (const [nick, state] of seatStage) lines.push(` ${nick.padEnd(12)} ${state}`); ctx.ui.setWidget("council", lines); } : () => {}; const setStage = (nick: string, state: string) => { seatStage.set(nick, state); renderWidget(); }; const active = members.filter(m => m.model).slice(0, LETTERS.length); for (const m of members.filter(m => !m.model)) { failures.push(`${m.nick}: model "${m.spec}" not found among authenticated models (run /council models, then /login the provider or fix council.json)`); } if (active.length < 2) { throw new Error( `Council needs at least 2 resolvable members, got ${active.length}.\n${failures.join("\n")}`, ); } if (!chairman.model) { throw new Error(`Chairman "${chairman.spec}" not resolved. Run /council models and fix council.json.`); } const sessionContext = config.includeSessionContext ? collectSessionContext(ctx, config.contextCharBudget) : undefined; const qBlock = questionBlock(question, sessionContext); // Shuffle seat → letter assignment so letter order carries no information. const shuffled = [...active]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]; } // Live graph: start/reuse the SSE server and announce the run. if (config.liveGraph) { const srv = ensureLiveServer(); if (srv) { const chairIdx = shuffled.findIndex( s => s.model && `${s.model.provider}/${s.model.id}` === `${chairman.model!.provider}/${chairman.model!.id}`, ); livePush({ type: "run_start", question: question.slice(0, 160), seats: shuffled.map((s, i) => ({ nick: s.nick, color: SEAT_COLORS_DARK[i % 8] })), chair: { nick: chairman.nick, color: chairIdx >= 0 ? SEAT_COLORS_DARK[chairIdx % 8] : "#9085e9" }, }); if (!liveOpened) { liveOpened = true; void openExternal(piRef, `http://localhost:${srv.port}/`); } } } // -- Stage 1: opinions ---------------------------------------------------- onProgress?.(`council: ${shuffled.length} members deliberating…`); const opinions = await Promise.all( shuffled.map(seat => { setStage(seat.nick, "⏳ forming opinion…"); livePush({ type: "op_start", seat: seat.nick }); return callModel(ctx, seat, opinionSystemPrompt(seat.nick, shuffled.length), qBlock, config.maxTokensOpinion, config.timeoutMs, signal).then(res => { setStage(seat.nick, res.error ? `✗ ${res.error.slice(0, 40)}` : `✓ answered ${fmtMs(res.elapsedMs)}`); livePush({ type: "op_end", seat: seat.nick, ok: !res.error, ms: res.elapsedMs }); return res; }); }), ); const answered: { seat: Seat; letter: string; answer: string }[] = []; opinions.forEach((res, i) => { const seat = shuffled[i]!; if (res.error || !res.text) { failures.push(`${seat.nick} (opinion): ${res.error ?? "no answer"}`); } else { answered.push({ seat, letter: LETTERS[answered.length]!, answer: res.text }); } }); if (answered.length < 2) { throw new Error(`Only ${answered.length} member(s) answered — not enough to deliberate.\n${failures.join("\n")}`); } const answersBlock = answered .map(a => `### Response ${a.letter}\n\n${a.answer}`) .join("\n\n---\n\n"); // -- Stage 2: anonymized peer review ------------------------------------- onProgress?.(`council: peer review (${answered.length} reviewers)…`); const reviewInput = `${qBlock}\n\n---\n\nThe ${answered.length} responses:\n\n${answersBlock}`; const reviewResults = await Promise.all( answered.map(a => { setStage(a.seat.nick, "⏳ reviewing peers…"); livePush({ type: "review_start", seat: a.seat.nick }); return callModel(ctx, a.seat, reviewSystemPrompt(a.seat.nick, answered.length), reviewInput, config.maxTokensReview, config.timeoutMs, signal).then(res => { setStage(a.seat.nick, res.error ? `✓ answered · ✗ review failed` : `✓ answered · ✓ reviewed ${fmtMs(res.elapsedMs)}`); livePush({ type: "review_end", seat: a.seat.nick, ok: !res.error, ms: res.elapsedMs }); return res; }); }), ); const reviews: { seat: Seat; text: string; elapsedMs: number }[] = []; reviewResults.forEach((res, i) => { const seat = answered[i]!.seat; if (res.error || !res.text) failures.push(`${seat.nick} (review): ${res.error ?? "empty"}`); else reviews.push({ seat, text: res.text, elapsedMs: res.elapsedMs }); }); const rankingScores = aggregateRankings(reviews.map(r => r.text), answered.length); const leaderboard = rankingScores ? answered .map((a, i) => ({ ...a, score: rankingScores[i]! })) .sort((x, y) => y.score - x.score) : undefined; // -- Stage 3: chairman synthesis ------------------------------------------ onProgress?.(`council: ${chairman.nick} synthesizing…`); setStage(`⚖ ${chairman.nick}`, "⏳ synthesizing verdict…"); livePush({ type: "synth_start" }); const reviewsBlock = reviews.length > 0 ? reviews.map((r, i) => `### Review by member ${i + 1}\n\n${r.text}`).join("\n\n---\n\n") : "(no peer reviews completed)"; const synthesisInput = `${qBlock}\n\n---\n\n## Member responses\n\n${answersBlock}\n\n---\n\n## Peer reviews\n\n${reviewsBlock}`; const synthesis = await callModel( ctx, chairman, chairmanSystemPrompt(chairman.nick), synthesisInput, config.maxTokensSynthesis, config.timeoutMs, signal, ); setStage(`⚖ ${chairman.nick}`, synthesis.error ? "✗ synthesis failed" : `✓ verdict ${fmtMs(synthesis.elapsedMs)}`); livePush({ type: "synth_end", ok: !synthesis.error, ms: synthesis.elapsedMs }); // -- Deliberation record (feeds the HTML dashboard) ------------------------ const seatRecords: SeatRecord[] = shuffled.map((seat, i) => { const op = opinions[i]!; const a = answered.find(x => x.seat === seat); const rv = reviews.find(x => x.seat === seat); const order = rv ? parseRanking(rv.text, answered.length) : undefined; return { letter: a?.letter ?? "–", nick: seat.nick, model: seat.model ? `${seat.model.provider}/${seat.model.id}` : seat.spec, answer: a?.answer, answerMs: a ? op.elapsedMs : undefined, answerTokens: a ? op.outputTokens : undefined, review: rv?.text, reviewMs: rv?.elapsedMs, rankingGiven: order?.map(ai => shuffled.indexOf(answered[ai]!.seat)).filter(x => x >= 0), error: op.error, }; }); const record: DeliberationRecord = { question, startedAtIso: new Date(startedAt).toISOString().slice(0, 19).replace("T", " "), durationMs: Date.now() - startedAt, chairman: { nick: chairman.nick, model: `${chairman.model.provider}/${chairman.model.id}`, error: synthesis.error, ms: synthesis.elapsedMs, }, seats: seatRecords, borda: rankingScores ? shuffled.map(seat => { const ai = answered.findIndex(x => x.seat === seat); return ai >= 0 ? (rankingScores[ai] ?? 0) : 0; }) : undefined, verdict: synthesis.text || undefined, failures, settings: { effort: config.effort, chairmanEffort: config.chairmanEffort, timeoutMs: config.timeoutMs, contextIncluded: Boolean(sessionContext), }, }; livePush({ type: "done", durationMs: record.durationMs, ranking: leaderboard?.map(e => e.seat.nick) ?? [], }); // Small machine-readable summary of the latest run, for /council list. try { const stateDir = join(ctx.cwd, ".omp", "council"); mkdirSync(stateDir, { recursive: true }); writeFileSync( join(stateDir, "last-run.json"), `${JSON.stringify( { at: record.startedAtIso, question: question.slice(0, 120), ranking: leaderboard?.map(e => ({ nick: e.seat.nick, score: Number(e.score.toFixed(2)) })) ?? [], }, null, 2, )}\n`, ); } catch { // best-effort } // -- Transcript + dashboard ------------------------------------------------ let transcriptPath: string | undefined; let dashboardPath: string | undefined; if (config.writeTranscript) { try { const dir = join(ctx.cwd, ".omp", "council"); mkdirSync(dir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); dashboardPath = join(dir, `${stamp}.html`); writeFileSync(dashboardPath, renderDashboardHtml(record)); transcriptPath = join(dir, `${stamp}.md`); const identity = answered.map(a => `- Response ${a.letter} = **${a.seat.nick}** (${a.seat.model?.provider}/${a.seat.model?.id})`).join("\n"); writeFileSync( transcriptPath, [ `# Council transcript`, ``, `**Question:** ${question}`, ``, `**Seats:**`, identity, ``, `Chairman: **${chairman.nick}** (${chairman.model.provider}/${chairman.model.id})`, ``, `## Stage 1 — opinions`, ``, ...answered.map(a => `### ${a.seat.nick} (Response ${a.letter})\n\n${a.answer}\n`), `## Stage 2 — peer reviews`, ``, ...(reviews.length ? reviews.map(r => `### ${r.seat.nick}\n\n${r.text}\n`) : ["(none)"]), `## Stage 3 — chairman synthesis (${chairman.nick})`, ``, synthesis.text || `FAILED: ${synthesis.error}`, ``, failures.length ? `## Failures\n\n${failures.map(f => `- ${f}`).join("\n")}` : ``, ].join("\n"), ); } catch { transcriptPath = undefined; // transcript is best-effort dashboardPath = undefined; } } // -- Assemble output ------------------------------------------------------- const seatLine = answered .map(a => `${a.letter}=${a.seat.nick}`) .join(", "); const rankLine = leaderboard ? leaderboard.map((e, i) => `${i + 1}. ${e.seat.nick}`).join(" ") : "not parseable"; const footerParts = [ `Chair: ${chairman.nick} · Seats: ${seatLine}`, `Peer ranking: ${rankLine}`, ]; if (dashboardPath) footerParts.push(`Dashboard: ${dashboardPath} (or /council last)`); if (transcriptPath) footerParts.push(`Full transcript: ${transcriptPath}`); if (failures.length) footerParts.push(`Issues: ${failures.join(" | ")}`); if (!synthesis.text) { // Chairman down: publish the raw material rather than nothing. const fallback = [ `⚠️ Chairman (${chairman.nick}) failed: ${synthesis.error}. Raw council output below.`, ``, answersBlock, ``, `---`, footerParts.join("\n"), ].join("\n"); return { verdict: fallback, summaryMarkdown: fallback, transcriptPath, dashboardPath, failures }; } const summaryMarkdown = [ `## 🏛️ Council verdict`, ``, synthesis.text, ``, `---`, footerParts.map(p => `*${p}*`).join("\n"), ].join("\n"); return { verdict: synthesis.text, summaryMarkdown, transcriptPath, dashboardPath, failures }; } // --------------------------------------------------------------------------- // Extension entry // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI) { pi.setLabel("Council"); const { z } = pi.zod; piRef = pi; pi.on("session_shutdown", () => { liveServer?.stop(); }); const listModels = (ctx: ExtensionContext): string => { const models = ctx.models.list(); if (models.length === 0) return "No authenticated models. Use /login first (anthropic, openai-codex, google-antigravity, …)."; const byProvider = new Map(); for (const m of models) { const list = byProvider.get(m.provider) ?? []; list.push(m.id); byProvider.set(m.provider, list); } return [...byProvider.entries()] .map(([provider, ids]) => `${provider}:\n${ids.map(id => ` ${provider}/${id}`).join("\n")}`) .join("\n"); }; const rosterText = (ctx: ExtensionContext): string => { const config = loadConfig(ctx.cwd); const { members, chairman } = resolveSeats(ctx, config); const line = (s: Seat, role: string) => s.model ? `✓ ${role} ${s.nick} → ${s.model.provider}/${s.model.id}` : `✗ ${role} ${s.nick} → "${s.spec}" UNRESOLVED (login or fix council.json)`; const out = [ ...members.map(m => line(m, "member ")), line(chairman, "chairman"), ]; if (configLoadError) out.push(`⚠️ config error: ${configLoadError}`); return out.join("\n"); }; pi.registerCommand("council", { description: "Convene the model council: opinions → peer review → chairman synthesis", getArgumentCompletions: prefix => { const p = prefix.trimStart(); if (p.toLowerCase().startsWith("remove ")) { const needle = p.slice("remove ".length).trim().toLowerCase(); const config = loadConfig(process.cwd()); const items = config.members .map(m => ({ value: `remove ${m.nick ?? m.model}`, label: m.nick ?? m.model, description: m.model, })) .filter(i => !needle || i.label.toLowerCase().includes(needle)); return items.length ? items : null; } const subs = [ { value: "models", label: "models", description: "List authenticated model IDs" }, { value: "roster", label: "roster", description: "Show resolved council seats" }, { value: "list", label: "list", description: "Table of members, position, and last peer rank" }, { value: "last", label: "last", description: "Open the latest deliberation dashboard" }, { value: "live", label: "live", description: "Open the live agent-communication graph" }, { value: "doctor", label: "doctor", description: "Runtime compatibility report" }, { value: "update", label: "update", description: "Check npm for a newer aicouncil and install it" }, { value: "help", label: "help", description: "List council commands" }, { value: "add ", label: "add", description: "add [nick] — seat a new member" }, { value: "remove ", label: "remove", description: "remove — unseat a member" }, { value: "chair ", label: "chair", description: "chair [nick] — set the chairman" }, ].filter(s => s.value.trimEnd().startsWith(p) || s.value.startsWith(p)); return subs.length ? subs : null; }, handler: async (args, ctx: ExtensionCommandContext) => { const trimmed = args.trim(); if (trimmed === "models") { ctx.ui.notify("Authenticated models written below", "info"); pi.sendMessage({ customType: "council-info", content: `Models available for council.json:\n\n\`\`\`\n${listModels(ctx)}\n\`\`\`` }); return; } if (trimmed === "update") { ctx.ui.setStatus("council", "checking npm for updates…"); let latest: string | undefined; try { const res = await fetch("https://registry.npmjs.org/@codeninza/aicouncil/latest", { signal: AbortSignal.timeout(10_000), }); latest = ((await res.json()) as { version?: string }).version; } catch (error) { ctx.ui.notify(`Could not reach npm: ${error instanceof Error ? error.message : String(error)}`, "error"); } finally { ctx.ui.setStatus("council", undefined); } if (!latest) return; if (latest === VERSION) { ctx.ui.notify(`aicouncil ${VERSION} is up to date`, "info"); return; } // Manual folder installs shouldn't be shadowed by a surprise plugin install. const installedAsPlugin = (() => { try { return /[/\\]plugins[/\\]/.test(fileURLToPath(import.meta.url)); } catch { return false; } })(); if (!installedAsPlugin) { pi.sendMessage({ customType: "council-info", content: `aicouncil ${latest} is available (you have ${VERSION}). This copy was installed manually, so update it by replacing the folder — or switch to npm installs with \`omp install @codeninza/aicouncil\`.`, }); return; } ctx.ui.setStatus("council", `updating to ${latest}…`); try { const result = await pi.exec("omp", ["install", "@codeninza/aicouncil@latest"]); if (result.code === 0) { pi.sendMessage({ customType: "council-info", content: `✓ aicouncil updated ${VERSION} → ${latest}. Restart omp (or /reload) to load the new version.`, }); } else { ctx.ui.notify(`Update failed (exit ${result.code}) — run \`omp install @codeninza/aicouncil\` manually`, "error"); } } catch { pi.sendMessage({ customType: "council-info", content: `aicouncil ${latest} is available (you have ${VERSION}). Update with: \`omp install @codeninza/aicouncil\``, }); } finally { ctx.ui.setStatus("council", undefined); } return; } if (trimmed === "doctor") { const reg = ctx.modelRegistry as unknown as Record; const cap = (o: unknown, k: string) => (o && typeof (o as Record)[k] === "function" ? "✓" : "✗"); const BunRt = (globalThis as { Bun?: { serve?: unknown; version?: string } }).Bun; const modelCount = (() => { try { return String(ctx.models.list().length); } catch { return "?"; } })(); const report = [ `aicouncil ${VERSION}`, `runtime: bun ${BunRt?.version ?? "unknown"} · ${process.platform}`, `auth surface on ctx.modelRegistry:`, ` getApiKeyAndHeaders ${cap(reg, "getApiKeyAndHeaders")} getApiKey ${cap(reg, "getApiKey")} getProviderHeaders ${cap(reg, "getProviderHeaders")} resolver ${cap(reg, "resolver")}`, `models facade: list ${cap(ctx.models, "list")} (${modelCount} authenticated) · resolve ${cap(ctx.models, "resolve")}`, `live graph: Bun.serve ${BunRt?.serve ? "✓" : "✗"}`, `user config: ${existsSync(userConfigPath()) ? userConfigPath() : "(none)"}`, `project config: ${existsSync(join(ctx.cwd, ".omp", "council.json")) ? join(ctx.cwd, ".omp", "council.json") : "(none)"}`, ``, rosterText(ctx), ].join("\n"); pi.sendMessage({ customType: "council-info", content: `Council doctor:\n\n\`\`\`\n${report}\n\`\`\`` }); return; } if (trimmed === "help") { pi.sendMessage({ customType: "council-info", content: [ "aicouncil commands:", "```", "/council convene the council", "/council models list authenticated model IDs", "/council roster show resolved seats + chairman", "/council list members table: position + last peer rank", "/council add [nick] seat a member", "/council remove unseat a member", "/council chair [nick] set the chairman", "/council last open the latest dashboard", "/council live open the live communication graph", "/council doctor runtime compatibility report", "/council update check npm and self-update", "```", ].join("\n"), }); return; } if (trimmed === "list") { const config = loadConfig(ctx.cwd); const { members, chairman } = resolveSeats(ctx, config); const chairCanonical = chairman.model ? `${chairman.model.provider}/${chairman.model.id}` : chairman.spec; // Last-run peer ranking, if a deliberation has happened in this project. let lastRun: { at?: string; question?: string; ranking?: { nick: string; score: number }[] } = {}; try { const p = join(ctx.cwd, ".omp", "council", "last-run.json"); if (existsSync(p)) lastRun = JSON.parse(readFileSync(p, "utf-8")); } catch { // unreadable state — list without rankings } const posOf = (nick: string): string => { const idx = lastRun.ranking?.findIndex(r => r.nick === nick) ?? -1; if (idx < 0) return "–"; const suffix = ["st", "nd", "rd"][idx] ?? "th"; return `${idx + 1}${suffix} (${lastRun.ranking![idx]!.score} pts)`; }; const rows = members.map((m, i) => { const canonical = m.model ? `${m.model.provider}/${m.model.id}` : m.spec; const role = canonical === chairCanonical ? "member · chair ⚖" : "member"; const status = m.model ? "✓" : "✗ unresolved"; return [`${i + 1}`, m.nick, canonical, role, status, posOf(m.nick)]; }); if (!members.some(m => (m.model ? `${m.model.provider}/${m.model.id}` : m.spec) === chairCanonical)) { rows.push(["–", chairman.nick, chairCanonical, "chair ⚖", chairman.model ? "✓" : "✗ unresolved", "–"]); } const headers = ["#", "Seat", "Model", "Position", "Auth", "Last peer rank"]; const widths = headers.map((h, c) => Math.max(h.length, ...rows.map(r => r[c]!.length))); const fmt = (r: string[]) => r.map((cell, c) => cell.padEnd(widths[c]!)).join(" "); const table = [fmt(headers), fmt(widths.map(w => "─".repeat(w))), ...rows.map(fmt)].join("\n"); const footer = lastRun.ranking?.length ? `\nlast deliberation: ${lastRun.at ?? "?"} — "${lastRun.question ?? ""}"` : "\nno deliberations in this project yet — peer ranks appear after the first /council run"; pi.sendMessage({ customType: "council-info", content: `Council seats:\n\n\`\`\`\n${table}${footer}\n\`\`\`` }); return; } if (trimmed === "roster" || trimmed === "config" || trimmed === "members") { pi.sendMessage({ customType: "council-info", content: `Council roster:\n\n\`\`\`\n${rosterText(ctx)}\n\`\`\`` }); return; } if (trimmed === "last") { const dir = join(ctx.cwd, ".omp", "council"); const dashboards = existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith(".html")).sort() : []; const latest = dashboards[dashboards.length - 1]; if (!latest) { ctx.ui.notify("No council dashboards yet — run /council first", "warning"); return; } const fullPath = join(dir, latest); if (await openExternal(pi, fullPath)) ctx.ui.notify(`Opened ${latest}`, "info"); else pi.sendMessage({ customType: "council-info", content: `Latest council dashboard: ${fullPath}` }); return; } if (trimmed === "live") { const srv = ensureLiveServer(); if (!srv) { ctx.ui.notify("Live graph unavailable (Bun.serve not found in this runtime)", "error"); return; } const url = `http://localhost:${srv.port}/`; liveOpened = true; if (await openExternal(pi, url)) ctx.ui.notify(`Live council graph: ${url}`, "info"); else pi.sendMessage({ customType: "council-info", content: `Live council graph: ${url}` }); return; } const [sub = "", ...restTokens] = trimmed.split(/\s+/); const rest = restTokens.join(" ").trim(); const canonicalOf = (spec: string): string | undefined => { const m = resolveModel(ctx, spec); return m ? `${m.provider}/${m.id}` : undefined; }; const showRoster = (headline: string) => pi.sendMessage({ customType: "council-info", content: `${headline}\n\n\`\`\`\n${rosterText(ctx)}\n\`\`\`` }); // -- /council add [nick…] ------------------------------------ if (sub === "add" && rest) { const spec = restTokens[0]!; const nick = restTokens.slice(1).join(" ").trim() || undefined; const canonical = canonicalOf(spec); if (!canonical) { ctx.ui.notify(`No authenticated model matches "${spec}" — run /council models to see what's available`, "error"); return; } const config = loadConfig(ctx.cwd); if (config.members.some(m => canonicalOf(m.model) === canonical)) { ctx.ui.notify(`${canonical} is already seated`, "warning"); return; } const members = [...config.members, { model: canonical, ...(nick ? { nick } : {}) }]; try { updateConfigFile(pickWriteTarget(ctx.cwd, "members"), json => { json.members = members; }); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return; } showRoster(`Seated ${nick ?? canonical} on the council.`); return; } // -- /council remove --------------------------------- if (sub === "remove" && rest) { const needle = rest.toLowerCase(); const config = loadConfig(ctx.cwd); const matches = config.members.filter( m => (m.nick ?? "").toLowerCase() === needle || m.model.toLowerCase().includes(needle), ); if (matches.length === 0) { ctx.ui.notify( `No member matches "${rest}". Current seats: ${config.members.map(m => m.nick ?? m.model).join(", ")}`, "error", ); return; } if (matches.length > 1) { ctx.ui.notify( `"${rest}" is ambiguous: ${matches.map(m => m.nick ?? m.model).join(", ")} — be more specific`, "warning", ); return; } const members = config.members.filter(m => m !== matches[0]); try { updateConfigFile(pickWriteTarget(ctx.cwd, "members"), json => { json.members = members; }); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return; } if (members.length < 2) { ctx.ui.notify("Fewer than 2 seats remain — the council needs at least 2 members to run", "warning"); } showRoster(`Removed ${matches[0]!.nick ?? matches[0]!.model} from the council.`); return; } // -- /council chair [nick…] ---------------------------------- if (sub === "chair" && rest) { const spec = restTokens[0]!; const canonical = canonicalOf(spec); if (!canonical) { ctx.ui.notify(`No authenticated model matches "${spec}" — run /council models to see what's available`, "error"); return; } const config = loadConfig(ctx.cwd); const nick = restTokens.slice(1).join(" ").trim() || config.members.find(m => canonicalOf(m.model) === canonical)?.nick || canonical.split("/")[1]!; try { updateConfigFile(pickWriteTarget(ctx.cwd, "chairman"), json => { json.chairman = canonical; json.chairmanNick = nick; }); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return; } showRoster(`${nick} now chairs the council.`); return; } let question = trimmed; if (!question) { question = (await ctx.ui.editor("Ask the council", "")) ?? ""; question = question.trim(); if (!question) return; } ctx.ui.setStatus("council", "🏛️ council convening…"); try { const result = await runCouncil(ctx, question, undefined, status => { ctx.ui.setStatus("council", `🏛️ ${status}`); ctx.ui.setWorkingMessage(status); }); pi.sendMessage({ customType: "council-verdict", content: result.summaryMarkdown, display: true }); ctx.ui.notify( result.failures.length ? `Council done with ${result.failures.length} issue(s) — see verdict footer` : "Council verdict delivered", result.failures.length ? "warning" : "info", ); } catch (error) { ctx.ui.notify(`Council failed: ${error instanceof Error ? error.message : String(error)}`, "error"); } finally { ctx.ui.setStatus("council", undefined); ctx.ui.setWorkingMessage(); if (ctx.hasUI) ctx.ui.setWidget("council", undefined); } }, }); // Let the main agent convene the council itself ("ask the council whether …"). const config = loadConfig(process.cwd()); if (config.exposeTool) { // Cast: zod schemas are accepted at runtime (see examples/extensions/api-demo.ts), // but tsc 5.x blows its instantiation-depth budget unifying them with TSchema. const registerTool = pi.registerTool.bind(pi) as (tool: unknown) => void; registerTool({ name: "council", label: "Model Council", description: "Convene a council of frontier models (parallel independent answers → anonymized peer review → chairman synthesis) and return the final verdict. Use when the user asks for the council's opinion, a second opinion across models, or when a hard/contested decision would benefit from independent deliberation. Costs several model calls — do not use for trivial questions.", parameters: z.object({ question: z.string().describe("The full, self-contained question for the council, including any needed context"), }), approval: "read", async execute( _toolCallId: string, params: { question: string }, signal: AbortSignal | undefined, onUpdate: ((update: { content: { type: "text"; text: string }[] }) => void) | undefined, ctx: ExtensionContext, ) { try { const result = await runCouncil(ctx, params.question, signal, status => { onUpdate?.({ content: [{ type: "text", text: status }] }); }); const text = result.transcriptPath ? `${result.summaryMarkdown}\n\n(full deliberation: ${result.transcriptPath})` : result.summaryMarkdown; return { content: [{ type: "text", text }] }; } catch (error) { return { content: [{ type: "text", text: `Council failed: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } finally { if (ctx.hasUI) ctx.ui.setWidget("council", undefined); } }, }); } }