SLOPSHOPPER

claude-council

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

newpanebandguardcommandtool
★ 854v2026.10.4MITupdated 2026-10-09hex/claude-council
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-council
│ ┃ Specialists ✕ › fix the failing auth test and add an audit log call │ ┃ SPECIALISTS (0) │ ┃ ╭─────────────────────────────────────────── ● claude-council: New: /specialists hands a coding task to a Codex ag │ ┃ │ No specialists yet. A specialist is a Code ⏺ Read(src/auth.ts) │ ┃ │ that works on its own branch. Pick a templ ⎿ Read 6 lines │ ┃ │ your own, or describe one to Claude. ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ [ + Add specialist ] ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ TEMPLATE USE WHEN │ ┃ │ bug-fixer A bug reproduces, the exp ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ and the fix stands apart │ ┃ │ ci-fixer A CI job fails on a named ✻ Worked for 42s · done 4:20 PM │ ┃ │ belongs in code, config o │ ┃ │ provider. › /council-pane │ ┃ │ refactorer A behaviour-preserving re ⎿ claude-council: No council run in this session yet. Start one wi │ ┃ │ extract, rename, move, sp │ ┃ │ duplication across three │ ┃ │ test-writer The task is to add or ext │ ┃ │ behaviour that has a spec │ ┃ │ or a confirmed expected o │ ┃ │ test-pruner Tests in a named area dup │ ┃ │ re-assert the implementat │ ┃ │ seams alive, and need pru │ ┃ │ docs-updater Docs must be updated or r │ ┃ │ that already changed: REA ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Specialists
SPECIALISTS (0) ╭─────────────────────────────────────────────────────────── │ No specialists yet. A specialist is a Codex agent │ that works on its own branch. Pick a template, add │ your own, or describe one to Claude. │ │ [ + Add specialist ] │ │ TEMPLATE USE WHEN │ bug-fixer A bug reproduces, the expected behaviour │ and the fix stands apart from the current │ ci-fixer A CI job fails on a named revision and th │ belongs in code, config or tests, not in │ provider. │ refactorer A behaviour-preserving restructure is nam │ extract, rename, move, split a module, or │ duplication across three or more sites. │ test-writer The task is to add or extend tests for ex │ behaviour that has a spec, a documented c │ or a confirmed expected output. │ test-pruner Tests in a named area duplicate stronger │ re-assert the implementation, or keep tes │ seams alive, and need pruning. │ docs-updater Docs must be updated or restructured to m │ that already changed: README, reference p │ help or config docs. ╰─────────────────────────────────────────────────────────── Tab next · Shift+Tab back · ↓ opens a list, Enter picks…
README

claude-council

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 in the streaming tmux pane, with the synthesis alongside

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

Quick start

# 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.

Features

  • Query Gemini, OpenAI (GPT/Codex), Grok, Perplexity, and Kimi (Moonshot AI) simultaneously
  • Seat any model OpenRouter routes to — Anthropic's Claude by default, so the council hears the one vendor it otherwise has no voice for
  • Use the codex, agy (Antigravity), grok, kimi (Kimi Code) and cursor-agent (Cursor) CLIs (subscription auth) when installed; the first four are preferred over their API siblings
  • Seat Claude Code itself (claude-cli) on your Claude subscription, opt-in, running without your CLAUDE.md, settings or plugins
  • Run a local ollama model as a council member — no key, no subscription, no network
  • Side-by-side comparison of responses with vendor-colored headers
  • Streaming tmux pane that renders responses as they land
  • Specialized roles, debate mode, and agent-enhanced deep analysis for high-stakes decisions
  • Background jobs (--async) for long-running queries, with /claude-council:result to fetch, list, and cancel
  • Opt-in stop-gate: a second model reviews your uncommitted diff before Claude ends its turn
  • Extensible provider system — add new AI agents easily
  • Put the conversation itself to the council with /claude-council:advise, which shows you what would leave the machine before it goes
  • Proactive agent that suggests consulting the council on architecture / debugging dead ends
  • Council as a tool (mcp__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
  • Specialists (experimental): hand a coding task to a Codex agent that works on its own git branch, following skills you pick

Installation

From Marketplace (Recommended)

# Add the hex-plugins marketplace
/plugin marketplace add hex/claude-marketplace

# Install claude-council
/plugin install claude-council

Manual (run from a local clone)

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:

  1. 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.
  2. pluginDirectories in settings.json does nothing — it isn't a real setting, so it's silently ignored (no error shown). Use --plugin-dir above for a local clone, or install via the marketplace / GitHub for a persistent setup.

Usage

Slash Commands

# 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>

Quick Reference

FlagDescription
--providers=listQuery 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=listAssign roles (e.g., security,performance, a preset like balanced, or provider=role pairs)
--debateEnable two-round debate mode
--file=pathInclude specific file in context
--image=pathAttach one image (e.g. a screenshot) for vision-capable providers
--output=pathExport response to markdown file
--quietShow only synthesis, hide individual responses
--agentsAgent-enhanced analysis, one Claude analyst per provider (slower, deeper)
--localLocal Claude-only council when you have no provider keys (see below)
--asyncDetach the query as a background job; fetch with /claude-council:result
--no-cacheForce fresh queries, skip cache
--no-auto-contextDisable automatic file detection
--no-paneDisable streaming tmux pane (default: on inside tmux)
--verbosity=LEVELResponse style: brief / standard / detailed

Specialized Roles

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, maintainability
  • security-focused - security, devil, compliance
  • architecture - scalability, maintainability, simplicity
  • review - security, maintainability, dx

A 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

Debate Mode

Enable multi-round discussions where providers critique each other:

/claude-council:ask --debate "How should I structure the database schema?"

How it works:

  1. Round 1: All providers answer the question normally
  2. Round 2: Each provider sees the others' responses and provides rebuttals
  3. Synthesis: Incorporates debate insights, consensus shifts, and unresolved tensions

Debate mode surfaces blind spots and stress-tests recommendations. The synthesis includes:

  • Strongest criticisms raised
  • Where providers changed positions after seeing alternatives
  • Genuine disagreements that remain

Combine with roles for focused debates:

/claude-council:ask --debate --roles=security,performance,simplicity "Review this architecture"

Agent-Enhanced Analysis (--agents)

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):

  1. Queries the provider
  2. Evaluates response quality - did it actually address the question?
  3. If the response is vague or off-topic, reformulates and retries
  4. Asks follow-up questions to surface deeper insights
  5. Extracts structured analysis: key recommendations, confidence level, blind spots

