Consult multiple AI coding agents (Gemini, OpenAI, Grok, Perplexity, Kimi, any model OpenRouter routes to, plus codex, antigravity, grok, kimi and cursor CLIs…

A Claude Code plugin that consults multiple AI coding agents in parallel and shows you their answers side-by-side. Useful when one model's bias could mislead you and the right call depends on cross-checking — architecture decisions, debugging dead ends, security reviews, framework picks.

Five providers answering the same question. Each banner names the provider, the model that answered and how long it took. The synthesis separates what they all agreed on from where they diverged — here, whether trapping EXIT INT TERM on one handler is correct, or whether signals should be converted into exits first — and when they agree instead, it names the assumption the answer rests on.
Quick start · Usage · Configuration · Reference · Development
# 1. Install via Claude Code plugin marketplace
/plugin marketplace add hex/claude-marketplace
/plugin install claude-council
# 2. Configure at least one provider — any of these works:
export OPENAI_API_KEY="..." # or GEMINI_API_KEY, XAI_API_KEY, PERPLEXITY_API_KEY, KIMI_API_KEY,
# OPENROUTER_API_KEY
# OR install the codex / antigravity (agy) / grok / kimi / cursor-agent CLIs (uses your
# existing subscription — no API key needed)
# 3. Ask anything
/claude-council:ask "Should I use UUID or BIGINT primary keys for a SaaS users table?"
You get side-by-side responses from each configured provider:
● Codex - default
Use UUID primary keys — they avoid enumeration, work across distributed
services, and survive imports/exports cleanly.
● Antigravity - default
UUIDv7 specifically: security of non-guessable IDs plus the index
locality of time-ordered sequences.
● Grok - grok-latest
BIGINT autoincrement — smaller index, faster joins. Handle public-
exposure concerns with a separate UUID slug column.
● Perplexity - sonar-reasoning-pro
BIGINT: 25% smaller than UUID, better cache locality, with citations
to Postgres benchmarks.
## Synthesis
Two providers prefer UUID(v7), two prefer BIGINT. Choice depends on
whether you need distributed ID generation.
When they all agree instead, the synthesis says what that agreement rests on, because agreement is where it is easiest to stop asking:
## Synthesis
All five recommend SQLite. Read that as agreement about the reasoning, not
as verification: every provider was given the same description of a system
none of them can inspect. The answer assumes this stays single-node — the
one premise that would flip it, and the one nobody here could check.
Inside tmux, results stream into a side pane in real time with vendor-colored banners. Run /claude-council:status to confirm what's configured and connected.
codex, agy (Antigravity), grok, kimi (Kimi Code) and cursor-agent (Cursor) CLIs (subscription auth) when installed; the first four are preferred over their API siblingsclaude-cli) on your Claude subscription, opt-in, running without your CLAUDE.md, settings or pluginsollama model as a council member — no key, no subscription, no network--async) for long-running queries, with /claude-council:result to fetch, list, and cancel/claude-council:advise, which shows you what would leave the machine before it goesmcp__claude-council__ask, the council_tool setting in /config, on by default): the model can call the council itself, every call opens a dialog that quotes the question and names the providers, and nothing leaves the machine until you choose Send to the council; needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1# Add the hex-plugins marketplace
/plugin marketplace add hex/claude-marketplace
# Install claude-council
/plugin install claude-council
For normal use, prefer the marketplace or GitHub install above — both persist across sessions. A manual clone is for running from a local working copy (development, or offline). Clone the repo anywhere, then point Claude Code at the repo root for the current session:
git clone https://github.com/hex/claude-council.git
claude --plugin-dir /path/to/claude-council # repo root; loaded for this session only
Cloned it and nothing loads? Two traps to avoid:
- Don't clone into
~/.claude/plugins/(Windows:%USERPROFILE%\.claude\plugins\). That's Claude Code's managed install cache — it is never scanned for manually-added plugins, so the plugin won't appear in the Installed tab or respond to its slash commands.pluginDirectoriesinsettings.jsondoes nothing — it isn't a real setting, so it's silently ignored (no error shown). Use--plugin-dirabove for a local clone, or install via the marketplace / GitHub for a persistent setup.
# Query all configured providers
/claude-council:ask "How should I structure authentication in this Express app?"
# Query specific providers
/claude-council:ask --providers=gemini,openai "What's the best approach for caching here?"
# Include a specific file for review
/claude-council:ask --file=src/auth.ts "What's wrong with this implementation?"
# Attach a screenshot for visual critique
/claude-council:ask --image=shot.png "Why does this dialog render off-center?"
# Export response to markdown file
/claude-council:ask --output=docs/auth-decision.md "How should we implement authentication?"
# Quiet mode - show only synthesis
/claude-council:ask --quiet "What's the best caching strategy?"
# Check connectivity and configured models for each provider
/claude-council:status
# Run a long query in the background, fetch it later
/claude-council:ask --async "Deep-dive the tradeoffs of event sourcing here"
/claude-council:result <job-id>
| Flag | Description |
|---|---|
--providers=list | Query specific providers (e.g., gemini,openai,codex). Naming one in plain words works too: "check with grok" runs the council with that provider alone. There is no --grok style flag |
--roles=list | Assign roles (e.g., security,performance, a preset like balanced, or provider=role pairs) |
--debate | Enable two-round debate mode |
--file=path | Include specific file in context |
--image=path | Attach one image (e.g. a screenshot) for vision-capable providers |
--output=path | Export response to markdown file |
--quiet | Show only synthesis, hide individual responses |
--agents | Agent-enhanced analysis, one Claude analyst per provider (slower, deeper) |
--local | Local Claude-only council when you have no provider keys (see below) |
--async | Detach the query as a background job; fetch with /claude-council:result |
--no-cache | Force fresh queries, skip cache |
--no-auto-context | Disable automatic file detection |
--no-pane | Disable streaming tmux pane (default: on inside tmux) |
--verbosity=LEVEL | Response style: brief / standard / detailed |
Assign different perspectives to each provider for more comprehensive reviews:
# Use specific roles
/claude-council:ask --roles=security,performance,maintainability "Review this auth code"
# Use a preset
/claude-council:ask --roles=balanced "Review this implementation"
# Bind a role to a named provider instead of a position
/claude-council:ask --roles=openrouter-2=security,perplexity=devil "Review this design"
Available roles:
security - Security Auditor (vulnerabilities, OWASP Top 10)performance - Performance Optimizer (efficiency, bottlenecks)maintainability - Maintainability Advocate (clarity, future changes)devil - Devil's Advocate (challenges assumptions)simplicity - Simplicity Champion (identifies over-engineering)scalability - Scalability Architect (growth, scaling)dx - Developer Experience (API ergonomics)compliance - Compliance Officer (GDPR, regulations)Presets:
balanced - security, performance, maintainabilitysecurity-focused - security, devil, compliancearchitecture - scalability, maintainability, simplicityreview - security, maintainability, dxA bare list is positional: the first role goes to the first provider discovery returns, the second to the second, and so on. That is fine for a fixed roster and fragile for a growing one — adding a provider script shifts every later provider's role by one, and reordering OPENROUTER_MODELS reassigns which router seat plays which part. Both happen silently, because only non-empty roles are printed.
provider=role pairs bind the two explicitly and survive both. The two forms cannot be mixed in one --roles (a bare entry alongside a keyed one is ambiguous); a pair naming a provider that is not being queried, or naming one provider twice, is refused rather than resolved; and any provider left without a role is named on stderr:
Note: no role for openai grok
Enable multi-round discussions where providers critique each other:
/claude-council:ask --debate "How should I structure the database schema?"
How it works:
Debate mode surfaces blind spots and stress-tests recommendations. The synthesis includes:
Combine with roles for focused debates:
/claude-council:ask --debate --roles=security,performance,simplicity "Review this architecture"
For complex decisions where deeper analysis justifies the extra time and cost, --agents runs one Workflow of parallel Claude analyst agents that each independently query, evaluate, and analyze their provider's response before the orchestrator synthesizes everything. Each analysis is returned as schema-enforced structured output, and an interrupted run can be resumed with the finished analysts served from cache. Needs a Claude Code with the Workflow tool.
# Explicit flag
/claude-council:ask --agents "Should we migrate from REST to GraphQL? What are the tradeoffs?"
# Combine with other flags
/claude-council:ask --agents --roles=security,scalability --providers=gemini,openai "Review this auth architecture"
What each analyst does (beyond a simple API call):
Enhanced synthesis includes:
Natural language triggers: The command also detects complex questions automatically. If your question contains architecture, security review, tradeoff analysis, or similar signals, you'll be asked whether to enable agent mode.
Cost and performance implications: Agent mode runs one Claude analyst agent per provider. This means ~4x more Claude API usage and ~15-25 seconds additional latency compared to standard mode. Use it for high-stakes decisions, not quick questions.
| Standard (default) | Agent-enhanced (--agents) | |
|---|---|---|
| Speed | ~3-5s | ~15-25s |
| Claude API cost | 1 context | 1 + N providers |
| Provider API cost | Same | Same |
| Analysis depth | Raw responses + synthesis | Pre-analyzed + enhanced synthesis |
| Best for | Quick questions, factual queries | Architecture decisions, security reviews, complex tradeoffs |
If you have no provider keys and no codex / agy / grok / kimi / cursor-agent CLI and no ollama installed, you can still convene a council, locally, using Claude alone:
# Explicit
/claude-council:ask --local "Is event sourcing worth it for this order service?"
# Pick the exact lenses yourself (skips the size prompt)
/claude-council:ask --local --roles=architecture "How should we shard this database?"
It spawns several Claude subagents in parallel, each pinned to a different role and blind to the others, then synthesizes their perspectives. When you don't pass --roles, it asks how many members to convene (default 4, up to 8) and fills them from a diverse ordering led by the sharpest lenses (devil's-advocate, simplicity, security, …). You don't need to pass --local explicitly: when a query finds no configured providers, the command offers a local council instead of erroring.
Honest caveat: every member is Claude, so they share priors and training. Agreement between them is a shared starting point to pressure-test, not cross-vendor corroboration. The value is independent angles and blind-spot coverage — for genuinely independent models, configure a provider key or a CLI (
/claude-council:statusshows what's available). The synthesis is framed around angles and tensions, not "consensus", to keep this distinction clear.
Get just the bottom line without individual provider responses:
/claude-council:ask --quiet "Should I use Redis or Memcached?"
Quiet mode still queries all providers and analyzes their responses, but only shows the synthesis with consensus/divergence analysis. Use when you want a quick answer without scrolling through multiple perspectives.
Providers are given a description of your problem and never the system itself, so they cannot test a premise your question asserts — they will reason from it correctly, and agree with each other while doing it. A wrong assumption therefore produces confident unanimity, which is the hardest failure to spot.
Before the question goes out, the council checks the claims it can check here and labels the rest:
OBSERVED: four sessions logged this failure, confirmed in .cs/memory/
NOT VERIFIED: that the error text carries the tokens those notes are keyed on
A provider told "I have not checked whether X holds" can answer "then check X first". One told "X holds" never will. If the answer turns mainly on facts living on your disk, establish those first — a single agent that can read the filesystem beats five that cannot.
The council automatically detects and includes relevant files based on your question:
/claude-council:ask "How should I refactor the authentication flow?"
# Auto-detects and includes: src/auth/*.ts, middleware/auth.ts, etc.
Before querying, you'll see which files were auto-included:
Auto-included context (3 files):
- src/auth/handler.ts (keyword: "auth")
- middleware/session.ts (keyword: "session")
- types/user.ts (keyword: "user")
To disable auto-context (for general questions not about your code):
/claude-council:ask --no-auto-context "What are best practices for API design?"
Auto-context limits:
--file= explicitlyAttach one image (e.g. a UI screenshot) so vision-capable providers can critique it:
/claude-council:ask --image=shot.png "Why does this dialog render off-center?"
gemini, openai, grok, perplexity, kimi and openrouter (on its default model) receive the image alongside the prompt.codex via openai, antigravity via gemini, grok-cli via grok, kimi-cli via kimi (the slot is marked as a fallback, with the reason <cli> cannot read images; the <sibling> API answered with the image). If the sibling is unusable (no API key), not vision-capable, or already answering in its own slot, the CLI provider answers text-only instead and its answer is prefixed with (answered without the image). Selecting ollama or cursor-cli directly is text-only.Privacy: the image is sent to the providers that can see it, but its bytes are not written to cache entries or the saved council-*.md transcripts — only a hash of the image keys the cache.
Responses are automatically cached to speed up repeated queries and save API costs:
# Uses cache if available (default)
/claude-council:ask "What's the best testing framework?"
# Force fresh queries, skip cache
/claude-council:ask --no-cache "What's the best testing framework?"
Cache configuration:
export COUNCIL_CACHE_DIR=".claude/council-cache" # Cache location (default)
export COUNCIL_CACHE_TTL=3600 # Cache lifetime in seconds (default: 1 hour)
Cached responses show cached instead of success in the status output. Cache is keyed by prompt + provider + model + role, so:
--roles creates separate cache entries (same prompt with different role = cache miss)Privacy: cache entries and the saved council-*.md transcripts store the full prompt in cleartext — including any files you pass with --file and the auto-included context. Council drops a .gitignore (*) into the cache dir so these never get committed, but the plaintext still lives on local disk under COUNCIL_CACHE_DIR until it ages out or you clear it.
Save council responses as clean markdown files for documentation or sharing:
/claude-council:ask --output=docs/decision.md "Should we use REST or GraphQL?"
The exported file includes:
Great for:
Reasoning and deep-research models can take minutes. --async detaches the query as a tracked background job instead of blocking the conversation:
/claude-council:ask --async "Compare migration strategies for this schema"
# → job id, returned immediately
/claude-council:result # list jobs
/claude-council:result <job-id> # fetch a finished result (synthesis included)
/claude-council:result cancel <id> # terminate a running job
Each job persists as a JSON record plus log under a per-workspace state directory ($CLAUDE_PLUGIN_DATA, falling back to tmp). A crashed worker is marked failed automatically; finished jobs are pruned beyond COUNCIL_MAX_JOBS (default 20).
The council-advisor agent will suggest consulting the council when:
/claude-council:ask sends a question you typed. /claude-council:advise sends a bounded slice of the current conversation, so providers see the reasoning rather than your summary of it. A model given only your framing tends to agree with your framing.
# Ask the council about the approach taken so far
/claude-council:advise "are we solving the right problem here?"
# Narrow the window
/claude-council:advise --turns=last:10 "what did we miss?"
Every run resolves this session's transcript, digests it, and shows the byte size, the turn count and the opening lines before asking whether to send. The digest carries human turns, assistant replies, and each AskUserQuestion exchange as the question, its options, and the pick; it excludes every other tool result and tool input, thinking blocks, hook output, and messages from other sessions. The script skips a damaged record inside the transcript, counts it, and writes the count into the digest itself, so an incomplete digest says so where both the user and the providers read it.
The confirmation is the privacy control, and deliberately so: a script cannot tell whose conversation it holds, because inside a subagent the ambient session id names the parent conversation.
A specialist is a Codex agent that Claude can hand a coding task to. It works in its own git worktree and branch, commits each round there, and you merge or discard the branch at the end. Claude offers one when a task matches its use-when and starts it only when you ask or agree.
Specialists live in the Claude Code mod, so they need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and a logged-in codex. Run /specialists to set one up. Each one has a name, a Codex model, an effort, a use-when for Claude, and the skills it follows: every SKILL.md you name opens every task it starts. The mod looks for a skill in ~/.claude/skills, ~/.codex/skills, ~/.agents/skills and its own bundled mods/council-pane/specialists/ folder, in that order, and the first folder with the name wins, so your own copy of a template's skill replaces the bundled one.
The plugin ships no specialists, but the screen offers six templates to start from: bug-fixer, ci-fixer, refactorer, test-writer, test-pruner and docs-updater. Each one comes with a full skill of its own. The first session where specialists can run shows a one-line note about them, once.
The mod README covers the screen, the tool calls, the worktrees and the limits.
Set environment variables (recommended):
export GEMINI_API_KEY="your-key"
export OPENAI_API_KEY="your-key"
export XAI_API_KEY="your-key" # GROK_API_KEY also accepted
export PERPLEXITY_API_KEY="your-key"
export KIMI_API_KEY="your-key" # MOONSHOT_API_KEY is read as a fallback,
# but only KIMI_API_KEY makes kimi discoverable
export OPENROUTER_API_KEY="your-key" # one key, any model on openrouter.ai/models
openrouter seats whatever model OPENROUTER_MODEL names, defaulting to anthropic/claude-fable-5.1 — the one vendor the council has no direct seat for. To seat several routed models at
mods/council-pane/hooks/pane.tsx 1618 lines1// ABOUTME: Hooks module that draws a council run's progress and answers in a Claude Code pane
2// ABOUTME: Polls the watch dir run-council.sh writes when COUNCIL_MOD_PANE_DIR is exported
3import type { Elements, EngineInterface, Register, RenderChildren } from 'claude-code'
4import { decideHost, HOST_LABELS, HOST_QUESTION, HOST_STORE_KEY, hostFrom, hostRowLabel, isCouncilRun, paneCommand, type HostSetting, type PaneHost } from './host'
5import { abandonedNotice, FINISH_NOTICE_MS, finishNotice, jobOutcome, noticeIsLive, reopenReply, runPid, wakePrompt, type FinishNotice, progressBand } from './notices'
6import { paneOptions, type PaneOptions } from './options'
7import { parseRetryOffer, retrySection, type RetryOffer, type RetrySection } from './retry'
8import { readText, readView, type Files } from './snapshot'
9import {
10 dialogOutcome, finishQuestion, introNotice, followUpRefusal, LIST_FIELD, freshList, parseSpecialist, parseSpecialistReport, specialistEntries, roundResult, runStamp, specialistCall,
11 specialistDescription, specialistPrompt, specialistRoster, specialistSchema,
12 commitSubject, latestStep, lostResult, readRuns, roundLiveness, roundProcessIdentity, roundProcessPresence, specialistSteps, specialistWake, startedReply, roundClock, roundStatus, workingLine, landsAtEnd, quietNote,
13 type RunRecord, type Specialist, type Step,
14} from './specialist'
15import { confirmOutcome, confirmQuestion, councilArgs, KEEP_LABEL, SEND_LABEL, TOOL_DESCRIPTION, TOOL_NAME, TOOL_SCHEMA } from './tool'
16import { extractSynthesis } from './synthesis'
17import { fitTables } from './tables'
18import { shimmer } from './chip'
19import { markdownBlocks, paneSections, queryingSince, seatFolds, seatsToCancel, unseenRun, type RunView, type Section } from './view'
20import { COLOR, FILL } from './theme'
21import { missingSkills, skillBody, skillIndex, skillsOpening, type Skill } from './skills'
22import { blankDraft, dropEntry, fieldsOf, savedName, flipEntry, putEntry, SUBMIT_HINT, saveIndex, staleMessage, type Draft, checkSpecialist, HEADERS, PAD, ruleLine, SEPARATOR, SWATCH_WIDTH, isDirty, parseCatalog, restoreSetup, setupView, draftAt, withModel, type Fields, type Target, type SetupState, type Status } from './setup'
23
24const PANE_ID = 'council'
25const REOPEN_COMMAND = 'council-pane'
26const RUN_TIMEOUT_MS = 600_000
27const POLL_MS = 500
28// Ten frames a second: the spinner's pace in the tmux pane.
29const FRAME_MS = 100
30// A second of frames for a just-opened pane to become scrollable.
31const PANE_FOLLOW_FRAMES = 10
32// The worker records its result seconds after .done; a worker killed outright
33// leaves its record at running, so the wait for it ends.
34const WAKE_WAIT_MS = 120_000
35// How often a run still going is asked whether its process is alive.
36const PID_CHECK_MS = 5_000
37const SPECIALIST_TOOL = 'specialist'
38const SPECIALIST_PANE = 'specialist'
39const RUNS_KEY = 'specialist-runs'
40const SETUP_PANE = 'specialist-setup'
41// The setup screen's draft is one session's own: another session editing at
42// the same time must not restore it over its own after a reload.
43const SETUP_KEY_PREFIX = 'specialist-setup:'
44// The list as the last write in any session left it (see freshList).
45const LATEST_KEY = 'specialists-latest'
46// Set once the specialists notice has shown, or was not needed.
47const INTRO_KEY = 'specialists-intro-shown'
48const SETUP_COMMAND = 'specialists'
49// Every colour comes from theme.ts; DESIGN.md says which one serves what.
50// A section header's mark: open shows its text, closed only the header.
51const OPEN_MARK = '\u25be'
52const CLOSED_MARK = '\u25b8'
53const STATUS_FILL: Record<Status['kind'], string> = { saved: FILL.saved, error: FILL.error, note: FILL.note }
54const STATUS_LABEL: Record<Status['kind'], string> = { saved: ' SAVED ', error: ' ERROR ', note: ' NOTE ' }
55// The settings field holding every specialist, as $.config names it; hidden
56// from the /config menu, since /specialists edits it.
57const LIST_KEY = `claude-council.${LIST_FIELD}`
58
59type PaneState = {
60 root: string
61 // Every installed skill's name, as the setup screen last read the folders.
62 skillNames?: string[]
63 runDir?: string
64 view?: RunView
65 drawn: string
66 isPolling: boolean
67 shown: Set<string>
68 lastError: string
69 pidCheckedAtMs: number
70 // When the followed specialist round's process was last checked.
71 specialistCheckedAtMs: number
72 pendingWake?: { prompt: string; jobFile: string; untilMs: number }
73 retry?: { offer: RetryOffer; seenAtMs: number }
74 retryShown?: RetrySection
75 synthesis?: string
76 finished?: FinishNotice
77 frame: number
78 nowMs: number
79 queryingSinceMs: Record<string, number>
80 // The sections the person closed, by their fold; each run starts with all open.
81 closed: Set<string>
82 // When the pane picked the live run up; the progress band's clock.
83 runStartedMs?: number
84 // Each section's Markdown blocks, for the text they were cut from: a frame
85 // redraws the tree, not the markdown.
86 fitted: Map<string, { text: string; blocks: string[] }>
87 // Each answer as rewritten for the pane's width (wide tables as records),
88 // for the width it was rewritten at: a resize starts it over.
89 tableFit: { columns: number; byText: Map<string, string> }
90 specialists: Specialist[]
91 // The specialist round this session is waiting on; the band's subject.
92 // outputLength and outputAtMs: how much of events.jsonl the last tick saw and
93 // when it last grew, for the band's no-output note; -1 until the first tick.
94 specialist?: { record: RunRecord; startedMs: number; stateDir: string; outputLength: number; outputAtMs: number }
95 // What the last specialist round did, step by step; the pane's subject. It
96 // outlives the round so the pane can still be read after it ends.
97 specialistLog?: { record: RunRecord; steps: Step[]; isLive: boolean }
98 // Runs whose end is being written up now, so one tick does not repeat another's.
99 finishing: Set<string>
100 identityFailures: Set<string>
101 specialistError: string
102 specialistFrame: number
103 // Frames left to retry following a just-opened pane: it becomes scrollable
104 // only once drawn, milliseconds after $.ui.open settles.
105 paneFollowFrames: number
106 // The setup screen's draft and catalog, mirrored from $.store so a reload redraws it.
107 setup?: SetupState
108 // Where this session keeps setup in the shared store; set at session.start.
109 setupKey?: string
110 // Bumped after each Enter in a setup field: the engine empties a submitted
111 // Input, and a field under a new key draws its value again. The engine keeps
112 // that emptied text across a reload of this module, so each load starts the
113 // epoch at its own clock reading rather than at a number an earlier load used.
114 inputEpoch: number
115 // The shared copy of the specialists list as last read (see latestList).
116 latest?: string
117}
118
119// The engine refuses $.fs passed as a value, so the snapshot reader gets the
120// three calls it needs spelled out.
121function files($: EngineInterface): Files {
122 return {
123 exists: path => $.fs.exists(path),
124 read: path => $.fs.read(path),
125 list: dir => $.fs.list(dir),
126 }
127}
128
129// The model and effort lists come from Codex itself; a failure is kept as the
130// catalog's error, never an empty list.
131async function readCatalog($: EngineInterface) {
132 const run = await $.process.run(['codex', 'debug', 'models'], { timeoutMs: 15_000 })
133 .catch((err: unknown) => ({ exitCode: 127, stdout: '', stderr: String(err) }))
134 return parseCatalog(run)
135}
136
137async function keepSetup($: EngineInterface, state: PaneState, setup: SetupState | undefined): Promise<void> {
138 if (!state.setupKey) throw new Error('the setup screen was used before session.start named its store key')
139 state.setup = setup
140 if (setup) await $.store.set(state.setupKey, setup)
141 else await $.store.delete(state.setupKey)
142 $.ui.invalidate('ui.render')
143}
144
145const currentSetup = (state: PaneState): SetupState => state.setup ?? { catalog: { loading: true } }
146const modelsOf = (setup: SetupState) => ('models' in setup.catalog ? setup.catalog.models : [])
147
148// Asks Codex for its models and fills them in; a new draft that opened before
149// they arrived takes the first listed model.
150// Where a specialist's skills are found, in order; a name in an earlier folder
151// shadows the same one later, so the user's own copy wins over a template's.
152// A folder that is not there is normal; one that cannot be listed is an error.
153async function findSkills($: EngineInterface): Promise<Map<string, Skill>> {
154 const home = await $.env.get('HOME')
155 const roots = [...(home ? [`${home}/.claude/skills`, `${home}/.codex/skills`, `${home}/.agents/skills`] : []), `${$.plugin.root}/mods/council-pane/specialists`]
156 const folders = await Promise.all(roots.map(async dir => {
157 if (!(await $.fs.exists(dir))) return { dir, names: [] }
158 // A linked skill folder lists as `other`, so only plain files are skipped.
159 const entries = (await $.fs.list(dir)).filter(entry => entry.kind !== 'file')
160 const found = await Promise.all(entries.map(async entry => ((await $.fs.exists(`${dir}/${entry.name}/SKILL.md`)) ? entry.name : undefined)))
161 return { dir, names: found.filter((name): name is string => name !== undefined) }
162 }))
163 return skillIndex(folders)
164}
165
166async function loadCatalog($: EngineInterface, state: PaneState): Promise<void> {
167 await keepSetup($, state, { ...currentSetup(state), catalog: { loading: true } })
168 state.skillNames = [...(await findSkills($)).keys()]
169 const catalog = await readCatalog($)
170 const setup = currentSetup(state)
171 const first = 'models' in catalog ? catalog.models.find(m => m.listed)?.slug : undefined
172 const draft = setup.draft && !setup.draft.model && first ? { ...setup.draft, model: first } : setup.draft
173 await keepSetup($, state, { ...setup, catalog, draft })
174}
175
176// Returns why the screen did not open, or undefined once it is open. It opens
177// at once and the catalog fills in after. A prefill comes from Claude's {setup}
178// call and starts a new row at the end of the list.
179async function openSetup($: EngineInterface, state: PaneState, options: Record<string, unknown>, prefill?: Partial<Fields>): Promise<string | undefined> {
180 // Only a draft with changes survives to the next open; an untouched one would
181 // reopen the form for nothing.
182 const kept = state.setup?.draft && isDirty(state.setup.draft) ? state.setup.draft : undefined
183 const { entries } = await latestList($, state, options)
184 let draft = kept
185 let status: Status | undefined
186 if (prefill) {
187 draft = blankDraft(entries.length, [], prefill)
188 } else if (kept) {
189 status = { kind: 'note', text: 'Your unsaved draft is back.' }
190 }
191 await keepSetup($, state, { catalog: { loading: true }, draft, status })
192 try { await $.ui.open({ id: SETUP_PANE, title: 'Specialists', focus: true, closeOnEscape: true }) } catch (err) { return String(err) }
193 await loadCatalog($, state)
194 return undefined
195}
196
197// Runs one press of the setup screen; a failure shows under the form instead
198// of vanishing with the press, and the log keeps it if even that fails.
199async function setupAction($: EngineInterface, state: PaneState, work: () => Promise<void>): Promise<void> {
200 try {
201 await work()
202 } catch (error) {
203 await keepSetup($, state, { ...currentSetup(state), status: { kind: 'error', text: String(error) } })
204 .catch(() => $.ui.log(`specialists: ${String(error)}`))
205 }
206}
207
208// Field edits read the draft at press time, so two quick edits both land.
209async function editSetup($: EngineInterface, state: PaneState, patch: Partial<Fields>): Promise<void> {
210 const setup = state.setup
211 if (setup?.draft) await keepSetup($, state, { ...setup, draft: { ...setup.draft, ...patch }, status: undefined, confirm: undefined })
212}
213
214// Enter in a setup field keeps what was typed and moves on to the next control.
215// An Input's key carries the epoch, so a next Input is named by its field and
216// gets the epoch this submit moves to.
217async function submitField($: EngineInterface, state: PaneState, patch: Partial<Fields>, next: { control: string } | { input: keyof Fields }): Promise<void> {
218 await editSetup($, state, patch)
219 state.inputEpoch += 1
220 $.ui.invalidate('ui.render')
221 const key = 'control' in next ? next.control : `${next.input}.${state.inputEpoch}`
222 await $.ui.focus({ requestId: SETUP_PANE, key })
223}
224
225async function pickModel($: EngineInterface, state: PaneState, slug: string): Promise<void> {
226 const setup = state.setup
227 if (!setup?.draft) return
228 const picked = withModel(setup.draft, slug, modelsOf(setup))
229 await keepSetup($, state, { ...setup, draft: picked.draft, status: picked.message ? { kind: 'note', text: picked.message } : undefined })
230}
231
232// Opening another row, or a new one, over unsaved edits asks first.
233async function openRow($: EngineInterface, state: PaneState, options: Record<string, unknown>, target: Target, force = false): Promise<void> {
234 const setup = currentSetup(state)
235 if (!force && setup.draft && setup.draft.index !== target && isDirty(setup.draft)) {
236 await keepSetup($, state, { ...setup, confirm: { kind: 'switch', target } })
237 return
238 }
239 const entries = (await latestList($, state, options)).entries
240 const draft = draftAt(target, entries, modelsOf(setup))
241 await keepSetup($, state, { ...setup, draft, confirm: undefined, status: undefined })
242}
243
244// The write reloads this module and the reload redraws from the store, so the
245// store is cleared first; a write that fails puts the draft back with the reason.
246// The shared copy is written first, since the settings write reloads this
247// module; a settings write that fails puts the copy back as it was.
248// after: what the screen shows once written; by default the roster alone.
249async function writeEntries($: EngineInterface, state: PaneState, setup: SetupState, entries: unknown[], done: string, after?: SetupState): Promise<void> {
250 await keepSetup($, state, after ?? { catalog: setup.catalog, status: { kind: 'saved', text: done } })
251 const value = JSON.stringify(entries)
252 const before = await $.store.get(LATEST_KEY)
253 await $.store.set(LATEST_KEY, value)
254 let written: { deny?: string }
255 try {
256 written = await $.config.set({ key: LIST_KEY, value })
257 } catch (error) {
258 written = { deny: String(error) }
259 }
260 if (!written.deny) return
261 await (typeof before === 'string' ? $.store.set(LATEST_KEY, before) : $.store.delete(LATEST_KEY))
262 await keepSetup($, state, { ...setup, confirm: undefined, status: { kind: 'error', text: written.deny } })
263}
264
265// Also keeps the copy for the screen, which draws without awaiting: it shows
266// what the last open or press read.
267async function latestList($: EngineInterface, state: PaneState, options: Record<string, unknown>): Promise<{ entries: unknown[]; problem?: string }> {
268 const stored = await $.store.get(LATEST_KEY)
269 state.latest = typeof stored === 'string' ? stored : undefined
270 return freshList(options, stored)
271}
272
273// A stored list that does not read would be overwritten by any write, so
274// Save and Remove refuse until it is fixed by hand.
275async function refuseUnreadable($: EngineInterface, state: PaneState, problem: string | undefined): Promise<boolean> {
276 if (!problem) return false
277 await keepSetup($, state, { ...currentSetup(state), confirm: undefined, status: { kind: 'error', text: `Nothing written: ${problem}. Fix it with /config first.` } })
278 return true
279}
280
281// Another session may have changed the entry this draft opened on; writing
282// over it would lose that change, so the draft stays and nothing is written.
283async function refuseChanged($: EngineInterface, state: PaneState, draft: Draft, entries: unknown[]): Promise<boolean> {
284 if (draft.baseline === '' || !staleMessage(draft, entries)) return false
285 const name = draft.name || `specialist ${draft.index + 1}`
286 await keepSetup($, state, { ...currentSetup(state), confirm: undefined, status: { kind: 'error', text: `Nothing written: ${name} changed in another session since you opened it. Discard changes to see it.` } })
287 return true
288}
289
290async function saveSetup($: EngineInterface, state: PaneState, options: Record<string, unknown>): Promise<void> {
291 const setup = state.setup
292 if (!setup?.draft) return
293 if ('loading' in setup.catalog) { await keepSetup($, state, { ...setup, status: { kind: 'note', text: 'Models are still loading; Save again in a moment.' } }); return }
294 if ('error' in setup.catalog) { await keepSetup($, state, { ...setup, status: { kind: 'error', text: `Cannot check the model: ${setup.catalog.error}` } }); return }
295 const { entries, problem } = await latestList($, state, options)
296 if (await refuseUnreadable($, state, problem) || await refuseChanged($, state, setup.draft, entries)) return
297 const index = saveIndex(setup.draft, entries)
298 state.skillNames = [...(await findSkills($)).keys()]
299 const checked = checkSpecialist(setup.draft, index, { models: setup.catalog.models, entries, skills: state.skillNames })
300 if ('error' in checked) { await keepSetup($, state, { ...setup, status: { kind: 'error', text: checked.error } }); return }
301 await writeEntries($, state, setup, putEntry(entries, index, checked.entry), `Saved ${setup.draft.name}.`)
302}
303
304// Switches the stored entry on or off at once. The form stays open on the
305// person's draft, unsaved edits included, now based on the switched entry.
306async function toggleSetup($: EngineInterface, state: PaneState, options: Record<string, unknown>): Promise<void> {
307 const setup = state.setup
308 if (!setup?.draft) return
309 const { entries, problem } = await latestList($, state, options)
310 if (await refuseUnreadable($, state, problem) || await refuseChanged($, state, setup.draft, entries)) return
311 const flipped = flipEntry(entries, setup.draft.index)
312 if ('error' in flipped) { await keepSetup($, state, { ...setup, confirm: undefined, status: { kind: 'error', text: flipped.error } }); return }
313 const entry = parseSpecialist(flipped.entries[setup.draft.index])
314 if ('error' in entry) throw new Error(`a switched specialist no longer reads: ${entry.error}`)
315 const done = entry.enabled === false ? `${entry.name} is off: Claude will not offer or start it.` : `${entry.name} is on again.`
316 const draft = { ...setup.draft, baseline: JSON.stringify(flipped.entries[setup.draft.index]) }
317 await writeEntries($, state, setup, flipped.entries, done, { ...setup, draft, confirm: undefined, status: { kind: 'saved', text: done } })
318}
319
320// The confirm row's yes: remove, or drop the unsaved draft and open the target.
321async function confirmSetup($: EngineInterface, state: PaneState, options: Record<string, unknown>): Promise<void> {
322 const setup = state.setup
323 const confirm = setup?.confirm
324 if (!setup?.draft || !confirm) return
325 if (confirm.kind === 'switch') { await openRow($, state, options, confirm.target, true); return }
326 const { entries, problem } = await latestList($, state, options)
327 if (await refuseUnreadable($, state, problem) || await refuseChanged($, state, setup.draft, entries)) return
328 const name = savedName(entries, setup.draft.index) ?? `specialist ${setup.draft.index + 1}`
329 await writeEntries($, state, setup, dropEntry(entries, setup.draft.index), `Removed ${name}.`)
330}
331
332// The store flag is shared by every session, so the notice shows once per install.
333// The codex check runs only for someone who could still get the notice.
334async function introduceSpecialists($: EngineInterface, specialists: number): Promise<void> {
335 const shown = (await $.store.get(INTRO_KEY)) === true
336 const codexReady = shown || specialists > 0 ? false
337 : (await $.process.run(['codex', 'login', 'status']).catch(() => undefined))?.exitCode === 0
338 const notice = introNotice({ shown, specialists, codexReady })
339 if (notice.log) $.ui.log(notice.log)
340 if (notice.markShown) await $.store.set(INTRO_KEY, true)
341}
342
343async function foldTemplates($: EngineInterface, state: PaneState): Promise<void> {
344 const { templatesOpen, ...setup } = currentSetup(state)
345 await keepSetup($, state, templatesOpen ? setup : { ...setup, templatesOpen: true })
346}
347
348async function askSetup($: EngineInterface, state: PaneState, confirm: SetupState['confirm']): Promise<void> {
349 await keepSetup($, state, { ...currentSetup(state), confirm })
350}
351
352// Drops the draft; the screen stays open on the roster.
353async function discardSetup($: EngineInterface, state: PaneState): Promise<void> {
354 await keepSetup($, state, { catalog: currentSetup(state).catalog })
355}
356
357// A list set by hand (`/config claude-council.specialists=...`) gets the same checks as a
358// Save, entry by entry; the first problem is the refusal.
359async function handEditDenial($: EngineInterface, value: unknown): Promise<string | undefined> {
360 const { entries, problem } = specialistEntries({ [LIST_FIELD]: value })
361 if (problem) return problem
362 if (entries.length === 0) return undefined
363 const catalog = await readCatalog($)
364 if ('error' in catalog) return `cannot check the models: ${catalog.error}`
365 const skills = [...(await findSkills($)).keys()]
366 for (const [index, entry] of entries.entries()) {
367 const parsed = parseSpecialist(entry)
368 if ('error' in parsed) return `specialist ${index + 1}: ${parsed.error}`
369 const checked = checkSpecialist(fieldsOf(parsed), index, { models: catalog.models, entries, skills })
370 if ('error' in checked) return `specialist ${index + 1}: ${checked.error}`
371 }
372 return undefined
373}
374
375// Submits the wake prompt once the job's record says completed. A job that
376// failed, or whose record never settles, wakes nobody: the prompt would send
377// the model to fetch a result that is not there.
378async function wakeWhenFetchable($: EngineInterface, state: PaneState): Promise<void> {
379 const pending = state.pendingWake
380 if (!pending) return
381 const outcome = jobOutcome(await readText(files($), pending.jobFile))
382 if (outcome === 'running' && (await $.clock.now()) < pending.untilMs) return
383 state.pendingWake = undefined
384 if (outcome === 'completed') await $.prompt.submit({ text: pending.prompt })
385 else $.ui.log(`council job record ${pending.jobFile} did not complete (${outcome}); no wake prompt sent`)
386}
387
388// .done comes from the run's EXIT trap, which a SIGKILL skips. A run whose
389// process is gone and that left no .done will never write one; without this
390// the pane would follow it for the rest of the session and see no later run.
391async function runHasDied($: EngineInterface, state: PaneState, runDir: string, now: number): Promise<boolean> {
392 if (now - state.pidCheckedAtMs < PID_CHECK_MS) return false
393 state.pidCheckedAtMs = now
394 const pid = runPid(await readText(files($), `${runDir}/pid`))
395 if (!pid) return false
396 const alive = await $.process.run(['kill', '-0', pid])
397 if (alive.exitCode === 0) return false
398 // The trap writes .done and then the process goes: look once more, so a run
399 // that ended normally between the two reads is not called dead.
400 return !(await $.fs.exists(`${runDir}/.done`))
401}
402
403async function poll($: EngineInterface, state: PaneState, settings: PaneOptions): Promise<void> {
404 // Temp cleaners remove the root under a long session; runs find it again
405 // only if it exists, so it is put back rather than reported.
406 if (!(await $.fs.exists(state.root))) {
407 await $.fs.write(`${state.root}/.keep`, '')
408 state.runDir = undefined
409 return
410 }
411 const now = await $.clock.now()
412 state.nowMs = now
413 if (state.finished && !noticeIsLive(state.finished, now)) {
414 state.finished = undefined
415 $.ui.invalidate('ui.render')
416 }
417 if (state.pendingWake) await wakeWhenFetchable($, state)
418 if (!state.runDir) {
419 const name = unseenRun(await $.fs.list(state.root), state.shown)
420 if (!name) return
421 state.shown.add(name)
422 state.runDir = `${state.root}/${name}`
423 state.synthesis = undefined
424 state.finished = undefined
425 state.queryingSinceMs = {}
426 state.closed = new Set()
427 state.runStartedMs = now
428 state.fitted.clear()
429 await $.ui.open({ id: PANE_ID, title: 'Council' })
430 }
431 const runDir = state.runDir
432 state.view = await readView(files($), runDir)
433 state.queryingSinceMs = queryingSince(state.queryingSinceMs, state.view.providers, now)
434 state.view.queryingSinceMs = state.queryingSinceMs
435 if (state.synthesis) state.view.synthesis = state.synthesis
436 // The run waits on its offer for a window of seconds; the offer file going
437 // away (accepted, declined or expired) withdraws the buttons.
438 const offer = parseRetryOffer(await readText(files($), `${runDir}/retry-offer`))
439 if (!offer) state.retry = undefined
440 else if (!state.retry) state.retry = { offer, seenAtMs: now }
441 state.retryShown = state.retry ? retrySection(state.retry.offer, state.retry.seenAtMs, now) : undefined
442 const text = JSON.stringify([state.view, state.retryShown])
443 if (text !== state.drawn) {
444 state.drawn = text
445 $.ui.invalidate('ui.render')
446 }
447 const hasDied = !state.view.isDone && (await runHasDied($, state, runDir, now))
448 // A dead run is drawn as over: the spinners stop and the list collapses.
449 if (hasDied) state.view = { ...state.view, isDone: true }
450 // The run is over once .done lands: its dir is removed so the next run is
451 // picked up, and the pane keeps the last view until the person closes it.
452 if (state.view.isDone) {
453 // A toast is one unstyled line for four seconds, easy to miss under a
454 // streaming reply; the band holds the notice where the offer was.
455 // The run's dir is visible before the run writes its job files, so they
456 // are read now, when they are certain to be there.
457 const jobId = (await readText(files($), `${runDir}/job-id`)).trim()
458 const notice = hasDied ? abandonedNotice(state.view, jobId) : finishNotice(state.view, jobId)
459 state.finished = { text: notice, untilMs: now + FINISH_NOTICE_MS, isFailure: hasDied }
460 $.ui.invalidate('ui.render')
461 // .done lands before the worker records where the result is, so the wake
462 // waits for the job record to say the result can be fetched.
463 const wake = settings.wakesOnAsyncDone && !hasDied ? wakePrompt(jobId) : undefined
464 const jobFile = (await readText(files($), `${runDir}/job-file`)).trim()
465 if (wake && jobFile) state.pendingWake = { prompt: wake, jobFile, untilMs: now + WAKE_WAIT_MS }
466 await $.process.run(['rm', '-rf', runDir])
467 state.runDir = undefined
468 state.retry = undefined
469 state.retryShown = undefined
470 }
471}
472
473// Advances the spinner and the clock the elapsed times read, only while a
474// provider is still querying; an idle pane costs no redraws.
475async function animate($: EngineInterface, state: PaneState): Promise<void> {
476 const view = state.view
477 if (!view || view.isDone || !view.providers.some(provider => provider.state === 'querying')) return
478 state.frame += 1
479 state.nowMs = await $.clock.now()
480 $.ui.invalidate('ui.render')
481}
482
483// Settles where this run's pane goes, asking once when the setting says ask
484// and nothing is remembered. A dismissed dialog or a headless run stores
485// nothing and the pane is drawn here, so the question comes back.
486async function paneHost($: EngineInterface, setting: HostSetting): Promise<PaneHost> {
487 const remembered = hostFrom(await $.store.get(HOST_STORE_KEY))
488 const decided = decideHost({ setting, remembered, isInTmux: Boolean(await $.env.get('TMUX')) })
489 if (decided !== 'ask') return decided
490 let asked: PaneHost | undefined
491 try {
492 asked = hostFrom(await $.ui.ask(HOST_QUESTION, { header: 'Council pane', options: [HOST_LABELS.mod, HOST_LABELS.tmux] }))
493 } catch {
494 asked = undefined
495 }
496 if (asked) await $.store.set(HOST_STORE_KEY, asked)
497 return asked ?? 'mod'
498}
499
500// Points the next run at this mod's pane or away from it; a Bash child reads
501// the variable when it starts, so this runs just before one does.
502// The watch root and the two timers start with the first council run, not the
503// session: a session that never convenes the council pays no polling and
504// leaves no directory behind.
505async function startWatching($: EngineInterface, state: PaneState, settings: PaneOptions): Promise<void> {
506 if (state.root) return
507 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
508 state.root = `${tmp}/council-mod.${await $.session.id()}`
509 await $.fs.write(`${state.root}/.keep`, '')
510 $.clock.every(FRAME_MS, () => { void animate($, state) })
511 $.clock.every(POLL_MS, () => { void pollOnce($, state, settings) })
512}
513
514async function aimRun($: EngineInterface, state: PaneState, settings: PaneOptions): Promise<void> {
515 await startWatching($, state, settings)
516 const host = await paneHost($, settings.host)
517 await $.env.set('COUNCIL_MOD_PANE_DIR', host === 'mod' ? state.root : undefined)
518}
519
520// A background failure is logged once per distinct message, never dropped.
521async function logFailure($: EngineInterface, state: PaneState, work: () => Promise<void>): Promise<void> {
522 try {
523 await work()
524 state.specialistError = ''
525 } catch (error) {
526 const message = `specialist: ${String(error)}`
527 if (message !== state.specialistError) $.ui.log(message)
528 state.specialistError = message
529 }
530}
531
532async function pollOnce($: EngineInterface, state: PaneState, settings: PaneOptions): Promise<void> {
533 if (state.isPolling) return
534 state.isPolling = true
535 try {
536 await poll($, state, settings)
537 state.lastError = ''
538 } catch (error) {
539 // The poll repeats twice a second: a failure that persists is logged once.
540 const message = String(error)
541 if (message !== state.lastError) $.ui.log(message)
542 state.lastError = message
543 } finally {
544 state.isPolling = false
545 }
546}
547
548function specialistRun($: EngineInterface, args: string[], init?: { stdin?: string }) {
549 return $.process.run(['bash', `${$.plugin.root}/scripts/specialist.sh`, ...args], init)
550}
551
552function keyValues(text: string): Record<string, string> {
553 return Object.fromEntries(text.split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
554}
555
556const stateDirOf = (record: RunRecord) => `${record.worktree.replace(/\/[^/]+$/, '')}/.state/${record.id}`
557
558// Unreadable run records already reported, so a poll does not repeat them.
559const reportedUnreadable = new Set<string>()
560
561async function loadRuns($: EngineInterface): Promise<Record<string, RunRecord>> {
562 const { runs, unreadable } = readRuns(await $.store.get(RUNS_KEY))
563 for (const id of unreadable.filter(id => !reportedUnreadable.has(id))) {
564 reportedUnreadable.add(id)
565 $.ui.log(`specialist run record ${id} in the plugin store does not read; it is skipped`)
566 }
567 return runs
568}
569
570// Every session shares the store, so a write re-reads it first rather than
571// overwriting the others' records with a stale copy. Records that do not read
572// are kept as they are, for a person to look at.
573async function saveRun($: EngineInterface, record: RunRecord): Promise<void> {
574 const stored = await $.store.get(RUNS_KEY)
575 const runs = stored !== null && typeof stored === 'object' && !Array.isArray(stored) ? { ...stored } : {}
576 await $.store.set(RUNS_KEY, { ...runs, [record.id]: record })
577}
578
579async function roundState($: EngineInterface, state: PaneState, record: RunRecord): Promise<'running' | 'ended' | 'lost'> {
580 const stateDir = stateDirOf(record)
581 const exitPath = `${stateDir}/exit`
582 const exitText = await readText(files($), exitPath)
583 if (exitText.trim() !== '') return 'ended'
584 const pid = (await readText(files($), `${stateDir}/pid`)).trim()
585 let presence: 'present' | 'absent' | 'unknown' = 'unknown'
586 let recordedIdentity: string | undefined
587 let observedIdentity: string | undefined
588 let failure = ''
589 if (!/^\d+$/.test(pid)) failure = `invalid round pid ${JSON.stringify(pid)}`
590 else {
591 try {
592 const startPath = `${stateDir}/start`
593 if (await $.fs.exists(startPath)) recordedIdentity = await $.fs.read(startPath)
594 const alive = await $.process.run(['kill', '-0', pid], { env: { LC_ALL: 'C' } })
595 presence = roundProcessPresence(alive.exitCode, alive.stderr)
596 if (presence === 'unknown') failure = `kill -0 exited ${alive.exitCode}: ${alive.stderr.trim()}`
597 if (presence === 'present' && recordedIdentity !== undefined) {
598 const observed = await specialistRun($, ['identity', pid])
599 if (observed.exitCode === 0) observedIdentity = observed.stdout
600 else failure = `identity exited ${observed.exitCode}: ${observed.stderr.trim()}`
601 }
602 } catch (error) {
603 presence = 'unknown'
604 failure = `identity check failed: ${String(error)}`
605 }
606 }
607 const identity = roundProcessIdentity(recordedIdentity, observedIdentity, presence)
608 if (identity === 'unknown') {
609 const reason = failure || (recordedIdentity?.trim() === '' ? 'recorded start time is empty' : 'observed start time is empty or unparseable')
610 const message = `specialist round ${record.id} pid ${pid}: ${reason}`
611 const key = `${record.id}:${pid}`
612 if (!state.identityFailures.has(key)) {
613 $.ui.log(message)
614 state.identityFailures.add(key)
615 }
616 }
617 return roundLiveness(await readText(files($), exitPath), identity, await $.fs.exists(stateDir))
618}
619
620// Closes a round that ended: commits it, keeps its result on the record for
621// {run, result: true}, and wakes the model to fetch it.
622async function finishRound($: EngineInterface, state: PaneState, record: RunRecord, how: 'ended' | 'lost'): Promise<void> {
623 if (state.finishing.has(record.id)) return
624 state.finishing.add(record.id)
625 try {
626 // Another session may have closed it already: stop following it here too.
627 const stored = (await loadRuns($))[record.id]
628 if (stored?.state !== 'running') {
629 if (state.specialist?.record.id === record.id) state.specialist = undefined
630 if (state.specialistLog?.record.id === record.id) state.specialistLog = { ...state.specialistLog, record: stored ?? record, isLive: false }
631 $.ui.invalidate('ui.render')
632 return
633 }
634 const stateDir = stateDirOf(record)
635 // Every session following the round sees it end; only one closes it.
636 const claim = await specialistRun($, ['claim', stateDir])
637 if (claim.exitCode !== 0) throw new Error(`specialist claim for ${record.id} exited ${claim.exitCode}: ${claim.stderr.trim()}`)
638 if (keyValues(claim.stdout).claimed !== 'yes') {
639 if (state.specialist?.record.id === record.id) state.specialist = undefined
640 if (state.specialistLog?.record.id === record.id) state.specialistLog = { ...state.specialistLog, isLive: false }
641 $.ui.invalidate('ui.render')
642 return
643 }
644 const events = await readText(files($), `${stateDir}/events.jsonl`)
645 const report = async () => {
646 const text = (await specialistRun($, ['report', record.worktree, record.roundBase, record.base])).stdout
647 return parseSpecialistReport(text)
648 }
649 let thread = record.thread
650 let outcome: { result: string; isError: boolean }
651 if (how === 'lost') {
652 outcome = lostResult(record, (await report()).status)
653 } else {
654 const exitCode = Number((await readText(files($), `${stateDir}/exit`)).trim())
655 const ended = (await readText(files($), `${stateDir}/reason`)).trim()
656 const reason = ended === 'timeout' || ended === 'stopped' ? ended : ''
657 thread = (await readText(files($), `${stateDir}/thread`)).trim() || record.thread
658 const commit = exitCode === 0 ? await specialistRun($, ['commit', record.worktree, record.subject]) : undefined
659 const didCommit = commit !== undefined && keyValues(commit.stdout).committed === 'yes'
660 const committed = didCommit ? (await specialistRun($, ['head', record.worktree])).stdout.trim().slice(0, 7) : ''
661 const commitError = commit && commit.exitCode !== 0 ? commit.stderr.trim() || `commit exited ${commit.exitCode}` : ''
662 const section = await report()
663 outcome = roundResult({
664 record, exitCode, commit: committed, commitError, reason,
665 lastMessage: (await readText(files($), `${stateDir}/last-message.md`)).trim(),
666 roundStat: section.round, totalStat: section.total, status: section.status,
667 stderrTail: (await readText(files($), `${stateDir}/stderr.txt`)).split('\n').slice(-15).join('\n').trim(),
668 })
669 }
670 const done: RunRecord = { ...record, thread, state: 'idle', last: outcome }
671 await saveRun($, done)
672 if (state.specialist?.record.id === record.id) state.specialist = undefined
673 state.specialistLog = { record: done, steps: specialistSteps(events, record.worktree), isLive: false }
674 $.ui.invalidate('ui.render')
675 await $.prompt.submit({ text: specialistWake(done) })
676 } finally {
677 state.finishing.delete(record.id)
678 }
679}
680
681// Once a second while a round runs: its steps for the band and the pane, and
682// its end.
683async function followSpecialist($: EngineInterface, state: PaneState): Promise<void> {
684 const working = state.specialist
685 if (!working) return
686 state.nowMs = await $.clock.now()
687 const events = await readText(files($), `${working.stateDir}/events.jsonl`)
688 const steps = specialistSteps(events, working.record.worktree)
689 if (state.specialist !== working) return
690 if (events.length !== working.outputLength) {
691 working.outputLength = events.length
692 working.outputAtMs = state.nowMs
693 }
694 state.specialistLog = { record: working.record, steps, isLive: true }
695 $.ui.invalidate('ui.render')
696 // The exit file is read every tick; the process check costs two execs, so
697 // it runs at the council's pid cadence.
698 const hasExit = (await readText(files($), `${working.stateDir}/exit`)).trim() !== ''
699 if (!hasExit && state.nowMs - state.specialistCheckedAtMs < PID_CHECK_MS) return
700 state.specialistCheckedAtMs = state.nowMs
701 const now = await roundState($, state, working.record)
702 if (now !== 'running') await finishRound($, state, working.record, now)
703}
704
705// A round survives the session that started it: at start, follow one still
706// running and close one that ended meanwhile.
707// Scrolls the specialist pane to its end, which the engine keeps up with as
708// steps arrive. A refusal is reported: to the transcript where the pane
709// should have followed, to the debug log where a closed pane is expected.
710async function followPaneEnd($: EngineInterface, when: string, to: 'transcript' | 'debug'): Promise<boolean> {
711 const moved = await $.ui.scroll({ in: SPECIALIST_PANE, to: 'end' })
712 if (moved.deny) $.ui.log(`specialist pane did not follow (${when}): ${moved.deny}`, { to })
713 return !moved.deny
714}
715
716// One try per frame after the pane opens; the last refusal is the one reported.
717async function followOpenedPane($: EngineInterface, state: PaneState): Promise<void> {
718 state.paneFollowFrames -= 1
719 const isLast = state.paneFollowFrames === 0
720 const moved = await followPaneEnd($, 'pane opened', isLast ? 'transcript' : 'debug')
721 if (moved) state.paneFollowFrames = 0
722}
723
724async function recoverRounds($: EngineInterface, state: PaneState): Promise<void> {
725 for (const record of Object.values(await loadRuns($))) {
726 if (record.state !== 'running') continue
727 const now = await roundState($, state, record)
728 if (now === 'running') state.specialist = { record, startedMs: record.startedMs, stateDir: stateDirOf(record), outputLength: -1, outputAtMs: record.startedMs }
729 else await finishRound($, state, record, now)
730 }
731}
732
733// The COUNCIL and SPECIALIST chip: a darker segment with a star, then the
734// label, white on solid colour. Coloured cells draw alike in every terminal,
735// where end-cap glyphs do not. Given a frame, a highlight moves across the
736// label's letters.
737function chip(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, key: string, label: string, frame?: number) {
738 return (
739 <ui.Box key={key} flexDirection="row" flexShrink={0}>
740 <ui.Text bold color={COLOR.onFill} backgroundColor={FILL.chipMark}>{' \u2726 '}</ui.Text>
741 <ui.Text backgroundColor={FILL.chip}>{' '}</ui.Text>
742 {shimmer(label, frame).map((letter, index) => (
743 <ui.Text key={`${key}-${index}`} bold color={letter.color} backgroundColor={FILL.chip}>{letter.text}</ui.Text>
744 ))}
745 <ui.Text backgroundColor={FILL.chip}>{' '}</ui.Text>
746 </ui.Box>
747 )
748}
749
750// What the offer's buttons do: the run waits on these two file names.
751// Cancel a seat: the marker query-council.sh polls for while the seat is
752// out. The next poll reads it back and draws the row as cancelling until the
753// run's log line lands.
754function cancelSeat($: EngineInterface, state: PaneState, name: string): void {
755 const runDir = state.runDir
756 if (!runDir) return
757 void $.fs.write(`${runDir}/cancel/${name}`, '').catch((err: unknown) => $.ui.log(`cancel ${name}: ${String(err)}`))
758}
759
760// A section header's press: close it if open, open it if closed.
761function toggleSection($: EngineInterface, state: PaneState, fold: string): void {
762 if (!state.closed.delete(fold)) state.closed.add(fold)
763 $.ui.invalidate('ui.render')
764}
765
766function retryPresses($: EngineInterface, runDir: string) {
767 return {
768 accept: () => { void $.process.run(['mv', '-f', `${runDir}/retry-offer`, `${runDir}/.retry`]) },
769 skip: () => { void $.fs.write(`${runDir}/.retry-declined`, '') },
770 }
771}
772
773// The offer's two buttons and its countdown. The keys work once the person has
774// given the site the keyboard (a click, ctrl+x tab); a click works at any time.
775function retryRow(
776 ui: Pick<Elements['terminal'], 'Box' | 'Button' | 'Text'>,
777 offer: RetrySection,
778 press: { accept: () => void; skip: () => void },
779) {
780 return (
781 <ui.Box key="retry" flexDirection="row" marginTop={1}>
782 {/* The other bands' chip; only the notice and the how-to give way when narrow. */}
783 {chip(ui, 'chip', offer.badge)}
784 <ui.Box flexShrink={1}>
785 <ui.Text bold color={COLOR.danger} wrap="truncate-end">{` \u2717 ${offer.notice} `}</ui.Text>
786 </ui.Box>
787 <ui.Box flexShrink={0} flexDirection="row">
788 <ui.Button key="retry:accept" hotkey="r" label={offer.label} onPress={press.accept} />
789 <ui.Text>{' '}</ui.Text>
790 <ui.Button key="retry:skip" hotkey="s" label={offer.skipLabel} onPress={press.skip} />
791 <ui.Text color={COLOR.accent}>{` ${offer.bar}`}</ui.Text>
792 <ui.Text dimColor>{` ${offer.remaining}s`}</ui.Text>
793 </ui.Box>
794 <ui.Box flexShrink={1}>
795 <ui.Text dimColor wrap="truncate-end">{' click, or ctrl+x tab then r / s'}</ui.Text>
796 </ui.Box>
797 </ui.Box>
798 )
799}
800
801export const register: Register = (on, options) => {
802 const settings = paneOptions(options)
803 const state: PaneState = { root: '', drawn: '', isPolling: false, shown: new Set(), lastError: '', pidCheckedAtMs: 0, specialistCheckedAtMs: 0, frame: 0, nowMs: 0, queryingSinceMs: {}, closed: new Set(), fitted: new Map(), tableFit: { columns: 0, byText: new Map() }, specialists: [], finishing: new Set(), identityFailures: new Set(), specialistError: '', specialistFrame: 0, paneFollowFrames: 0, inputEpoch: 0 }
804
805 on('session.start', async ($, e, next) => {
806 await $.command.register({ name: REOPEN_COMMAND, description: 'Reopen the council pane, or forget where it was told to open', argumentHint: '[ask]', immediate: true })
807 await $.command.register({ name: SETUP_COMMAND, description: 'Add, edit or remove specialists', immediate: true })
808 if (settings.offersTool) await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA })
809 // An entry that does not parse is reported, never dropped quietly.
810 const roster = specialistRoster(options)
811 for (const problem of roster.problems) $.ui.log(problem)
812 state.specialists = roster.specialists
813 state.inputEpoch = await $.clock.now()
814 // A Save reloads this module; the screen's draft comes back from the store.
815 // This session's list is the newest at load; the shared copy starts from it.
816 if (typeof options[LIST_FIELD] === 'string') {
817 await $.store.set(LATEST_KEY, options[LIST_FIELD])
818 state.latest = options[LIST_FIELD]
819 }
820 state.setupKey = `${SETUP_KEY_PREFIX}${await $.session.id()}`
821 state.setup = restoreSetup(await $.store.get(state.setupKey), specialistEntries(options).entries)
822 // A write made while the models were loading reloads this module before
823 // they arrive; ask again, or Save would wait for them forever.
824 if (state.setup && 'loading' in state.setup.catalog) void logFailure($, state, () => loadCatalog($, state))
825 // An open setup screen was drawn by the reloaded module before the store
826 // was read; draw it again with it.
827 $.ui.invalidate('ui.render')
828 // Registered with no specialists too, so a user can ask Claude to set the first one up.
829 // A run outlives its specialist's removal: it can still be followed, fetched and finished.
830 const hasRuns = Object.values(await loadRuns($)).some(record => record.state !== 'finished')
831 await $.tool.register({ name: SPECIALIST_TOOL, description: specialistDescription(roster.specialists, hasRuns), inputSchema: specialistSchema(roster.specialists, hasRuns) })
832 void logFailure($, state, () => introduceSpecialists($, roster.specialists.length))
833 if (roster.specialists.length > 0 || hasRuns) {
834 // The band's clock moves only while a round runs.
835 $.clock.every(1000, () => { void logFailure($, state, () => followSpecialist($, state)) })
836 // The chip's shimmer moves only while a round runs.
837 $.clock.every(FRAME_MS, () => {
838 if (!state.specialist) return
839 state.specialistFrame += 1
840 if (state.paneFollowFrames > 0) void followOpenedPane($, state)
841 $.ui.invalidate('ui.render')
842 })
843 await logFailure($, state, () => recoverRounds($, state))
844 }
845 return next(e)
846 })
847
848 // The synthesis is written into the reply after the run ends; nothing on disk
849 // holds it, so it is lifted from the main loop's final message.
850 on('turn.complete', async ($, e, next) => {
851 const result = await next(e)
852 const view = state.view
853 const synthesis = e.agentId === undefined && view && !state.synthesis ? extractSynthesis(e.answer) : undefined
854 if (view && synthesis) {
855 state.synthesis = synthesis
856 state.view = { ...view, synthesis }
857 // The answers close so the synthesis reads near the top; each opens again on a press.
858 state.closed = new Set(seatFolds(paneSections(state.view)))
859 state.drawn = JSON.stringify([state.view, state.retryShown])
860 $.ui.invalidate('ui.render')
861 }
862 return result
863 })
864
865 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
866 if (isCouncilRun(e.command)) await aimRun($, state, settings)
867 return next(e)
868 })
869
870 // The settings row says ask while an answer is remembered; its label says which.
871 on('config.describe', async ($, e, next) => {
872 const row = await next(e)
873 // /specialists edits the list; the menu would only show its JSON.
874 if (e.key === LIST_KEY) return { ...row, isHidden: true }
875 if (!e.key.endsWith('.pane_host')) return row
876 return { ...row, label: hostRowLabel(row.label, settings.host, hostFrom(await $.store.get(HOST_STORE_KEY))) }
877 })
878
879 // The generated types list the tools connected when they were written, so a
880 // tool registered at run time is matched by pattern.
881 on('tool.call', { tool: /^mcp__claude-council__ask$/ }, async ($, e) => {
882 // `e` is flat: the tool's input fields sit beside `tool` and `tool_use_id`.
883 // Its declared type is the union of the listed tools, none of them this one.
884 const input = e as unknown as Record<string, unknown>
885 const parsed = councilArgs(input)
886 if ('deny' in parsed) return { deny: parsed.deny }
887 const script = `${$.plugin.root}/scripts/run-council.sh`
888 if (!(await $.fs.exists(script))) return { deny: `council script not found at ${script}` }
889 // The question goes to third-party providers, so the user confirms every
890 // call; this gate holds even where the tool itself needs no permission.
891 let answer: string | undefined
892 try {
893 answer = await $.ui.ask(confirmQuestion(input), { header: 'Council', options: [SEND_LABEL, KEEP_LABEL] })
894 } catch {
895 answer = undefined
896 }
897 const outcome = confirmOutcome(answer)
898 if ('deny' in outcome) return { deny: outcome.deny }
899 if ('reply' in outcome) return { result: outcome.reply }
900 await aimRun($, state, settings)
901 const run = await $.process.run(['bash', script, ...parsed.args], { timeoutMs: RUN_TIMEOUT_MS })
902 const saved = run.stdout.trim().split('\n').pop() ?? ''
903 if (run.exitCode !== 0 || !saved) return { result: run.stderr || 'council run failed', isError: true }
904 return { result: await $.fs.read(saved) }
905 })
906
907 // Every start, follow-up and finish is confirmed in a dialog: a specialist
908 // writes code with its own model, and a finish merges or deletes its branch.
909 on('tool.call', { tool: /^mcp__claude-council__specialist$/ }, async ($, e) => {
910 const call = specialistCall(e as unknown as Record<string, unknown>, state.specialists)
911 if ('deny' in call) return { deny: call.deny }
912 // Claude proposes, the user saves: the screen opens prefilled and nothing is written here.
913 if (call.kind === 'setup') {
914 // A prefill never replaces the person's unsaved edits: the screen opens
915 // on them, and Claude hears why its proposal is not there.
916 const kept = state.setup?.draft
917 if (kept && isDirty(kept)) {
918 const refused = await openSetup($, state, options)
919 if (refused) return { deny: `the setup screen did not open: ${refused}` }
920 return { deny: `the setup screen is open on unsaved changes to ${kept.name || 'a new specialist'}; ask the user to save or discard them, then call {setup} again` }
921 }
922 const refused = await openSetup($, state, options, call.fields)
923 return refused ? { deny: `the setup screen did not open: ${refused}` } : { result: 'Opened the setup screen; nothing is saved until the user presses Save.' }
924 }
925 const sh = (args: string[], init?: { stdin?: string }) => specialistRun($, args, init)
926 const kv = keyValues
927 // One round at a time across every session: they all share the store. A
928 // round that ended with nobody following it is closed first.
929 for (const r of Object.values(await loadRuns($))) {
930 if (r.state !== 'running') continue
931 const now = await roundState($, state, r)
932 if (now !== 'running') await finishRound($, state, r, now)
933 }
934 const runs = await loadRuns($)
935 const live = Object.values(runs).find(r => r.state === 'running')
936 const ask = async (question: string, go: string, stop: string) => {
937 try { return await $.ui.ask(question, { header: 'Specialist', options: [go, stop] }) } catch { return undefined }
938 }
939
940 // Starts a round and returns at once; followSpecialist closes it.
941 // The store says running only once the round's pid is on disk, so no
942 // session reads a round that has not launched yet as lost or ended. A
943 // round that fails to launch leaves the record as it was.
944 const round = async (record: RunRecord, prompt: string, thread: string, subject: string, isDirty = false) => {
945 const roundBase = (await sh(['head', record.worktree])).stdout.trim()
946 const running: RunRecord = { ...record, rounds: record.rounds + 1, state: 'running', startedMs: await $.clock.now(), roundBase, subject }
947 const stateDir = stateDirOf(running)
948 const limit = String(settings.roundLimitSeconds)
949 const launched = await sh(['codex', record.worktree, stateDir, record.model, record.effort ?? '', limit, ...(thread ? [thread] : [])], { stdin: prompt })
950 if (launched.exitCode !== 0) {
951 await saveRun($, record)
952 return { result: `Run ${record.id}: the round did not start: ${launched.stderr.trim()}`, isError: true as const }
953 }
954 await saveRun($, running)
955 state.specialist = { record: running, startedMs: running.startedMs, stateDir, outputLength: -1, outputAtMs: running.startedMs }
956 state.specialistLog = { record: running, steps: [], isLive: true }
957 $.ui.invalidate('ui.render')
958 // An open pane follows the new round; a closed one refuses, and follows
959 // once opened from the band.
960 await followPaneEnd($, 'round start', 'debug')
961 return { result: startedReply(running, isDirty) }
962 }
963
964 if (call.kind === 'start') {
965 if (live) return { deny: `${live.specialist} is still working on run ${live.id}` }
966 const login = await $.process.run(['codex', 'login', 'status']).catch(() => undefined)
967 if (!login || login.exitCode !== 0) return { deny: 'codex is not installed or not logged in; run `codex login`' }
968 const cwd = await $.session.cwd()
969 const top = await $.process.run(['git', '-C', cwd, 'rev-parse', '--short=7', 'HEAD'])
970 if (top.exitCode !== 0) return { deny: `${cwd} is not inside a git repository with a commit` }
971 const dirty = (await $.process.run(['git', '-C', cwd, 'status', '--porcelain'])).stdout.trim() !== ''
972 const s = call.specialist
973 // Every skill is read before the worktree exists, so a missing one costs nothing.
974 const index = await findSkills($)
975 const missing = missingSkills(s.skills ?? [], index)
976 if (missing.length > 0) return { deny: `${s.name} follows skills that are not installed: ${missing.join(', ')}; fix it in /specialists` }
977 const chosen = (s.skills ?? []).flatMap(name => index.get(name) ?? [])
978 const loaded = await Promise.all(chosen.map(async skill => ({ ...skill, body: skillBody(await $.fs.read(`${skill.dir}/SKILL.md`)) })))
979 const opening = skillsOpening(loaded)
980 const ts = runStamp(new Date(await $.clock.now()))
981 const started = await sh(['start', cwd, s.name, ts])
982 if (started.exitCode !== 0) return { result: started.stderr.trim() || 'could not create the worktree', isError: true }
983 const at = kv(started.stdout)
984 const record: RunRecord = {
985 id: `${s.name}-${ts}`, specialist: s.name, model: s.model, ...(s.effort ? { effort: s.effort } : {}), skills: s.skills ?? [],
986 repo: at.repo ?? '', worktree: at.worktree ?? '', branch: at.branch ?? '', base: at.base ?? '', thread: '', rounds: 0, state: 'idle', startedMs: 0, roundBase: '', subject: '',
987 }
988 return await round(record, specialistPrompt(opening, call.task), '', commitSubject(s.name, call.task), dirty)
989 }
990
991 const record = Object.hasOwn(runs, call.run) ? runs[call.run] : undefined
992 if (call.kind === 'result') {
993 if (!record) return { deny: `no run ${call.run}` }
994 if (record.state === 'running') return { deny: `${record.specialist} is still working on run ${record.id}; a prompt arrives when the round ends` }
995 if (!record.last) return { deny: `run ${record.id} has no finished round yet` }
996 return record.last.isError ? { result: record.last.result, isError: true as const } : { result: record.last.result }
997 }
998 if (call.kind === 'followUp') {
999 const exists = record ? await $.fs.exists(record.worktree) : false
1000 const refusal = followUpRefusal(record, call.run, exists)
1001 if (refusal || !record) return { deny: refusal ?? `no run ${call.run}` }
1002 if (live) return { deny: `${live.specialist} is still working on run ${live.id}` }
1003 return await round(record, call.message, record.thread, `specialist ${record.specialist}: round ${record.rounds + 1}`)
1004 }
1005
1006 if (call.kind === 'stop') {
1007 if (!record || record.state !== 'running') return { deny: `run ${call.run} has no round running` }
1008 // The round the user is asked about, by its pid and start time: a
1009 // follow-up may start another in the same state dir while the dialog is open.
1010 const roundPid = (await readText(files($), `${stateDirOf(record)}/pid`)).trim()
1011 const roundStart = (await readText(files($), `${stateDirOf(record)}/start`)).trim()
1012 const question = `Stop ${record.specialist}'s round ${record.rounds} on run ${record.id}? Its edits so far stay uncommitted in the worktree.`
1013 const outcome = dialogOutcome(await ask(question, 'Stop it', 'Let it run'), 'Stop it', 'Let it run', `let run ${record.id} keep running`)
1014 if ('deny' in outcome) return { deny: outcome.deny }
1015 if ('reply' in outcome) return { result: outcome.reply }
1016 const stopped = await sh(['stop', stateDirOf(record), roundPid, roundStart])
1017 if (stopped.exitCode !== 0) return { result: `Run ${record.id} not stopped: ${stopped.stderr.trim()}`, isError: true }
1018 return { result: `Stopped the round on run ${record.id}; a prompt arrives with its result once the round has closed.` }
1019 }
1020
1021 if (!record || record.state === 'finished') return { deny: `no open run ${call.run}` }
1022 if (record.state === 'running') return { deny: `${record.specialist} is still working on run ${record.id}` }
1023 const counted = await sh(['counts', record.repo, record.branch, record.base])
1024 // A branch deleted by hand has nothing to merge; discard still closes the run.
1025 if (counted.exitCode !== 0 && call.finish === 'merge') return { result: `Run ${record.id} not finished: ${counted.stderr.trim()}`, isError: true }
1026 const counts = kv(counted.stdout)
1027 const target = counts.target || 'a detached HEAD'
1028 const go = call.finish === 'merge' ? 'Merge' : 'Discard'
1029 const question = finishQuestion(record, call.finish, Number(counts.commits ?? 0), Number(counts.files ?? 0), target)
1030 const outcome = dialogOutcome(await ask(question, go, 'Keep it'), go, 'Keep it', `kept run ${record.id}`)
1031 if ('deny' in outcome) return { deny: outcome.deny }
1032 if ('reply' in outcome) return { result: outcome.reply }
1033 const current = (await loadRuns($))[record.id]
1034 if (current?.state !== 'idle') return { deny: `run ${record.id} changed while the dialog was open` }
1035 const finished = await sh(['finish', current.repo, current.worktree, current.branch, call.finish])
1036 if (finished.exitCode === 0) {
1037 await saveRun($, { ...current, state: 'finished' })
1038 return { result: `Run ${current.id}: ${call.finish === 'merge' ? `merged into ${target}` : 'discarded'}; worktree and branch removed.` }
1039 }
1040 const lines = finished.stdout.trim()
1041 const why = finished.exitCode === 3 ? `merge conflicts, merge aborted; worktree and branch kept:\n${lines}`
1042 : finished.exitCode === 4 ? `your uncommitted changes touch the branch's files; commit or stash them first:\n${lines}`
1043 : finished.exitCode === 5 ? 'the repository is on a detached HEAD; check out a branch to merge into'
1044 : finished.exitCode === 6 ? `git refused the merge:\n${finished.stderr.trim()}`
1045 : finished.exitCode === 8 ? `the branch changes files git runs as hooks, which would run on this machine as the merge commits; review them and merge by hand if they are safe:\n${lines}`
1046 : finished.exitCode === 7 ? `the worktree holds changes no round committed (a round failed or its commit was refused); commit them in ${current.worktree} or ask for another round, or discard the run:\n${lines}`
1047 : finished.stderr.trim() || 'finish failed'
1048 return { result: `Run ${current.id} not finished: ${why}`, isError: true }
1049 })
1050
1051 on('command.run', { command: REOPEN_COMMAND }, async ($, e) => {
1052 const command = paneCommand(e.args)
1053 if (command.action === 'forget') {
1054 await $.store.delete(HOST_STORE_KEY)
1055 return { text: 'Forgotten. With the setting on ask, the next council run inside tmux asks where to open its pane.' }
1056 }
1057 if (command.action === 'unknown') return { text: 'Usage: /council-pane [ask]. Choose the pane in /config, row "Pane opens in".' }
1058 if (state.view) await $.ui.open({ id: PANE_ID, title: 'Council' })
1059 return { text: reopenReply(state.view !== undefined) }
1060 })
1061
1062 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
1063 // The band sits on the prompt, so the offer stays in view however far the
1064 // pane has scrolled; a survey owns the band while it runs.
1065 const retry = state.retryShown
1066 const runDir = state.runDir
1067 if (e.props.hasSurvey) return next(e)
1068 const finished = state.finished
1069 if (finished && !retry) {
1070 const ui = $.ui.resolve(e)
1071 return (
1072 <ui.Box key="finished" flexDirection="row" marginTop={1}>
1073 {chip(ui, 'chip', 'COUNCIL')}
1074 <ui.Text bold {...(finished.isFailure ? { color: COLOR.danger } : {})}>{` ${finished.isFailure ? '\u2717' : '\u2713'} ${finished.text} `}</ui.Text>
1075 <ui.Button key="finished:open" hotkey="o" label={'o \u00b7 open pane'} onPress={() => { void $.ui.open({ id: PANE_ID, title: 'Council' }) }} />
1076 <ui.Text>{' '}</ui.Text>
1077 <ui.Button
1078 key="finished:dismiss"
1079 hotkey="x"
1080 label={'x \u00b7 dismiss'}
1081 onPress={() => {
1082 state.finished = undefined
1083 $.ui.invalidate('ui.render')
1084 }}
1085 />
1086 </ui.Box>
1087 )
1088 }
1089 if (retry && runDir) return retryRow($.ui.resolve(e), retry, retryPresses($, runDir))
1090 const working = state.specialist
1091 if (working) {
1092 const ui = $.ui.resolve(e)
1093 const clock = roundClock(working.startedMs, state.nowMs)
1094 const quiet = quietNote(working.outputAtMs, state.nowMs)
1095 return (
1096 <ui.Box key="specialist" flexDirection="row" marginTop={1}>
1097 {/* Only the step gives way when the band is narrow, as beside an open pane. */}
1098 {chip(ui, 'chip', 'SPECIALIST', state.specialistFrame)}
1099 <ui.Box flexShrink={0}>
1100 <ui.Text>
1101 <ui.Text bold>{` ${working.record.specialist}`}</ui.Text>
1102 <ui.Text color={COLOR.model}>{` ${working.record.model}`}</ui.Text>
1103 <ui.Text bold color={roundStatus(true, undefined, clock).color}>{` \u25cf ${clock}`}</ui.Text>
1104 {quiet && <ui.Text color={COLOR.hint}>{` ${quiet}`}</ui.Text>}
1105 </ui.Text>
1106 </ui.Box>
1107 <ui.Box flexGrow={1} flexShrink={1}>
1108 <ui.Text dimColor wrap="truncate-end">{` ${latestStep(state.specialistLog?.steps ?? [])} `}</ui.Text>
1109 </ui.Box>
1110 <ui.Box flexShrink={0}>
1111 <ui.Button key="specialist:open" hotkey="o" label={'o \u00b7 open pane'} onPress={() => { void $.ui.open({ id: SPECIALIST_PANE, title: `Specialist ${working.record.specialist}` }).then(() => { state.paneFollowFrames = PANE_FOLLOW_FRAMES }) }} />
1112 </ui.Box>
1113 </ui.Box>
1114 )
1115 }
1116 const view = state.view
1117 const progress = view ? progressBand(view, state.runStartedMs, state.nowMs) : undefined
1118 if (!view || !progress) return next(e)
1119 const ui = $.ui.resolve(e)
1120 // One marker per seat; the run cancels them one by one, as it would rows
1121 // pressed in turn. A press reads the seats again, since one may have
1122 // answered since the band was drawn.
1123 const cancelAll = () => { if (state.view) for (const name of seatsToCancel(state.view)) cancelSeat($, state, name) }
1124 return (
1125 <ui.Box key="progress" flexDirection="row" marginTop={1}>
1126 {/* The specialist band's slots; only the event gives way when narrow. */}
1127 {chip(ui, 'chip', 'COUNCIL', state.frame)}
1128 <ui.Box flexShrink={0}>
1129 <ui.Text>
1130 <ui.Text bold>{` ${progress.count}`}</ui.Text>
1131 <ui.Text color={COLOR.accent}>{` ${progress.bar}`}</ui.Text>
1132 {progress.clock ? <ui.Text bold color={roundStatus(true, undefined, progress.clock).color}>{` \u25cf ${progress.clock}`}</ui.Text> : null}
1133 </ui.Text>
1134 </ui.Box>
1135 <ui.Box flexGrow={1} flexShrink={1}>
1136 <ui.Text dimColor wrap="truncate-end">{` ${progress.event ?? ''} `}</ui.Text>
1137 </ui.Box>
1138 {seatsToCancel(view).length > 0 && (
1139 <ui.Box flexShrink={0}>
1140 <ui.Button key="progress:cancel" hotkey="c" label={'c \u00b7 cancel all'} onPress={cancelAll} />
1141 <ui.Text>{' '}</ui.Text>
1142 </ui.Box>
1143 )}
1144 <ui.Box flexShrink={0}>
1145 <ui.Button key="progress:open" hotkey="o" label={'o \u00b7 open pane'} onPress={() => { void $.ui.open({ id: PANE_ID, title: 'Council' }) }} />
1146 </ui.Box>
1147 </ui.Box>
1148 )
1149 })
1150
1151 // The setup screen's own writes skip this hook (the engine does not run a
1152 // plugin's hooks for its own $.config.set); a list set by hand lands here.
1153 on('config.set', { key: LIST_KEY }, async ($, e, next) => {
1154 const denial = await handEditDenial($, e.value)
1155 if (denial) return { deny: denial }
1156 const written = await next(e)
1157 if (!('deny' in written) || !written.deny) await $.store.set(LATEST_KEY, e.value)
1158 return written
1159 })
1160
1161 on('command.run', { command: SETUP_COMMAND }, async ($) => {
1162 const refused = await openSetup($, state, options)
1163 return { text: refused ? `The setup screen did not open: ${refused}` : 'Specialists: esc closes the screen.' }
1164 })
1165
1166 // Closing the screen keeps a draft only when it holds changes; an untouched
1167 // one would reopen the form for nothing.
1168 // A session's draft goes with it; the next session starts on the roster.
1169 on('session.end', async ($, e, next) => {
1170 if (state.setupKey) await $.store.delete(state.setupKey)
1171 return next(e)
1172 })
1173
1174 on('ui.close', { id: SETUP_PANE }, async ($, e, next) => {
1175 const draft = state.setup?.draft
1176 if (e.origin.kind === 'person' && !(draft && isDirty(draft))) await keepSetup($, state, undefined)
1177 return next(e)
1178 })
1179
1180 on('ui.render', { component: 'Pane' }, ($, e, next) => {
1181 if (e.requestId !== SETUP_PANE) return next(e)
1182 const ui = $.ui.resolve(e)
1183 if (!('Input' in ui)) return <ui.Text key="setup-mobile">Specialists are set up in the terminal or desktop app.</ui.Text>
1184 const { Box, Text, Input, Select, Button } = ui
1185 const setup = currentSetup(state)
1186 const stored = freshList(options, state.latest)
1187 const view = setupView(setup, stored.entries, stored.problem, state.skillNames)
1188 const draft = setup.draft
1189 const editor = view.editor
1190 const templates = view.templates
1191 const width = Math.max(30, e.props.bodyColumns - 2)
1192 const press = (work: () => Promise<void>) => () => { void setupAction($, state, work) }
1193 const edit = (patch: Partial<Fields>) => { void setupAction($, state, () => editSetup($, state, patch)) }
1194 // Help sits under its field in the light hint grey and wraps, so it always reads whole.
1195 // A table cell keeps its width; only the last column gives way.
1196 const cell = (key: string, cellWidth: number, content: RenderChildren) => <Box key={key} width={cellWidth} flexShrink={0}>{content}</Box>
1197 // A dim bar between two columns.
1198 const separator = (key: string) => (
1199 <Box key={key} width={SEPARATOR.length} flexShrink={0}><Text key="text" color={COLOR.line}>{SEPARATOR}</Text></Box>
1200 )mods/council-pane/hooks/host.ts 56 lines1// ABOUTME: Where a council run's pane is drawn: inside Claude Code by this mod, or in tmux
2// ABOUTME: The pane_host setting decides; on ask the answer is asked once, kept in the plugin store, and forgotten with /council-pane ask
3
4export type PaneHost = 'mod' | 'tmux'
5
6export const HOST_STORE_KEY = 'pane_host'
7
8export const HOST_QUESTION = 'Where should the council pane open?'
9
10export const HOST_LABELS: Record<PaneHost, string> = {
11 mod: 'Inside Claude Code',
12 tmux: 'tmux pane',
13}
14
15// A query starts a pane; fetching, cancelling or listing jobs, listing models
16// and asking for help do not, and neither does a command that only names the
17// script (a grep, a cat): the script has to be what bash is given to run.
18export function isCouncilRun(command: string): boolean {
19 return /\bbash\s+["']?[^\s"']*run-council\.sh\b/.test(command) && !/--(result|cancel|jobs|help|list-[a-z-]+)(=|\s|$)/.test(command)
20}
21
22export function hostFrom(value: unknown): PaneHost | undefined {
23 if (value === 'mod' || value === HOST_LABELS.mod) return 'mod'
24 if (value === 'tmux' || value === HOST_LABELS.tmux) return 'tmux'
25 return undefined
26}
27
28export type HostSetting = 'ask' | 'claude-code' | 'tmux'
29
30export function hostSetting(value: unknown): HostSetting {
31 return value === 'claude-code' || value === 'tmux' ? value : 'ask'
32}
33
34// The settings row decides outright unless it says ask; then the remembered
35// answer does, and with none the person is asked. Outside tmux the pane in
36// Claude Code is the only one there is, so nothing is asked there.
37export function decideHost(facts: { setting: HostSetting; remembered: PaneHost | undefined; isInTmux: boolean }): PaneHost | 'ask' {
38 if (facts.setting === 'claude-code') return 'mod'
39 if (facts.setting === 'tmux') return 'tmux'
40 if (!facts.isInTmux) return 'mod'
41 return facts.remembered ?? 'ask'
42}
43
44export function hostRowLabel(label: string, setting: HostSetting, remembered: PaneHost | undefined): string {
45 if (setting !== 'ask' || !remembered) return label
46 return `${label} \u2192 ${remembered === 'mod' ? 'Claude' : 'tmux'}`
47}
48
49export type PaneCommand = { action: 'reopen' } | { action: 'forget' } | { action: 'unknown' }
50
51export function paneCommand(args: string): PaneCommand {
52 const word = args.trim().toLowerCase()
53 if (word === '') return { action: 'reopen' }
54 return word === 'ask' ? { action: 'forget' } : { action: 'unknown' }
55}
56mods/council-pane/hooks/notices.ts 89 lines1// ABOUTME: Words for a council run outside the pane: the finish notice, the wake prompt, the reopen reply
2// ABOUTME: Pure functions over the run's provider states
3
4import { ANSWERED_STATES, count, ENDED_STATES, type ProviderStatus } from './status'
5import { roundClock } from './specialist'
6
7type RunProgress = { providers: ProviderStatus[]; isDone: boolean }
8
9export function finishNotice({ providers }: RunProgress, jobId = ''): string {
10 const errors = count(providers, 'error')
11 const cancelled = count(providers, 'cancelled')
12 // The band draws a COUNCIL badge ahead of this, so the text does not repeat the name.
13 const subject = jobId ? `job ${jobId} finished` : 'finished'
14 const answered = `${count(providers, ...ANSWERED_STATES)} of ${providers.length} answered`
15 return `${subject}: ${answered}${errors > 0 ? `, ${errors} error` : ''}${cancelled > 0 ? `, ${cancelled} cancelled` : ''}`
16}
17
18const PROGRESS_CELLS = 8
19
20// The band above the prompt while a run is live, in the specialist band's
21// slots: how many providers finished (an error or a cancel included), a thin line of the
22// same share, an m:ss clock and the latest event. Both counts round down, so
23// the line never reads full while a provider is still out. `startedAtMs` is
24// when the pane picked the run up; before that there is no clock.
25export function progressBand(
26 { providers, isDone, latest }: RunProgress & { latest?: string },
27 startedAtMs: number | undefined,
28 nowMs: number,
29): { count: string; bar: string; clock?: string; event?: string } | undefined {
30 if (isDone || providers.length === 0) return undefined
31 const finished = count(providers, ...ENDED_STATES)
32 const filled = Math.floor((finished / providers.length) * PROGRESS_CELLS)
33 return {
34 count: `${finished} of ${providers.length}`,
35 bar: '\u2501'.repeat(filled) + '\u2500'.repeat(PROGRESS_CELLS - filled),
36 ...(startedAtMs === undefined ? {} : { clock: roundClock(startedAtMs, nowMs) }),
37 ...(latest ? { event: latest } : {}),
38 }
39}
40
41// A run whose process died without writing .done: what the band says instead.
42export function abandonedNotice({ providers }: RunProgress, jobId = ''): string {
43 const subject = jobId ? `job ${jobId} stopped` : 'stopped'
44 return `${subject} before it finished: ${count(providers, ...ANSWERED_STATES)} of ${providers.length} answered`
45}
46
47// The pid a run left in its watch dir, fit to hand to kill -0: digits, not zero.
48export function runPid(text: string): string | undefined {
49 const pid = text.trim()
50 return /^[1-9]\d*$/.test(pid) ? pid : undefined
51}
52
53export function wakePrompt(jobId: string): string | undefined {
54 if (!jobId) return undefined
55 return `The background council job ${jobId} has finished. Fetch it with /claude-council:result ${jobId} and summarise it.`
56}
57
58export function reopenReply(hasRun: boolean): string {
59 return hasRun
60 ? 'Council pane reopened with the last run.'
61 : 'No council run in this session yet. Start one with /claude-council:ask.'
62}
63
64export type FinishNotice = { text: string; untilMs: number; isFailure?: boolean }
65
66export const FINISH_NOTICE_MS = 20_000
67
68export function noticeIsLive(notice: FinishNotice | undefined, nowMs: number): boolean {
69 return notice !== undefined && nowMs < notice.untilMs
70}
71
72export type JobOutcome = 'completed' | 'failed' | 'running'
73
74// Reads a job record as run-council.sh --result does: completed can be
75// fetched, queued and running cannot yet, anything else never will. A record
76// cut off mid-write is read again on the next poll; one that is gone or is
77// not a record is over.
78export function jobOutcome(record: string): JobOutcome {
79 if (!record.trim()) return 'failed'
80 let status: unknown
81 try {
82 status = (JSON.parse(record) as { status?: unknown }).status
83 } catch {
84 return 'running'
85 }
86 if (status === 'completed') return 'completed'
87 return status === 'queued' || status === 'running' ? 'running' : 'failed'
88}
89mods/council-pane/hooks/options.ts 35 lines1// ABOUTME: Reads the pane's settings from the options the engine passes to register
2// ABOUTME: Field names match plugin.json's userConfig; a missing or mistyped value takes the default
3
4import { hostSetting, type HostSetting } from './host'
5
6export type PaneOptions = {
7 host: HostSetting
8 collapsesWhenDone: boolean
9 wakesOnAsyncDone: boolean
10 offersTool: boolean
11 // How long a specialist round may run, in whole seconds; 0 means no limit.
12 roundLimitSeconds: number
13}
14
15// The setting is in minutes and may be fractional; the round takes whole
16// seconds, and a positive limit never rounds down to 0, which means none.
17function limitSeconds(minutes: unknown, fallback: number): number {
18 if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes < 0) return fallback
19 return minutes === 0 ? 0 : Math.max(1, Math.round(minutes * 60))
20}
21
22function flag(value: unknown, fallback: boolean): boolean {
23 return typeof value === 'boolean' ? value : fallback
24}
25
26export function paneOptions(options: Record<string, unknown>): PaneOptions {
27 return {
28 host: hostSetting(options.pane_host),
29 collapsesWhenDone: flag(options.collapse_when_done, true),
30 wakesOnAsyncDone: flag(options.wake_on_async_done, false),
31 offersTool: flag(options.council_tool, true),
32 roundLimitSeconds: limitSeconds(options.specialist_round_limit, 3600),
33 }
34}
35mods/council-pane/hooks/retry.ts 27 lines1// ABOUTME: Reads the retry offer a council run leaves in its watch dir and shapes the pane's countdown for it
2// ABOUTME: The run waits on the offer; the pane accepts by renaming it to .retry or declines with .retry-declined
3
4export type RetryOffer = { seconds: number; providers: string[] }
5
6export type RetrySection = { kind: 'retry'; badge: string; notice: string; label: string; skipLabel: string; remaining: number; bar: string }
7
8const BAR_CELLS = 8
9
10export function parseRetryOffer(text: string): RetryOffer | undefined {
11 const [window = '', ...rest] = text.split('\n').map(line => line.trim())
12 const providers = rest.filter(Boolean)
13 if (!/^[1-9]\d*$/.test(window) || providers.length === 0) return undefined
14 return { seconds: Number(window), providers }
15}
16
17export function retrySection(offer: RetryOffer, seenAtMs: number, nowMs: number): RetrySection {
18 const remaining = Math.max(0, offer.seconds - Math.floor((nowMs - seenAtMs) / 1000))
19 const { providers } = offer
20 const filled = Math.ceil((remaining / offer.seconds) * BAR_CELLS)
21 // The thin line the progress band draws; shade blocks render dithered in some fonts.
22 const bar = '\u2501'.repeat(filled) + '\u2500'.repeat(BAR_CELLS - filled)
23 const notice = providers.length === 1 ? `${providers[0]} failed` : `${providers.length} providers failed: ${providers.join(', ')}`
24 // The labels name their hotkeys: a terminal Button draws as `[ label ]` and shows no key of its own.
25 return { kind: 'retry', badge: 'COUNCIL', notice, label: 'r \u00b7 retry', skipLabel: 's \u00b7 skip', remaining, bar }
26}
27mods/council-pane/hooks/snapshot.ts 51 lines1// ABOUTME: Reads one snapshot of a run's watch dir: statuses, answers, errors, colors, and whether it is done
2// ABOUTME: Takes the engine's file access as a parameter so a scripted one can stand in
3
4import { lastEvent, parseStatus } from './status'
5import { parseColors, type RunView } from './view'
6
7export type Files = {
8 exists: (path: string) => Promise<boolean>
9 read: (path: string) => Promise<string>
10 list: (dir: string) => Promise<{ kind: string; name: string }[]>
11}
12
13export async function readText(fs: Files, path: string): Promise<string> {
14 return (await fs.exists(path)) ? await fs.read(path) : ''
15}
16
17async function readFolder(fs: Files, dir: string, suffix: string): Promise<Record<string, string>> {
18 const texts: Record<string, string> = {}
19 if (!(await fs.exists(dir))) return texts
20 for (const entry of await fs.list(dir)) {
21 if (entry.kind !== 'file' || entry.name.startsWith('.') || !entry.name.endsWith(suffix)) continue
22 texts[entry.name.slice(0, -suffix.length)] = await fs.read(`${dir}/${entry.name}`)
23 }
24 return texts
25}
26
27// The names of a folder's plain files; a marker is its name, with nothing to read.
28async function fileNames(fs: Files, dir: string): Promise<string[]> {
29 if (!(await fs.exists(dir))) return []
30 return (await fs.list(dir)).filter(entry => entry.kind === 'file' && !entry.name.startsWith('.')).map(entry => entry.name)
31}
32
33export async function readView(fs: Files, runDir: string): Promise<RunView> {
34 // .done is the run's last write, so it is looked for first: files read
35 // after it was seen are final, where a run ending mid-read would otherwise
36 // be called done over a snapshot taken before its last answer.
37 const isDone = await fs.exists(`${runDir}/.done`)
38 const status = await readText(fs, `${runDir}/status`)
39 const latest = lastEvent(status)
40 return {
41 providers: parseStatus(status),
42 responses: await readFolder(fs, `${runDir}/responses`, '.md'),
43 errors: await readFolder(fs, `${runDir}/errors`, '.txt'),
44 // The seats the pane pressed for cancel; a marker stays until the run logs the seat as cancelled.
45 cancels: await fileNames(fs, `${runDir}/cancel`),
46 colors: parseColors(await readText(fs, `${runDir}/colors`)),
47 isDone,
48 ...(latest ? { latest } : {}),
49 }
50}
51mods/council-pane/hooks/specialist.ts 568 lines1// ABOUTME: Pure decisions for the specialist tool: rows, call shapes, dialog text, prompts, results
2// ABOUTME: No engine calls here, so every rule runs under bun test
3import { spinner } from './view'
4import { COLOR } from './theme'
5import type { Fields } from './setup'
6
7// effort is Codex's reasoning effort; without it the user's own Codex default applies.
8// skills: the installed skills whose SKILL.md opens every task it starts; without them it just follows the task.
9// enabled: false keeps it in the list but out of Claude's reach; without it the specialist is on.
10export type Specialist = { name: string; model: string; effort?: string; when: string; skills?: string[]; enabled?: false }
11
12export const SKILLS_MAX = 8
13const SKILL_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/
14const EFFORT = /^[a-z]+$/
15const NAME = /^[a-z][a-z0-9-]{0,23}$/
16export const WHEN_MAX = 200
17const FIELDS = ['name', 'model', 'effort', 'when']
18const ENABLED = 'enabled'
19const SKILLS = 'skills'
20const SHAPE = 'a specialist must be a JSON object with name, model and when'
21// The one settings field that holds every specialist, as a JSON list of objects.
22export const LIST_FIELD = 'specialists'
23
24// One wording for a bad name, whether it came from the list or the setup screen's field.
25export function nameProblem(name: string): string | undefined {
26 return NAME.test(name) ? undefined : `name '${name}' must be lowercase letters, digits and dashes, starting with a letter`
27}
28
29const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
30
31// A field a person or another version wrote by hand must say what is wrong
32// with it; a misspelt key would otherwise be dropped without a word.
33function textField(entry: Record<string, unknown>, field: string, isRequired: boolean): string | undefined | { error: string } {
34 const value = entry[field]
35 if (value === undefined) return isRequired ? { error: `${field} is missing` } : undefined
36 return typeof value === 'string' ? value : { error: `${field} must be a string` }
37}
38
39function skillList(value: unknown): { list: string[] } | { error: string } {
40 if (value === undefined) return { list: [] }
41 if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) return { error: 'skills must be a list of skill names' }
42 const bad = value.find(item => !SKILL_NAME.test(item))
43 if (bad !== undefined) return { error: `skill '${bad}' must be lowercase letters, digits, dots, dashes or underscores` }
44 const twice = value.find((item, at) => value.indexOf(item) !== at)
45 if (twice !== undefined) return { error: `skill '${twice}' is listed twice` }
46 if (value.length > SKILLS_MAX) return { error: `at most ${SKILLS_MAX} skills` }
47 return { list: value }
48}
49
50export function parseSpecialist(entry: unknown): Specialist | { error: string } {
51 if (!isRecord(entry)) return { error: SHAPE }
52 const unknown = Object.keys(entry).find(key => !FIELDS.includes(key) && key !== ENABLED && key !== SKILLS)
53 if (unknown !== undefined) return { error: `unknown field '${unknown}'` }
54 const read: Record<string, string | undefined> = {}
55 for (const field of FIELDS) {
56 const value = textField(entry, field, field === 'name' || field === 'model' || field === 'when')
57 if (typeof value === 'object') return value
58 read[field] = value
59 }
60 const { name = '', model = '', effort, when = '' } = read
61 const badName = nameProblem(name)
62 if (badName) return { error: badName }
63 if (model.trim() === '') return { error: 'model is empty' }
64 if (when.trim() === '') return { error: 'use-when is empty' }
65 // The tool's description and the form's inputs hold one line.
66 if (/[\r\n]/.test(when)) return { error: 'use-when must be one line' }
67 if (when.length > WHEN_MAX) return { error: `use-when is longer than ${WHEN_MAX} characters` }
68 if (effort !== undefined && !EFFORT.test(effort)) return { error: `effort '${effort}' must be lowercase letters` }
69 const skills = skillList(entry[SKILLS])
70 if ('error' in skills) return skills
71 const enabled = entry[ENABLED]
72 if (enabled !== undefined && typeof enabled !== 'boolean') return { error: 'enabled must be true or false' }
73 return {
74 name, model, ...(effort === undefined ? {} : { effort }), when, ...(skills.list.length > 0 ? { skills: skills.list } : {}),
75 ...(enabled === false ? { enabled } : {}),
76 }
77}
78
79// Reads the specialists list from the plugin's options; a value that is not a
80// JSON list is a named problem, never an empty list in disguise. Each entry is
81// checked by parseSpecialist, so a bad one is reported by its place.
82export function specialistEntries(options: Record<string, unknown>): { entries: unknown[]; problem?: string } {
83 const value = options[LIST_FIELD]
84 if (value === undefined || (typeof value === 'string' && value.trim() === '')) return { entries: [] }
85 if (typeof value !== 'string') return { entries: [], problem: 'the specialists setting must be a JSON list' }
86 let data: unknown
87 try { data = JSON.parse(value) } catch { return { entries: [], problem: `the specialists setting is not JSON: ${value}` } }
88 if (!Array.isArray(data)) return { entries: [], problem: 'the specialists setting must be a JSON list' }
89 return { entries: data }
90}
91
92// Every session loads the list once, and another session's write does not
93// reach it; the plugin's store is shared and read live, so the last write any
94// session made there is the newest list. Without one, the loaded list stands.
95export function freshList(options: Record<string, unknown>, stored: unknown): { entries: unknown[]; problem?: string } {
96 return specialistEntries(typeof stored === 'string' ? { [LIST_FIELD]: stored } : options)
97}
98
99export function specialistRoster(options: Record<string, unknown>): { specialists: Specialist[]; problems: string[] } {
100 const { entries, problem } = specialistEntries(options)
101 const specialists: Specialist[] = []
102 const problems: string[] = problem ? [problem] : []
103 const usedBy = new Map<string, number>()
104 entries.forEach((entry, index) => {
105 const parsed = parseSpecialist(entry)
106 if ('error' in parsed) { problems.push(`specialist ${index + 1} ignored: ${parsed.error}`); return }
107 const earlier = usedBy.get(parsed.name)
108 if (earlier !== undefined) { problems.push(`specialist ${index + 1} ignored: the name '${parsed.name}' is already used by specialist ${earlier + 1}`); return }
109 usedBy.set(parsed.name, index)
110 specialists.push(parsed)
111 })
112 return { specialists, problems }
113}
114
115// Without this Claude picks skills on its own, and they open every task.
116const SKILLS_HINT = 'skills is optional: comma-separated names of installed skills the specialist follows on each new task; leave it out unless the user named some.'
117
118const isOn = (s: Specialist) => s.enabled !== false
119
120// hasRuns: a run not yet finished is in the store, so its calls stay offered
121// even once every specialist is removed. A switched-off specialist is named
122// only so Claude knows not to offer it.
123export function specialistDescription(all: Specialist[], hasRuns = false): string {
124 const list = all.filter(isOn)
125 const off = all.filter(s => !isOn(s)).map(s => s.name).join(', ')
126 if (list.length === 0) {
127 const open = hasRuns
128 ? ' A run started before its specialist was removed still takes {run, message}, {run, result: true}, {run, stop: true} and {run, finish: "merge"|"discard"}; close it only after the user chose.'
129 : ''
130 return (
131 'Set up a specialist: a coding agent that works in its own git worktree with its own model. ' +
132 (off ? `Every specialist is switched off by the user (${off}); never offer or start one, and if one fits, say it can be turned on in /specialists. ` : 'None are set up yet. ') +
133 `When the user asks for one, call {setup: {${SETUP_FIELDS.join(', ')}}} with what they described; ` +
134 'it opens a screen with those fields filled in and nothing is saved until the user presses Save. ' +
135 SKILLS_HINT + open
136 )
137 }
138 const roster = list.map(s => `${s.name} (${s.model}), use when: ${s.when}`).join('; ')
139 return (
140 'Hand a coding task to a specialist that works in its own git worktree with its own model. ' +
141 `Specialists: ${roster}. ` +
142 (off ? `Switched off by the user, so never offer or start: ${off}. ` : '') +
143 'When a task matches a use-when, offer that specialist to the user; start one only when the user asked for it or agreed. ' +
144 `When no specialist fits and the user wants one, or the user describes one, call {setup: {${SETUP_FIELDS.join(', ')}}}; it opens a screen with those fields filled in and nothing is saved until the user presses Save. ` +
145 `${SKILLS_HINT} ` +
146 'Start with {specialist, task}; send review feedback with {run, message}; ' +
147 'rounds run in the background and a prompt arrives when one ends, then fetch it with {run, result: true}; ' +
148 'the tool commits each round itself, so never tell a specialist to commit; ' +
149 'close a run with {run, finish: "merge"|"discard"} only after the user chose; end a running round early with {run, stop: true} only when the user asks. ' +
150 'A start or follow-up runs at once; the user confirms every finish and stop. A refusal comes back as the result; do not retry unless the reply asks for a change.'
151 )
152}
153
154export function specialistSchema(all: Specialist[], hasRuns = false): Record<string, unknown> {
155 const list = all.filter(isOn)
156 const setup = {
157 type: 'object',
158 description: 'Open the setup screen prefilled with a proposed specialist; the user saves it.',
159 properties: Object.fromEntries(SETUP_FIELDS.map(field => [field, { type: 'string' }])),
160 additionalProperties: false,
161 }
162 // With no specialists there is nothing to start; with no open run either,
163 // setting one up is the only call.
164 if (list.length === 0 && !hasRuns) return { type: 'object', properties: { setup }, additionalProperties: false }
165 const start = list.length === 0 ? {} : {
166 specialist: { type: 'string', enum: list.map(s => s.name) },
167 task: { type: 'string', description: 'Start: the task, self-contained; the specialist sees only its worktree.' },
168 }
169 return {
170 type: 'object',
171 properties: {
172 ...start,
173 run: { type: 'string', description: 'Follow-up or finish: the run id a start returned.' },
174 message: { type: 'string', description: 'Follow-up: feedback for the specialist, e.g. a failing test.' },
175 finish: { type: 'string', enum: ['merge', 'discard'] },
176 result: { type: 'boolean', description: 'Fetch the last round\'s result: {run, result: true}, after the prompt saying the round ended.' },
177 stop: { type: 'boolean', description: 'End the running round early: {run, stop: true}, only when the user asked; the user confirms.' },
178 setup,
179 },
180 additionalProperties: false,
181 }
182}
183
184export type RunRecord = {
185 id: string; specialist: string; model: string; effort?: string
186 // The skills the run started with, by name; each round's text is not kept.
187 skills: string[]
188 repo: string; worktree: string; branch: string; base: string; thread: string
189 rounds: number; state: 'running' | 'idle' | 'finished'
190 // When the current or last round started; the band's clock.
191 startedMs: number
192 // What the current or last round started from, and the subject its commit gets.
193 roundBase: string
194 subject: string
195 // The last finished round's result, fetched with {run, result: true}.
196 last?: { result: string; isError: boolean }
197}
198
199const RUN_STRINGS = ['id', 'specialist', 'model', 'repo', 'worktree', 'branch', 'base', 'thread', 'roundBase', 'subject'] as const
200
201function isRunRecord(v: unknown): v is RunRecord {
202 if (!isRecord(v)) return false
203 if (!RUN_STRINGS.every(key => typeof v[key] === 'string')) return false
204 if (typeof v.rounds !== 'number' || typeof v.startedMs !== 'number') return false
205 if (v.state !== 'running' && v.state !== 'idle' && v.state !== 'finished') return false
206 if (!Array.isArray(v.skills) || !v.skills.every(s => typeof s === 'string')) return false
207 if (v.effort !== undefined && typeof v.effort !== 'string') return false
208 return v.last === undefined || (isRecord(v.last) && typeof v.last.result === 'string' && typeof v.last.isError === 'boolean')
209}
210
211// The run store is shared by every session and can be edited by hand: a record
212// that does not read is named, never trusted.
213export function readRuns(value: unknown): { runs: Record<string, RunRecord>; unreadable: string[] } {
214 if (value === undefined || value === null) return { runs: {}, unreadable: [] }
215 if (!isRecord(value)) return { runs: {}, unreadable: ['(the whole store)'] }
216 const runs: Record<string, RunRecord> = {}
217 const unreadable: string[] = []
218 for (const [id, record] of Object.entries(value)) {
219 if (isRunRecord(record)) runs[id] = record
220 else unreadable.push(id)
221 }
222 return { runs, unreadable }
223}
224
225export type SpecialistCall =
226 | { kind: 'start'; specialist: Specialist; task: string }
227 | { kind: 'followUp'; run: string; message: string }
228 | { kind: 'finish'; run: string; finish: 'merge' | 'discard' }
229 | { kind: 'stop'; run: string }
230 | { kind: 'result'; run: string }
231 | { kind: 'setup'; fields: Partial<Fields> }
232
233const SHAPES = 'give one of {specialist, task}, {run, message}, {run, finish}, {run, stop: true}, {run, result: true} or {setup}'
234const SETUP_FIELDS = ['name', 'model', 'effort', 'when', 'skills']
235const filled = (value: unknown) => typeof value === 'string' && value.trim() !== ''
236
237export function specialistCall(input: Record<string, unknown>, list: Specialist[]): SpecialistCall | { deny: string } {
238 const { specialist, task, run, message, finish, result, stop } = input
239 const has = (v: unknown) => v !== undefined
240 if (has(input.setup)) {
241 if (has(specialist) || has(task) || has(run) || has(message) || has(finish) || has(result) || has(stop)) return { deny: SHAPES }
242 const fields = input.setup
243 const isFields = typeof fields === 'object' && fields !== null && !Array.isArray(fields) &&
244 Object.entries(fields).every(([key, value]) => SETUP_FIELDS.includes(key) && typeof value === 'string')
245 if (!isFields) return { deny: `setup fields must be strings: ${SETUP_FIELDS.join(', ')}` }
246 return { kind: 'setup', fields: fields as Partial<Fields> }
247 }
248 if (has(stop)) {
249 if (stop !== true || !filled(run) || has(specialist) || has(task) || has(message) || has(finish) || has(result)) return { deny: SHAPES }
250 return { kind: 'stop', run: run as string }
251 }
252 if (has(result)) {
253 if (result !== true || !filled(run) || has(specialist) || has(task) || has(message) || has(finish)) return { deny: SHAPES }
254 return { kind: 'result', run: run as string }
255 }
256 if (has(specialist) && !has(run) && !has(message) && !has(finish)) {
257 const found = list.find(s => s.name === specialist)
258 if (!found) return { deny: `no specialist named '${String(specialist)}'; configured: ${list.map(s => s.name).join(', ')}` }
259 if (!isOn(found)) return { deny: `${found.name} is switched off; the user can turn it on in /specialists` }
260 if (!filled(task)) return { deny: 'task must be a non-empty string' }
261 return { kind: 'start', specialist: found, task: task as string }
262 }
263 if (has(run) && has(message) && !has(finish) && !has(specialist) && !has(task)) {
264 if (!filled(run) || !filled(message)) return { deny: 'run and message must be non-empty strings' }
265 return { kind: 'followUp', run: run as string, message: message as string }
266 }
267 if (has(run) && has(finish) && !has(message) && !has(specialist) && !has(task)) {
268 if (finish !== 'merge' && finish !== 'discard') return { deny: 'finish must be merge or discard' }
269 if (!filled(run)) return { deny: 'run must be a non-empty string' }
270 return { kind: 'finish', run: run as string, finish }
271 }
272 return { deny: SHAPES }
273}
274
275const two = (n: number) => String(n).padStart(2, '0')
276export function runStamp(date: Date): string {
277 return `${date.getFullYear()}${two(date.getMonth() + 1)}${two(date.getDate())}-${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}`
278}
279
280export function finishQuestion(record: RunRecord, finish: 'merge' | 'discard', commits: number, files: number, target: string): string {
281 return finish === 'merge'
282 ? `Merge ${record.branch} (${commits} commits, ${files} files) into ${target}?`
283 : `Discard run ${record.id} and delete its branch?`
284}
285
286// Text typed under Other comes back as a reply, which the tool returns as a
287// plain result: a refusal is drawn as an error, and a typed answer is not one.
288export function dialogOutcome(answer: string | undefined, go: string, stop: string, refusal: string): { go: true } | { deny: string } | { reply: string } {
289 if (answer === go) return { go: true }
290 if (answer === undefined) return { deny: `The user was not asked (dialog dismissed or no one to ask), so the user ${refusal}.` }
291 if (answer === stop) return { deny: `The user ${refusal}.` }
292 return { reply: `The user ${refusal} and said: ${answer}` }
293}
294
295// Specialists need the mod and a logged-in codex, so the one notice about them
296// waits for a session where both hold, and someone with specialists never gets it.
297export function introNotice(at: { shown: boolean; specialists: number; codexReady: boolean }): { log?: string; markShown: boolean } {
298 if (at.shown) return { markShown: false }
299 if (at.specialists > 0) return { markShown: true }
300 if (!at.codexReady) return { markShown: false }
301 return { log: 'New: /specialists hands a coding task to a Codex agent on its own git branch, with six templates to start from. This note shows once.', markShown: true }
302}
303
304// opening: skillsOpening's text, which already ends in a blank line; '' for none.
305export function specialistPrompt(opening: string, task: string): string {
306 return `${opening.trim() === '' ? '' : opening}Task:\n${task}\n\nWork only inside this directory, which is already this task's own git branch: do not create or switch branches. Run the tests you touch. Do not commit: the tool commits your changes after each round.`
307}
308
309export function followUpRefusal(record: RunRecord | undefined, id: string, worktreeExists: boolean): string | undefined {
310 if (!record) return `no run ${id}; start a new one`
311 if (record.state === 'finished') return `run ${id} is finished; start a new one`
312 if (record.state === 'running') return `${record.specialist} is still working on run ${id}`
313 if (!worktreeExists) return `run ${id} has no worktree any more; start a new one`
314 if (!record.thread) return `run ${id} has no Codex thread to resume; start a new one`
315 return undefined
316}
317
318const OWN_REPORT = "The specialist's own report (written before the tool committed):"
319
320export type TestResult = 'pass' | 'fail' | 'not_run'
321export type RoundReport = { summary: string; tests: { command: string; result: TestResult; detail: string }[]; open_questions: string[] }
322
323const TEST_RESULTS: readonly string[] = ['pass', 'fail', 'not_run']
324const isString = (v: unknown): v is string => typeof v === 'string'
325const hasKeys = (v: Record<string, unknown>, keys: string[]) => {
326 const own = Object.keys(v)
327 return own.length === keys.length && keys.every((k) => own.includes(k))
328}
329
330// The round's last message, in the shape scripts/specialist-report.schema.json
331// asks Codex for. The two describe one shape: change them together. Anything
332// else, including valid JSON of another shape, is undefined.
333export function parseRoundReport(text: string): RoundReport | undefined {
334 let value: unknown
335 try {
336 value = JSON.parse(text)
337 } catch {
338 return undefined
339 }
340 if (!isRecord(value) || !hasKeys(value, ['summary', 'tests', 'open_questions'])) return undefined
341 const { summary, tests, open_questions } = value
342 if (!isString(summary) || !Array.isArray(tests) || !Array.isArray(open_questions) || !open_questions.every(isString)) return undefined
343 const testOk = (t: unknown) => isRecord(t) && hasKeys(t, ['command', 'result', 'detail']) &&
344 isString(t.command) && isString(t.detail) && isString(t.result) && TEST_RESULTS.includes(t.result)
345 if (!tests.every(testOk)) return undefined
346 return value as RoundReport
347}
348
349export function parseSpecialistReport(output: string): { round: string; total: string; status: string } {
350 const sections = { round: '', total: '', status: '' }
351 const parts = output.split(/^--- (round|total|status)\n/gm)
352 for (let i = 1; i < parts.length; i += 2) {
353 sections[parts[i] as keyof typeof sections] = parts[i + 1]?.trimEnd() ?? ''
354 }
355 return sections
356}
357
358const TEST_LABEL: Record<TestResult, string> = { pass: 'pass', fail: 'fail', not_run: 'not run' }
359const oneLine = (text: string) => text.replace(/[ \t\r\n]+/g, (space) => space.includes('\n') || space.includes('\r') ? ' ' : space).trim()
360
361// What the specialist said about its round, for Claude to review. A message
362// that is not a schema-shaped report is shown as written, and says so.
363function reportText(lastMessage: string): string {
364 if (!lastMessage) return 'The specialist wrote no report.'
365 const report = parseRoundReport(lastMessage)
366 if (!report) return `It did not match the report schema; its last message as written:\n${lastMessage}`
367 const tests = report.tests.length
368 ? `Tests:\n${report.tests.map((t) => `- ${TEST_LABEL[t.result]}: ${t.command}${t.detail ? ` (${oneLine(t.detail)})` : ''}`).join('\n')}`
369 : 'Tests: none reported.'
370 const questions = report.open_questions.length
371 ? `Open questions:\n${report.open_questions.map((q) => `- ${oneLine(q)}`).join('\n')}`
372 : 'Open questions: none.'
373 return [report.summary, tests, questions].join('\n\n')
374}
375
376export function roundResult(r: {
377 record: RunRecord; exitCode: number; lastMessage: string; roundStat: string; totalStat: string
378 // The short sha of the commit the tool made for this round; '' when it made none.
379 commit: string; status: string; stderrTail: string; commitError: string
380 // Why the round was ended early: its time limit, or a stop request; '' when it ended by itself.
381 reason: '' | 'timeout' | 'stopped'
382}): { result: string; isError: boolean } {
383 const { record } = r
384 // A reason written after Codex finished on its own does not undo the finish:
385 // the exit code decided the commit, so it decides the report.
386 const how = r.exitCode === 0 ? 'finished' : r.reason ? 'stopped' : 'failed'
387 const head = `Run ${record.id} (${record.specialist}, round ${record.rounds}) ${how}.\nBranch: ${record.branch}\nWorktree: ${record.worktree}`
388 if (r.exitCode !== 0) {
389 const why = r.reason === 'timeout' ? 'It reached the round time limit (specialist_round_limit in /config) and was stopped'
390 : r.reason === 'stopped' ? 'It was stopped on request'
391 : `Codex exited ${r.exitCode}`
392 const parts = [head, `${why}; nothing was committed.`]
393 if (r.status) parts.push(`Uncommitted in the worktree:\n${r.status}`)
394 if (r.lastMessage) parts.push(`Specialist's last message:\n${r.lastMessage}`)
395 if (r.stderrTail) parts.push(`Codex stderr (tail):\n${r.stderrTail}`)
396 return { isError: true, result: parts.join('\n\n') }
397 }
398 if (r.commitError) {
399 const refused = `The round's commit failed; the changes are uncommitted in the worktree:\n${r.status}\n\ngit said:\n${r.commitError}`
400 return { isError: true, result: [head, refused, `${OWN_REPORT}\n${reportText(r.lastMessage)}`].join('\n\n') }
401 }
402 if (!r.commit && !r.roundStat) return { isError: false, result: [head, 'No changes this round.', `${OWN_REPORT}\n${reportText(r.lastMessage)}`].join('\n\n') }
403 // The specialist wrote its report before the tool committed, so it cannot
404 // know the commit exists; the tool's own line settles it.
405 const who = r.commit ? `The tool committed this round on the branch as ${r.commit}.` : 'The specialist committed this round itself.'
406 const changes = `This round:\n${r.roundStat}\nSince ${record.base}:\n${r.totalStat}`
407 return { isError: false, result: [head, who, changes, `${OWN_REPORT}\n${reportText(r.lastMessage)}`].join('\n\n') }
408}
409
410const SUBJECT_MAX = 72
411const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
412
413// A git subject line: the task's first real line, cut at a word so the whole
414// subject stays near 72 characters.
415export function commitSubject(name: string, task: string): string {
416 const prefix = `specialist ${name}: `
417 const line = task.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
418 const room = SUBJECT_MAX - prefix.length - 1
419 if (line.length <= room + 1) return prefix + line
420 const space = line.lastIndexOf(' ', room)
421 return `${prefix}${space > room / 2 ? line.slice(0, space) : line.slice(0, room)}…`
422}
423
424// think is a short reasoning summary, Codex's own bold title for what it is working out.
425export type Step = { kind: 'think' | 'say' | 'run' | 'edit'; text: string; state: 'running' | 'done' | 'failed' }
426
427// Codex runs a quoted command or a single unquoted token through the login shell.
428const SHELL_WRAP = /^\/bin\/\w+ -lc (?:(["'])([\s\S]*)\1|([^\s"']+))$/
429
430// The round's closing message is the report as JSON; the pane shows what a
431// reader wants from it. The commands already show above it, so tests do not.
432function sayText(text: string): string {
433 const report = parseRoundReport(text)
434 if (!report) return text
435 if (!report.open_questions.length) return report.summary
436 return `${report.summary}\n\nOpen questions:\n${report.open_questions.map((q) => `- ${q}`).join('\n')}`
437}
438
439// One step per Codex item, in first-seen order, each updated as its later
440// events arrive. The file is read while Codex writes it, so a half-written
441// last line is skipped rather than parsed.
442export function specialistSteps(events: string, worktree: string): Step[] {
443 const order: string[] = []
444 const byId = new Map<string, Step>()
445 for (const line of events.split('\n')) {
446 let event: { type?: string; item?: Record<string, unknown> }
447 try { event = JSON.parse(line) } catch { continue }
448 const item = event.item
449 if (!item || typeof item.id !== 'string') continue
450 const status = item.status === 'failed' ? 'failed' : event.type === 'item.completed' ? 'done' : 'running'
451 let step: Step | undefined
452 if (item.type === 'reasoning' && typeof item.text === 'string') step = { kind: 'think', text: item.text.replace(/\*\*/g, '').trim(), state: 'done' }
453 if (item.type === 'agent_message' && typeof item.text === 'string') step = { kind: 'say', text: sayText(item.text), state: 'done' }
454 if (item.type === 'command_execution' && typeof item.command === 'string') {
455 const match = SHELL_WRAP.exec(item.command)
456 step = { kind: 'run', text: match?.[2] ?? match?.[3] ?? item.command, state: status }
457 }
458 if (item.type === 'file_change' && Array.isArray(item.changes)) {
459 const paths = item.changes.map(c => String((c as { path?: unknown }).path ?? '')).map(p => p.startsWith(`${worktree}/`) ? p.slice(worktree.length + 1) : p)
460 step = { kind: 'edit', text: paths.join(', '), state: status }
461 }
462 if (!step) continue
463 if (!byId.has(item.id)) order.push(item.id)
464 byId.set(item.id, step)
465 }
466 return order.map(id => byId.get(id) as Step)
467}
468
469const BAND_STEP_MAX = 59
470
471export function latestStep(steps: Step[]): string {
472 const step = steps[steps.length - 1]
473 if (!step) return ''
474 if (step.kind === 'run') return `$ ${cut(step.text, BAND_STEP_MAX)}`
475 if (step.kind === 'edit') return `✎ ${cut(step.text, BAND_STEP_MAX)}`
476 return cut(step.text.split('\n')[0] ?? '', BAND_STEP_MAX + 2)
477}
478
479// A round is over once its exit file exists; a process gone without one was
480// killed, and nothing will ever finish it.
481export type RoundProcessIdentity = 'same' | 'gone' | 'unknown'
482
483// A state dir removed by hand takes the pid file with it, so the process can
484// no longer be checked; without this such a round would run forever.
485export function roundLiveness(exitText: string, identity: RoundProcessIdentity, hasStateDir: boolean): 'running' | 'ended' | 'lost' {
486 if (exitText.trim() !== '') return 'ended'
487 if (!hasStateDir) return 'lost'
488 return identity === 'gone' ? 'lost' : 'running'
489}
490
491export function roundProcessPresence(exitCode: number, stderr: string): 'present' | 'absent' | 'unknown' {
492 if (exitCode === 0) return 'present'
493 // The round subshell belongs to this user, so EPERM means its pid was reused.
494 if (/no such process|operation not permitted/i.test(stderr)) return 'absent'
495 return 'unknown'
496}
497
498export function roundProcessIdentity(
499 recordedIdentity: string | undefined, observedIdentity: string | undefined, presence: 'present' | 'absent' | 'unknown',
500): RoundProcessIdentity {
501 if (presence === 'absent') return 'gone'
502 if (presence === 'unknown') return 'unknown'
503 if (recordedIdentity === undefined) return 'same'
504 const recorded = recordedIdentity.trim().replace(/\s+/g, ' ')
505 const observed = observedIdentity?.trim().replace(/\s+/g, ' ')
506 // ps's lstart where ps has -o; Git Bash's procfs start time (`proc:<ticks>`) elsewhere.
507 const kindOf = (text: string) =>
508 /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{1,2} [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}$/.test(text) ? 'lstart'
509 : /^proc:[0-9]+$/.test(text) ? 'proc' : undefined
510 const kind = kindOf(recorded)
511 if (kind === undefined || observed === undefined || kindOf(observed) !== kind) return 'unknown'
512 return recorded === observed ? 'same' : 'gone'
513}
514
515// A fixed-width m:ss clock, so the line it sits on does not shift each second.
516export function roundClock(startedMs: number, nowMs: number): string {
517 const secs = Math.max(0, Math.floor((nowMs - startedMs) / 1000))
518 const hours = Math.floor(secs / 3600)
519 const mins = Math.floor((secs % 3600) / 60)
520 const ss = String(secs % 60).padStart(2, '0')
521 return hours > 0 ? `${hours}:${String(mins).padStart(2, '0')}:${ss}` : `${mins}:${ss}`
522}
523
524// Codex writes an event only when a step ends, so a long test run is silent
525// too; past two minutes the band says so rather than guessing it is stuck.
526export function quietNote(lastOutputMs: number, nowMs: number): string {
527 return nowMs - lastOutputMs < 120_000 ? '' : `no output for ${roundClock(lastOutputMs, nowMs)}`
528}
529
530// The pane's last line while a round runs, so it moves even while Codex
531// thinks and no step is running.
532export function workingLine(frame: number, startedMs: number, nowMs: number): string {
533 return `${spinner(frame)} working ${roundClock(startedMs, nowMs)}`
534}
535
536// The engine keeps a pane scrolled to its end only until something else moves
537// it. A move of the person's that lands on the last rows asks to follow again;
538// the window's last offset is contentRows - bodyRows, and a tree that fits has
539// none.
540export function landsAtEnd(e: { offset: number; bodyRows: number; contentRows: number; origin: { kind: string } }): boolean {
541 return e.origin.kind === 'person' && e.offset >= e.contentRows - e.bodyRows
542}
543
544// The one coloured item in the pane's header: how the round stands.
545export function roundStatus(isLive: boolean, last: RunRecord['last'], clock: string): { glyph: string; text: string; color: string } {
546 if (isLive) return { glyph: '\u25cf', text: clock, color: COLOR.warning }
547 if (last?.isError) return { glyph: '\u2717', text: `failed ${clock}`, color: COLOR.danger }
548 return { glyph: '\u2713', text: `ended ${clock}`, color: COLOR.success }
549}
550
551// Submitted as a prompt when a round ends. It names the run only: the
552// specialist's own words reach the model as a tool result, never as a prompt.
553export function specialistWake(record: RunRecord): string {
554 return `Specialist ${record.specialist} finished round ${record.rounds} of run ${record.id}. ` +
555 `Fetch its result with mcp__claude-council__specialist {"run": "${record.id}", "result": true}, review it, and ask the user before any follow-up or finish.`
556}
557
558export function startedReply(record: RunRecord, isDirty = false): string {
559 const dirty = isDirty ? 'Your uncommitted changes are not in its worktree.\n' : ''
560 return `Run ${record.id} (${record.specialist}, round ${record.rounds}) started in the background.\nBranch: ${record.branch}\nWorktree: ${record.worktree}\n${dirty}\n` +
561 'A prompt arrives when the round ends; the band above the prompt shows its progress. Do not poll for it.'
562}
563
564export function lostResult(record: RunRecord, status: string): { result: string; isError: boolean } {
565 const head = `Run ${record.id} (${record.specialist}, round ${record.rounds}) stopped before it finished: its process is gone and left no exit code.\nBranch: ${record.branch}\nWorktree: ${record.worktree}`
566 return { isError: true, result: status ? `${head}\n\nUncommitted in the worktree:\n${status}` : head }
567}
568mods/council-pane/hooks/tool.ts 66 lines1// ABOUTME: The council tool the model can call: its schema, and its input turned into run-council.sh arguments
2// ABOUTME: Input is model-written, so it is validated here before it reaches a command line
3
4export const TOOL_NAME = 'ask'
5
6export const TOOL_DESCRIPTION =
7 'Ask the council of external AI models one question and get each answer back. ' +
8 'Call this when the user asks for the council, or when a decision would benefit from outside perspectives. ' +
9 'The user is asked to confirm before anything is sent; a refusal comes back as the result; do not retry unless the reply asks for a change.'
10
11export const TOOL_SCHEMA = {
12 type: 'object',
13 properties: {
14 question: { type: 'string', description: 'The question, self-contained: the council sees nothing else.' },
15 providers: { type: 'array', items: { type: 'string' }, description: 'Provider names; omit for every configured provider.' },
16 verbosity: { type: 'string', enum: ['brief', 'standard', 'detailed'] },
17 },
18 required: ['question'],
19 additionalProperties: false,
20}
21
22const VERBOSITIES = ['brief', 'standard', 'detailed']
23
24export const SEND_LABEL = 'Send to the council'
25export const KEEP_LABEL = "Don't send"
26
27// The dialog shows a question of this many characters at most; the run still
28// receives the whole question.
29const QUOTED_MAX = 300
30
31// The confirmation the user answers before the question leaves the machine.
32// Called on input councilArgs has already accepted.
33export function confirmQuestion(input: Record<string, unknown>): string {
34 const question = String(input.question)
35 const quoted = question.length > QUOTED_MAX ? `${question.slice(0, QUOTED_MAX)}\u2026` : question
36 const who = Array.isArray(input.providers) ? input.providers.join(', ') : 'every configured provider'
37 return `Send "${quoted}" to ${who}?`
38}
39
40// Only the exact send label sends. `answer` is undefined when the dialog
41// rejected: dismissed, or a run with no one to ask. Free text typed under
42// Other goes back to the model as a plain result, not a refusal: it is
43// usually an instruction, and a refusal is drawn as an error.
44export function confirmOutcome(answer: string | undefined): { send: true } | { deny: string } | { reply: string } {
45 if (answer === SEND_LABEL) return { send: true }
46 if (answer === undefined) return { deny: 'The user was not asked (dialog dismissed or no one to ask), so nothing was sent to the council.' }
47 if (answer === KEEP_LABEL) return { deny: 'The user chose not to send this to the council.' }
48 return { reply: `The user did not send this to the council and said: ${answer}` }
49}
50
51export function councilArgs(input: Record<string, unknown>): { args: string[] } | { deny: string } {
52 const { question, providers, verbosity } = input
53 if (typeof question !== 'string' || question.trim() === '') return { deny: 'question must be a non-empty string' }
54 const flags: string[] = []
55 if (providers !== undefined) {
56 const isNames = Array.isArray(providers) && providers.length > 0 && providers.every(name => typeof name === 'string' && /^[a-z][a-z0-9-]*$/.test(name))
57 if (!isNames) return { deny: 'providers must be names like codex or openrouter-2' }
58 flags.push(`--providers=${(providers as string[]).join(',')}`)
59 }
60 if (verbosity !== undefined) {
61 if (typeof verbosity !== 'string' || !VERBOSITIES.includes(verbosity)) return { deny: 'verbosity must be brief, standard or detailed' }
62 flags.push(`--verbosity=${verbosity}`)
63 }
64 return { args: [...flags, '--', question] }
65}
66mods/council-pane/hooks/synthesis.ts 13 lines1// ABOUTME: Lifts the council synthesis out of the assistant's reply so the pane can show it
2// ABOUTME: Nothing on disk holds the synthesis; the reply's "## Synthesis" section is its only source
3
4export function extractSynthesis(reply: string): string | undefined {
5 const lines = reply.split('\n')
6 const start = lines.findIndex(line => /^#{1,6}\s+Synthesis\s*$/.test(line))
7 if (start < 0) return undefined
8 const rest = lines.slice(start + 1)
9 const saved = rest.findIndex(line => line.includes('Full output saved'))
10 const body = (saved < 0 ? rest : rest.slice(0, saved)).join('\n').replace(/\n-{3,}\s*$/, '').trim()
11 return body.replace(/^-{3,}$/, '').trim() || undefined
12}
13mods/council-pane/hooks/tables.ts 58 lines1// ABOUTME: Rewrites markdown tables too wide for the pane as one labelled record per row
2// ABOUTME: The engine lays a table out to the terminal's width, so a wide one wraps into noise in a narrower pane
3
4const SEPARATOR = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
5const FENCE = /^\s*(```|~~~)/
6
7function cells(row: string): string[] {
8 const inner = row.trim().replace(/^\|/, '').replace(/\|$/, '')
9 return inner.split(/(?<!\\)\|/).map(cell => cell.trim())
10}
11
12// Each column takes its longest cell plus a border and two spaces of padding.
13function drawnWidth(rows: string[][]): number {
14 const columns = Math.max(...rows.map(row => row.length))
15 let width = 1
16 for (let column = 0; column < columns; column++) {
17 width += Math.max(...rows.map(row => (row[column] ?? '').length)) + 3
18 }
19 return width
20}
21
22function records(header: string[], body: string[][]): string[] {
23 const lines: string[] = []
24 body.forEach((row, index) => {
25 if (index > 0) lines.push('')
26 const label = row[0] ?? ''
27 lines.push(/^\*\*.*\*\*$/.test(label) ? label : `**${label}**`)
28 for (let column = 1; column < header.length; column++) {
29 lines.push(`- ${header[column]}: ${row[column] ?? ''}`)
30 }
31 })
32 return lines
33}
34
35export function fitTables(markdown: string, paneColumns: number): string {
36 const lines = markdown.split('\n')
37 const out: string[] = []
38 let isFenced = false
39 for (let i = 0; i < lines.length; i++) {
40 const line = lines[i] ?? ''
41 if (FENCE.test(line)) isFenced = !isFenced
42 const separator = lines[i + 1] ?? ''
43 const isTable = !isFenced && line.includes('|') && separator.includes('-') && SEPARATOR.test(separator)
44 if (!isTable) {
45 out.push(line)
46 continue
47 }
48 let end = i + 2
49 while (end < lines.length && (lines[end] ?? '').includes('|') && (lines[end] ?? '').trim() !== '') end++
50 const header = cells(line)
51 const body = lines.slice(i + 2, end).map(cells)
52 if (drawnWidth([header, ...body]) <= paneColumns) out.push(...lines.slice(i, end))
53 else out.push(...records(header, body))
54 i = end - 1
55 }
56 return out.join('\n')
57}
58mods/council-pane/hooks/chip.ts 22 lines1// ABOUTME: The chip's shimmer: a warm highlight that moves across the label's
2// ABOUTME: letters while something runs; the chip's background never changes
3
4import { COLOR, SHIMMER } from './theme'
5
6export type ChipLetter = { text: string; color: string }
7
8// Frames the chip holds still between two passes, after the highlight has
9// slid off the last letter.
10export const SHIMMER_PAUSE = 10
11
12// Without a frame the label is plain white. Only the letters' colour moves, so
13// the chip draws the same in every font and terminal.
14export function shimmer(label: string, frame?: number): ChipLetter[] {
15 const letters = [...label]
16 const at = frame === undefined ? -2 : frame % (letters.length + 1 + SHIMMER_PAUSE)
17 return letters.map((text, index) => {
18 const distance = Math.abs(index - at)
19 return { text, color: distance === 0 ? SHIMMER.peak : distance === 1 ? SHIMMER.near : COLOR.onFill }
20 })
21}
22mods/council-pane/hooks/view.ts 327 lines1// ABOUTME: Decides which council run the pane shows and turns its state into the markdown drawn there
2// ABOUTME: Pure functions over plain values, so they run without the engine
3import { ANSWERED_STATES, count, type ProviderStatus } from './status'
4import { COLOR } from './theme'
5
6export type RunView = {
7 providers: ProviderStatus[]
8 responses: Record<string, string>
9 errors: Record<string, string>
10 // Seats pressed for cancel in the pane, from the watch dir's cancel folder.
11 cancels?: string[]
12 colors: Record<string, string>
13 isDone: boolean
14 // The status log's last event in words, for the band.
15 latest?: string
16 synthesis?: string
17 // When the pane first saw each provider querying; the status log carries no clock.
18 queryingSinceMs?: Record<string, number>
19}
20
21type DirEntry = { name: string; kind: string }
22
23export function unseenRun(entries: readonly DirEntry[], shown: ReadonlySet<string>): string | undefined {
24 return entries.find(entry => entry.kind === 'dir' && entry.name.startsWith('run.') && !shown.has(entry.name))?.name
25}
26
27export type Section =
28 | { kind: 'note'; text: string }
29 // cancel: on a querying row while the run is live, the button's seat (the
30 // name unpadded) and, for the first ten rows, its digit.
31 | { kind: 'status'; glyph: string; glyphColor: string; name: string; state: string; stateColor: string; time: string; model: string; cancel?: { seat: string; hotkey?: string } }
32 | { kind: 'summary'; text: string }
33 // fold: what the jump opens, the fold of the section it lands on.
34 | { kind: 'strip'; items: { glyph: string; color: string; name: string; hotkey: string; target?: string; fold?: string }[] }
35 // A section the person closed is drawn as its header alone: a banner is
36 // followed by no reason or body, and a closed error or synthesis carries no
37 // text. fold names its open or closed state apart from its jump key, so an
38 // error closed by the person does not close the answer a retry brings.
39 | { kind: 'banner'; key: string; fold: string; title: string; subtitle: string; background: string; open: boolean }
40 | { kind: 'body'; text: string }
41 | { kind: 'reason'; text: string }
42 | { kind: 'synthesis'; key: string; fold: string; open: true; text: string }
43 | { kind: 'synthesis'; key: string; fold: string; open: false }
44 | { kind: 'error'; key: string; fold: string; title: string; open: true; text: string }
45 | { kind: 'error'; key: string; fold: string; title: string; open: false }
46 // Above the first section, closing every one while any is open, else opening all.
47 | { kind: 'toggleAll'; closes: boolean; folds: string[] }
48
49const STATE_COLORS: Record<string, string> = { querying: COLOR.warning, complete: COLOR.success, cached: COLOR.info, error: COLOR.danger, fallback: COLOR.warning, cancelled: COLOR.muted, cancelling: COLOR.muted }
50export const CANCEL_HINT = 'click cancel on a row, or ctrl+x tab then its digit'
51// A provider with no colour of its own; a data colour, like the vendors' own.
52const NEUTRAL_RGB = '113;113;122'
53const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
54export const spinner = (frame: number) => SPINNER[frame % SPINNER.length] ?? '\u25cf'
55
56const jumpKey = (name: string) => `jump:${name}`
57
58function seconds(ms: number | undefined): string {
59 return ms === undefined ? '' : `${(ms / 1000).toFixed(1)}s`
60}
61
62export function parseColors(log: string): Record<string, string> {
63 const colors: Record<string, string> = {}
64 for (const line of log.split('\n')) {
65 const [name, rgb] = line.replace(/\r$/, '').split('\t')
66 if (name && rgb && /^\d+;\d+;\d+$/.test(rgb)) colors[name] = rgb
67 }
68 return colors
69}
70
71// Columns are padded here so the rows line up whatever the surface's layout does.
72// A cancelled seat's glyph is hollow and grey, where an error is a red cross.
73const glyphColor = (name: string, state: string, vendor: (name: string) => string) =>
74 state === 'error' ? COLOR.danger : state === 'cancelled' ? COLOR.muted : vendor(name)
75
76// Each seat cancel all would take carries its own cancel button, with the
77// row's digit for the first nine and 0 for the tenth, as a keyboard's number
78// row runs (once the run is done the digits jump to the answers instead, and
79// 0 to the synthesis), and a hint line follows the rows.
80function statusRows(
81 providers: ProviderStatus[],
82 vendor: (name: string) => string,
83 glyph: (state: string) => string,
84 elapsed: (name: string) => string,
85 cancellable: string[],
86): Section[] {
87 const shownTime = ({ name, state, ms }: ProviderStatus) => (state === 'querying' ? elapsed(name) : seconds(ms))
88 const width = (texts: string[]) => Math.max(...texts.map(text => text.length))
89 const names = width(providers.map(provider => provider.name))
90 const states = width(providers.map(provider => provider.state))
91 const times = width(providers.map(shownTime))
92 const rows: Section[] = providers.map((provider, index) => ({
93 kind: 'status',
94 glyph: glyph(provider.state),
95 glyphColor: glyphColor(provider.name, provider.state, vendor),
96 name: provider.name.padEnd(names),
97 state: provider.state.padEnd(states),
98 stateColor: STATE_COLORS[provider.state] ?? COLOR.muted,
99 time: shownTime(provider).padStart(times),
100 model: provider.model ?? '',
101 ...(cancellable.includes(provider.name) ? { cancel: { seat: provider.name, ...(index < 10 ? { hotkey: String((index + 1) % 10) } : {}) } } : {}),
102 }))
103 if (rows.some(row => row.kind === 'status' && row.cancel)) rows.push({ kind: 'note', text: CANCEL_HINT })
104 return rows
105}
106
107// What the band's cancel all presses: every seat a live run still has
108// querying whose cancel has not been pressed yet, in the log's order.
109export function seatsToCancel({ providers, cancels = [], isDone }: Pick<RunView, 'providers' | 'cancels' | 'isDone'>): string[] {
110 if (isDone) return []
111 return providers.filter(({ name, state }) => state === 'querying' && !cancels.includes(name)).map(({ name }) => name)
112}
113
114function doneSummary(
115 providers: ProviderStatus[],
116 vendor: (name: string) => string,
117 glyph: (state: string) => string,
118 hasSection: (name: string) => boolean,
119 foldOf: (name: string) => string | undefined,
120): Section[] {
121 const tally = (state: string, word: string) => (count(providers, state) > 0 ? `${count(providers, state)} ${word}` : '')
122 const slowest = Math.max(0, ...providers.map(provider => provider.ms ?? 0))
123 const parts = [
124 `${count(providers, ...ANSWERED_STATES)} of ${providers.length} answered`,
125 tally('error', 'error'),
126 tally('cancelled', 'cancelled'),
127 tally('fallback', 'fell back'),
128 tally('cached', 'cached'),
129 slowest > 0 ? seconds(slowest) : '',
130 ]
131 return [
132 { kind: 'summary', text: parts.filter(Boolean).join(' \u00b7 ') },
133 {
134 kind: 'strip',
135 // The digit is the hotkey that jumps to the provider's section while the pane has the keys.
136 items: providers.map(({ name, state }, index) => ({
137 glyph: glyph(state),
138 color: glyphColor(name, state, vendor),
139 name,
140 hotkey: index < 9 ? String(index + 1) : '',
141 ...(hasSection(name) ? { target: jumpKey(name) } : {}),
142 ...(foldOf(name) ? { fold: foldOf(name) } : {}),
143 })),
144 },
145 ]
146}
147
148// When each provider now querying was first seen querying. One that stops
149// loses its entry, so a provider the person retries starts a fresh clock.
150export function queryingSince(since: Record<string, number>, providers: ProviderStatus[], nowMs: number): Record<string, number> {
151 const next: Record<string, number> = {}
152 for (const { name, state } of providers) {
153 if (state === 'querying') next[name] = since[name] ?? nowMs
154 }
155 return next
156}
157
158export function paneSections(
159 { providers, responses, errors, cancels = [], colors, isDone, synthesis, queryingSinceMs = {} }: RunView,
160 { collapsesWhenDone = true, frame = 0, nowMs = 0, fitText = (text: string) => text, closed = new Set<string>() }: { collapsesWhenDone?: boolean; frame?: number; nowMs?: number; fitText?: (text: string) => string; closed?: ReadonlySet<string> } = {},
161): Section[] {
162 if (providers.length === 0) {
163 return [{ kind: 'note', text: isDone ? 'Council finished with no answers.' : 'Waiting for the council...' }]
164 }
165 // A seat still querying as far as the log knows, with its marker in the
166 // watch dir, is cancelling: the run removes the marker once it logs the seat.
167 const shown = providers.map(provider => (provider.state === 'querying' && cancels.includes(provider.name) ? { ...provider, state: 'cancelling' } : provider))
168 const vendor = (name: string) => `rgb(${(colors[name] ?? NEUTRAL_RGB).replaceAll(';', ',')})`
169 const glyph = (state: string) => {
170 if (state === 'error') return '\u2717'
171 if (state === 'cancelled' || state === 'cancelling') return '\u25cb'
172 return state === 'querying' ? spinner(frame) : '\u25cf'
173 }
174 // A querying provider's time runs from when the pane first saw it, in whole tenths.
175 const elapsed = (name: string) => {
176 const since = queryingSinceMs[name]
177 return since === undefined ? '' : `${(Math.floor(Math.max(0, nowMs - since) / 100) / 10).toFixed(1)}s`
178 }
179 const hasSection = (name: string) => responses[name] !== undefined || errors[name] !== undefined
180 const foldOf = (name: string) => {
181 if (responses[name] !== undefined) return fold('banner', jumpKey(name))
182 return errors[name] !== undefined ? fold('error', jumpKey(name)) : undefined
183 }
184 const sections: Section[] = isDone && collapsesWhenDone ? doneSummary(shown, vendor, glyph, hasSection, foldOf) : statusRows(shown, vendor, glyph, elapsed, seatsToCancel({ providers, cancels, isDone }))
185 for (const { name, state, ms, model } of providers) {
186 const response = responses[name]
187 const error = errors[name]
188 const key = jumpKey(name)
189 if (response !== undefined) {
190 const timing = ms === undefined ? '' : `(${seconds(ms)})`
191 const banner = fold('banner', key)
192 const open = !closed.has(banner)
193 sections.push({
194 kind: 'banner',
195 key,
196 fold: banner,
197 title: name.toUpperCase(),
198 subtitle: [model, timing].filter(Boolean).join(' '),
199 background: vendor(name),
200 open,
201 })
202 if (!open) continue
203 // A seat whose API sibling answered has both files: the error is why
204 // the seat itself did not, and it belongs above that answer. The state
205 // decides, since a seat that failed and then answered on a retry keeps
206 // its first attempt's error file.
207 if (state === 'fallback' && error !== undefined) sections.push({ kind: 'reason', text: noticeText(`${name} fell back: ${error}`) })
208 // fitText rewrites an answer for the pane's width before the budget is
209 // counted: a wide table becomes records that repeat every header, which
210 // can lengthen it several times over.
211 sections.push({ kind: 'body', text: fitText(response) })
212 } else if (error !== undefined && state !== 'fallback') {
213 // A fallback seat's error file is its reason, written just before the
214 // answer its API gave: with no answer yet there is nothing to show.
215 const header = { kind: 'error', key, fold: fold('error', key), title: `${name} error` } as const
216 sections.push(closed.has(header.fold) ? { ...header, open: false } : { ...header, open: true, text: noticeText(error) })
217 }
218 }
219 if (synthesis) {
220 // It is written last and read last; landing above the answers would push them down mid-read.
221 const key = jumpKey('synthesis')
222 const header = { kind: 'synthesis', key, fold: fold('synthesis', key) } as const
223 sections.push(closed.has(header.fold) ? { ...header, open: false } : { ...header, open: true, text: fitText(synthesis) })
224 const strip = sections.find(section => section.kind === 'strip')
225 if (strip?.kind === 'strip') {
226 strip.items.push({ glyph: '\u2261', color: `rgb(${NEUTRAL_RGB.replaceAll(';', ',')})`, name: 'synthesis', hotkey: '0', target: key, fold: header.fold })
227 }
228 }
229 const first = sections.findIndex(isClosable)
230 const folds = sections.filter(isClosable).map(section => section.fold)
231 if (folds.length > 1) sections.splice(first, 0, { kind: 'toggleAll', closes: folds.some(each => !closed.has(each)), folds })
232 return withinTextBudget(sections)
233}
234
235type ClosableSection = Extract<Section, { open: boolean }>
236const isClosable = (section: Section): section is ClosableSection => section.kind === 'banner' || section.kind === 'error' || section.kind === 'synthesis'
237
238const fold = (kind: ClosableSection['kind'], key: string) => `${kind}:${key}`
239
240// The folds of the seats' answers and errors, which close once the synthesis
241// lands so it reads near the top; the synthesis stays open.
242export function seatFolds(sections: Section[]): string[] {
243 return sections.filter(isClosable).flatMap(section => (section.kind === 'synthesis' ? [] : [section.fold]))
244}
245
246// Claude Code refuses a Pane render carrying more than 100000 characters of
247// text and draws its own. The budget sits under that with room for what the
248// pane draws around the sections (the retry row, the jump strip's names).
249const PANE_TEXT_BUDGET = 80000
250const CLIPPED = (more: number) => `\n\n_\u2026 ${more} more characters; the whole answer is in the result (/claude-council:result)_`
251
252// An error or a fallback reason is drawn as its opening only: a provider can
253// hand back a whole HTML error page. The section carries what is drawn, so
254// the budget below counts that and no more.
255const NOTICE_LIMIT = 2000
256const noticeText = (text: string) => markdownBlocks(text, NOTICE_LIMIT)[0] ?? ''
257
258// Every string a section carries, counted without copying any of them: this
259// runs on each frame of a live pane.
260const sectionLength = (section: Section) => {
261 let length = 0
262 for (const value of Object.values(section)) if (typeof value === 'string') length += value.length
263 return length
264}
265
266// Cuts the answer bodies, and only them, until the pane fits the budget: the
267// synthesis, banners, status rows and errors keep their text, and the bodies
268// share what is left. A body within an even share is left whole and its unused
269// room goes to the longer ones, which are all cut to the same length, so one
270// long answer beside short ones is cut only as far as the budget demands.
271export function withinTextBudget(sections: Section[], budget: number = PANE_TEXT_BUDGET): Section[] {
272 const total = sections.reduce((sum, section) => sum + sectionLength(section), 0)
273 if (total <= budget) return sections
274 const lengths = sections.flatMap(section => (section.kind === 'body' ? [section.text.length] : [])).sort((a, b) => a - b)
275 let room = budget - (total - lengths.reduce((sum, length) => sum + length, 0))
276 let left = lengths.length
277 for (const length of lengths) {
278 if (length * left > room) break
279 room -= length
280 left--
281 }
282 const cap = Math.max(0, Math.floor(room / Math.max(1, left)))
283 return sections.map(section => (section.kind !== 'body' || section.text.length <= cap ? section : { kind: 'body', text: clipped(section.text, cap) }))
284}
285
286const FENCE_CLOSE = '\n```'
287
288// The opening of an answer and a note saying how much is left out, together
289// within `room`. The room set aside is for the longest the note can be and a
290// closing fence, so the result never runs past it. The cut does not fall
291// between the two halves of one character, and a code block it lands in is
292// closed first (a backtick fence; a tilde one is rare enough to leave), so the
293// note reads as text and the rest of the pane is not drawn as code.
294function clipped(text: string, room: number): string {
295 let cut = Math.max(0, room - CLIPPED(text.length).length - FENCE_CLOSE.length)
296 const last = cut > 0 ? text.charCodeAt(cut - 1) : 0
297 if (last >= 0xd800 && last <= 0xdbff) cut--
298 const kept = text.slice(0, cut)
299 const isInsideFence = (kept.match(/^ {0,3}```/gm)?.length ?? 0) % 2 === 1
300 return kept + (isInsideFence ? FENCE_CLOSE : '') + CLIPPED(text.length - cut)
301}
302
303// A Markdown element takes at most this many characters, tab and newline its
304// only control characters; a tree holding one that breaks either rule is not drawn.
305const MARKDOWN_LIMIT = 10000
306
307export function markdownBlocks(text: string, limit: number = MARKDOWN_LIMIT): string[] {
308 const clean = text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
309 const blocks: string[] = []
310 let block = ''
311 for (const paragraph of clean.split('\n\n')) {
312 const joined = block ? `${block}\n\n${paragraph}` : paragraph
313 if (joined.length <= limit) {
314 block = joined
315 continue
316 }
317 if (block) blocks.push(block)
318 block = paragraph
319 while (block.length > limit) {
320 blocks.push(block.slice(0, limit))
321 block = block.slice(limit)
322 }
323 }
324 if (block) blocks.push(block)
325 return blocks
326}
327