Enhanced synthesis includes:

  • Confidence-weighted consensus (high-confidence agreement weighted more)
  • Cross-provider blind spot analysis
  • Divergence with context (why providers disagree)

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 cost1 context1 + N providers
Provider API costSameSame
Analysis depthRaw responses + synthesisPre-analyzed + enhanced synthesis
Best forQuick questions, factual queriesArchitecture decisions, security reviews, complex tradeoffs

Local Council (--local)

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:status shows what's available). The synthesis is framed around angles and tensions, not "consensus", to keep this distinction clear.

Quiet Mode

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.

Stated vs Assumed

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.

Auto-Context Injection

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:

  • Maximum 5 files included
  • Maximum ~10,000 tokens of context
  • Skipped if you provide --file= explicitly

Image Input

Attach 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?"
  • Single image per query, raw size up to 10 MB, extensions: png / jpg / jpeg / webp / gif.
  • gemini, openai, grok, perplexity, kimi and openrouter (on its default model) receive the image alongside the prompt.
  • CLI providers answer through their vision sibling: 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.

Response Caching

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:

  • Changing models invalidates the cache
  • Using --roles creates separate cache entries (same prompt with different role = cache miss)
  • Debate mode round 2 rebuttals are not cached (they depend on round 1 content) — with one exception: if a CLI provider fails in round 2 and falls back to its API sibling, that fallback rebuttal is cached, keyed on the full debate prompt (which already includes the round 1 answers)

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.

Export to File

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:

  • Metadata header (query, date, providers)
  • Each provider's full response
  • Synthesis with consensus/divergence analysis

Great for:

  • Documenting architectural decisions
  • Sharing with team members who aren't using Claude
  • Creating an audit trail of AI-assisted decisions

Background Jobs (--async)

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).

Proactive Agent

The council-advisor agent will suggest consulting the council when:

  • Discussing architecture or design decisions
  • Stuck debugging after multiple failed attempts

Asking about the conversation itself

/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.

Specialists (experimental)

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.

Configuration

API Keys

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

Source 16 files
mods/council-pane/hooks/pane.tsx 1618 lines
1// 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 lines
1// 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}
56
mods/council-pane/hooks/notices.ts 89 lines
1// 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}
89
mods/council-pane/hooks/options.ts 35 lines
1// 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}
35
mods/council-pane/hooks/retry.ts 27 lines
1// 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}
27
mods/council-pane/hooks/snapshot.ts 51 lines
1// 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}
51
mods/council-pane/hooks/specialist.ts 568 lines
1// 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}
568
mods/council-pane/hooks/tool.ts 66 lines
1// 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}
66
mods/council-pane/hooks/synthesis.ts 13 lines
1// 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}
13
mods/council-pane/hooks/tables.ts 58 lines
1// 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}
58
mods/council-pane/hooks/chip.ts 22 lines
1// 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}
22
mods/council-pane/hooks/view.ts 327 lines
1// 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