Agent panels and ping-pong planning with Claude subagents at a chosen model and effort

<img src="assets/lockup.svg" alt="Agent Panel" width="330">
Agent Panel is a Claude Code plugin. It sends one question or one task to a group of Claude subagents. Each subagent uses the model and the effort level that you select. Claude then examines their answers and writes one result on a web page.
/panel opus:xhigh sonnet:high opus:high:contrarian -- Why is the feed query slow?
/pingpong opus:xhigh sonnet:high rounds=2 -- Plan the billing migration
/panel review brief=bughunt -- the retry changes on this branch
| Term | Meaning |
|---|---|
| Run | One panel or one ping-pong, from the start to the result. |
| Panel | A run that you start with /panel. All seats answer the same question. |
| Ping-pong | A run that you start with /pingpong. Two seats make a plan together. |
| Seat | One subagent in a run. A seat has a model, an effort level and, optionally, skills. |
| Round | One step in which each seat writes an answer. |
| Synthesis | The one answer that Claude makes from the answers of all seats. |
| Verdict | The result that Claude records on the page: the synthesis of a panel, or the summary of a plan. |
| Preset | A set of seats and options, saved with a name. |
| Brief | Review instructions that all seats in a panel use. |
| Pane | The live view of runs in the Claude Code terminal. |
| Page | The HTML file that shows a run and its verdict. |
Do these steps in a Claude Code terminal session:
/plugin install agent-panel --marketplace koomai/claude-kit
y to add the marketplace.The plugin needs only Claude Code.
This section is for Claude. If a user asks you to install this plugin or to explain it, do the steps in this section.
claude plugin install agent-panel --marketplace koomai/claude-kit --scope user
For one project only, use --scope project. The command adds the marketplace if it is not added yet.
/panel help. The help output comes into your context. It shows the current options, the presets and the briefs of this user, which can be different from this README. /panel -- <a question about their code>
/panel review brief=bughunt -- <the changes on their branch>
/pingpong -- <a task that needs a plan>
You cannot type slash commands. To start a run, use the tools of the plugin:
| Tool | Required field | Optional fields |
|---|---|---|
panel | question | seats, preset, rounds, brief |
pingpong | task | seats (two), preset, rounds |
Write the question or the task so that a seat can understand it without this conversation. Include the file paths and the facts that the seats need. Set rounds above 1 only when the user asks for a discussion.
The tool returns at once. The answers come later as a new message. That message tells you what to do next: examine the claims, then record the verdict with the verdict tool. For a ping-pong, merge the two plans first and give the merged plan to pingpong_merge.
/panel or /pingpong. You can also ask Claude in plain English, for example "get an agent panel on this"./panel-view.Seats are read-only. A seat can use only these tools: Read, Grep, Glob, WebFetch, WebSearch and a git tool. The git tool can only do status, diff, log, show and blame. A seat cannot change files or run commands.
| Command | What it does |
|---|---|
/panel | Starts a panel. All seats answer the same question without seeing the other answers. Then Claude writes the synthesis. With rounds=N, the seats also read and reply to the other answers before the synthesis. |
/pingpong | Starts a ping-pong. Two seats each write a plan, and Claude merges the two plans. Then, in each round, seat A challenges the plan and seat B changes it. |
/panel-preset | Saves, lists, shows and deletes presets. It also lists the skills in your skill folders. |
/panel-stop | Stops a run after the current round. Without a run ID, it stops the newest run. To stop a different run, type /panel-stop <run id>. |
/panel-view | Opens the pane. |
/panel-design | Shows which design files the page uses. /panel-design copy makes a copy that you can change. |
To see all options, presets and briefs, type /panel help, /pingpong help or /panel-preset help.
Put the seats and options before --. Put the question or the task after --. Without --, the plugin starts nothing and shows the help. Thus a typo, for example /panel hep, cannot start a run.
| Option | Commands | What it does |
|---|---|---|
rounds=N | both | Sets the number of rounds, from 1 to 5. The default is 1. For a panel, the number includes the first round. For a ping-pong, it is the number of challenge rounds. |
brief=name | /panel | All seats use this brief. |
skills=dir[,dir] | both | The plugin looks for +skill names in these folders first. |
as=name | both | Saves the seats and options of this run as a preset with this name. |
Write a seat as model:effort[:contrarian][+skill]. The parts in square brackets are optional.
| Part | Values | What it does |
|---|---|---|
model | opus, sonnet, haiku, fable | The Claude model of the seat. |
effort | low, medium, high, xhigh, max | How much the seat thinks before it answers. |
:contrarian | none | The seat argues against the obvious answer, or it answers PASS. In a discussion, it attacks the position that the other seats agree on. |
+skill | a skill name | The seat gets the full instructions of the skill before it starts. To add more skills, add more +skill parts. |
If you do not give seats, /panel uses the quick preset and /pingpong uses the duo preset.
The plugin looks for a +name skill in these places, in this order:
skills=<folder>[,<folder>] on the command, or the folders in the preset./skills shows. Examples: +laravel-php-standards, +plugin:skill.A skill folder must directly contain <name>/SKILL.md. Thus, give the folder above the skills. For example, for ~/skills/marketing/ads/SKILL.md, give ~/skills/marketing. To see the skills in your folders, type /panel-preset skills [filter].
The plugin reads the skill file and removes its frontmatter. Then the plugin gives the text to the seat, together with the base folder of the skill. Thus, the seat can open the references/ files of the skill.
A seat with a skill is still read-only:
allowed-tools and context to skills from folders. /panel-preset skills marks the skills that have this frontmatter.If the plugin cannot find a skill, the start message shows a warning and the nearest names. The run continues.
A brief gives all seats the same review instructions and the same answer format. Add brief=<name> to /panel. After --, tell the seats what to review.
The plugin has four briefs: bughunt, security, regression and contracts. A brief is a Markdown file. The first line of the file tells what the brief looks for.
The plugin uses the first brief that it finds, in this order:
.claude/agent-panel/briefs/<name>.md in the project.~/.claude/agent-panel/briefs/<name>.md./panel content-council rounds=2 -- Critique the home page redesign
In the first round, each seat answers without seeing the other answers. Each round after the first is a reply round. Thus, rounds=2 gives one reply round.
In a reply round, each seat reads the latest answers of the other seats. The plugin identifies the other seats by number and skills, never by model. Each seat then writes these parts:
A seat must change its position only because of evidence. The number of seats that agree is not evidence.
The plugin continues each seat with its earlier work. If the plugin cannot continue a seat, it starts the seat again. The new seat gets the question, its last answer and the answers of the other seats.
If a seat has nothing to change, it answers NO CHANGE. If no seat changes anything in a round, the discussion stops. The discussion also stops after the current round in these conditions:
/panel-stop.The synthesis includes all rounds. It tells which seat changed its position, because of which argument, and if the evidence is correct.
Each reply round runs all seats again. The start message shows the maximum number of seat runs.
/pingpong opus:xhigh sonnet:high rounds=2 -- Plan the billing migration
A ping-pong has two seats:
A ping-pong has these steps:
The plugin continues each seat with its earlier work: seat A challenges with what it learned while it wrote its plan, and seat B changes the plan with what it learned while it wrote its own. If the plugin cannot continue a seat, it starts the seat again with the full plan and the challenge.
The default is 1 round. The maximum is 5 rounds.
To open the pane, type /panel-view. The pane shows one card for each run. A card shows the current step of the run. For each seat, the card shows:
The card of the newest run has a link to its page: "Open the discussion page" or "Open the plan page". Each earlier run is a row. Click a row to open the page of that run.
The ≡ text control at the top of the pane sets the number of lines of live text for each seat.
Each /panel run also writes a page, discussion.html, in its run folder. The plugin writes the page again each time a seat replies. If the page is open during the run, it loads again when a new reply arrives.
The page shows these parts, in this order:
Two controls change how the discussion shows:
When you scroll an answer, the round and the name of the seat stay on the screen. In a wide window, an outline of the page stays on the right. The outline marks your position on the page.
A /pingpong run writes the same file as a plan page. The page starts with the current plan. When the run ends, this plan is the final plan. Below the plan, the page shows how the plan was made:
The first plans and the old plans are closed. Click one to open it.
When the run ends, Claude records the verdict on the page with the verdict tool. Then Claude gives you the link in the chat.
Claude can add up to two figures below the headline. Claude adds a figure only when a picture shows something better than text, for example a sequence of steps.
The page does not need an internet connection. To get a claude.ai link, tell Claude to publish the page of the run. For this purpose, the plugin keeps a copy of the page, discussion.artifact.html, in the run folder.
Each page shows a history of the saved runs of the project. In a wide window, the history is a column on the left. In a narrow window, it is a list below the page. Each entry shows the question of a run and, when available, its headline. Click an entry to open the page of that run.
The plugin makes the history on the first run in a project. At that time, it also makes a page for each earlier run that has no page. It makes these pages from the saved answers.
One file controls how the page looks and how Claude writes on it: page/design.md. The file has these parts:
| Part | What it controls |
|---|---|
| Frontmatter | The design tokens: colours, the two font stacks, text sizes and column widths. Each token becomes a CSS variable on the page. webfonts is the URL of the font stylesheet. |
| "Writing" | The writing rules that Claude gets when it records a verdict. |
| "Illustrations" | When Claude draws a figure, and how. Make this section empty to stop all figures. |
| "Styles" | CSS that the plugin adds after its own stylesheet. |
To change the design, do these steps:
/panel-design copy. This command writes a copy of the file to ~/.claude/agent-panel/design.md. For a copy in this project only, type /panel-design copy project.The plugin reads the design files in this order: its own file, your file, then the project file. A later file replaces only the keys and sections that it contains. Thus, this short file is a complete design file:
---
accent: #8a3b12
sheet: #fbf7ef
---
Claude writes each figure as an SVG. The plugin keeps only the drawing elements and attributes. Thus, a figure cannot run a script, load a file or link to a different page.
The design file cannot change the layout or the behaviour of the page. These stay in the plugin: hooks/page.ts, page/page.css and page/page.js.
The plugin saves the answer of each seat when it arrives. It also saves the question. For a ping-pong, it also saves each first plan, the merged plan, each challenge and each changed plan. The file names of a discussion include the round, for example round-2-seat-1-…. The file discussion.json contains the full run and the verdict.
To change a setting, type /config.
| Setting | Default | What it does |
|---|---|---|
| Runs folder | ~/.claude/agent-panel/runs | The folder for saved files. It has one folder for each project. Each project folder has one folder for each run. Leave it empty to use the default. |
| Default skill folders | none | Folders that contain <skill>/SKILL.md, for example ~/Code/my-skills. Separate the folders with commas. +skill looks in these folders. |
The runs folder has this structure:
~/.claude/agent-panel/runs/
<project>/
history.js the history that each page shows
2026-10-13-2ed564-<question>/
question.md
draft-a-opus-high.md the plans and rounds of a ping-pong,
round-1-challenge.md or one file for each seat and round of a panel
discussion.json the full run and the verdict
discussion.html the page
discussion.artifact.html the copy to publish as a claude.ai link
The plugin has four presets. They are in presets.json:
| Preset | Seats | Used when |
|---|---|---|
quick | sonnet:medium sonnet:low haiku:high | /panel has no seats (the default) |
duo | opus:high sonnet:high | /pingpong has no seats (the default) |
deep | opus:xhigh opus:high sonnet:high | you name it |
review | opus:xhigh sonnet:high opus:high:contrarian | you name it |
To save a preset, use one of these methods:
/panel-preset save <name> <seats>.as=<name> to a /panel or /pingpong run. The plugin saves the preset only after the run passes all checks.Examples:
/panel-preset save content-council skills=~/skills/marketing sonnet:high+ads+ad-creative sonnet:high+copywriting opus:high:contrarian+cro
/panel-preset save plan-pair opus:high sonnet:high rounds=2
/panel sonnet:high+copywriting sonnet:high+cro as=copy-pair -- Rewrite the pricing page headline
/panel-preset list
/panel-preset show content-council
/panel-preset delete content-council
| Command | File | Who can use the preset |
|---|---|---|
/panel-preset save … | ~/.claude/agent-panel/presets.json | You, in all projects. |
/panel-preset save … scope=project | .claude/agent-panel/presets.json in the project | All users of the repository, after you commit the file. |
To replace a preset that has the same name, add force.
The plugin looks for a preset in this order:
A project file can come from a cloned repository. Thus, the plugin applies two limits to it:
skillDirs of a project preset.A preset is a list of seats, or an object:
{
"plan-pair": { "seats": ["opus:high", "sonnet:high"], "rounds": 2 },
"bug-review": { "seats": ["opus:high", "sonnet:high", "opus:high:contrarian"], "brief": "bughunt" },
"content-council": {
"description": "Marketers on copy, content and paid",
"skillDirs": ["~/skills/marketing"],
"seats": ["sonnet:high+ads+ad-creative", "sonnet:high+copywriting", "opus:high:contrarian+cro"]
}
}
These rules apply:
brief applies only when you use the preset with /panel.rounds applies to both commands. For /panel, it is the total number of rounds. For /pingpong, it is the number of challenge rounds. Thus, plan-pair with /panel gives one reply round.brief=, rounds= and skills= on the command override the preset./panel help shows the entries that the plugin ignored and the files that it cannot read. The plugin never writes over a file that it cannot read.
You can also ask Claude in plain English, for example "get an agent panel on this, two Opus and a contrarian". The plugin gives Claude these tools:
| Tool | What it does |
|---|---|
panel | Starts a panel. |
pingpong | Starts a ping-pong. |
pingpong_merge | Gives the merged plan back to the ping-pong, which then starts the rounds. |
verdict | Records the verdict on the page. |
The plugin writes only these files:
| File | When |
|---|---|
The run folder in the runs folder (see Saved answers and settings): question.md, one Markdown file for each answer, discussion.json, discussion.html, discussion.rev.js and discussion.artifact.html | When a run starts, when each seat answers, when Claude records the verdict, and when you open an older run from the pane |
history.json and history.js in the project folder of the runs folder | When a run starts or ends, when Claude records the verdict, and when you open a page from the pane |
~/.claude/agent-panel/presets.json | When you save or delete a preset, or add as=<name> to a run |
.claude/agent-panel/presets.json in the project | When you save or delete a preset with scope=project |
~/.claude/agent-panel/design.md or .claude/agent-panel/design.md in the project | Only when you type /panel-design copy or /panel-design copy project, and only if the file does not exist |
The plugin does not write settings files, instruction files such as CLAUDE.md, or build and start-up files. The two .claude/agent-panel/ files in a project are this plugin's own presets and page design, and nothing else reads them.
HOME environment variable. The plugin uses it only to find ~/.claude/agent-panel/ and to expand ~ in the runs folder and in skill folders. It reads no other environment variable.~/.claude/agent-panel/ and in .claude/agent-panel/ in the project.SKILL.md files in the skill folders that you name with skills= or the "Default skill folders" setting.+skill can name.git, with one of status, diff, log, show or blame and the arguments that a seat gives. This is the git tool of the seats, so they can read the history of the code under review. The plugin refuses every other git command, the --output option, which writes a file, and the --ext-diff and --textconv options, which run other programs. It adds --no-pager, and --no-ext-diff and --no-textconv, so that git does not run a diff tool or a text converter from your git configuration.open on macOS, or xdg-open on Linux, with the path of a saved page. The plugin runs it only when you click a run in the pane, to open its page in your browser.The plugin itself sends nothing over the network. Its seats are Claude subagents, so their requests go to Claude in the same way as any other subagent of your session. A seat can use WebFetch and WebSearch, which reach the web as they do for Claude.
The plugin sends text to two places in your session:
session.send, to continue a seat in the next round. This text is the next brief: the question or plan, and the answers or challenge of the other seat.hooks/register.tsx 1787 lines1import type { Elements, EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type { FolderSkill, Phase, PresetMap, PresetSource, RoundEntry, Run, RunKind, Seat } from '../types'
4import {
5 EFFORTS,
6 FALLBACK_PRESETS,
7 GIT_COMMANDS,
8 MAX_ROUNDS,
9 MODELS,
10 SEAT_SYSTEM_PROMPT,
11 agentTypeName,
12 answers,
13 artifactOf,
14 challengeBrief,
15 closeRound,
16 collectedNote,
17 costLine,
18 draftBrief,
19 excerptOf,
20 gitHelperOptions,
21 gitRefusal,
22 ignoredSkillFeatures,
23 integrateBrief,
24 isFolderSkillName,
25 isPass,
26 nearestNames,
27 panelBrief,
28 panelSynthesis,
29 parseArgs,
30 planAsk,
31 presetEntry,
32 presetNameError,
33 readPresetLayer,
34 replyBrief,
35 roundsError,
36 seatList,
37 seatSpec,
38 skillSummary,
39 slugify,
40 splitSkill,
41 toolArgs,
42 withSkills,
43} from './seats'
44import type { ToolInput } from './seats'
45import { ROUND_LEGEND, activityOf, clip, fitChips, flow, formatDuration, formatTokens, isLive, meter, pingpongSteps, roundCells, runBadge, runTime, seatChips, seatColor, seatTime, spinner, usageTokens } from './pane'
46import type { Span, StepState, Tone } from './pane'
47import { pageData, pageFor, pageHtml, planData, readVerdict } from './page'
48import type { AnyPage, PageAssets } from './page'
49import { designCss, hashOf, layerDesign, readDesign } from './design'
50import type { Design, DesignLayer, Guide } from './design'
51import { historyEntry, historyScript, savedRun, sortHistory, withEntry } from './history'
52import type { History } from './history'
53import { helpLines } from './help'
54import type { HelpTone } from './help'
55
56
57const PLUGIN = 'agent-panel'
58const PANE = 'agent-panel'
59const GIT_TOOL = 'mcp__agent-panel__git'
60const READ_ONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', GIT_TOOL]
61const GIT_OUTPUT_LIMIT = 60000
62const DEFAULT_RUNS_DIR = '~/.claude/agent-panel/runs'
63const PRESETS_PATH = '.claude/agent-panel/presets.json'
64const DESIGN_PATH = '.claude/agent-panel/design.md'
65
66type Config = { runsDir: string; skillDirs: string[] }
67type BriefEntry = { name: string; source: string; summary: string }
68type BriefFolder = { path: string; source: string }
69
70function readConfig(options: PluginOptions): Config {
71 return {
72 runsDir: typeof options.runsDir === 'string' && options.runsDir !== '' ? options.runsDir : DEFAULT_RUNS_DIR,
73 skillDirs: typeof options.skillDirs === 'string' ? options.skillDirs.split(',').map(dir => dir.trim()).filter(Boolean) : [],
74 }
75}
76
77async function changeRuns($: EngineInterface, change: (list: Run[]) => Run[]): Promise<Run[]> {
78 for (;;) {
79 const held = await $.state.get({ plugin: 'agent-panel', key: 'runs' })
80 const next = change(held.value ?? [])
81 const written = await $.state.set({ plugin: 'agent-panel', key: 'runs' }, next, { ifVersion: held.version })
82 if (written.isSet) return next
83 }
84}
85
86const HELP_ARGS = ['', 'help', '--help']
87const PRESET_INPUT = { type: 'string', description: 'The name of one preset in place of seats: deep, quick, duo, review, or one the user saved with /panel-preset' }
88
89const SEAT_OPTIONS = ` model ${Object.keys(MODELS).join(', ')}
90 effort ${EFFORTS.join(', ')}
91 +skill loads a skill into the seat: a folder under skills= or the "Default skill folders" setting, else a session skill by the name /skills shows; repeat for more`
92
93function nameWidth(names: readonly string[]): number {
94 return Math.max(16, ...names.map(name => name.length + 1))
95}
96
97function presetLines(presets: PresetMap, seatCount?: number): string {
98 const shown = Object.entries(presets).filter(([, preset]) => seatCount === undefined || preset.seats.length === seatCount)
99 const width = nameWidth(shown.map(([name]) => name))
100
101 return shown.map(([name, preset]) => ` ${name.padEnd(width)} ${preset.source.padEnd(9)} ${clip(preset.description ?? preset.seats.join(' '), 72)}`).join('\n')
102}
103
104function presetHelp(presets: PresetMap): string {
105 const hasSaved = Object.values(presets).some(preset => preset.source !== 'built-in')
106
107 return [
108 ...(hasSaved ? [] : [' You have no saved presets yet.']),
109 ' See yours: /panel-preset list, or /panel-preset show <name> for one',
110 ' Save one: /panel-preset save <name> <seats> [scope=project], or as=<name> on any run',
111 ' Remove one: /panel-preset delete <name> [scope=project]',
112 ` Presets save to ~/${PRESETS_PATH}, for you in every project; scope=project saves to ${PRESETS_PATH} in this project, to share through git.`,
113 ].join('\n')
114}
115
116function noticeLines(notices: string[]): string {
117 return notices.length === 0 ? '' : `\nPreset files\n${notices.map(notice => ` ${notice}`).join('\n')}\n`
118}
119
120function briefLines(briefs: BriefEntry[]): string {
121 if (briefs.length === 0) return ' (none found)'
122
123 return briefs.map(brief => ` ${brief.name.padEnd(12)} ${brief.source.padEnd(9)} ${brief.summary.slice(0, 70)}`).join('\n')
124}
125
126const RUN_OPTIONS = ` skills=dir folders of <skill>/SKILL.md for this run's +skill names, comma separated
127 as=name also saves this run's seats and options as a preset of that name`
128
129function panelUsage(loaded: Loaded, briefs: BriefEntry[]): string {
130 return `Usage: /panel [seats or preset] [rounds=N] [brief=name] [skills=dir] [as=name] -- <question>
131
132Seats, space separated: model:effort[:contrarian][+skill]
133${SEAT_OPTIONS}
134 :contrarian the seat attacks the obvious answer instead of answering
135 brief=name every seat reviews what you name through that brief
136 rounds=N rounds, 1 to ${MAX_ROUNDS} (no rounds: 1); round 1 is independent, in each later round every seat
137 reads the others and replies; 2 is usual; a round where no seat changes anything ends it
138${RUN_OPTIONS}
139
140Presets (no seats: "quick")
141${presetLines(loaded.presets)}
142${presetHelp(loaded.presets)}
143${noticeLines(loaded.notices)}
144Briefs
145${briefLines(briefs)}
146 Add your own as .claude/agent-panel/briefs/<name>.md in the project or under your home folder.
147
148Examples
149 /panel -- Is the retry in the queue worker safe?
150 /panel deep -- Should we split this table before or after the migration?
151 /panel opus:xhigh sonnet:high opus:high:contrarian -- Should we split this table?
152 /panel sonnet:high+laravel-php-standards opus:high -- Is OrderService idiomatic?
153 /panel review brief=bughunt -- the retry changes on this branch
154 /panel deep rounds=2 -- Should we split this table before or after the migration?
155
156/panel-stop ends a run after its current round. /panel-view opens the pane. /pingpong help covers plan ping-pong. Settings are under /config.`
157}
158
159function pingpongUsage(loaded: Loaded): string {
160 return `Usage: /pingpong [two seats or preset] [rounds=N] [skills=dir] [as=name] -- <task>
161
162Two seats, space separated: model:effort[+skill]. The first challenges, the second integrates.
163${SEAT_OPTIONS}
164 rounds=N challenge rounds, 1 to ${MAX_ROUNDS} (no rounds: 1); a challenger with nothing material to raise ends them early
165${RUN_OPTIONS}
166
167Two-seat presets (no seats: "duo")
168${presetLines(loaded.presets, 2)}
169${presetHelp(loaded.presets)}
170${noticeLines(loaded.notices)}
171Examples
172 /pingpong -- Plan the billing migration
173 /pingpong opus:xhigh sonnet:high rounds=2 -- Plan the billing migration
174
175/panel-view opens the pane. /panel help covers question panels. Settings are under /config.`
176}
177
178const registeredTypes = new Set<string>()
179
180function reason(error: unknown): string {
181 return error instanceof Error ? error.message : String(error)
182}
183
184type Loaded = { presets: PresetMap; builtIn: PresetMap; notices: string[] }
185type JsonFile = { state: 'missing' } | { state: 'corrupt'; reason: string } | { state: 'ok'; value: unknown }
186type Scope = 'user' | 'project'
187
188async function readJsonFile($: EngineInterface, path: string): Promise<JsonFile> {
189 if (!(await $.fs.exists(path).catch(() => false))) return { state: 'missing' }
190 try {
191 return { state: 'ok', value: JSON.parse(await $.fs.read(path)) }
192 } catch (error) {
193 return { state: 'corrupt', reason: reason(error) }
194 }
195}
196
197async function homeDir($: EngineInterface): Promise<string | undefined> {
198 return $.env.get('HOME').catch(() => undefined)
199}
200
201function expandHome(path: string, home: string | undefined): string {
202 return home !== undefined && (path === '~' || path.startsWith('~/')) ? `${home}${path.slice(1)}` : path
203}
204
205async function presetsPath($: EngineInterface, scope: Scope): Promise<string | undefined> {
206 if (scope === 'project') return PRESETS_PATH
207 const home = await homeDir($)
208
209 return home === undefined ? undefined : `${home}/${PRESETS_PATH}`
210}
211
212async function loadPresets($: EngineInterface): Promise<Loaded> {
213 const shipped = await readJsonFile($, `${$.plugin.root}/presets.json`)
214 const builtIn = shipped.state === 'ok' ? readPresetLayer(shipped.value, 'built-in').presets : FALLBACK_PRESETS
215 const notices: string[] = []
216 const presets: PresetMap = { ...builtIn }
217
218 for (const source of ['user', 'project'] as const) {
219 const path = await presetsPath($, source)
220 const json: JsonFile = path === undefined ? { state: 'missing' } : await readJsonFile($, path)
221 if (json.state === 'corrupt') notices.push(`${path} could not be read: ${json.reason}`)
222 if (json.state !== 'ok') continue
223 const layer = readPresetLayer(json.value, source, Object.keys(builtIn))
224 notices.push(...layer.ignored.map(note => `${path}: ignored ${note}`))
225 Object.assign(presets, layer.presets)
226 }
227
228 return { presets, builtIn, notices }
229}
230
231async function changePresets($: EngineInterface, scope: Scope, change: (file: Record<string, unknown>, path: string) => string | undefined): Promise<{ path: string } | { error: string }> {
232 const path = await presetsPath($, scope)
233 if (path === undefined) return { error: 'HOME is not set, so there is no user presets file.' }
234 const json = await readJsonFile($, path)
235 const isObject = json.state === 'ok' && typeof json.value === 'object' && json.value !== null && !Array.isArray(json.value)
236 if (json.state === 'corrupt' || (json.state === 'ok' && !isObject)) {
237 return { error: `${path} is not a JSON object of presets${json.state === 'corrupt' ? ` (${json.reason})` : ''}. It was left as it is; fix it by hand.` }
238 }
239 const file: Record<string, unknown> = json.state === 'ok' ? { ...(json.value as Record<string, unknown>) } : {}
240 const refusal = change(file, path)
241 if (refusal !== undefined) return { error: refusal }
242 try {
243 await $.fs.write(path, `${JSON.stringify(file, null, 2)}\n`)
244 } catch (error) {
245 return { error: `${path} could not be written: ${reason(error)}` }
246 }
247
248 return { path }
249}
250
251async function skillRoots($: EngineInterface, dirs: readonly string[]): Promise<string[]> {
252 const home = await homeDir($)
253
254 return [...new Set(dirs.map(dir => expandHome(dir.trim(), home)).filter(Boolean))]
255}
256
257async function findFolderSkill($: EngineInterface, roots: readonly string[], name: string): Promise<FolderSkill | undefined> {
258 for (const root of roots) {
259 const stat = await $.fs.stat(`${root}/${name}`, { resolve: true }).catch(() => undefined)
260 const path = stat?.kind === 'dir' ? stat.realPath : undefined
261 if (path !== undefined && (await $.fs.exists(`${path}/SKILL.md`).catch(() => false))) return { name, path }
262 }
263
264 return undefined
265}
266
267async function folderSkillNames($: EngineInterface, roots: readonly string[]): Promise<string[]> {
268 const lists = await Promise.all(roots.map(root => $.fs.list(root).catch(() => [])))
269
270 return lists.flat().filter(entry => entry.kind === 'dir' || entry.isLink).map(entry => entry.name)
271}
272
273async function sessionSkillNames($: EngineInterface): Promise<string[]> {
274 const commands = await $.command.list().catch(() => [])
275
276 return commands.filter(command => (command.source === 'user' || command.source === 'plugin') && command.plugin !== PLUGIN).map(command => command.name)
277}
278
279async function resolveSkills($: EngineInterface, seats: readonly Seat[], dirs: readonly string[]): Promise<{ seats: Seat[]; warnings: string[] }> {
280 const roots = await skillRoots($, dirs)
281 const warnings: string[] = []
282 const resolved: Seat[] = []
283 let known: string[] | undefined
284
285 for (const [index, seat] of seats.entries()) {
286 const folderSkills: FolderSkill[] = []
287 const skills: string[] = []
288 const unknownSkills: string[] = []
289 for (const name of seat.skills) {
290 const found = isFolderSkillName(name) ? await findFolderSkill($, roots, name) : undefined
291 if (found) {
292 folderSkills.push(found)
293 continue
294 }
295 skills.push(name)
296 if (known === undefined) {
297 const sessionNames = await sessionSkillNames($)
298 const folderNames = await folderSkillNames($, roots)
299 known = [...sessionNames, ...folderNames]
300 }
301 if (known.includes(name)) continue
302 unknownSkills.push(name)
303 const near = nearestNames(name, known)
304 warnings.push(`Seat ${index + 1}: no skill "${name}" in the skill folders or this session; the seat may run without it.${near.length > 0 ? ` Did you mean ${near.join(', ')}?` : ''}`)
305 }
306 resolved.push({ ...seat, skills, ...(folderSkills.length > 0 ? { folderSkills } : {}), ...(unknownSkills.length > 0 ? { unknownSkills } : {}) })
307 }
308
309 return { seats: resolved, warnings }
310}
311
312function skillLines(seats: readonly Seat[]): string[] {
313 return seats.flatMap((seat, index) => [
314 ...(seat.folderSkills ?? []).map(skill => `Seat ${index + 1} +${skill.name}: ${skill.path}`),
315 ...seat.skills.filter(name => !seat.unknownSkills?.includes(name)).map(name => `Seat ${index + 1} +${name}: session skill`),
316 ])
317}
318
319async function briefFolders($: EngineInterface): Promise<BriefFolder[]> {
320 const home = await $.env.get('HOME').catch(() => undefined)
321
322 return [
323 { path: '.claude/agent-panel/briefs', source: 'project' },
324 ...(home === undefined ? [] : [{ path: `${home}/.claude/agent-panel/briefs`, source: 'user' }]),
325 ]
326}
327
328async function readBriefFile($: EngineInterface, folder: string, name: string): Promise<string | undefined> {
329 try {
330 return (await $.fs.read(`${folder}/${name}.md`)).trim() || undefined
331 } catch {
332 return undefined
333 }
334}
335
336async function folderBriefs($: EngineInterface, folder: BriefFolder): Promise<BriefEntry[]> {
337 const entries = await $.fs.list(folder.path).catch(() => [])
338 const names = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.md')).map(entry => entry.name.slice(0, -3))
339
340 return Promise.all(
341 names.map(async name => {
342 const text = await readBriefFile($, folder.path, name)
343
344 return { name, source: folder.source, summary: (text ?? '').split('\n', 1)[0] ?? '' }
345 }),
346 )
347}
348
349async function loadBrief($: EngineInterface, name: string): Promise<string | undefined> {
350 for (const folder of await briefFolders($)) {
351 const text = await readBriefFile($, folder.path, name)
352 if (text !== undefined) return text
353 }
354
355 return readBriefFile($, `${$.plugin.root}/briefs`, name)
356}
357
358async function listBriefs($: EngineInterface): Promise<BriefEntry[]> {
359 const folders = await briefFolders($)
360 const own = await Promise.all(folders.map(folder => folderBriefs($, folder)))
361 const builtIn = await folderBriefs($, { path: `${$.plugin.root}/briefs`, source: 'built-in' })
362 const seen = new Set<string>()
363
364 return [...own.flat(), ...builtIn].filter(brief => !seen.has(brief.name) && seen.add(brief.name))
365}
366
367async function openRecord($: EngineInterface, run: Run, config: Config): Promise<Partial<Run>> {
368 const kind = run.kind === 'panel' ? 'Panel' : 'Ping-pong'
369 const brief = run.brief === undefined ? '' : `, brief "${run.brief}"`
370 const rounds = run.kind === 'panel' && run.rounds > 1 ? `, up to ${run.rounds} rounds` : ''
371 const header = `${kind} ${run.id}${brief}${rounds}\n\n${[seatList(run.seats), ...skillLines(run.seats)].join('\n')}\n\n## ${run.kind === 'panel' ? 'Question' : 'Task'}\n\n${run.question}\n`
372 const record: Partial<Run> = {}
373 const notes: string[] = []
374
375 try {
376 const home = await $.env.get('HOME')
377 const root = config.runsDir.startsWith('~') && home !== undefined ? `${home}${config.runsDir.slice(1)}` : config.runsDir
378 const project = (await $.session.root()).split(/[\\/]/).filter(Boolean).pop() ?? 'project'
379 const day = new Date(await $.clock.now()).toISOString().slice(0, 10)
380 const dir = `${root}/${slugify(project)}/${day}-${run.id}-${slugify(run.question)}`
381 await $.fs.write(`${dir}/question.md`, header)
382 record.dir = dir
383 record.page = `${dir}/discussion.html`
384 } catch (error) {
385 notes.push(`The answers are not being saved to files: ${reason(error)}`)
386 }
387
388 if (notes.length > 0) record.saveNote = notes.join('\n')
389
390 return record
391}
392
393async function saveArtifact($: EngineInterface, run: Run, file: string, title: string, text: string): Promise<void> {
394 const failures: string[] = []
395 if (run.dir !== undefined) {
396 await $.fs.write(`${run.dir}/${file}.md`, `# ${title}\n\n${text}\n`).catch(error => failures.push(`"${title}" was not written to a file: ${reason(error)}`))
397 }
398 if (failures.length === 0) return
399
400 await patchRun($, run.id, current => ({ ...current, saveNote: [current.saveNote, ...failures].filter(Boolean).join('\n') }))
401}
402
403function fileUrl(path: string): string {
404 return `file://${encodeURI(path)}`
405}
406
407type PageFiles = { css: string; script: string; marked: string; design: string }
408type DesignSource = { scope: Scope; path: string }
409
410let pageFiles: Promise<PageFiles> | undefined
411const pageQueue = new Map<string, Promise<void>>()
412
413function loadPageFiles($: EngineInterface): Promise<PageFiles> {
414 pageFiles ??= Promise.all(['page.css', 'page.js', 'marked.min.js', 'design.md'].map(name => $.fs.read(`${$.plugin.root}/page/${name}`))).then(([css = '', script = '', marked = '', design = '']) => ({ css, script, marked, design }))
415 pageFiles.catch(() => {
416 pageFiles = undefined
417 })
418
419 return pageFiles
420}
421
422async function designSources($: EngineInterface): Promise<DesignSource[]> {
423 const home = await homeDir($)
424
425 return [...(home === undefined ? [] : [{ scope: 'user' as const, path: `${home}/${DESIGN_PATH}` }]), { scope: 'project' as const, path: DESIGN_PATH }]
426}
427
428async function loadDesign($: EngineInterface): Promise<{ design: Design; changedBy: DesignSource[] }> {
429 const files = await loadPageFiles($)
430 const sources = await designSources($)
431 const layers = await Promise.all(sources.map(source => $.fs.read(source.path).then(readDesign, (): DesignLayer | undefined => undefined)))
432
433 return { design: layerDesign([readDesign(files.design), ...layers.flatMap(layer => (layer === undefined ? [] : [layer]))]), changedBy: sources.filter((_, i) => layers[i] !== undefined) }
434}
435
436function loadGuide($: EngineInterface): Promise<Guide | undefined> {
437 return loadDesign($).then(
438 loaded => loaded.design,
439 () => undefined,
440 )
441}
442
443async function loadPageAssets($: EngineInterface): Promise<PageAssets> {
444 const [files, { design }] = await Promise.all([loadPageFiles($), loadDesign($)])
445
446 return { css: designCss(design, files.css), script: files.script, marked: files.marked, fonts: design.webfonts }
447}
448
449async function designCommand($: EngineInterface, args: string): Promise<string> {
450 const [verb = '', where = 'user'] = args.trim().toLowerCase().split(/\s+/)
451 const sources = await designSources($)
452 const shipped = `${$.plugin.root}/page/design.md`
453 const how = `A design file holds the page's colours, fonts and sizes, the writing and illustration rules given to the session that records a verdict, and extra CSS. A copy replaces only the keys and sections it contains.`
454
455 if (verb === 'copy') {
456 const target = sources.find(source => source.scope === where)
457 if (target === undefined) return where === 'user' ? 'HOME is not set, so there is no user design file.' : `Copy to "user" or "project", not "${where}".`
458 if (await $.fs.exists(target.path).catch(() => false)) return `${target.path} already exists and was left as it is.`
459 try {
460 const files = await loadPageFiles($)
461 await $.fs.write(target.path, files.design)
462 } catch (error) {
463 return `${target.path} could not be written: ${reason(error)}`
464 }
465
466 return `Wrote ${target.path}. Edit it and delete what you do not want to change, so those parts keep following the built-in design. Saved pages are redrawn with it the next time a run starts, finishes or is opened from the pane.`
467 }
468 if (!HELP_ARGS.includes(verb)) return `Unknown option "${verb}". Use /panel-design or /panel-design copy [user|project].`
469
470 try {
471 const { design, changedBy } = await loadDesign($)
472 const changes = changedBy.length === 0 ? 'No user or project design file changes it.' : `Changed by ${changedBy.map(source => `${source.path} (${source.scope})`).join(', then ')}.`
473
474 return [`Page design: ${design.name || 'unnamed'}. Built in at ${shipped}. ${changes}`, how, `/panel-design copy writes a starter copy to ${sources.find(source => source.scope === 'user')?.path ?? `~/${DESIGN_PATH}`}; /panel-design copy project writes ${DESIGN_PATH} in this project.`].join('\n\n')
475 } catch (error) {
476 return `The page design could not be read: ${reason(error)}`
477 }
478}
479
480async function putPage($: EngineInterface, dir: string, data: AnyPage): Promise<void> {
481 const assets = await loadPageAssets($)
482 await $.fs.write(`${dir}/discussion.json`, `${JSON.stringify(data, null, 1)}\n`)
483 await $.fs.write(`${dir}/discussion.html`, pageHtml(data, assets, 'local'))
484 await $.fs.write(`${dir}/discussion.rev.js`, `window.__panelRev&&window.__panelRev(${data.rev})\n`)
485}
486
487async function writePageNow($: EngineInterface, id: string): Promise<void> {
488 const stored = await $.state.get({ plugin: 'agent-panel', key: 'runs' })
489 const run = (stored.value ?? []).find(one => one.id === id)
490 if (!run || run.dir === undefined || run.page === undefined || run.isReported === true) return
491 try {
492 await putPage($, run.dir, pageFor(run, await $.clock.now()))
493 } catch (error) {
494 await patchRun($, id, current => {
495 const { page: _page, ...kept } = current
496
497 return { ...kept, saveNote: [current.saveNote, `The discussion page was not written: ${reason(error)}`].filter(Boolean).join('\n') }
498 })
499 }
500}
501
502function writePage($: EngineInterface, id: string): Promise<void> {
503 const next = (pageQueue.get(id) ?? Promise.resolve()).then(() => writePageNow($, id))
504 pageQueue.set(id, next)
505
506 return next
507}
508
509async function savedPage($: EngineInterface, dir: string): Promise<AnyPage | undefined> {
510 const listed = (await $.fs.list(dir)).filter(entry => entry.kind === 'file' && entry.name.endsWith('.md'))
511 const texts = await Promise.all(listed.map(entry => $.fs.read(`${dir}/${entry.name}`).catch(() => '')))
512 const written = listed.find(entry => entry.name === 'question.md')?.mtimeMs ?? 0
513 const day = Date.parse(dir.slice(dir.lastIndexOf('/') + 1, dir.lastIndexOf('/') + 11))
514 const run = savedRun(Object.fromEntries(listed.map((entry, i) => [entry.name, texts[i] ?? ''])), written > 0 ? written : Number.isNaN(day) ? undefined : day)
515
516 return run === undefined ? undefined : pageFor(run, await $.clock.now())
517}
518
519async function scanHistory($: EngineInterface, root: string, stamp: string): Promise<History> {
520 const assets = await loadPageAssets($)
521 const folders = (await $.fs.list(root)).filter(entry => entry.kind === 'dir')
522 const entries = await Promise.all(
523 folders.map(async ({ name }) => {
524 const dir = `${root}/${name}`
525 try {
526 if (await $.fs.exists(`${dir}/discussion.json`)) {
527 const kept = JSON.parse(await $.fs.read(`${dir}/discussion.json`)) as AnyPage
528 await $.fs.write(`${dir}/discussion.html`, pageHtml(kept, assets, 'local'))
529
530 return [historyEntry(kept, name)]
531 }
532 const rebuilt = await savedPage($, dir)
533 if (rebuilt === undefined) return []
534 await putPage($, dir, rebuilt)
535
536 return [historyEntry(rebuilt, name)]
537 } catch {
538 return []
539 }
540 }),
541 )
542
543 return { stamp, runs: sortHistory(entries.flat()) }
544}
545
546let historyQueue: Promise<void> = Promise.resolve()
547
548async function syncHistoryNow($: EngineInterface, id: string): Promise<void> {
549 const stored = await $.state.get({ plugin: 'agent-panel', key: 'runs' })
550 const run = (stored.value ?? []).find(one => one.id === id)
551 if (!run || run.dir === undefined || run.page === undefined) return
552 const root = run.dir.slice(0, run.dir.lastIndexOf('/'))
553 const assets = await loadPageAssets($)
554 const version = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(text => String((JSON.parse(text) as { version?: unknown }).version), () => '')
555 const stamp = [version, hashOf(assets.css + assets.fonts + assets.script)].join(':')
556 const kept = await $.fs.read(`${root}/history.json`).then(text => JSON.parse(text) as History, () => undefined)
557 const data = JSON.parse(await $.fs.read(`${run.dir}/discussion.json`)) as AnyPage
558 const history = kept?.stamp === stamp ? withEntry(kept, historyEntry(data, run.dir.slice(root.length + 1))) : await scanHistory($, root, stamp)
559 await $.fs.write(`${root}/history.json`, `${JSON.stringify(history, null, 1)}\n`)
560 await $.fs.write(`${root}/history.js`, historyScript(history))
561}
562
563function syncHistory($: EngineInterface, id: string): Promise<void> {
564 historyQueue = historyQueue.then(() => syncHistoryNow($, id)).catch(() => undefined)
565
566 return historyQueue
567}
568
569async function restorePage($: EngineInterface, run: Run): Promise<void> {
570 if (run.dir === undefined) return
571 const dir = run.dir
572 if (run.kind === 'pingpong') {
573 const saved = (file: string) => $.fs.read(`${dir}/${file}.md`).then(text => text.replace(/^# .*\n+/, '').trim(), () => '')
574 const drafts = await Promise.all(run.seats.map((_, index) => saved(artifactOf({ ...run, phase: 'drafting' }, index).file)))
575 const revised = await Promise.all(run.challenges.map((_, i) => saved(artifactOf({ ...run, phase: 'integrating', round: i + 1 }, 1).file)))
576 const plans = [await saved('candidate'), ...revised]
577 const kept = plans.findIndex(plan => plan === '')
578 await putPage($, dir, planData({ ...run, drafts, plans: kept === -1 ? plans : plans.slice(0, kept) }, await $.clock.now()))
579 return
580 }
581 const filled = (entries: readonly RoundEntry[], r: number) =>
582 Promise.all(
583 entries.map(async (entry, index): Promise<RoundEntry> => {
584 if (entry.text !== '' || entry.state === 'absent') return entry
585 const saved = await $.fs.read(`${dir}/${artifactOf({ ...run, round: r + 1 }, index).file}.md`).catch(() => '')
586
587 return { ...entry, text: saved.replace(/^# .*\n+/, '').trim() }
588 }),
589 )
590 const transcript = run.transcript === undefined ? undefined : await Promise.all(run.transcript.map(filled))
591 await putPage($, dir, pageData({ ...run, ...(transcript === undefined ? {} : { transcript }) }, await $.clock.now()))
592}
593
594async function openRun($: EngineInterface, id: string): Promise<void> {
595 const stored = await $.state.get({ plugin: 'agent-panel', key: 'runs' })
596 const run = (stored.value ?? []).find(one => one.id === id)
597 if (!run || run.dir === undefined) return
598 const page = `${run.dir}/discussion.html`
599 if (!isLive(run) && !(await $.fs.exists(page))) await restorePage($, run).catch(() => undefined)
600 const target = (await $.fs.exists(page)) ? page : run.dir
601 if (target === page) await syncHistory($, id)
602 for (const opener of ['open', 'xdg-open']) {
603 const ran = await $.process.run([opener, target]).catch(() => undefined)
604 if (ran?.exitCode === 0) return
605 }
606 $.ui.toast(`Could not open ${target}`)
607}
608
609function savedLines(run: Run): string[] {
610 return [
611 run.dir === undefined ? '' : `The answers are saved in ${run.dir}`,
612 run.page === undefined ? '' : `The ${run.kind === 'panel' ? 'discussion' : 'plan'} page is ${fileUrl(run.page)}`,
613 run.saveNote ?? '',
614 ].filter(Boolean)
615}
616
617async function ensureAgentType($: EngineInterface, seat: Seat): Promise<string> {
618 const name = agentTypeName(seat)
619 if (!registeredTypes.has(name)) {
620 await $.agent.register({
621 name: name,
622 description: `Read-only panel seat on ${seat.model} at ${seat.effort} effort`,
623 prompt: SEAT_SYSTEM_PROMPT,
624 model: MODELS[seat.model],
625 effort: seat.effort,
626 tools: READ_ONLY_TOOLS,
627 skills: seat.skills,
628 })
629 registeredTypes.add(name)
630 }
631
632 return `${PLUGIN}:${name}`
633}
634
635async function patchRun($: EngineInterface, id: string, fn: (run: Run) => Run): Promise<Run | undefined> {
636 const written = await changeRuns($, list => list.map(run => (run.id === id ? fn(run) : run)))
637
638 return written.find(run => run.id === id)
639}
640
641async function patchSeat($: EngineInterface, id: string, index: number, patch: Partial<Seat>): Promise<Run | undefined> {
642 return patchRun($, id, run => ({
643 ...run,
644 seats: run.seats.map((seat, i) => (i === index ? { ...seat, ...patch } : seat)),
645 }))
646}
647
648async function claimStep($: EngineInterface, id: string, from: Phase, to: Phase, isReady: (run: Run) => boolean): Promise<Run | undefined> {
649 const token = crypto.randomUUID()
650 const run = await patchRun($, id, current =>
651 current.phase === from && isReady(current) ? { ...current, phase: to, claim: token } : current,
652 )
653
654 return run?.claim === token ? run : undefined
655}
656
657async function claimRound($: EngineInterface, id: string, round: number): Promise<Run | undefined> {
658 const token = crypto.randomUUID()
659 const run = await patchRun($, id, current =>
660 current.kind === 'panel' && (current.phase === 'drafting' || current.phase === 'discussing') && Math.max(current.round, 1) === round && current.seats.every(isSettled)
661 ? { ...closeRound(current), claim: token }
662 : current,
663 )
664
665 return run?.claim === token ? run : undefined
666}
667
668async function spawnSeat($: EngineInterface, run: Run, index: number, prompt: string): Promise<void> {
669 const seat = run.seats[index]
670 if (!seat) return
671 try {
672 const subagentType = await ensureAgentType($, seat)
673 const skills = await Promise.all((seat.folderSkills ?? []).map(async skill => ({ ...skill, body: splitSkill(await $.fs.read(`${skill.path}/SKILL.md`)).body })))
674 const spawned = await $.agent.spawn({ subagentType, prompt: withSkills(prompt, skills), description: `${run.kind} ${run.id}${run.kind === 'panel' && run.rounds > 1 ? ` round ${run.round}` : ''}: ${seat.label}` })
675 if (spawned.deny !== undefined || spawned.agentId === undefined) {
676 await failSeat($, run.id, index, spawned.deny ?? 'the subagent did not start')
677 return
678 }
679 await patchSeat($, run.id, index, { agentId: spawned.agentId, status: 'working', startedAt: await $.clock.now() })
680 startTicker($)
681 } catch (error) {
682 await failSeat($, run.id, index, error instanceof Error ? error.message : String(error))
683 }
684}
685
686async function failSeat($: EngineInterface, id: string, index: number, reason: string): Promise<void> {
687 const run = await patchSeat($, id, index, { status: 'failed', answer: `(failed: ${reason})` })
688 await writePage($, id)
689 if (run) await advance($, run)
690}
691
692async function continueSeat($: EngineInterface, run: Run, index: number, agentId: string, text: string): Promise<boolean> {
693 await patchSeat($, run.id, index, { status: 'working', startedAt: await $.clock.now() })
694 resuming += 1
695 const sent = await $.session.send({ to: { agentId }, text }).catch(() => ({ isDelivered: false }))
696 resuming -= 1
697 if (sent.isDelivered) {
698 startTicker($)
699 return true
700 }
701 await patchSeat($, run.id, index, { status: 'starting' })
702
703 return false
704}
705
706async function askSeat($: EngineInterface, run: Run, index: number, brief: (isContinued: boolean) => string): Promise<void> {
707 const agentId = run.seats[index]?.agentId
708 if (agentId !== undefined) {
709 const isContinued = await continueSeat($, run, index, agentId, brief(true))
710 if (isContinued) return
711 }
712 await spawnSeat($, run, index, brief(false))
713}
714
715type Launch = { seats: Seat[]; question: string; rounds: number; brief?: { name: string; text: string }; notes: string[]; warnings: string[] }
716
717async function startRun($: EngineInterface, kind: RunKind, request: Launch, config: Config): Promise<Run> {
718 const { seats, question, rounds, brief } = request
719 const opened: Run = {
720 id: crypto.randomUUID().slice(0, 6),
721 kind,
722 question,
723 seats,
724 phase: 'drafting',
725 round: kind === 'panel' ? 1 : 0,
726 rounds,
727 challenges: [],
728 startedAt: await $.clock.now(),
729 ...(brief === undefined ? {} : { brief: brief.name, briefText: brief.text }),
730 ...(request.notes.length === 0 ? {} : { notes: request.notes }),
731 ...(request.warnings.length === 0 ? {} : { warnings: request.warnings }),
732 }
733 const record = await openRecord($, opened, config)
734 const run = { ...opened, ...record }
735 await changeRuns($, list => [...list, run].slice(-10))
736 await writePage($, run.id)
737 void $.ui.open({ id: PANE, title: 'Agent panel' })
738 $.ui.status(`${kind} ${run.id}: ${rounds > 1 && kind === 'panel' ? `round 1 of ${rounds}, ` : ''}${seats.length} agents working`)
739
740 await Promise.all(
741 seats.map((seat, index) =>
742 spawnSeat($, run, index, kind === 'panel' ? panelBrief(question, seat.role, brief?.text) : draftBrief(question)),
743 ),
744 )
745 await writePage($, run.id)
746 await syncHistory($, run.id)
747
748 const stored = await $.state.get({ plugin: 'agent-panel', key: 'runs' })
749
750 const current = (stored.value ?? []).find(one => one.id === run.id)
751
752 return current ?? run
753}
754
755function isSettled(seat: Seat): boolean {
756 return seat.status === 'done' || seat.status === 'failed'
757}
758
759async function finish($: EngineInterface, run: Run, phase: Phase, text: string, outcome?: string): Promise<void> {
760 const endedAt = await $.clock.now()
761 const ended = await patchRun($, run.id, current => ({ ...current, phase, endedAt, ...(outcome === undefined ? {} : { outcome }) }))
762 const saved = savedLines(ended ?? run)
763 await writePage($, run.id)
764 await syncHistory($, run.id)
765 const held = await $.state.get({ plugin: 'agent-panel', key: 'runs' })
766 const list = held.value ?? []
767 if (!list.some(isLive)) stopTicker()
768 $.ui.status(undefined)
769 $.ui.toast(`${run.kind} ${run.id} ${phase === 'failed' ? 'failed' : 'finished'}`)
770 const isReported = await submit($, saved.length === 0 ? text : `${text}\n\nEnd by telling me this:\n${saved.join('\n')}`)
771 await patchRun($, run.id, current => {
772 const { briefText: _briefText, drafts: _drafts, plans: _plans, ...kept } = current
773 if (!isReported) return { ...current, isReported }
774
775 return { ...kept, isReported, ...(current.transcript === undefined ? {} : { transcript: current.transcript.map(entries => entries.map(entry => ({ ...entry, text: '' }))) }) }
776 })
777}
778
779async function submit($: EngineInterface, text: string): Promise<boolean> {
780 try {
781 await $.prompt.submit({ text })
782 return true
783 } catch {
784 // A submit from the command or tool call that started the run is refused; that caller reports the failure itself.
785 return false
786 }
787}
788
789function finalPlan(run: Run, outcome: string, author: string, guide: Guide | undefined): string {
790 const log = run.challenges.map((challenge, i) => `### Round ${i + 1} challenge (${run.seats[0]?.label})\n\n${challenge}`).join('\n\n')
791
792 return `Ping-pong ${run.id} ${outcome}
793
794Task:
795${run.question}
796
797## Final plan (${author})
798
799${run.candidate}
800
801## Challenges
802
803${log}
804
805${
806 run.page === undefined
807 ? `Present one coherent plan to me with the material disagreements, the decisions still open, the number of completed rounds, and the seats used:
808${seatList(run.seats)}`
809 : planAsk(run, guide)
810 }`
811}
812
813async function advancePanel($: EngineInterface, run: Run): Promise<void> {
814 const claimed = await claimRound($, run.id, Math.max(run.round, 1))
815 if (!claimed) return
816 if (claimed.phase !== 'discussing') {
817 await finish($, claimed, 'done', panelSynthesis(claimed, await loadGuide($)))
818 return
819 }
820
821 const asked = claimed.seats.flatMap((seat, index) => (seat.status === 'starting' ? [index] : []))
822 $.ui.status(`panel ${claimed.id}: round ${claimed.round} of ${claimed.rounds}, ${asked.length} agents replying`)
823 await writePage($, claimed.id)
824 await Promise.all(asked.map(index => askSeat($, claimed, index, isContinued => replyBrief(claimed, index, isContinued))))
825 await writePage($, claimed.id)
826}
827
828async function advance($: EngineInterface, run: Run): Promise<void> {
829 if (run.kind === 'panel') {
830 await advancePanel($, run)
831 return
832 }
833
834 if (run.phase === 'drafting') {
835 const claimed = await claimStep($, run.id, 'drafting', 'merging', current => current.seats.every(isSettled))
836 if (!claimed) return
837
838 if (claimed.seats.some(seat => seat.status === 'failed')) {
839 await finish($, claimed, 'failed', `Ping-pong ${claimed.id} stopped: a planner failed.\n\n${answers(claimed)}\n\nTell me what failed and stop.`, 'Stopped before the merge: a draft failed.')
840 return
841 }
842
843 await patchRun($, run.id, current => ({ ...current, drafts: current.seats.map(seat => seat.answer ?? '') }))
844 await writePage($, run.id)
845 await submit($, `Ping-pong ${claimed.id}: both independent drafts are in.
846
847Task:
848${claimed.question}
849
850${answers(claimed)}
851
852Compare both drafts against the code or files they cite and settle factual disagreements by inspection, not by vote. Merge them into one candidate plan. Then call the tool mcp__${PLUGIN}__pingpong_merge with runId "${claimed.id}" and the full candidate plan. Do not present the plan to me yet: ${claimed.rounds} challenge round(s) follow.`)
853 return
854 }
855
856 const [challenger, integrator] = run.seats
857 if (!challenger || !integrator) return
858
859 if (run.phase === 'challenging') {
860 const seat = challenger
861 if (!isSettled(seat)) return
862 if (seat.status === 'failed') {
863 await finish($, run, 'failed', `Ping-pong ${run.id} stopped in round ${run.round}: the challenger failed. ${seat.answer}\n\nLast candidate plan:\n\n${run.candidate}\n\nShow me this candidate and say the rounds did not complete.`, `Stopped in round ${run.round} of ${run.rounds}: the challenger failed.`)
864 return
865 }
866 if (isPass(seat.answer ?? '')) {
867 const claimed = await claimStep($, run.id, 'challenging', 'done', () => true)
868 if (!claimed) return
869 const passed = await patchRun($, run.id, current => ({ ...current, challenges: [...current.challenges, seat.answer ?? ''] }))
870 if (!passed) return
871 const author = run.round === 1 ? 'your merge, unchanged' : `last integrated by ${integrator.label}`
872 await finish($, passed, 'done', finalPlan(passed, `stopped early in round ${run.round} of ${run.rounds}: the challenger found nothing material, after ${run.round - 1} integrated round(s).`, author, await loadGuide($)), `Stopped early in round ${run.round} of ${run.rounds}: the challenger found nothing material.`)
873 return
874 }
875 const claimed = await claimStep($, run.id, 'challenging', 'integrating', () => true)
876 if (!claimed) return
877 const withChallenge = await patchRun($, run.id, current => ({ ...current, challenges: [...current.challenges, seat.answer ?? ''] }))
878 if (withChallenge) {
879 await askSeat($, withChallenge, 1, isContinued => integrateBrief(withChallenge.candidate ?? '', seat.answer ?? '', run.round, run.rounds, isContinued))
880 }
881 await writePage($, run.id)
882 return
883 }
884
885 if (run.phase === 'integrating') {
886 const seat = integrator
887 if (!isSettled(seat)) return
888 if (seat.status === 'failed') {
889 await finish($, run, 'failed', `Ping-pong ${run.id} stopped in round ${run.round}: the integrator failed. ${seat.answer}\n\nLast candidate plan:\n\n${run.candidate}\n\nShow me this candidate and say round ${run.round} did not complete.`, `Stopped in round ${run.round} of ${run.rounds}: the integrator failed.`)
890 return
891 }
892 const isLastRound = run.round >= run.rounds
893 const claimed = await claimStep($, run.id, 'integrating', isLastRound ? 'done' : 'challenging', () => true)
894 if (!claimed) return
895 const next = await patchRun($, run.id, current => ({
896 ...current,
897 candidate: seat.answer,
898 plans: [...(current.plans ?? []), seat.answer ?? ''],
899 round: isLastRound ? current.round : current.round + 1,
900 }))
901 if (!next) return
902
903 if (isLastRound) {
904 await finish($, next, 'done', finalPlan(next, `completed ${next.round} of ${next.rounds} round(s).`, `last integrated by ${integrator.label}`, await loadGuide($)), `Completed ${next.round} of ${next.rounds} ${next.rounds === 1 ? 'round' : 'rounds'}.`)
905 return
906 }
907
908 await askSeat($, next, 0, isContinued => challengeBrief(next.candidate ?? '', next.round, next.rounds, isContinued))
909 await writePage($, run.id)
910 }
911}
912
913function describePhase(run: Run): string {
914 const where = run.isReported === false ? 'Its message could not be posted to the chat.' : 'Its message was posted to the chat.'
915 switch (run.phase) {
916 case 'drafting':
917 if (run.kind === 'pingpong') return 'A and B are each drafting a plan.'
918 return run.rounds > 1 ? `Round 1 of ${run.rounds}: agents are answering independently.` : 'Agents are answering.'
919 case 'discussing':
920 return `Round ${run.round} of ${run.rounds}: agents are replying to each other.`
921 case 'merging':
922 return 'Drafts are in. Claude is merging them into one plan.'
923 case 'challenging':
924 return `Round ${run.round} of ${run.rounds}: A is looking for problems in the plan.`
925 case 'integrating':
926 return `Round ${run.round} of ${run.rounds}: B is revising the plan.`
927 case 'done':
928 return `Finished. ${where}`
929 case 'failed':
930 return `Stopped. ${where}`
931 }
932}
933
934function presetFrom(seats: readonly Seat[], brief: string | undefined, rounds: number | undefined, skillDirs: readonly string[]) {
935 return presetEntry({
936 seats: seats.map(seatSpec),
937 ...(brief === undefined ? {} : { brief }),
938 ...(rounds === undefined ? {} : { rounds }),
939 ...(skillDirs.length === 0 ? {} : { skillDirs: [...new Set(skillDirs)] }),
940 })
941}
942
943function separatorError(kind: RunKind, question: string): string {
944 const what = kind === 'panel' ? 'question' : 'task'
945 if (!/\s/.test(question)) return `"${question}" is not a seat, a preset or an option. Nothing started.`
946
947 return `Nothing started: put -- before the ${what}, as in /${kind} -- ${question}`
948}
949
950function prepare(kind: RunKind, args: string, loaded: Loaded, rounds?: number) {
951 const parsed = parseArgs(args, loaded.presets)
952 const seats = parsed.seats.length > 0 ? parsed.seats : parseArgs(kind === 'panel' ? 'quick' : 'duo', loaded.builtIn).seats
953 const errors = [...parsed.errors]
954 const notes = [...parsed.presets.map(name => `Preset "${name}" (${loaded.presets[name]?.source}).`), ...parsed.notes]
955 if (kind === 'pingpong' && seats.length !== 2) errors.push(`Ping-pong takes exactly two seats; got ${seats.length}.`)
956 if (parsed.question.length === 0) errors.push(kind === 'panel' ? 'No question given.' : 'No task given.')
957 if (parsed.question.length > 0 && !parsed.hasSeparator) errors.push(separatorError(kind, parsed.question))
958 if (kind === 'pingpong' && parsed.brief !== undefined) errors.push('brief= applies to /panel only.')
959 if (kind === 'pingpong' && parsed.brief === undefined && parsed.presetBrief !== undefined) notes.push(`Brief "${parsed.presetBrief}" from the preset is not used: briefs apply to /panel only.`)
960 const roundsProblem = rounds === undefined ? undefined : roundsError(rounds)
961 if (roundsProblem) errors.push(roundsProblem)
962 if (parsed.as !== undefined && parsed.seats.length === 0) errors.push('as= needs seats or a preset on the line to save.')
963 if (parsed.as !== undefined && Object.hasOwn(loaded.presets, parsed.as)) errors.push(`A preset named "${parsed.as}" exists. To replace it: /panel-preset save ${parsed.as} <seats> force`)
964 const brief = parsed.brief ?? (kind === 'panel' ? parsed.presetBrief : undefined)
965 const typedRounds = rounds ?? parsed.rounds
966 const chosenRounds = typedRounds ?? parsed.presetRounds
967 const isDiscussion = kind === 'panel' && (chosenRounds ?? 1) > 1
968 const isAlone = isDiscussion && seats.length < 2
969 if (isAlone && typedRounds !== undefined) errors.push(`A discussion needs at least two seats; got ${seats.length}.`)
970 if (isAlone && typedRounds === undefined) notes.push(`The preset asks for ${chosenRounds} rounds, but a discussion needs at least two seats: this runs one round.`)
971 if (isDiscussion && !isAlone && typedRounds === undefined) notes.push(`${chosenRounds} rounds come from the preset; rounds=1 on the command runs it without the discussion.`)
972 const skillDirs = [...parsed.skillDirs, ...parsed.presetSkillDirs]
973
974 return {
975 seats,
976 question: parsed.question,
977 rounds: isAlone ? 1 : (chosenRounds ?? 1),
978 brief,
979 skillDirs,
980 preset: parsed.as === undefined ? undefined : { name: parsed.as, entry: presetFrom(parsed.seats, brief, chosenRounds, skillDirs) },
981 notes,
982 errors,
983 }
984}
985
986async function usage($: EngineInterface, kind: RunKind, config: Config): Promise<string> {
987 const loaded = await loadPresets($)
988
989 return kind === 'panel' ? panelUsage(loaded, await listBriefs($)) : pingpongUsage(loaded)
990}
991
992async function launch($: EngineInterface, kind: RunKind, args: string, config: Config, rounds?: number): Promise<{ errors: string[] } | { started: string }> {
993 const loaded = await loadPresets($)
994 const prepared = prepare(kind, args, loaded, rounds)
995 if (prepared.errors.length > 0) return { errors: [...prepared.errors, ...loaded.notices] }
996
997 const text = prepared.brief === undefined ? undefined : await loadBrief($, prepared.brief)
998 if (prepared.brief !== undefined && text === undefined) {
999 const briefs = await listBriefs($)
1000 const known = briefs.map(brief => brief.name).join(', ')
1001
1002 return { errors: [`No brief named "${prepared.brief}". Known briefs: ${known || 'none'}.`] }
1003 }
1004
1005 const resolved = await resolveSkills($, prepared.seats, [...prepared.skillDirs, ...config.skillDirs])
1006 const notes = [...prepared.notes]
1007 if (prepared.preset !== undefined) {
1008 const { name, entry } = prepared.preset
1009 const saved = await changePresets($, 'user', file => {
1010 file[name] = entry
1011
1012 return undefined
1013 })
1014 if ('error' in saved) return { errors: [saved.error] }
1015 notes.push(`Saved as preset "${name}" in ${saved.path}.`)
1016 }
1017
1018 const run = await startRun($, kind, {
1019 seats: resolved.seats,
1020 question: prepared.question,
1021 rounds: prepared.rounds,
1022 ...(prepared.brief !== undefined && text !== undefined ? { brief: { name: prepared.brief, text } } : {}),
1023 notes,
1024 warnings: resolved.warnings,
1025 }, config)
1026
1027 return { started: describeStart(run) }
1028}
1029
1030function describeStart(run: Run): string {
1031 const isDiscussion = run.kind === 'panel' && run.rounds > 1
1032 const rounds = run.kind === 'pingpong' ? `, ${run.rounds} round(s)` : isDiscussion ? `, up to ${run.rounds} rounds` : ''
1033 const failed = run.seats.filter(seat => seat.status === 'failed').map(seat => `${seat.label} did not start: ${seat.answer}`)
1034 const brief = run.brief === undefined ? '' : `, brief "${run.brief}"`
1035
1036 return [
1037 `${run.kind === 'panel' ? 'Panel' : 'Ping-pong'} ${run.id} started${rounds}${brief}:`,
1038 seatList(run.seats),
1039 ...skillLines(run.seats),
1040 ...(run.notes ?? []),
1041 ...(run.warnings ?? []),
1042 ...failed,
1043 ...(isDiscussion ? [costLine(run.seats.length, run.rounds)] : []),
1044 ...savedLines(run),
1045 isDiscussion ? 'Results arrive as a new message after the last round.' : 'Results arrive as a new message when the agents finish.',
1046 ].join('\n')
1047}
1048
1049const PRESET_USAGE = `Usage: /panel-preset save <name> <seats or presets> [brief=name] [rounds=N] [skills=dir[,dir]] [scope=project] [force]
1050 /panel-preset list
1051 /panel-preset show <name>
1052 /panel-preset delete <name> [scope=project]
1053 /panel-preset skills [filter]
1054
1055A preset is a named set of seats: run it as /panel <name> -- <question> or /pingpong <name> -- <task>.
1056 save writes to ~/${PRESETS_PATH}; scope=project writes to ${PRESETS_PATH} in this project
1057 skills= folders of <skill>/SKILL.md the preset's +skill names load from, ahead of the "Default skill folders" setting and the session's skills
1058 brief= the brief /panel uses with this preset; brief= on the command still wins
1059 rounds= under /panel the total rounds, the first independent; under /pingpong the challenge rounds; rounds= on the command still wins
1060 force replaces a preset of the same name in that file
1061 skills lists what the "Default skill folders" setting and your presets' folders offer
1062
1063A project file cannot replace a built-in preset or carry skill folders. as=<name> on /panel or /pingpong saves the seats of a run.
1064
1065Example
1066 /panel-preset save content-council skills=~/skills/marketing sonnet:high+ads+ad-creative sonnet:high+copywriting opus:high:contrarian+cro
1067 /panel content-council -- Critique the home page redesign`
1068
1069const PRESET_GROUPS: { source: PresetSource; title: string; empty?: string }[] = [
1070 { source: 'built-in', title: 'Built-in presets' },
1071 { source: 'user', title: `Your presets ~/${PRESETS_PATH}`, empty: ' none yet: /panel-preset save <name> <seats>' },
1072 { source: 'project', title: `Project presets ${PRESETS_PATH}` },
1073]
1074
1075function presetDetail(name: string, preset: PresetMap[string], width: number, warnings: readonly string[] = []): string {
1076 const indent = ' '.repeat(width + 3)
1077 const seats = preset.seats.map(spec => spec.replaceAll('+', ' +'))
1078 const options = [preset.brief === undefined ? '' : `brief=${preset.brief}`, preset.rounds === undefined ? '' : `rounds=${preset.rounds}`].filter(Boolean)
1079 const dirs = preset.skillDirs ?? []
1080 const oneLine = [...seats, ...options].join(' ')
1081 if (preset.description === undefined && dirs.length === 0 && warnings.length === 0 && !oneLine.includes('+') && oneLine.length <= 60) return ` ${name.padEnd(width)} ${oneLine}`
1082 const [first = '', ...rest] = preset.description === undefined ? seats : [preset.description, ...seats]
1083
1084 return [
1085 ` ${name.padEnd(width)} ${first}`,
1086 ...rest.map(line => `${indent}${line}`),
1087 ...(options.length === 0 ? [] : [`${indent}${options.join(' ')}`]),
1088 ...dirs.map((dir, i) => `${indent}${i === 0 ? 'skills from ' : ' '.repeat(12)}${dir}`),
1089 ...warnings.map(warning => `${indent}! ${warning}`),
1090 ].join('\n')
1091}
1092
1093async function listPresets($: EngineInterface, config: Config): Promise<string> {
1094 const loaded = await loadPresets($)
1095 const width = nameWidth(Object.keys(loaded.presets))
1096 const blocks = await Promise.all(
1097 Object.entries(loaded.presets).map(async ([name, preset]) => {
1098 const seats = preset.seats.flatMap(spec => parseArgs(spec, {}).seats)
1099 const { warnings } = seats.some(seat => seat.skills.length > 0) ? await resolveSkills($, seats, [...(preset.skillDirs ?? []), ...config.skillDirs]) : { warnings: [] }
1100
1101 return { source: preset.source, text: presetDetail(name, preset, width, warnings) }
1102 }),
1103 )
1104 const groups = PRESET_GROUPS.flatMap(group => {
1105 const texts = blocks.filter(block => block.source === group.source).map(block => block.text)
1106 if (texts.length === 0 && group.empty === undefined) return []
1107
1108 return [`${group.title}\n${texts.length === 0 ? group.empty : texts.join(texts.some(text => text.includes('\n')) ? '\n\n' : '\n')}`]
1109 })
1110
1111 return `${groups.join('\n\n')}\n${noticeLines(loaded.notices)}`.trimEnd()
1112}
1113
1114async function showPreset($: EngineInterface, name: string, config: Config): Promise<string> {
1115 const loaded = await loadPresets($)
1116 const preset = Object.hasOwn(loaded.presets, name) ? loaded.presets[name] : undefined
1117 if (preset === undefined) return `No preset named "${name}". Known presets: ${Object.keys(loaded.presets).join(', ')}.${noticeLines(loaded.notices)}`
1118 const resolved = await resolveSkills($, preset.seats.flatMap(spec => parseArgs(spec, {}).seats), [...(preset.skillDirs ?? []), ...config.skillDirs])
1119 const path = preset.source === 'built-in' ? `${$.plugin.root}/presets.json` : await presetsPath($, preset.source)
1120
1121 const skills = skillLines(resolved.seats)
1122
1123 return [`Preset "${name}" in ${path}:`, presetDetail(name, preset, nameWidth([name]), resolved.warnings), ...(skills.length === 0 ? [] : ['', ...skills])].join('\n')
1124}
1125
1126function scopeOf(words: readonly string[]): Scope | undefined {
1127 const word = words.find(one => /^scope=/i.test(one))
1128 const scope = word === undefined ? 'user' : word.slice(6).toLowerCase()
1129
1130 return scope === 'user' || scope === 'project' ? scope : undefined
1131}
1132
1133async function deletePreset($: EngineInterface, name: string, words: readonly string[]): Promise<string> {
1134 const scope = scopeOf(words)
1135 if (scope === undefined) return 'scope must be user or project.'
1136 const loaded = await loadPresets($)
1137 const deleted = await changePresets($, scope, (file, path) => {
1138 if (Object.hasOwn(file, name)) {
1139 delete file[name]
1140
1141 return undefined
1142 }
1143 const source = Object.hasOwn(loaded.presets, name) ? loaded.presets[name]?.source : undefined
1144 if (source === 'built-in') return `"${name}" is a built-in preset; it can be replaced by a preset of the same name but not deleted.`
1145
1146 return `No preset "${name}" in ${path}.${source === undefined || source === scope ? '' : ` It is in the ${source} file: add scope=${source}.`}`
1147 })
1148
1149 return 'error' in deleted ? deleted.error : `Deleted preset "${name}" from ${deleted.path}.`
1150}
1151
1152async function savePreset($: EngineInterface, name: string, words: readonly string[], config: Config): Promise<string> {
1153 const scope = scopeOf(words)
1154 const isForced = words.some(word => word.toLowerCase() === 'force')
1155 const loaded = await loadPresets($)
1156 const parsed = parseArgs(words.filter(word => word.toLowerCase() !== 'force' && !/^scope=/i.test(word)).join(' '), loaded.presets)
1157 const brief = parsed.brief ?? parsed.presetBrief
1158 const skillDirs = [...parsed.skillDirs, ...parsed.presetSkillDirs]
1159 const nameError = presetNameError(name)
1160 const errors = [...parsed.errors]
1161 if (nameError) errors.push(`"${name}": ${nameError}`)
1162 if (scope === undefined) errors.push('scope must be user or project.')
1163 if (parsed.question !== '') errors.push(`"${parsed.question}" is not a seat, a preset or an option.`)
1164 if (parsed.seats.length === 0) errors.push('Give at least one seat.')
1165 if (parsed.as !== undefined) errors.push('as= belongs on /panel and /pingpong.')
1166 if (scope === 'project' && skillDirs.length > 0) errors.push('A project preset cannot carry skill folders, since a repository could then feed its own text to the seats. Put the folder in the "Default skill folders" setting, or save without scope=project.')
1167 if (scope === 'project' && Object.hasOwn(loaded.builtIn, name)) errors.push(`A project preset cannot be named "${name}": that is a built-in.`)
1168 const briefText = brief === undefined ? undefined : await loadBrief($, brief)
1169 if (brief !== undefined && briefText === undefined) errors.push(`No brief named "${brief}".`)
1170 if (errors.length > 0 || scope === undefined) return `${errors.join('\n')}\n\n${PRESET_USAGE}`
1171
1172 const saved = await changePresets($, scope, (file, path) => {
1173 if (Object.hasOwn(file, name) && !isForced) return `"${name}" is already in ${path}. Add force to replace it.`
1174 file[name] = presetFrom(parsed.seats, brief, parsed.rounds ?? parsed.presetRounds, skillDirs)
1175
1176 return undefined
1177 })
1178 if ('error' in saved) return saved.error
1179
1180 const resolved = await resolveSkills($, parsed.seats, [...skillDirs, ...config.skillDirs])
1181 const other = Object.hasOwn(loaded.presets, name) ? loaded.presets[name]?.source : undefined
1182 const shadow =
1183 other === 'built-in' ? [`It is used in place of the built-in "${name}" when you name it; a command with no seats still runs the built-in.`]
1184 : other === 'project' && scope === 'user' ? [`The project preset "${name}" still wins in this project.`]
1185 : other === 'user' && scope === 'project' ? [`It is used in place of your user preset "${name}" in this project.`]
1186 : []
1187
1188 return [`Saved preset "${name}" in ${saved.path}:`, seatList(resolved.seats), ...skillLines(resolved.seats), ...parsed.notes, ...shadow, ...resolved.warnings].join('\n')
1189}
1190
1191async function listSkills($: EngineInterface, filter: string, config: Config): Promise<string> {
1192 const loaded = await loadPresets($)
1193 const roots = await skillRoots($, [...config.skillDirs, ...Object.values(loaded.presets).flatMap(preset => preset.skillDirs ?? [])])
1194 if (roots.length === 0) return 'No skill folders are set. Add one to the "Default skill folders" setting under /config, or pass skills=<folder> to /panel-preset save.'
1195
1196 const blocks = await Promise.all(
1197 roots.map(async root => {
1198 const names = await folderSkillNames($, [root])
1199 const found = await Promise.all(
1200 names.sort().map(async name => {hooks/seats.ts 792 lines1import type { Preset, PresetMap, PresetSource, Role, RoundEntry, RoundState, Run, Seat } from '../types'
2import type { Guide } from './design'
3
4export const MODELS: Record<string, string> = {
5 opus: 'claude-opus-5-5',
6 sonnet: 'claude-sonnet-5-5',
7 haiku: 'claude-haiku-5-5',
8 fable: 'claude-fable-5-1',
9}
10
11export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
12
13export const MAX_ROUNDS = 5
14
15
16
17export const GIT_COMMANDS = ['status', 'diff', 'log', 'show', 'blame'] as const
18
19
20const SEAT = /^(opus|sonnet|haiku|fable):(low|medium|high|xhigh|max)(?::(panelist|contrarian))?((?:\+[\w:./-]+)*)$/i
21const MISPLACED_SKILL = /^((?:opus|sonnet|haiku|fable):(?:low|medium|high|xhigh|max)):([\w./-]+)$/i
22
23export function parseSeat(token: string): Seat | undefined {
24 const match = SEAT.exec(token)
25 if (!match?.[1] || !match[2]) return undefined
26
27 const model = match[1].toLowerCase()
28 const effort = match[2].toLowerCase()
29 const role = (match[3]?.toLowerCase() ?? 'panelist') as Role
30 const skills = match[4] ? match[4].split('+').filter(Boolean) : []
31 const label = [`${model}·${effort}`, role === 'contrarian' ? 'contrarian' : '', ...skills.map(s => `+${s}`)]
32 .filter(Boolean)
33 .join(' ')
34
35 return { label, model, effort, role, skills, status: 'starting' }
36}
37
38export function seatSpec(seat: Pick<Seat, 'model' | 'effort' | 'role' | 'skills'>): string {
39 return `${seat.model}:${seat.effort}${seat.role === 'contrarian' ? ':contrarian' : ''}${seat.skills.map(skill => `+${skill}`).join('')}`
40}
41
42const PRESET_NAME = /^[a-z][a-z0-9-]{1,30}$/
43const BRIEF_NAME = /^[\w.-]+$/
44const FOLDER_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
45
46export const PRESET_COMMANDS = ['help', 'save', 'list', 'show', 'delete', 'skills'] as const
47
48export function presetNameError(name: string): string | undefined {
49 if (!PRESET_NAME.test(name)) return 'a preset name is 2 to 31 lowercase letters, digits or hyphens, starting with a letter.'
50 if (Object.hasOwn(MODELS, name) || (PRESET_COMMANDS as readonly string[]).includes(name)) return `"${name}" is reserved.`
51
52 return undefined
53}
54
55export function isFolderSkillName(name: string): boolean {
56 return FOLDER_SKILL_NAME.test(name)
57}
58
59export type PresetLayer = { presets: PresetMap; ignored: string[] }
60
61function isStrings(value: unknown): value is string[] {
62 return Array.isArray(value) && value.every(item => typeof item === 'string')
63}
64
65export function readPresetLayer(raw: unknown, source: PresetSource, builtInNames: readonly string[] = []): PresetLayer {
66 const presets: PresetMap = {}
67 const ignored: string[] = []
68 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return { presets, ignored: ['the file is not a JSON object of presets'] }
69
70 for (const [name, value] of Object.entries(raw)) {
71 const entry: Record<string, unknown> = isStrings(value) ? { seats: value } : typeof value === 'object' && value !== null ? { ...value } : {}
72 const { seats, skillDirs } = entry
73 const nameError = presetNameError(name)
74 const badSeat = isStrings(seats) ? seats.find(spec => parseSeat(spec) === undefined) : undefined
75 if (nameError) {
76 ignored.push(`"${name}": ${nameError}`)
77 } else if (source === 'project' && builtInNames.includes(name)) {
78 ignored.push(`"${name}": a project file cannot replace a built-in preset.`)
79 } else if (!isStrings(seats) || seats.length === 0) {
80 ignored.push(`"${name}": needs a list of seats.`)
81 } else if (badSeat !== undefined) {
82 ignored.push(`"${name}": "${badSeat}" is not a seat.`)
83 } else {
84 const hasFolders = isStrings(skillDirs) && skillDirs.length > 0
85 if (source === 'project' && hasFolders) ignored.push(`"${name}": skill folders in a project file are not used.`)
86 presets[name] = {
87 seats,
88 source,
89 ...(typeof entry.description === 'string' ? { description: entry.description } : {}),
90 ...(typeof entry.brief === 'string' && BRIEF_NAME.test(entry.brief) ? { brief: entry.brief.toLowerCase() } : {}),
91 ...(typeof entry.rounds === 'number' && roundsError(entry.rounds) === undefined ? { rounds: entry.rounds } : {}),
92 ...(source !== 'project' && isStrings(skillDirs) && hasFolders ? { skillDirs } : {}),
93 }
94 }
95 }
96
97 return { presets, ignored }
98}
99
100export function presetEntry(preset: Omit<Preset, 'source'>): string[] | Omit<Preset, 'source'> {
101 return Object.keys(preset).length === 1 ? preset.seats : preset
102}
103
104export const FALLBACK_PRESETS: PresetMap = readPresetLayer(
105 {
106 deep: ['opus:xhigh', 'opus:high', 'sonnet:high'],
107 quick: ['sonnet:medium', 'sonnet:low', 'haiku:high'],
108 duo: ['opus:high', 'sonnet:high'],
109 review: ['opus:xhigh', 'sonnet:high', 'opus:high:contrarian'],
110 },
111 'built-in',
112).presets
113
114export type ParsedArgs = {
115 seats: Seat[]
116 rounds?: number
117 brief?: string
118 as?: string
119 skillDirs: string[]
120 presets: string[]
121 presetBrief?: string
122 presetRounds?: number
123 presetSkillDirs: string[]
124 notes: string[]
125 question: string
126 hasSeparator: boolean
127 errors: string[]
128}
129
130export function parseArgs(args: string, presets: PresetMap): ParsedArgs {
131 const parsed: ParsedArgs = { seats: [], skillDirs: [], presets: [], presetSkillDirs: [], notes: [], question: '', hasSeparator: false, errors: [] }
132 const { seats, errors, notes } = parsed
133 let rest = args.trim()
134
135 while (rest.length > 0) {
136 const token = rest.split(/\s+/, 1)[0] ?? ''
137 const after = rest.slice(token.length).trimStart()
138
139 if (token === '--') {
140 parsed.hasSeparator = true
141 rest = after
142 break
143 }
144
145 const roundsMatch = /^rounds=(\d+)$/i.exec(token)
146 const briefMatch = /^brief=([\w.-]+)$/i.exec(token)
147 const asMatch = /^as=(.+)$/i.exec(token)
148 const skillsMatch = /^skills=(.+)$/i.exec(token)
149 const misplaced = MISPLACED_SKILL.exec(token)
150 const seat = parseSeat(token)
151 const name = token.toLowerCase()
152
153 if (roundsMatch) {
154 const error = roundsError(Number(roundsMatch[1]))
155 if (error) errors.push(`"${token}": ${error}`)
156 else parsed.rounds = Number(roundsMatch[1])
157 } else if (briefMatch?.[1]) {
158 parsed.brief = briefMatch[1].toLowerCase()
159 } else if (asMatch?.[1]) {
160 const error = presetNameError(asMatch[1])
161 if (error) errors.push(`"${token}": ${error}`)
162 else parsed.as = asMatch[1]
163 } else if (skillsMatch?.[1]) {
164 parsed.skillDirs.push(...skillsMatch[1].split(',').filter(Boolean))
165 } else if (seat) {
166 seats.push(seat)
167 } else if (Object.hasOwn(presets, name)) {
168 const preset = presets[name] as Preset
169 parsed.presets.push(name)
170 seats.push(...preset.seats.flatMap(spec => parseSeat(spec) ?? []))
171 parsed.presetSkillDirs.push(...(preset.skillDirs ?? []))
172 if (preset.brief !== undefined && parsed.presetBrief === undefined) parsed.presetBrief = preset.brief
173 else if (preset.brief !== undefined && preset.brief !== parsed.presetBrief) notes.push(`Preset "${name}" asks for brief "${preset.brief}"; "${parsed.presetBrief}" from an earlier preset is used.`)
174 if (preset.rounds !== undefined && parsed.presetRounds === undefined) parsed.presetRounds = preset.rounds
175 else if (preset.rounds !== undefined && preset.rounds !== parsed.presetRounds) notes.push(`Preset "${name}" asks for ${preset.rounds} round(s); ${parsed.presetRounds} from an earlier preset is used.`)
176 } else if (misplaced?.[1] && misplaced[2]) {
177 errors.push(`"${token}": after the effort comes :contrarian or +skill. Use ${misplaced[1]}+${misplaced[2]}.`)
178 } else if (/^[a-z]+:[a-z]+/i.test(token) && !token.includes('/')) {
179 errors.push(`"${token}" is not a seat. Use model:effort, e.g. opus:xhigh.`)
180 } else if (after === '--' || after.startsWith('-- ')) {
181 errors.push(`"${token}" is not a preset or a seat. Known presets: ${Object.keys(presets).join(', ')}.`)
182 } else {
183 break
184 }
185
186 rest = after
187 }
188
189 parsed.question = rest.trim()
190
191 return parsed
192}
193
194export type ToolInput = { preset?: unknown; seats?: unknown; brief?: unknown }
195
196export function toolArgs(input: ToolInput): { args: string } | { error: string } {
197 const { preset, seats = [], brief } = input
198 if (preset !== undefined && (typeof preset !== 'string' || !PRESET_NAME.test(preset.toLowerCase()))) return { error: 'preset must be the name of one preset.' }
199 if (!isStrings(seats)) return { error: 'seats must be a list of strings.' }
200 const badSeat = seats.find(spec => parseSeat(spec) === undefined)
201 if (badSeat !== undefined) return { error: `"${badSeat}" is not a seat. Use model:effort[:contrarian][+skill], e.g. opus:xhigh.` }
202 if (brief !== undefined && (typeof brief !== 'string' || !BRIEF_NAME.test(brief))) return { error: 'brief must be the name of one brief.' }
203
204 return { args: [preset ?? '', ...seats, brief === undefined ? '' : `brief=${brief}`].filter(Boolean).join(' ') }
205}
206
207const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/
208
209export function splitSkill(text: string): { frontmatter: string; body: string } {
210 const match = FRONTMATTER.exec(text)
211
212 return match ? { frontmatter: match[1] ?? '', body: text.slice(match[0].length).trim() } : { frontmatter: '', body: text.trim() }
213}
214
215export function skillSummary(frontmatter: string): string {
216 const lines = frontmatter.split(/\r?\n/)
217 const at = lines.findIndex(line => line.startsWith('description:'))
218 if (at === -1) return ''
219 const inline = (lines[at] ?? '').replace(/^description:\s*/, '').replace(/^[>|][+-]?\s*$/, '')
220 const value = inline !== '' ? inline : (lines.slice(at + 1).find(line => line.trim() !== '' && /^\s/.test(line)) ?? '')
221
222 return value.trim().replace(/^["']|["']$/g, '')
223}
224
225const ENGINE_ONLY_KEYS = ['allowed-tools', 'context', 'disable-model-invocation']
226
227export function ignoredSkillFeatures(frontmatter: string, body: string): string[] {
228 return [
229 ...ENGINE_ONLY_KEYS.filter(key => new RegExp(`^${key}:`, 'm').test(frontmatter)),
230 ...(body.includes('${CLAUDE_SKILL_DIR}') ? ['${CLAUDE_SKILL_DIR}'] : []),
231 ...(/!`[^`]+`/.test(body) ? ['command injection'] : []),
232 ]
233}
234
235function editDistance(a: string, b: string): number {
236 let row = Array.from({ length: b.length + 1 }, (_, i) => i)
237 for (let i = 1; i <= a.length; i++) {
238 const next = [i]
239 for (let j = 1; j <= b.length; j++) {
240 next[j] = Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1))
241 }
242 row = next
243 }
244
245 return row[b.length] ?? 0
246}
247
248export function nearestNames(name: string, candidates: readonly string[], max = 3): string[] {
249 const limit = Math.max(2, Math.floor(name.length / 3))
250
251 return candidates
252 .map(candidate => ({ candidate, distance: candidate.endsWith(`:${name}`) ? 0 : editDistance(name.toLowerCase(), candidate.toLowerCase()) }))
253 .filter(scored => scored.distance <= limit)
254 .sort((a, b) => a.distance - b.distance)
255 .slice(0, max)
256 .map(scored => scored.candidate)
257}
258
259const SKILL_RULES = `You carry the skills below. Their discipline governs this answer and overrides the code-centric defaults where they conflict: cite file:line only for claims about files, and name the skill behind each point. Where a skill says to ask the user, state your assumption instead. Where a skill says to write files or run commands, recommend it instead; you are read-only.`
260
261export function withSkills(prompt: string, skills: readonly { name: string; path: string; body: string }[]): string {
262 if (skills.length === 0) return prompt
263 const blocks = skills.map(skill => `## Skill: ${skill.name}\n\nBase directory: ${skill.path}\nFiles the skill refers to, such as references/, may exist under it; open them with Read.\n\n${skill.body}`)
264
265 return `${SKILL_RULES}\n\n${blocks.join('\n\n')}\n\n## Your task\n\n${prompt}`
266}
267
268export function roundsError(rounds: number): string | undefined {
269 if (Number.isInteger(rounds) && rounds >= 1 && rounds <= MAX_ROUNDS) return undefined
270
271 return `rounds must be a whole number from 1 to ${MAX_ROUNDS}.`
272}
273
274export function agentTypeName(seat: Pick<Seat, 'model' | 'effort' | 'skills'>): string {
275 const base = `${seat.model}-${seat.effort}`
276 if (seat.skills.length === 0) return base
277
278 let hash = 5381
279 for (const char of [...seat.skills].sort().join('+')) hash = ((hash * 33) ^ char.charCodeAt(0)) >>> 0
280
281 return `${base}-${hash.toString(36)}`
282}
283
284export const SEAT_SYSTEM_PROMPT = `You are one seat on a panel of AI agents working in the repository in your current directory. You have read-only tools. For git history and diffs use the git tool (status, diff, log, show, blame).
285
286Investigate the code before you claim anything about it. Separate what you verified (cite file:line) from what you infer. Answer in the format the brief asks for. Do not ask questions back; state your assumptions and continue. Your final message is your whole answer: the coordinator reads only that.`
287
288export function panelBrief(question: string, role: Role, brief?: string): string {
289 if (role === 'contrarian') {
290 return `Contrarian review. Other agents answer the same question separately; you will not see their answers.
291
292Question:
293${question}
294${brief === undefined ? '' : `\nThe other seats review through this brief; aim your objections at what it would find or miss:\n${brief}\n`}
295Attack the strongest assumptions behind the obvious answer. Trace failure cases. For each objection give concrete counterevidence and say whether it is a verified fact or a guess. If you cannot support a meaningful objection, answer PASS.`
296 }
297
298 if (brief !== undefined) {
299 return `Independent panel round. Other agents work from the same brief separately; you will not see their answers.
300
301Focus:
302${question}
303
304Brief:
305${brief}
306
307You start with no earlier conversation: the work under review is what Focus names. You are read-only: where the brief says to change or run something, recommend it instead. Answer in the format the brief asks for and cite the evidence you checked (file:line).`
308 }
309
310 return `Independent panel round. Other agents answer the same question separately; you will not see their answers.
311
312Question:
313${question}
314
315Answer with:
3161. Your answer or recommendation.
3172. The evidence you checked (file:line).
3183. Risks, and what you are unsure of.`
319}
320
321export function draftBrief(task: string): string {
322 return `Independent planning round. Another agent drafts a plan from the same brief; you will not see it yet.
323
324Task:
325${task}
326
327Write a complete plan: the steps, the files involved (verified paths), risks, and open decisions.`
328}
329
330export function challengeBrief(candidate: string, round: number, rounds: number, isContinued = false): string {
331 const context = !isContinued ? '' : round === 1 ? '\n\nYour draft and the other agent\'s were merged into the candidate below; check it against what you found while drafting.' : '\n\nThe other agent revised the plan after your last challenge. Its revision and the corrections it rejected are below; raise a rejected correction again only with new evidence.'
332
333 return `Ping-pong round ${round} of ${rounds}: challenge this candidate plan.${context}
334
335Find concrete errors, gaps and risks, each with evidence (file:line). Do not rewrite the plan. If nothing material is wrong, answer with PASS alone on the first line; that ends the rounds.
336
337## Candidate plan
338
339${candidate}`
340}
341
342export function isPass(answer: string): boolean {
343 const firstLine = answer.trim().split('\n', 1)[0] ?? ''
344
345 return /^[\s*_`#]*PASS[\s*_`.!]*$/.test(firstLine)
346}
347
348export function integrateBrief(candidate: string, challenge: string, round: number, rounds: number, isContinued = false): string {
349 const context = !isContinued ? '' : round === 1 ? ' It merges your draft with that agent\'s draft.' : ' It is your last revision.'
350
351 return `Ping-pong round ${round} of ${rounds}: another agent challenged the candidate plan below.${context}
352
353Check each correction against the code or files it cites and integrate only the ones the evidence supports. Reply with the full revised plan, then a section "Rejected corrections" that lists each correction you rejected and why.
354
355## Candidate plan
356
357${candidate}
358
359## Challenge
360
361${challenge}`
362}
363
364export function seatList(seats: Seat[]): string {
365 return seats.map((seat, i) => `${i + 1}. ${seat.label} (${MODELS[seat.model]}, effort ${seat.effort})`).join('\n')
366}
367
368export function slugify(text: string, max = 40): string {
369 return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, max).replace(/^-+|-+$/g, '') || 'untitled'
370}
371
372export function artifactOf(run: Pick<Run, 'kind' | 'phase' | 'round' | 'rounds' | 'seats'>, index: number): { file: string; title: string } {
373 const label = run.seats[index]?.label ?? ''
374 if (run.kind === 'panel' && run.rounds > 1) {
375 const round = Math.max(run.round, 1)
376
377 return { file: `round-${round}-seat-${index + 1}-${slugify(label)}`, title: `Round ${round}, seat ${index + 1}: ${label}` }
378 }
379 if (run.kind === 'panel') return { file: `seat-${index + 1}-${slugify(label)}`, title: `Seat ${index + 1}: ${label}` }
380 if (run.phase === 'drafting') {
381 const side = index === 0 ? 'a' : 'b'
382
383 return { file: `draft-${side}-${slugify(label)}`, title: `Draft ${side.toUpperCase()}: ${label}` }
384 }
385 if (run.phase === 'challenging') return { file: `round-${run.round}-challenge`, title: `Round ${run.round} challenge (${label})` }
386
387 return { file: `round-${run.round}-plan`, title: `Round ${run.round} revised plan (${label})` }
388}
389
390export function gitHelperOptions(command: string): string[] {
391 if (command === 'blame') return ['--no-textconv']
392
393 return command === 'status' ? [] : ['--no-ext-diff', '--no-textconv']
394}
395
396export function gitRefusal(command: string, args: readonly unknown[]): string | undefined {
397 if (!(GIT_COMMANDS as readonly string[]).includes(command)) return `git ${command} is not available here. Use one of: ${GIT_COMMANDS.join(', ')}.`
398 if (args.some(arg => typeof arg !== 'string')) return 'args must be a list of strings.'
399 if (args.some(arg => /^--output(=|$)/.test(arg as string))) return '--output writes a file; this tool is read-only.'
400 if (args.some(arg => /^--(ext-diff|textconv)$/.test(arg as string))) return '--ext-diff and --textconv run other programs; this tool runs only git.'
401
402 return undefined
403}
404
405const NO_CHANGE_LINE = /^[\s*_`#]*NO CHANGE[\s*_`.!]*$/i
406const NO_CHANGE_MAX = 300
407const PEER_ANSWER_MAX = 30_000
408const PEER_BUDGET = 120_000
409const SYNTHESIS_BUDGET = 150_000
410
411const FIRST_SECTION = /^[\s>*_#-]*1[.)]?[\s*_]*(?:changed|withdrawn)\b/i
412const SECOND_SECTION = /^[\s>*_#-]*2[.)]?[\s*_]*\S/
413
414export function movedIn(reply: string): boolean | undefined {
415 const lines = reply.split('\n')
416 const start = lines.findIndex(line => FIRST_SECTION.test(line))
417 if (start === -1) return undefined
418 const end = lines.findIndex((line, i) => i > start && SECOND_SECTION.test(line))
419 const body = [(lines[start] ?? '').replace(FIRST_SECTION, ''), ...lines.slice(start + 1, end === -1 ? undefined : end)].join(' ').replace(/^[\s\-*_>:.]+/, '')
420 if (body === '') return undefined
421
422 return !/^(?:nothing|none|no change|n\/a)\b/i.test(body)
423}
424
425export type SectionKind = 'stance' | 'change' | 'dispute' | 'missed' | 'support'
426export type Section = { label: string; kind: SectionKind; body: string }
427
428const SECTION_HEAD = /^(\*\*)?\s*(?:(\d+)[.)]\s*)?(\*\*)?\s*([A-Z][^:*\n]{1,60}?):(\*\*)?\s*(.*)$/
429
430export function sectionKind(label: string): SectionKind {
431 if (/^(position now|answer|recommendation|obvious pick)/i.test(label)) return 'stance'
432 if (/^(changed|withdrawn)/i.test(label)) return 'change'
433 if (/(disputed|objection)/i.test(label)) return 'dispute'
434 if (/^missed/i.test(label)) return 'missed'
435
436 return 'support'
437}
438
439function dedent(lines: readonly string[]): string[] {
440 const indents = lines.filter(line => line.trim() !== '').map(line => line.length - line.trimStart().length)
441 const cut = indents.length === 0 ? 0 : Math.min(...indents)
442
443 return lines.map(line => line.slice(cut))
444}
445
446function bareHead(line: string): string | undefined {
447 const inner = /^#{1,6}\s+(.+)$/.exec(line)?.[1] ?? /^\*\*(.+)\*\*:?$/.exec(line.trimEnd())?.[1]
448 const label = inner?.replace(/\*\*/g, '').replace(/^\d+[.)]\s*/, '').replace(/:$/, '').trim() ?? ''
449
450 return /^[A-Z][^:]{1,60}$/.test(label) && !/[.!?]$/.test(label) ? label : undefined
451}
452
453export function splitSections(answer: string): { lead: string; sections: Section[] } {
454 const lead: string[] = []
455 const found: { label: string; first: string; rest: string[] }[] = []
456
457 for (const line of answer.split('\n')) {
458 const match = /^\S/.test(line) ? SECTION_HEAD.exec(line) : null
459 const label = match?.[4]?.trim() ?? ''
460 const isHead = match !== null && (match[2] !== undefined || line.startsWith('**')) && !/^Seats? \d/.test(label)
461 const bare = isHead ? undefined : bareHead(line)
462 if (isHead) {
463 const isLeftOpen = (match[1] !== undefined || match[3] !== undefined) && match[5] === undefined
464 found.push({ label, first: `${isLeftOpen ? '**' : ''}${match[6] ?? ''}`, rest: [] })
465 } else if (bare !== undefined) found.push({ label: bare, first: '', rest: [] })
466 else (found.at(-1)?.rest ?? lead).push(line)
467 }
468
469 return {
470 lead: lead.join('\n').trim(),
471 sections: found.map(({ label, first, rest }) => ({ label, kind: sectionKind(label), body: [first, ...dedent(rest)].join('\n').trim() })),
472 }
473}
474
475export function excerptOf(answer: string, isReply: boolean): { label: string; text: string } | undefined {
476 if (answer.trim() === '' || (isReply && (isNoChange(answer) || isPass(answer)))) return undefined
477 const { sections } = splitSections(answer)
478 const stance = isReply ? sections.findLast(section => section.kind === 'stance') : sections.find(section => section.kind === 'stance')
479 const picked = (isReply && movedIn(answer) === true ? sections.find(section => section.kind === 'change') : undefined) ?? stance ?? sections[0]
480
481 return picked === undefined || picked.body === '' ? { label: '', text: answer.trim() } : { label: picked.label, text: picked.body }
482}
483
484export function isNoChange(answer: string): boolean {
485 const text = answer.trim()
486
487 return text.length <= NO_CHANGE_MAX && text.split('\n').some(line => NO_CHANGE_LINE.test(line))
488}
489
490export function clipAnswer(text: string, max: number): string {
491 if (text.length <= max) return text
492 const head = Math.floor(max * 0.6)
493 const tail = max - head
494
495 return `${text.slice(0, head)}\n\n[... ${text.length - max} of ${text.length} characters cut from the middle ...]\n\n${text.slice(text.length - tail)}`
496}
497
498export function standing(transcript: readonly (readonly RoundEntry[])[], index: number): { round: number; text: string } | undefined {
499 for (let round = transcript.length; round >= 1; round -= 1) {
500 const entry = transcript[round - 1]?.[index]
501 if (entry?.state === 'answered') return { round, text: entry.text }
502 }
503
504 return undefined
505}
506
507export function seatSkills(seat: Pick<Seat, 'skills' | 'folderSkills' | 'unknownSkills'>): string[] {
508 const unknown = new Set(seat.unknownSkills ?? [])
509
510 return [...(seat.folderSkills ?? []).map(skill => skill.name), ...seat.skills.filter(name => !unknown.has(name))]
511}
512
513export function peerName(seat: Pick<Seat, 'role' | 'skills' | 'folderSkills' | 'unknownSkills'>, index: number): string {
514 const notes = [seat.role === 'contrarian' ? 'contrarian: argues against the obvious answer by role' : '', seatSkills(seat).map(name => `+${name}`).join(' ')].filter(Boolean)
515
516 return notes.length === 0 ? `Seat ${index + 1}` : `Seat ${index + 1} (${notes.join(', ')})`
517}
518
519export function collectedNote(runs: readonly Pick<Run, 'id' | 'kind' | 'seats'>[], notification: string): string | undefined {
520 for (const run of runs) {
521 const index = run.seats.findIndex(seat => seat.agentId !== undefined && notification.includes(`<task-id>${seat.agentId}</task-id>`))
522 if (index !== -1) return run.kind === 'pingpong' ? `ping-pong ${run.id} has seat ${index === 0 ? 'A' : 'B'}'s reply.` : `panel ${run.id} has seat ${index + 1}'s reply.`
523 }
524
525 return undefined
526}
527
528type Discussion = Pick<Run,'question' | 'seats' | 'round' | 'rounds' | 'transcript' | 'briefText'>
529
530export function closedEntry(run: Pick<Run, 'kind' | 'round' | 'rounds' | 'transcript'>, seat: Seat, index: number): RoundEntry {
531 const round = Math.max(run.round, 1)
532 const text = seat.answer ?? ''
533 if (round > 1 && standing(run.transcript ?? [], index) === undefined) return { state: 'absent', text: '' }
534 if (seat.isInterrupted === true) return { state: 'interrupted', text }
535 if (seat.status !== 'done') return { state: 'failed', text }
536 if (round > 1 && (isNoChange(text) || isPass(text))) return { state: 'unchanged', text }
537 const isMoved = round > 1 ? movedIn(text) : undefined
538
539 return { state: 'answered', text, ...(isMoved === undefined ? {} : { isMoved }) }
540}
541
542function roundOutcome(run: Pick<Run, 'rounds' | 'isStopping'>, round: number, transcript: readonly RoundEntry[][]): string | undefined {
543 const entries = transcript[round - 1] ?? []
544 const interrupted = entries.findIndex(entry => entry.state === 'interrupted')
545 const answered = entries.filter((_, index) => standing(transcript, index) !== undefined).length
546 if (round < run.rounds && interrupted !== -1) return `stopped in round ${round} of ${run.rounds}: seat ${interrupted + 1} was interrupted`
547 if (round < run.rounds && run.isStopping === true) return `stopped after round ${round} of ${run.rounds} as you asked`
548 if (answered < 2) return 'the discussion did not run: fewer than two seats answered'
549 if (round >= run.rounds) return `completed ${round} of ${run.rounds} rounds`
550 if (round >= 2 && !entries.some(entry => entry.state === 'answered')) {
551 return entries.some(entry => entry.state === 'unchanged') ? `stopped early in round ${round} of ${run.rounds}: no seat changed its answer` : `stopped in round ${round} of ${run.rounds}: no seat replied`
552 }
553
554 return undefined
555}
556
557function seatForNextRound(seat: Seat, entry: RoundEntry | undefined, hasStanding: boolean): Seat {
558 if (!hasStanding) return seat
559 const { answer: _answer, isInterrupted: _isInterrupted, agentId, ...kept } = seat
560 const isResumable = agentId !== undefined && (entry?.state === 'answered' || entry?.state === 'unchanged')
561
562 return { ...kept, status: 'starting', ...(isResumable ? { agentId } : {}) }
563}
564
565export function closeRound(run: Run): Run {
566 const round = Math.max(run.round, 1)
567 const transcript = [...(run.transcript ?? []), run.seats.map((seat, index) => closedEntry(run, seat, index))]
568 if (run.rounds <= 1) return { ...run, round, transcript, phase: 'done' }
569
570 const outcome = roundOutcome(run, round, transcript)
571 if (outcome !== undefined) return { ...run, round, transcript, phase: 'done', outcome }
572
573 return {
574 ...run,
575 transcript,
576 phase: 'discussing',
577 round: round + 1,
578 seats: run.seats.map((seat, index) => seatForNextRound(seat, transcript[round - 1]?.[index], standing(transcript, index) !== undefined)),
579 }
580}
581
582function peerBlocks(run: Discussion, index: number): string {
583 const transcript = run.transcript ?? []
584 const last = transcript.length
585 const peers = run.seats.map((seat, i) => ({ seat, i, held: standing(transcript, i) })).filter(peer => peer.i !== index)
586 const max = Math.min(PEER_ANSWER_MAX, Math.floor(PEER_BUDGET / Math.max(1, peers.filter(peer => peer.held !== undefined).length)))
587
588 return peers
589 .map(({ seat, i, held }) => {
590 if (held === undefined) return `Seat ${i + 1} did not answer and is not in the discussion.`
591 if (seat.role === 'contrarian' && isPass(held.text)) return `### Seat ${i + 1} (contrarian) raised no objection in round ${held.round}`
592 const state = transcript[last - 1]?.[i]?.state
593 const where =
594 held.round === last
595 ? `, round ${held.round}`
596 : state === 'unchanged'
597 ? `, round ${held.round}; unchanged in round ${last}`
598 : `: did not reply in round ${last}; this is its round ${held.round} answer`
599
600 return `### ${peerName(seat, i)}${where}\n\n${clipAnswer(held.text, max)}`
601 })
602 .join('\n\n')
603}
604
605const PANELIST_REPLY = (brief: boolean) => `Hold your position or move it on evidence, never on numbers. That another seat disagrees, or that most seats agree with each other, is not a reason to change. A fact you checked or an argument you cannot answer is. Before you accept or reject a claim about a file, a page or a source, open it. A position kept and defended is worth as much as one changed.
606
607Reply with these four sections and nothing else:
608
6091. Changed: each point of your answer you now change, the seat whose argument moved you, and what you checked that confirms it. "Nothing" if none.
6102. Still disputed: each claim by another seat that you reject. Name the seat, quote the claim, give your counter-evidence, say whether it is a verified fact or a judgement, and say what finding would settle it. Include objections to your answer that you do not accept, and why.
6113. Missed: what no answer covers, yours included, that bears on the question.
6124. Position now: your full answer as it stands after this round, complete enough to read without the earlier rounds${brief ? ', in the format the brief asks for' : ''}.
613
614Do not list agreements, summarise the other answers or thank anyone. Refer to seats by number.
615
616If you change nothing, dispute nothing you have not already disputed, and have nothing to add, answer NO CHANGE alone and write nothing else. Your earlier answer then stands. A round in which no seat changes anything ends the discussion.`
617
618const CONTRARIAN_REPLY = `You hold no position and you join none. Your target this round is what the other seats now agree on, and the reasons seats gave for changing their answers.
619
6201. Withdrawn: each of your objections that another seat answered with evidence you checked, and what answered it. "None" if none. Never withdraw an objection because it was outvoted or ignored.
6212. Standing objections: each objection that survives, restated against the answers as they now stand, with counterevidence, marked verified fact or guess.
6223. New objections: attack the strongest point the other seats share, and any change of position made without evidence. Trace the failure case.
6234. What would settle it: for each standing or new objection, the check that would prove it right or wrong.
624
625PASS is not an answer in this round. If your objections stand exactly as stated and you have none to add or withdraw, answer NO CHANGE alone and write nothing else.`
626
627export function replyBrief(run: Discussion, index: number, isContinued: boolean): string {
628 const seat = run.seats[index]
629 const isContrarian = seat?.role === 'contrarian'
630 const own = standing(run.transcript ?? [], index)
631 const who = `You are seat ${index + 1}${isContrarian ? ', the contrarian' : ''}.`
632 const others = `## The other seats\n\n${peerBlocks(run, index)}`
633 const reply = `## Your reply\n\n${isContrarian ? CONTRARIAN_REPLY : PANELIST_REPLY(run.briefText !== undefined)}`
634
635 if (isContinued) {
636 const mine = isContrarian
637 ? 'Your latest objections, earlier in this conversation, stand until you withdraw them here.'
638 : 'Your latest full answer, earlier in this conversation, is your position until you change it here.'
639
640 return `Panel discussion, round ${run.round} of ${run.rounds}. ${who} Every seat answered the question you were given without seeing the others. In this round each seat reads the others' latest answers and replies. ${mine}\n\n${others}\n\n${reply}`
641 }
642
643 const mine = isContrarian
644 ? 'You have no memory of the earlier rounds: your latest objections are below, and they stand until you withdraw them here.'
645 : 'You have no memory of the earlier rounds: your latest answer is below, and it is your position until you change it here.'
646 const brief = run.briefText === undefined ? '' : `\nThe seats work from this brief:\n${run.briefText}\n`
647
648 return `Panel discussion, round ${run.round} of ${run.rounds}. ${who} In round 1 every seat answered the question below without seeing the others. In this round each seat reads the others' latest answers and replies. ${mine}
649
650Question:
651${run.question}
652${brief}
653## Your answer so far (seat ${index + 1}, round ${own?.round ?? 1})
654
655${own?.text ?? '(none)'}
656
657${others}
658
659${reply}`
660}
661
662export function answers(run: Pick<Run, 'seats'>): string {
663 return run.seats.map((seat, i) => `## Seat ${i + 1}: ${seat.label}${seat.role === 'contrarian' ? '' : ` (${seat.role})`}\n\n${seat.answer ?? '(no answer)'}`).join('\n\n')
664}
665
666const DID: Record<RoundState, string> = { answered: 'replied', unchanged: 'no change', failed: 'failed', interrupted: 'interrupted', absent: 'not asked' }
667
668function roundSection(run: Pick<Run, 'seats'>, entries: readonly RoundEntry[], round: number, max: number): string {
669 const blocks = entries.map((entry, i) => {
670 const label = run.seats[i]?.label ?? ''
671 if (entry.state === 'answered') return `### Seat ${i + 1}: ${label}\n\n${clipAnswer(entry.text, max)}`
672 if (entry.state === 'absent') return ''
673 if (entry.state === 'unchanged') return `Seat ${i + 1}: no change.${entry.text.trim().includes('\n') ? `\n${entry.text.trim()}` : ''}`
674
675 return `Seat ${i + 1}: ${entry.state}. ${entry.text}`.trim()
676 })
677
678 return `## Round ${round}: ${round === 1 ? 'independent answers' : 'replies'}\n\n${blocks.filter(Boolean).join('\n\n')}`
679}
680
681const FIGURES_FIELD = 'figures: optional illustrations, each an inline SVG with a caption, drawn to the rules under Figures below'
682
683const houseRules = (guide: Guide | undefined) => [guide?.writing ?? '', guide?.illustrations ? `Figures:\n${guide.illustrations}` : ''].filter(Boolean).map(rule => `\n\n${rule}`).join('')
684
685function verdictAsk(run: Pick<Run, 'id' | 'rounds'>, guide?: Guide): string {
686 const isDiscussion = run.rounds > 1
687 const fields = [
688 'headline: the bottom line in one or two sentences',
689 `conclusions: the supported conclusions${isDiscussion ? ', each with basis "independent" (held in round 1, before the seats read each other) or "discussion" (reached after)' : ''}`,
690 isDiscussion ? 'changes: which seat moved, from what to what, on whose argument, and whether the evidence holds' : '',
691 'rejected: the rejected claims',
692 `disagreements: real disagreement${isDiscussion ? ' that survived the discussion' : ''}, each side's best evidence, and what would settle it`,
693 'uncertainty: residual uncertainty',
694 `blindSpots: what no seat examined${isDiscussion ? ' in any round' : ''}`,
695 isDiscussion ? 'ending: how the discussion ended, and any seat that failed or dropped out and in which round' : '',
696 'checks: a short note on any single answer whose claim you verified or refuted, by round and seat',
697 guide?.illustrations ? FIGURES_FIELD : '',
698 ]
699
700 return `Then record your synthesis with the mcp__agent-panel__verdict tool, runId "${run.id}". It goes on the discussion page, which is where I will read it, so write every field as plain sentences that make sense without the transcript:
701${fields.filter(Boolean).map(line => `- ${line}`).join('\n')}${houseRules(guide)}
702
703After the tool call, give me only the headline, the supported conclusions in one line each, and the link the tool returns. If the tool is not available, give me the whole synthesis here instead.`
704}
705
706export function planAsk(run: Pick<Run, 'id'>, guide?: Guide): string {
707 const fields = [
708 'headline: what the plan does and whether it is ready to act on, in one or two sentences',
709 'conclusions: the plan in brief, its main steps in one line each',
710 'changes: what each challenge round changed in the plan, with rounds set to e.g. "Round 1"',
711 'rejected: the corrections the integrator rejected, and any you think it should have taken',
712 "disagreements: material disagreements between the agents that remain, each side's best evidence, and what would settle it",
713 'uncertainty: the decisions still open, each as a question I can answer',
714 'blindSpots: what neither agent examined',
715 'ending: how the rounds ended and how many completed',
716 'checks: a short note on any challenge or revision whose claim you verified or refuted, by round, with seat 1 for the challenger and seat 2 for the integrator',
717 'plan: the full corrected plan, only if your checks changed the final plan; leave it out otherwise',
718 guide?.illustrations ? FIGURES_FIELD : '',
719 ]
720
721 return `Check the final plan against the code or files it cites. Then record your read of it with the mcp__agent-panel__verdict tool, runId "${run.id}". It goes on the plan page, which already shows the final plan with every draft, challenge and revision, and which is where I will read it. Do not repeat the plan; write every field as plain sentences that make sense on their own:
722${fields.filter(Boolean).map(line => `- ${line}`).join('\n')}${houseRules(guide)}
723
724After the tool call, give me only the headline, the plan in brief, the decisions still open, and the link the tool returns. If the tool is not available, present the whole plan here instead.`
725}
726
727export function panelSynthesis(run: Run, guide?: Guide): string {
728 if (run.rounds <= 1) {
729 return `Panel ${run.id} is complete. Every seat answered the same question independently.
730
731Question:
732${run.question}
733
734${answers(run)}
735
736Check the claims against the code or files they cite. ${
737 run.page === undefined
738 ? `Then give me a synthesis, not a transcript: supported conclusions, rejected claims, real disagreement, residual uncertainty, blind spots (what no seat examined), and the seats used:
739${seatList(run.seats)}`
740 : verdictAsk(run, guide)
741 }`
742 }
743
744 const transcript = run.transcript ?? []
745 const max = Math.min(PEER_ANSWER_MAX, Math.floor(SYNTHESIS_BUDGET / Math.max(1, run.seats.length * transcript.length)))
746 const isCut = transcript.some(entries => entries.some(entry => entry.state === 'answered' && entry.text.length > max))
747 const whole = run.dir !== undefined ? `in ${run.dir}` : 'not saved'
748 const didIn = (entry: RoundEntry | undefined, round: number) => {
749 if (entry?.state !== 'answered') return DID[entry?.state ?? 'absent']
750 if (round === 0) return 'answered'
751
752 return entry.isMoved === undefined ? 'replied' : entry.isMoved ? 'moved' : 'held'
753 }
754 const did = run.seats.map((seat, i) => `Seat ${i + 1} ${seat.label}: ${transcript.map((entries, r) => didIn(entries[i], r)).join(', ')}`)
755
756 return `Panel ${run.id} is complete: ${transcript.length} round(s) across ${run.seats.length} seats. ${run.outcome ?? ''}
757
758Round 1 answers were written independently. In each later round every seat read the others' latest answers and replied. NO CHANGE means the seat kept its previous answer; moved and held are read from the first section of a reply, so check them.${isCut ? ` Answers cut for size are whole ${whole}.` : ''}
759
760Question:
761${run.question}
762
763## What each seat did
764
765${did.join('\n')}
766
767${transcript.map((entries, r) => roundSection(run, entries, r + 1, max)).join('\n\n')}
768
769Check the claims against the code or files they cite, and check the evidence behind every change of position. A seat that moved without evidence it checked conformed, and its new position adds no support. Agreement the seats reached independently in round 1 counts for more than agreement that appeared after they read each other. The contrarian seat argues against the consensus by role: weigh its objections by their evidence, not as a vote.
770
771${
772 run.page === undefined
773 ? `Then give me a synthesis, not a transcript:
774- supported conclusions, each marked as held independently in round 1 or reached in discussion
775- position changes: which seat moved, from what to what, on whose argument, and whether the evidence holds
776- rejected claims
777- real disagreement that survived the discussion, with each side's best evidence and what would settle it
778- residual uncertainty
779- blind spots (what no seat examined in any round)
780- how the discussion ended, and any seat that failed or dropped out and in which round
781- the seats used:
782${seatList(run.seats)}`
783 : verdictAsk(run, guide)
784 }`
785}
786
787export function costLine(seatCount: number, rounds: number): string {
788 const replies = rounds === 2 ? 'Round 2 is a reply round' : `Rounds 2 to ${rounds} are reply rounds`
789
790 return `${seatCount} seats × ${rounds} rounds = up to ${seatCount * rounds} agent runs. ${replies}: each seat reads the other answers and replies. A round in which no seat changes anything may end the discussion early; /panel-stop ends it after the current round.`
791}
792hooks/pane.ts 271 lines1import type { RoundState, Run, Seat } from '../types'
2import { isPass } from './seats'
3
4export type Tone = 'live' | 'good' | 'bad' | 'warn' | 'quiet' | 'plain'
5export type Chip = { text: string; tone: Tone; isBold?: boolean }
6export type StepState = 'done' | 'now' | 'todo' | 'skipped' | 'failed'
7export type Step = { label: string; cells: StepState[] }
8
9const SPINNER = ['◐', '◓', '◑', '◒']
10
11export function spinner(now: number): string {
12 return SPINNER[Math.floor(now / 1000) % SPINNER.length] ?? '◐'
13}
14
15export function formatDuration(ms: number): string {
16 const seconds = Math.max(0, Math.round(ms / 1000))
17 if (seconds < 60) return `${seconds}s`
18 const minutes = Math.floor(seconds / 60)
19 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
20
21 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
22}
23
24export function formatTokens(count: number): string {
25 if (count < 1000) return String(count)
26 const [value, unit] = count < 999_500 ? [count / 1000, 'k'] : [count / 1_000_000, 'M']
27
28 return `${value < 9.95 ? Math.round(value * 10) / 10 : Math.round(value)}${unit}`
29}
30
31export function meter(value: number, max: number, width: number): { on: string; off: string } {
32 const filled = max <= 0 || value <= 0 ? 0 : Math.max(1, Math.min(width, Math.round((value / max) * width)))
33
34 return { on: '━'.repeat(filled), off: '─'.repeat(width - filled) }
35}
36
37export function isLive(run: Pick<Run, 'phase'>): boolean {
38 return run.phase !== 'done' && run.phase !== 'failed'
39}
40
41export function seatTime(seat: Pick<Seat, 'status' | 'startedAt' | 'durationMs'>, now: number): number | undefined {
42 if (seat.status === 'working') return seat.startedAt === undefined ? seat.durationMs : (seat.durationMs ?? 0) + Math.max(0, now - seat.startedAt)
43
44 return seat.durationMs
45}
46
47export function runTime(run: Pick<Run, 'startedAt' | 'endedAt'>, now: number): number | undefined {
48 return run.startedAt === undefined ? undefined : Math.max(0, (run.endedAt ?? now) - run.startedAt)
49}
50
51export function seatChips(seat: Pick<Seat, 'label' | 'unknownSkills'>, prefix?: string): Chip[] {
52 const [name = '', ...rest] = seat.label.split(' ')
53 const unknown = new Set(seat.unknownSkills ?? [])
54
55 return [
56 ...(prefix === undefined ? [] : [{ text: prefix, tone: 'quiet' as const }]),
57 { text: name, tone: 'plain', isBold: true },
58 ...rest.map((part): Chip => {
59 if (!part.startsWith('+')) return { text: part, tone: 'warn' }
60
61 return unknown.has(part.slice(1)) ? { text: `${part}?`, tone: 'warn' } : { text: part, tone: 'quiet' }
62 }),
63 ]
64}
65
66export function fitChips(chips: readonly Chip[], width: number): Chip[] {
67 const fitted: Chip[] = []
68 let room = width
69 for (const chip of chips) {
70 const space = room - (fitted.length === 0 ? 0 : 1)
71 if (space <= 0) break
72 if (chip.text.length <= space) {
73 fitted.push(chip)
74 room = space - chip.text.length
75 continue
76 }
77 if (space >= 2) fitted.push({ ...chip, text: `${chip.text.slice(0, space - 1)}…` })
78 break
79 }
80
81 return fitted
82}
83
84export function pingpongSteps(run: Pick<Run, 'phase' | 'round' | 'rounds' | 'challenges'>): Step[] {
85 const isOver = run.phase === 'done' || run.phase === 'failed'
86 const hasMerged = run.round > 0
87 const drafts: StepState = run.phase === 'drafting' ? 'now' : run.phase === 'failed' && !hasMerged && run.challenges.length === 0 ? 'failed' : 'done'
88 const merge: StepState = hasMerged ? 'done' : run.phase === 'merging' ? 'now' : isOver ? 'skipped' : 'todo'
89
90 const rounds = Array.from({ length: run.rounds }, (_, i): Step => {
91 const round = i + 1
92 const label = `r${round}`
93 if (round < run.round) return { label, cells: ['done', 'done'] }
94 if (round > run.round || !hasMerged) return { label, cells: isOver ? ['skipped', 'skipped'] : ['todo', 'todo'] }
95 if (run.phase === 'challenging') return { label, cells: ['now', 'todo'] }
96 if (run.phase === 'integrating') return { label, cells: ['done', 'now'] }
97
98 const isChallenged = run.challenges.length >= round
99 if (run.phase === 'failed') return { label, cells: isChallenged ? ['done', 'failed'] : ['failed', 'skipped'] }
100
101 return { label, cells: ['done', isPass(run.challenges[round - 1] ?? '') ? 'skipped' : 'done'] }
102 })
103
104 return [{ label: 'drafts', cells: [drafts] }, { label: 'merge', cells: [merge] }, ...rounds]
105}
106
107export function runBadge(run: Run, now: number): Chip {
108 const time = runTime(run, now)
109 const clock = time === undefined ? '' : formatDuration(time)
110 if (run.phase === 'failed') return { text: ['✗ failed', clock].filter(Boolean).join(' · '), tone: 'bad' }
111 if (run.phase === 'done') return { text: `✓ ${clock || 'done'}`, tone: 'good' }
112
113 const spin = spinner(now)
114 const settled = run.seats.filter(seat => seat.status === 'done' || seat.status === 'failed').length
115 const step =
116 run.kind === 'panel'
117 ? `${spin} ${run.rounds > 1 ? `round ${Math.max(run.round, 1)}/${run.rounds} · ` : ''}${settled}/${run.seats.length} in`
118 : run.phase === 'drafting'
119 ? `${spin} drafting`
120 : run.phase === 'merging'
121 ? '◆ merging'
122 : `${spin} round ${run.round}/${run.rounds}`
123
124 return { text: [step, clock].filter(Boolean).join(' · '), tone: 'live' }
125}
126
127export function clip(text: string, width: number): string {
128 const line = text.replace(/\s+/g, ' ').trim()
129 if (width <= 0) return ''
130
131 return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line
132}
133
134export function usageTokens(usage: unknown): number {
135 if (typeof usage !== 'object' || usage === null) return 0
136
137 return Object.values(usage).reduce<number>((sum, value) => (typeof value === 'number' ? sum + value : sum), 0)
138}
139
140const ROUND_CELL: Record<RoundState, Chip> = {
141 answered: { text: '●', tone: 'good' },
142 unchanged: { text: '=', tone: 'quiet' },
143 failed: { text: '✗', tone: 'bad' },
144 interrupted: { text: '■', tone: 'warn' },
145 absent: { text: '·', tone: 'quiet' },
146}
147
148export function roundCells(run: Pick<Run, 'kind' | 'phase' | 'round' | 'rounds' | 'transcript' | 'seats'>, index: number, now: number): Chip[] {
149 if (run.kind !== 'panel' || run.rounds <= 1) return []
150 const closed = run.transcript ?? []
151 const seat = run.seats[index]
152
153 return Array.from({ length: run.rounds }, (_, i): Chip => {
154 const entry = closed[i]?.[index]
155 if (entry?.state === 'answered' && entry.isMoved !== undefined) return entry.isMoved ? { text: '◆', tone: 'live' } : { text: '○', tone: 'good' }
156 if (entry) return ROUND_CELL[entry.state]
157 if (i + 1 !== run.round || !isLive(run) || !seat) return { text: '·', tone: 'quiet' }
158 if (seat.status === 'working') return { text: spinner(now), tone: 'live' }
159 if (seat.status === 'done') return { text: '●', tone: 'good' }
160
161 return seat.status === 'failed' ? { text: '✗', tone: 'bad' } : { text: '·', tone: 'quiet' }
162 })
163}
164
165const SEAT_COLORS = ['#6aa9ff', '#4fd1a5', '#ff9d5c', '#c39bff', '#ff7eb6', '#c6d655']
166
167export function seatColor(index: number): string {
168 return SEAT_COLORS[index % SEAT_COLORS.length] ?? '#6aa9ff'
169}
170
171export type Span = { text: string; isCode: boolean; isBold: boolean }
172type Word = Span & { isJoined: boolean }
173
174const LIST_ITEM = /^\s*(?:[-*•]|\d+[.)])\s+/
175
176function paragraphs(text: string): string[] {
177 const out: string[] = []
178 let isOpen = false
179 for (const raw of text.split('\n')) {
180 const line = raw.replace(/^\s*#+\s*/, '').trimEnd()
181 if (line.trim() === '') {
182 isOpen = false
183 continue
184 }
185 if (isOpen && !LIST_ITEM.test(line)) out[out.length - 1] += ` ${line.trim()}`
186 else out.push(line.trim().replace(/^[-*]\s+/, '• '))
187 isOpen = true
188 }
189
190 return out
191}
192
193function words(paragraph: string): Word[] {
194 const out: Word[] = []
195 let isCode = false
196 let isBold = false
197 let isSpaced = true
198 let word = ''
199 const push = () => {
200 if (word !== '') out.push({ text: word, isCode, isBold, isJoined: !isSpaced && out.length > 0 })
201 if (word !== '') isSpaced = false
202 word = ''
203 }
204
205 for (let i = 0; i < paragraph.length; i += 1) {
206 const char = paragraph[i] ?? ''
207 if (char === '`') {
208 push()
209 isCode = !isCode
210 } else if (char === '*' && paragraph[i + 1] === '*' && !isCode) {
211 push()
212 isBold = !isBold
213 i += 1
214 } else if (/\s/.test(char)) {
215 push()
216 isSpaced = true
217 } else word += char
218 }
219 push()
220
221 return out
222}
223
224function wrap(list: readonly Word[], width: number): Span[][] {
225 const lines: Span[][] = []
226 let line: Span[] = []
227 let used = 0
228 const end = () => {
229 if (line.length > 0) lines.push(line)
230 line = []
231 used = 0
232 }
233
234 for (const whole of list) {
235 for (let at = 0; at < whole.text.length; at += width) {
236 const text = whole.text.slice(at, at + width)
237 const gap = used === 0 || (whole.isJoined && at === 0) ? '' : ' '
238 if (used + gap.length + text.length > width) end()
239 const lead = used === 0 ? '' : gap
240 const last = line.at(-1)
241 if (last && last.isCode === whole.isCode && last.isBold === whole.isBold) last.text += lead + text
242 else line.push({ text: lead + text, isCode: whole.isCode, isBold: whole.isBold })
243 used += lead.length + text.length
244 }
245 }
246 end()
247
248 return lines
249}
250
251export function flow(text: string, width: number): Span[][] {
252 if (width <= 0) return []
253
254 return paragraphs(text).flatMap(paragraph => wrap(words(paragraph), width))
255}
256
257export const ROUND_LEGEND ='● answered ◆ moved ○ held = no change'
258
259export function activityOf(tool: string, input: Record<string, unknown>): string {
260 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
261 const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
262 if (tool === 'Read') return `read ${base(text('file_path'))}`
263 if (tool === 'Grep') return `grep ${text('pattern')}`
264 if (tool === 'Glob') return `glob ${text('pattern')}`
265 if (tool === 'WebFetch') return `fetch ${text('url').replace(/^https?:\/\//, '').split('/')[0] ?? ''}`
266 if (tool === 'WebSearch') return `search ${text('query')}`
267 if (tool.endsWith('__git')) return `git ${text('command')}`
268
269 return tool.replace(/^mcp__/, '')
270}
271hooks/page.ts 631 lines1import type { RoundEntry, Run, Seat } from '../types'
2import { isLive } from './pane'
3import { closedEntry, isPass, seatSkills, splitSections, standing } from './seats'
4import type { Section } from './seats'
5
6export type PageState = 'answered' | 'moved' | 'held' | 'unchanged' | 'failed' | 'interrupted' | 'absent' | 'working' | 'waiting'
7export type PageMessage = { round: number; seat: number; state: PageState; lead: string; sections: Section[]; replyTo: number[]; keptFrom?: number; note?: string }
8export type PageSeat = { n: number; model: string; role: string; skills: string[] }
9export type Basis = 'independent' | 'discussion'
10
11export type Verdict = {
12 headline: string
13 author?: string
14 conclusions: { text: string; detail?: string; basis?: Basis }[]
15 changes: { seat?: number; rounds?: string; text: string; holds?: string }[]
16 rejected: string[]
17 disagreements: { text: string; sides: { who: string; evidence: string }[]; settle?: string }[]
18 uncertainty: string[]
19 blindSpots: string[]
20 ending?: string
21 checks: { round: number; seat: number; text: string }[]
22 plan?: string
23 figures?: { svg: string; caption: string }[]
24}
25
26type PageBase = {
27 id: string
28 question: string
29 seats: PageSeat[]
30 rounds: number
31 isLive: boolean
32 status: string
33 startedAt?: number
34 rev: number
35 verdict?: Verdict
36}
37
38export type PageData = PageBase & { kind?: 'panel'; messages: PageMessage[] }
39
40export type PlanState = 'drafted' | 'merged' | 'challenged' | 'passed' | 'revised' | 'failed' | 'working' | 'waiting'
41export type PlanTurn = { step: 'draft' | 'merge' | 'challenge' | 'revision'; round: number; by: 0 | 1 | 2; state: PlanState; text: string }
42export type PlanData = PageBase & { kind: 'pingpong'; turns: PlanTurn[]; isFailed: boolean; plan?: { text: string; from: string } }
43export type AnyPage = PageData | PlanData
44
45export type PageAssets = { css: string; script: string; marked: string; fonts: string }
46
47const MARKED_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js'
48const WORD: Record<PageState, string> = { answered: 'answered', moved: 'moved', held: 'held', unchanged: 'no change', failed: 'failed', interrupted: 'interrupted', absent: 'not in this round', working: 'writing', waiting: 'waiting' }
49
50const SEAT_LIST = /\bseats?\s+(\d+(?:(?:\s*[,&]\s*(?:and\s+|or\s+)?|\s+(?:and|or)\s+)\d+)*)/gi
51
52function mentions(sections: readonly Section[], self: number, seatCount: number): number[] {
53 const seen = new Set<number>()
54 for (const section of sections) {
55 if (section.kind === 'stance' || section.kind === 'support') continue
56 for (const hit of section.body.matchAll(SEAT_LIST)) for (const n of (hit[1] ?? '').match(/\d+/g) ?? []) seen.add(Number(n))
57 }
58
59 return [...seen].filter(n => n !== self && n >= 1 && n <= seatCount).sort((a, b) => a - b)
60}
61
62const hue = (seat: number) => ((seat - 1) % 6) + 1
63
64function messageOf(entry: RoundEntry, round: number, seat: number, transcript: readonly RoundEntry[][]): PageMessage {
65 const state: PageState = entry.state !== 'answered' ? entry.state : entry.isMoved === undefined ? 'answered' : entry.isMoved ? 'moved' : 'held'
66 const split = entry.state === 'answered' ? splitSections(entry.text) : { lead: '', sections: [] }
67 const hasStance = round > 1 || split.sections.some(section => section.kind === 'stance')
68 const sections = split.sections.map((section, i): Section => (!hasStance && i === 0 ? { ...section, kind: 'stance' } : section))
69 const keptFrom = standing(transcript.slice(0, round - 1), seat - 1)?.round
70
71 return {
72 round,
73 seat,
74 state,
75 lead: split.lead,
76 sections,
77 replyTo: round > 1 ? mentions(sections, seat, transcript[0]?.length ?? 0) : [],
78 ...(entry.state === 'unchanged' && keptFrom !== undefined ? { keptFrom } : {}),
79 ...(entry.state === 'failed' || entry.state === 'interrupted' ? { note: entry.text } : {}),
80 }
81}
82
83function openEntry(run: Run, seat: Seat, index: number, closed: readonly RoundEntry[][]): PageMessage {
84 const round = Math.max(run.round, 1)
85 const pending = (state: PageState): PageMessage => ({ round, seat: index + 1, state, lead: '', sections: [], replyTo: [] })
86 if (round > 1 && standing(closed, index) === undefined) return pending('absent')
87 if (seat.status === 'working') return pending('working')
88 if (seat.status === 'starting') return pending('waiting')
89
90 return messageOf(closedEntry(run, seat, index), round, index + 1, [...closed])
91}
92
93export function pageData(run: Run, rev: number): PageData {
94 const live = isLive(run)
95 const recorded = run.transcript ?? []
96 const closed = recorded.length === 0 && !live ? [run.seats.map((seat, index) => closedEntry({ ...run, round: 1, transcript: [] }, seat, index))] : recorded
97 const open = Math.max(run.round, 1)
98 const isOpen = live && closed.length < open
99 const settled = run.seats.filter(seat => seat.status === 'done' || seat.status === 'failed').length
100
101 return {
102 id: run.id,
103 question: run.question,
104 seats: run.seats.map((seat, index) => ({ n: index + 1, model: seat.label.split(' ')[0] ?? seat.model, role: seat.role === 'contrarian' ? 'contrarian' : '', skills: seatSkills(seat) })),
105 rounds: Math.max(run.rounds, closed.length),
106 messages: [
107 ...closed.flatMap((entries, r) => entries.map((entry, index) => messageOf(entry, r + 1, index + 1, closed))),
108 ...(isOpen ? run.seats.map((seat, index) => openEntry(run, seat, index, closed)) : []),
109 ],
110 isLive: live,
111 status: live ? `Round ${open} of ${run.rounds} is in progress: ${settled} of ${run.seats.length} seats have replied.` : (run.outcome ?? (run.phase === 'failed' ? 'The run failed.' : 'Complete.')),
112 ...(run.startedAt === undefined ? {} : { startedAt: run.startedAt }),
113 rev,
114 }
115}
116
117const REJECTED_HEAD = /^[ \t]*(?:#{1,6}[ \t]*)?(?:\*\*)?(?:\d+[.)][ \t]*)?Rejected corrections(?:\*\*)?[ \t]*:?[ \t]*(?:\*\*)?[ \t]*(.*)$/gim
118
119export function splitPlan(revision: string): { plan: string; rejected: string } {
120 const head = [...revision.matchAll(REJECTED_HEAD)].pop()
121 if (head?.index === undefined) return { plan: revision.trim(), rejected: '' }
122
123 return { plan: revision.slice(0, head.index).trim(), rejected: `${head[1] ?? ''}${revision.slice(head.index + head[0].length)}`.trim() }
124}
125
126export const turnId = (turn: Pick<PlanTurn, 'step' | 'round' | 'by'>) => (turn.step === 'draft' ? `draft-${turn.by === 1 ? 'a' : 'b'}` : turn.step === 'merge' ? 'merge' : `r${turn.round}-s${turn.by}`)
127
128function planStatus(run: Run): string {
129 if (run.phase === 'drafting') return 'A and B are each drafting a plan.'
130 if (run.phase === 'merging') return 'Both drafts are in. The session is merging them into one plan.'
131 if (run.phase === 'challenging') return `Round ${run.round} of ${run.rounds}: A is looking for problems in the plan.`
132 if (run.phase === 'integrating') return `Round ${run.round} of ${run.rounds}: B is revising the plan.`
133
134 return run.outcome ?? (run.phase === 'failed' ? 'The run failed.' : 'Complete.')
135}
136
137export function planData(run: Run, rev: number): PlanData {
138 const [a, b] = run.seats
139 const plans = run.plans ?? []
140 const pending = (seat: Seat | undefined): PlanState => (seat?.status === 'working' ? 'working' : seat?.status === 'failed' ? 'failed' : 'waiting')
141 const failure = (seat: Seat | undefined) => (seat?.status === 'failed' ? (seat.answer ?? '') : '')
142 const draft = (seat: Seat | undefined, index: 0 | 1): PlanTurn => {
143 const by = index === 0 ? 1 : 2
144 const kept = run.drafts?.[index]
145 if (kept !== undefined || run.round > 0) return { step: 'draft', round: 0, by, state: 'drafted', text: kept ?? '' }
146 if (seat?.status === 'done') return { step: 'draft', round: 0, by, state: 'drafted', text: seat.answer ?? '' }
147
148 return { step: 'draft', round: 0, by, state: pending(seat), text: failure(seat) }
149 }
150 const turns: PlanTurn[] = [draft(a, 0), draft(b, 1)]
151 if (plans[0] !== undefined) turns.push({ step: 'merge', round: 0, by: 0, state: 'merged', text: plans[0] })
152 else if (run.phase === 'merging') turns.push({ step: 'merge', round: 0, by: 0, state: 'working', text: '' })
153
154 for (let round = 1; round <= run.round; round += 1) {
155 const isOpen = round === run.round && (isLive(run) || run.phase === 'failed')
156 const challenge = run.challenges[round - 1]
157 if (challenge === undefined) {
158 if (isOpen) turns.push({ step: 'challenge', round, by: 1, state: pending(a), text: failure(a) })
159 break
160 }
161 turns.push({ step: 'challenge', round, by: 1, state: isPass(challenge) ? 'passed' : 'challenged', text: challenge })
162 const revision = plans[round]
163 if (revision !== undefined) turns.push({ step: 'revision', round, by: 2, state: 'revised', text: revision })
164 else if (isOpen && !isPass(challenge)) turns.push({ step: 'revision', round, by: 2, state: pending(b), text: failure(b) })
165 }
166 const latest = [...turns].reverse().find(turn => turn.state === 'merged' || turn.state === 'revised')
167 const current = latest === undefined ? '' : splitPlan(latest.text).plan
168
169 return {
170 kind: 'pingpong',
171 id: run.id,
172 question: run.question,
173 seats: run.seats.map((seat, index) => ({ n: index + 1, model: seat.label.split(' ')[0] ?? seat.model, role: index === 0 ? 'challenges' : 'revises', skills: seatSkills(seat) })),
174 rounds: run.rounds,
175 turns,
176 isLive: isLive(run),
177 isFailed: run.phase === 'failed',
178 status: planStatus(run),
179 ...(latest === undefined || current === '' ? {} : { plan: { text: current, from: turnId(latest) } }),
180 ...(run.startedAt === undefined ? {} : { startedAt: run.startedAt }),
181 rev,
182 }
183}
184
185export function pageFor(run: Run, rev: number): AnyPage {
186 return run.kind === 'pingpong' ? planData(run, rev) : pageData(run, rev)
187}
188
189const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
190const items = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
191const field = (value: unknown, key: string): unknown => (typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined)
192const texts = (value: unknown): string[] => items(value).map(item => text(item) || text(field(item, 'text'))).filter(Boolean)
193const whole = (value: unknown): number | undefined => (Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : undefined)
194const optional = <K extends string>(key: K, value: string): { [P in K]?: string } => (value === '' ? {} : ({ [key]: value } as { [P in K]?: string }))
195
196export const MAX_FIGURES = 2
197const SVG_MAX = 30_000
198const SVG_ELEMENTS = new Set(['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'defs', 'marker'])
199const SVG_DROPPED_WHOLE = new Set(['svg', 'script', 'style', 'foreignobject', 'title', 'desc'])
200const SVG_ATTRIBUTES = new Map(
201 [
202 ...['viewBox', 'preserveAspectRatio', 'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'd', 'points', 'dx', 'dy', 'transform', 'id'],
203 ...['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'opacity'],
204 ...['font-size', 'font-weight', 'font-style', 'text-anchor', 'dominant-baseline', 'letter-spacing'],
205 ...['marker-start', 'marker-mid', 'marker-end', 'markerWidth', 'markerHeight', 'markerUnits', 'refX', 'refY', 'orient'],
206 ].map(name => [name.toLowerCase(), name]),
207)
208const SVG_TOKEN = /<!--[\s\S]*?-->|<(\/?)([A-Za-z][\w:-]*)((?:\s+[\w:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>]+))?)*)\s*(\/?)>|[^<]+|</g
209const SVG_ATTRIBUTE = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)))?/g
210const SVG_REACHES_OUT = /url\(\s*(?!['"]?#)|javascript:|expression\(|@import/i
211const SVG_ID = /^[\w-]+$/
212const SVG_LENGTH = /^\d+(?:\.\d+)?(?:px)?$/
213const entity = (value: string) => value.replace(/&(?![a-z]+;|#\d+;|#x[\da-f]+;)/gi, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
214
215export function cleanSvg(source: string, prefix: string): string | undefined {
216 const open: string[] = []
217 let out = ''
218 let skipped: { name: string; depth: number } | undefined
219
220 for (const [raw, slash, tag, attributes = '', selfClosed] of source.trim().matchAll(SVG_TOKEN)) {
221 if (tag === undefined) {
222 if (skipped === undefined && open.length > 0 && !raw.startsWith('<!--')) out += entity(raw)
223 continue
224 }
225 const name = tag.toLowerCase()
226 if (skipped !== undefined) {
227 if (name === skipped.name && selfClosed !== '/') skipped.depth += slash === '/' ? -1 : 1
228 if (skipped.depth === 0) skipped = undefined
229 continue
230 }
231 if (!SVG_ELEMENTS.has(name) || (slash !== '/' && (name === 'svg') !== (open.length === 0))) {
232 if (SVG_DROPPED_WHOLE.has(name) && slash !== '/' && selfClosed !== '/') skipped = { name, depth: 1 }
233 continue
234 }
235 if (slash === '/') {
236 if (open[open.length - 1] !== name) continue
237 open.pop()
238 out += `</${name}>`
239 if (open.length === 0) return out
240 continue
241 }
242
243 const kept = new Map<string, string>()
244 for (const [, key = '', double, single, bare] of attributes.matchAll(SVG_ATTRIBUTE)) {
245 const known = SVG_ATTRIBUTES.get(key.toLowerCase())
246 const value = (double ?? single ?? bare ?? '').trim()
247 if (known === undefined || SVG_REACHES_OUT.test(value) || (known === 'id' && !SVG_ID.test(value))) continue
248 kept.set(known, known === 'id' ? `${prefix}${value}` : value.replace(/url\(\s*(['"]?)#/g, `url($1#${prefix}`))
249 }
250 if (name === 'svg') {
251 const [width = '', height = ''] = [kept.get('width'), kept.get('height')]
252 if (!kept.has('viewBox') && SVG_LENGTH.test(width) && SVG_LENGTH.test(height)) kept.set('viewBox', `0 0 ${parseFloat(width)} ${parseFloat(height)}`)
253 if (!kept.has('viewBox')) return undefined
254 kept.delete('width')
255 kept.delete('height')
256 }
257 out += `<${name}${[...kept].map(([key, value]) => ` ${key}="${entity(value)}"`).join('')}${selfClosed === '/' ? '/' : ''}>`
258 if (selfClosed !== '/') open.push(name)
259 }
260
261 return out === '' ? undefined : out + open.reverse().map(name => `</${name}>`).join('')
262}
263
264function readFigures(input: unknown): NonNullable<Verdict['figures']> | { error: string } {
265 const drawn = items(input)
266 if (drawn.length > MAX_FIGURES) return { error: `A page takes at most ${MAX_FIGURES} figures. Keep the ones that carry the most and send the verdict again.` }
267 const figures: NonNullable<Verdict['figures']> = []
268 for (const [index, item] of drawn.entries()) {
269 const source = text(field(item, 'svg'))
270 const caption = text(field(item, 'caption'))
271 if (caption === '') return { error: `Figure ${index + 1} needs a caption: one sentence that states what it shows.` }
272 if (source.length > SVG_MAX) return { error: `Figure ${index + 1} is ${source.length} characters of SVG; the limit is ${SVG_MAX}. Simplify it or leave it out.` }
273 const svg = cleanSvg(source, `figure-${index + 1}-`)
274 if (svg === undefined) return { error: `Figure ${index + 1} is not usable: it must be one <svg> element with a viewBox. Fix it or leave it out.` }
275 figures.push({ svg, caption })
276 }
277
278 return figures
279}
280
281export function readVerdict(input: unknown): Verdict | { error: string } {
282 const headline = text(field(input, 'headline'))
283 if (headline === '') return { error: 'The verdict needs a headline: the bottom line in one or two sentences.' }
284 const figures = readFigures(field(input, 'figures'))
285 if ('error' in figures) return figures
286
287 return {
288 headline,
289 ...optional('author', text(field(input, 'author'))),
290 conclusions: items(field(input, 'conclusions')).flatMap(item => {
291 const basis = text(field(item, 'basis'))
292 const body = text(item) || text(field(item, 'text'))
293
294 return body === '' ? [] : [{ text: body, ...optional('detail', text(field(item, 'detail'))), ...(basis === 'independent' || basis === 'discussion' ? { basis: basis as Basis } : {}) }]
295 }),
296 changes: items(field(input, 'changes')).flatMap(item => {
297 const body = text(item) || text(field(item, 'text'))
298 const seat = whole(field(item, 'seat'))
299
300 return body === '' ? [] : [{ text: body, ...(seat === undefined ? {} : { seat }), ...optional('rounds', text(field(item, 'rounds'))), ...optional('holds', text(field(item, 'holds'))) }]
301 }),
302 rejected: texts(field(input, 'rejected')),
303 disagreements: items(field(input, 'disagreements')).flatMap(item => {
304 const body = text(item) || text(field(item, 'text'))
305 const sides = items(field(item, 'sides')).flatMap(side => (text(field(side, 'evidence')) === '' ? [] : [{ who: text(field(side, 'who')), evidence: text(field(side, 'evidence')) }]))
306
307 return body === '' ? [] : [{ text: body, sides, ...optional('settle', text(field(item, 'settle'))) }]
308 }),
309 uncertainty: texts(field(input, 'uncertainty')),
310 blindSpots: texts(field(input, 'blindSpots')),
311 ...optional('ending', text(field(input, 'ending'))),
312 checks: items(field(input, 'checks')).flatMap(item => {
313 const round = whole(field(item, 'round'))
314 const seat = whole(field(item, 'seat'))
315 const body = text(field(item, 'text'))
316
317 return round === undefined || seat === undefined || body === '' ? [] : [{ round, seat, text: body }]
318 }),
319 ...optional('plan', text(field(input, 'plan'))),
320 ...(figures.length === 0 ? {} : { figures }),
321 }
322}
323
324const esc = (value: string) => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
325const md = (source: string) => `<div class="md raw" data-md>${esc(source)}</div>`
326const inline = (source: string) => `<span class="raw" data-md="inline">${esc(source)}</span>`
327const chips = (seat: PageSeat, hasRole = true) => [hasRole && seat.role !== '' ? `<span class="role">${esc(seat.role)}</span>` : '', ...seat.skills.map(skill => `<span class="skill">+${esc(skill)}</span>`)].join('')
328const none = '<span class="none" title="Took no part">–</span>'
329
330type Stop = { href: string; text: string; note?: string; hue?: number; isSub?: boolean }
331
332type Words = {
333 kind: string
334 answer: string
335 pending: string
336 byline: string
337 conclusions: string
338 changes: string
339 rejected: string
340 disagreements: string
341 uncertainty: string
342 blindNote: string
343 check: string
344 seat: (n: number) => string
345}
346
347const PANEL_WORDS: Words = {
348 kind: 'Panel',
349 answer: 'Verdict',
350 pending: 'No verdict has been added to this page yet. It appears here when the session that ran the panel records one.',
351 byline: 'after checking the seats’ claims',
352 conclusions: 'Supported conclusions',
353 changes: 'Position changes',
354 rejected: 'Rejected claims',
355 disagreements: 'Disagreement that survived',
356 uncertainty: 'Residual uncertainty',
357 blindNote: 'What no seat examined in any round.',
358 check: 'Synthesiser’s check',
359 seat: n => `Seat ${n}`,
360}
361
362const PLAN_WORDS: Words = {
363 kind: 'Ping-pong',
364 answer: 'Summary',
365 pending: 'No summary has been added to this page yet. It appears here when the session that ran the ping-pong records one.',
366 byline: 'after checking the plan against the files it cites',
367 conclusions: 'The plan in brief',
368 changes: 'What the rounds changed',
369 rejected: 'Corrections not taken up',
370 disagreements: 'Material disagreements',
371 uncertainty: 'Decisions still open',
372 blindNote: 'What neither agent examined.',
373 check: 'Session’s check',
374 seat: n => (n === 1 ? 'A' : n === 2 ? 'B' : `Seat ${n}`),
375}
376
377const stamp = (data: AnyPage) => (data.startedAt === undefined ? '' : `<span data-ts="${data.startedAt}">${new Date(data.startedAt).toISOString().slice(0, 10)}</span>`)
378const checkNote = (words: Words, note: string) => `<aside class="check"><strong>${words.check}.</strong> ${inline(note)}</aside>`
379
380function hero(data: AnyPage, words: Words, facts: readonly string[], hasPending: boolean, flag = ''): string {
381 const verdict = data.verdict
382 const answer = verdict
383 ? `<div class="answer" id="verdict"><p class="headline">${inline(verdict.headline)}</p><p class="byline">${words.answer} by ${esc(verdict.author ?? 'the session that ran it')}, ${words.byline}.${flag}</p></div>`
384 : data.isLive
385 ? `<p class="live-note">${esc(data.status)} This page updates as replies arrive.</p>`
386 : hasPending
387 ? `<div class="answer pending" id="verdict"><p class="quiet">${words.pending}</p></div>`
388 : ''
389
390 return `<header class="masthead">
391<p class="meta">${words.kind} ${esc(data.id)}${stamp(data)}${facts.map(fact => `<span>${fact}</span>`).join('')}</p>
392<h1 class="question clamped" id="question">${inline(data.question)}</h1>
393${answer}
394</header>`
395}
396
397const glance = (data: AnyPage, tracker: string, stops: readonly Stop[]) => `<section class="at-a-glance" aria-label="Who did what">
398${data.isLive ? '' : `<p class="legend">${esc(data.status)}</p>`}
399${tracker}
400<nav class="jump" aria-label="Sections">${stops.filter(stop => stop.isSub !== true).map(stop => `<a href="${stop.href}">${stop.text}</a>`).join('')}</nav>
401</section>`
402
403const outline = (stops: readonly Stop[]) => `<nav class="outline" id="outline" aria-label="On this page"><p class="rail-title">On this page</p><ol>
404${stops.map(stop => `<li class="${stop.isSub === true ? 'sub' : 'top'}${stop.hue === undefined ? '' : ` s${stop.hue}`}"><a href="${stop.href}"><span>${stop.text}</span>${stop.note === undefined ? '' : `<span class="note">${stop.note}</span>`}</a></li>`).join('\n')}
405</ol></nav>`
406
407const plain = (list: readonly string[]) => `<ul class="plain">${list.map(item => `<li>${inline(item)}</li>`).join('')}</ul>`
408const part = (title: string, body: string, note = '', isAsk = false) => (body === '' ? '' : `<div class="part${isAsk ? ' ask" id="open' : ''}"><h2>${title}</h2>\n${note === '' ? '' : `<p class="detail">${note}</p>\n`}${body}</div>`)
409
410function drawVerdict(data: AnyPage, words: Words): string {
411 const verdict = data.verdict
412 if (!verdict) return ''
413 const basis = (value: Basis | undefined) => (value === undefined ? '' : `<p class="basis ${value}">${value === 'independent' ? 'Held independently in round 1' : 'Reached in discussion'}</p>`)
414 const conclusions = verdict.conclusions.map(item => `<li><p class="claim">${inline(item.text)}</p>${item.detail === undefined ? '' : `<p class="detail">${inline(item.detail)}</p>`}${basis(item.basis)}</li>`)
415 const changes = verdict.changes.map(
416 item =>
417 `<li class="${item.seat === undefined ? '' : `s${hue(item.seat)}`}"><p class="change-who">${item.seat === undefined ? '' : `<span class="who">${words.seat(item.seat)}</span>`}${item.rounds === undefined ? '' : `<span class="model">${esc(item.rounds)}</span>`}</p><p>${inline(item.text)}</p>${item.holds === undefined ? '' : `<p class="detail">${inline(item.holds)}</p>`}</li>`,
418 )
419 const disputes = verdict.disagreements.map(
420 item =>
421 `<div class="dispute-box"><p class="claim">${inline(item.text)}</p>${item.sides.length === 0 ? '' : `<div class="sides">${item.sides.map(side => `<div class="side"><p class="side-who">${inline(side.who)}</p><p>${inline(side.evidence)}</p></div>`).join('')}</div>`}${item.settle === undefined ? '' : `<p class="detail"><strong>What would settle it.</strong> ${inline(item.settle)}</p>`}</div>`,
422 )
423 const figures = (verdict.figures ?? []).map((figure, i) => `<figure class="figure" id="figure-${i + 1}"><div class="figure-art">${figure.svg}</div><figcaption>${inline(figure.caption)}</figcaption></figure>`)
424 const isPlan = data.kind === 'pingpong'
425 const parts = [
426 isPlan ? part(words.uncertainty, verdict.uncertainty.length === 0 ? '' : plain(verdict.uncertainty), '', true) : '',
427 figures.length === 0 ? '' : `<div class="figures">\n${figures.join('\n')}\n</div>`,
428 part(words.conclusions, conclusions.length === 0 ? '' : isPlan ? `<ol class="conclusions steps">${conclusions.join('\n')}</ol>` : `<ul class="conclusions">${conclusions.join('\n')}</ul>`),
429 part(words.changes, changes.length === 0 ? '' : `<ul class="changes">${changes.join('\n')}</ul>`),
430 part(words.rejected, verdict.rejected.length === 0 ? '' : plain(verdict.rejected)),
431 part(words.disagreements, disputes.join('\n')),
432 isPlan ? '' : part(words.uncertainty, verdict.uncertainty.length === 0 ? '' : plain(verdict.uncertainty)),
433 part('Blind spots', verdict.blindSpots.length === 0 ? '' : plain(verdict.blindSpots), words.blindNote),
434 part('How it ended', verdict.ending === undefined ? '' : `<p>${inline(verdict.ending)}</p>`),
435 ].filter(Boolean)
436
437 return parts.length === 0 ? '' : `<section class="verdict" id="verdict-detail" aria-label="${words.answer} in detail">\n${parts.join('\n')}\n</section>`
438}
439
440function drawMessage(data: PageData, round: number, seat: PageSeat): string {
441 const message = data.messages.find(one => one.round === round && one.seat === seat.n)
442 if (!message || message.state === 'absent') return `<article class="msg empty s${hue(seat.n)}"><p class="quiet">Seat ${seat.n} did not take part in this round.</p></article>`
443 const checks = (data.verdict?.checks ?? []).filter(check => check.round === round && check.seat === seat.n)
444 const replyTo = message.replyTo.map(n => `<a class="ref s${hue(n)}" href="#r${round - 1}-s${n}">Seat ${n}</a>`).join(' ')
445 const quiet: Partial<Record<PageState, string>> = {
446 unchanged: message.keptFrom === undefined ? 'Kept its earlier answer without changes.' : `Kept its <a href="#r${message.keptFrom}-s${seat.n}">round ${message.keptFrom} answer</a> without changes.`,
447 working: 'Writing its reply.',
448 waiting: 'Not started yet.',
449 failed: `This seat failed in this round. ${esc(message.note ?? '')}`,
450 interrupted: `This seat was interrupted. ${esc(message.note ?? '')}`,
451 }
452 const isCore = (section: Section) => section.kind === 'stance' || section.kind === 'change'
453 const folded = message.sections.some(isCore) ? message.sections.filter(section => !isCore(section)) : []
454 const said = [
455 message.lead === '' ? '' : `<div class="sec-body">${md(message.lead)}</div>`,
456 ...message.sections.map(section => `<section class="sec ${section.kind}${folded.includes(section) ? ' fold-away' : ''}"><h4 class="sec-label">${esc(section.label)}</h4><div class="sec-body">${md(section.body)}</div></section>`),
457 folded.length === 0 ? '' : `<button type="button" class="more" data-more>Show ${esc(folded.map(section => section.label.toLowerCase()).join(', '))}</button>`,
458 ]
459
460 return `<article class="msg s${hue(seat.n)}" id="r${round}-s${seat.n}">
461<header class="msg-head"><span class="who">Seat ${seat.n}</span><span class="model">${esc(seat.model)}</span>${chips(seat)}${message.state === 'answered' ? '' : `<span class="state ${message.state}">${WORD[message.state]}</span>`}</header>
462${replyTo === '' ? '' : `<p class="reply-to">Replying to ${replyTo}</p>`}
463${quiet[message.state] === undefined ? said.filter(Boolean).join('\n') : `<p class="quiet">${quiet[message.state]}</p>`}
464${checks.map(check => checkNote(PANEL_WORDS, check.text)).join('\n')}
465</article>`
466}
467
468function drawTracker(data: PageData, shown: number): string {
469 const cell = (seat: PageSeat, round: number) => {
470 const state = data.messages.find(one => one.round === round && one.seat === seat.n)?.state
471 if (state === undefined || state === 'absent') return none
472
473 return `<a class="state ${state}" href="#r${round}-s${seat.n}">${WORD[state]}</a>`
474 }
475 const row = (seat: PageSeat) =>
476 `<tr class="s${hue(seat.n)}"><th scope="row"><span class="seat-name"><span class="who">Seat ${seat.n}</span><span class="model">${esc(seat.model)}</span>${chips(seat)}</span></th>${Array.from({ length: shown }, (_, i) => `<td>${cell(seat, i + 1)}</td>`).join('')}</tr>`
477
478 return `<div class="scroll"><table class="tracker">
479<thead><tr><th scope="col">Seat</th>${Array.from({ length: shown }, (_, i) => `<th scope="col">Round ${i + 1}</th>`).join('')}</tr></thead>
480<tbody>
481${data.seats.map(row).join('\n')}
482</tbody></table></div>`
483}
484
485const toggle = (name: string, attribute: string, options: readonly [string, string][]) =>
486 `<div class="toggle" role="group" aria-label="${name}">${options.map(([value, text], i) => `<button type="button" ${attribute}="${value}" aria-pressed="${i === 0}">${text}</button>`).join('')}</div>`
487
488function panelBody(data: PageData): { body: string; stops: Stop[] } {
489 const shown = Math.max(1, ...data.messages.map(message => message.round))
490 const roundNote = (round: number) => (round === 1 ? 'Independent answers. No seat saw another.' : 'Replies. Each seat read the others’ latest answers.')
491 const rounds = Array.from({ length: shown }, (_, i) => `<h3 class="round" id="r${i + 1}"><span class="round-n">Round ${i + 1}</span><span class="round-what">${roundNote(i + 1)}</span></h3>\n${data.seats.map(seat => drawMessage(data, i + 1, seat)).join('\n')}`)
492 const stops: Stop[] = [
493 ...(data.verdict === undefined ? [] : [{ href: '#verdict', text: 'Verdict' }]),
494 ...Array.from({ length: shown }, (_, i) => [
495 { href: `#r${i + 1}`, text: `Round ${i + 1}` },
496 ...data.messages.filter(message => message.round === i + 1 && message.state !== 'absent').map(message => ({ href: `#r${i + 1}-s${message.seat}`, text: `Seat ${message.seat}`, isSub: true, hue: hue(message.seat), ...(message.state === 'answered' ? {} : { note: WORD[message.state] }) })),
497 ]).flat(),
498 ]
499 const body = `${hero(data, PANEL_WORDS, [`${data.seats.length} seats`, data.rounds === 1 ? '1 round' : `up to ${data.rounds} rounds`], !data.isLive)}
500${glance(data, drawTracker(data, shown), stops)}
501${drawVerdict(data, PANEL_WORDS)}
502<section class="discussion" id="discussion">
503<div class="bar"><h2>Discussion</h2><div class="tools">${toggle('Detail', 'data-set-density', [['full', 'Full answers'], ['brief', 'Positions only']])}${toggle('Layout', 'data-set-view', [['thread', 'Thread'], ['columns', 'Side by side']])}</div></div>
504<div class="rounds-scroll"><div class="rounds" style="--seats:${data.seats.length}">
505${rounds.join('\n')}
506</div></div>
507</section>`
508
509 return { body, stops }
510}
511
512const PLAN_WORD: Record<PlanState, string> = { drafted: 'drafted', merged: 'merged', challenged: 'challenged', passed: 'passed', revised: 'revised', failed: 'failed', working: 'writing', waiting: 'waiting' }
513const PLAN_TONE: Record<PlanState, PageState> = { drafted: 'answered', merged: 'answered', challenged: 'moved', passed: 'unchanged', revised: 'answered', failed: 'failed', working: 'working', waiting: 'waiting' }
514const STEP_NAME: Record<PlanTurn['step'], string> = { draft: 'Draft', merge: 'Merge', challenge: 'Challenge', revision: 'Revision' }
515const WORKING: Record<PlanTurn['step'], string> = { draft: 'Writing its draft.', merge: 'Comparing the two drafts and merging them into one plan.', challenge: 'Looking for problems in the plan.', revision: 'Checking the challenge and revising the plan.' }
516const wordCount = (source: string) => `${source.split(/\s+/).filter(Boolean).length.toLocaleString('en')} words`
517const fold = (id: string, title: string, source: string) => `<details class="fold" id="${id}-text"><summary><span>${title}</span><span class="count">${wordCount(source)}</span></summary><div class="fold-body">${md(source)}</div></details>`
518const actor = (by: PlanTurn['by']) => (by === 0 ? 'Session' : by === 1 ? 'A' : 'B')
519const isRoutine = (state: PlanState) => state === 'drafted' || state === 'merged' || state === 'challenged' || state === 'revised'
520
521function drawTurn(data: PlanData, turn: PlanTurn): string {
522 const id = turnId(turn)
523 const seat = data.seats[turn.by - 1]
524 const name = actor(turn.by)
525 const checks = turn.step === 'challenge' || turn.step === 'revision' ? (data.verdict?.checks ?? []).filter(check => check.round === turn.round && check.seat === turn.by) : []
526 const isCurrent = data.plan?.from === id
527 const shownAbove = `<p class="quiet">This is the <a href="#plan">${data.isLive ? 'current' : data.isFailed ? 'last' : 'final'} plan</a>, shown in full above.</p>`
528 const said = (): string => {
529 if (turn.state === 'working') return `<p class="quiet">${WORKING[turn.step]}</p>`
530 if (turn.state === 'waiting') return '<p class="quiet">Not started yet.</p>'
531 if (turn.state === 'failed') return `<p class="quiet">This agent failed here. ${esc(turn.text)}</p>`
532 if (turn.step === 'challenge') {
533 const rest = turn.state === 'passed' ? turn.text.trim().split('\n').slice(1).join('\n').trim() : turn.text
534
535 return `${turn.state === 'passed' ? '<p class="quiet">PASS: found nothing material to change. That ended the rounds.</p>' : ''}${rest === '' ? '' : `<div class="sec-body">${md(rest)}</div>`}`
536 }
537 if (turn.text === '') return '<p class="quiet">This text was not kept.</p>'
538 if (turn.step === 'draft') return fold(id, `Draft ${name}`, turn.text)
539 const { plan, rejected } = splitPlan(turn.text)
540 const reply = rejected === '' ? '' : `<section class="sec dispute"><h4 class="sec-label">Rejected corrections</h4><div class="sec-body">${md(rejected)}</div></section>\n`
541
542 return `${reply}${isCurrent ? shownAbove : fold(id, turn.step === 'merge' ? 'Merged candidate plan' : `Plan as revised in round ${turn.round}`, plan)}`
543 }
544
545 return `<article class="msg${turn.by === 0 ? '' : ` s${turn.by}`}" id="${id}">
546<header class="msg-head"><span class="who">${name}</span>${seat === undefined ? '' : `<span class="model">${esc(seat.model)}</span>${chips(seat, false)}`}<span class="state ${PLAN_TONE[turn.state]}">${isRoutine(turn.state) ? STEP_NAME[turn.step] : `${STEP_NAME[turn.step]}: ${PLAN_WORD[turn.state]}`}</span></header>
547${said()}
548${checks.map(check => checkNote(PLAN_WORDS, check.text)).join('\n')}
549</article>`
550}
551
552function planBody(data: PlanData): { body: string; stops: Stop[] } {
553 const shown = Math.max(0, ...data.turns.map(turn => turn.round))
554 const hasMerge = data.turns.some(turn => turn.step === 'merge')
555 const columns = ['Drafts', ...(hasMerge ? ['Merge'] : []), ...Array.from({ length: shown }, (_, i) => `Round ${i + 1}`)]
556 const columnOf = (turn: PlanTurn) => (turn.step === 'draft' ? 'Drafts' : turn.step === 'merge' ? 'Merge' : `Round ${turn.round}`)
557 const cell = (by: number, column: string) => {
558 const turn = data.turns.find(one => one.by === by && columnOf(one) === column)
559
560 return turn ? `<a class="state ${PLAN_TONE[turn.state]}" href="#${turnId(turn)}">${PLAN_WORD[turn.state]}</a>` : none
561 }
562 const row = (by: PlanTurn['by'], note: string) => `<tr${by === 0 ? '' : ` class="s${by}"`}><th scope="row"><span class="seat-name"><span class="who">${actor(by)}</span>${note}</span></th>${columns.map(column => `<td>${cell(by, column)}</td>`).join('')}</tr>`
563 const seatNote = (seat: PageSeat | undefined) => (seat === undefined ? '' : `<span class="model">${esc(seat.model)}</span>${chips(seat)}`)
564 const tracker = `<div class="scroll"><table class="tracker">
565<thead><tr><th scope="col">Who</th>${columns.map(column => `<th scope="col">${column}</th>`).join('')}</tr></thead>
566<tbody>
567${row(1, seatNote(data.seats[0]))}
568${row(2, seatNote(data.seats[1]))}
569${hasMerge ? row(0, '<span class="model">merges the drafts</span>') : ''}
570</tbody></table></div>`
571 const head = (id: string, title: string, note: string) => `<h3 class="round" id="${id}"><span class="round-n">${title}</span><span class="round-what">${note}</span></h3>`
572 const of = (pick: (turn: PlanTurn) => boolean) => data.turns.filter(pick).map(turn => drawTurn(data, turn)).join('\n')
573 const steps = [
574 `${head('drafts', 'Drafts', 'Independent. Neither agent saw the other’s draft.')}\n${of(turn => turn.step === 'draft')}`,
575 hasMerge ? `${head('merging', 'Merge', 'The session compared the drafts against the files and merged them into one candidate plan.')}\n${of(turn => turn.step === 'merge')}` : '',
576 ...Array.from({ length: shown }, (_, i) => `${head(`r${i + 1}`, `Round ${i + 1}`, 'A challenges the plan. B checks each correction and revises.')}\n${of(turn => turn.round === i + 1)}`),
577 ]
578 const planTitle = data.isLive ? 'Current plan' : data.isFailed ? 'Last candidate plan' : 'Final plan'
579 const corrected = data.verdict?.plan
580 const from = data.turns.find(turn => turnId(turn) === data.plan?.from)
581 const author = corrected !== undefined ? `Corrected by ${esc(data.verdict?.author ?? 'the session')} after its checks.` : from?.step === 'revision' ? `As revised by B in <a href="#${turnId(from)}">round ${from.round}</a>.` : `The session’s <a href="#merge">merge</a> of the two drafts, ${data.isLive ? 'not yet revised' : 'unchanged by the rounds'}.`
582 const plan = data.plan === undefined ? '' : `<section class="plan" id="plan">
583<h2>${planTitle}</h2><p class="byline">${author}</p>
584${md(corrected ?? data.plan.text)}
585</section>`
586 const sub = (turn: PlanTurn): Stop => ({ href: `#${turnId(turn)}`, text: actor(turn.by), isSub: true, note: isRoutine(turn.state) ? STEP_NAME[turn.step].toLowerCase() : PLAN_WORD[turn.state], ...(turn.by === 0 ? {} : { hue: turn.by }) })
587 const stops: Stop[] = [
588 ...(data.verdict === undefined ? [] : [{ href: '#verdict', text: 'Summary' }]),
589 ...(data.plan === undefined ? [] : [{ href: '#plan', text: planTitle }]),
590 { href: '#drafts', text: 'Drafts' },
591 ...data.turns.filter(turn => turn.step === 'draft').map(sub),
592 ...(hasMerge ? [{ href: '#merging', text: 'Merge' }] : []),
593 ...Array.from({ length: shown }, (_, i) => [{ href: `#r${i + 1}`, text: `Round ${i + 1}` }, ...data.turns.filter(turn => turn.round === i + 1).map(sub)]).flat(),
594 ]
595 const open = data.verdict?.uncertainty.length ?? 0
596 const flag = open === 0 ? '' : ` <a class="flag" href="#open">${open === 1 ? '1 decision is' : `${open} decisions are`} still open</a>`
597 const body = `${hero(data, PLAN_WORDS, ['2 agents', data.rounds === 1 ? '1 challenge round' : `up to ${data.rounds} challenge rounds`], !data.isLive && !data.isFailed && data.plan !== undefined, flag)}
598${glance(data, tracker, stops)}
599${drawVerdict(data, PLAN_WORDS)}
600${plan}
601<section class="discussion" id="discussion">
602<div class="bar"><h2>How the plan was made</h2></div>
603<div class="rounds">
604${steps.filter(Boolean).join('\n')}
605</div>
606</section>`
607
608 return { body, stops }
609}
610
611export function pageHtml(data: AnyPage, assets: PageAssets, target: 'local' | 'artifact'): string {
612 const isPolling = target === 'local' && data.verdict === undefined
613 const { body, stops } = data.kind === 'pingpong' ? planBody(data) : panelBody(data)
614 const page = `<title>${data.kind === 'pingpong' ? 'Ping-pong' : 'Panel'} ${esc(data.id)}</title>
615${assets.fonts === '' ? '' : `<link rel="stylesheet" href="${esc(assets.fonts)}">\n`}<style>
616${assets.css}
617</style>
618<div class="frame${target === 'local' ? ' with-history' : ''}">
619<div class="page" id="page" data-view="thread" data-density="full" data-run="${esc(data.id)}" data-rev="${data.rev}"${isPolling ? ' data-poll="yes"' : ''}>
620${body}
621</div>
622${outline(stops)}
623</div>
624${target === 'local' ? '<aside class="history" id="history" aria-label="Earlier runs"><p class="rail-title">History<span id="history-where"></span></p><ol class="history-list" id="history-list"></ol></aside>' : ''}
625${target === 'local' ? `<script>${assets.marked.replace(/<\/script/gi, '<\\/script')}</script>` : `<script src="${MARKED_CDN}"></script>`}
626<script>${assets.script.replace(/<\/script/gi, '<\\/script')}</script>
627`
628
629 return target === 'artifact' ? page : `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n</head>\n<body>\n${page}</body>\n</html>\n`
630}
631hooks/design.ts 73 lines1import { splitSkill } from './seats'
2
3export type Design = { name: string; webfonts: string; tokens: Record<string, string>; writing: string; illustrations: string; styles: string }
4export type DesignLayer = Partial<Design>
5export type Guide = Pick<Design, 'writing' | 'illustrations'>
6
7const KEY_LINE = /^(?:--)?([a-z][\w-]*)[ \t]*:[ \t]*(.*)$/i
8const BREAKS_OUT = /[;{}<>]/
9const WEBFONTS = /^https:\/\/[^\s"'<>]+$/
10const HEADING = /^##[ \t]+(.+?)[ \t]*$/
11const FENCE = /```[\w-]*\r?\n([\s\S]*?)```/
12const SECTIONS = ['writing', 'illustrations', 'styles'] as const
13
14type SectionName = (typeof SECTIONS)[number]
15
16const isSection = (name: string): name is SectionName => (SECTIONS as readonly string[]).includes(name)
17
18export function readDesign(text: string): DesignLayer {
19 const { frontmatter, body } = splitSkill(text)
20 const layer: DesignLayer = {}
21 const tokens: Record<string, string> = {}
22
23 for (const line of frontmatter.split(/\r?\n/)) {
24 const at = KEY_LINE.exec(line.trim())
25 if (at?.[1] === undefined) continue
26 const key = at[1].toLowerCase()
27 const value = (at[2] ?? '').trim()
28 if (key === 'name') layer.name = value
29 else if (key === 'webfonts') layer.webfonts = WEBFONTS.test(value) ? value : ''
30 else if (value !== '' && !BREAKS_OUT.test(value)) tokens[key] = value
31 }
32 if (Object.keys(tokens).length > 0) layer.tokens = tokens
33
34 const sections: Partial<Record<SectionName, string[]>> = {}
35 let current: SectionName | undefined
36 for (const line of body.split(/\r?\n/)) {
37 const name = (HEADING.exec(line)?.[1] ?? '').toLowerCase()
38 if (isSection(name)) {
39 current = name
40 sections[current] = []
41 } else if (current !== undefined) sections[current]?.push(line)
42 }
43 for (const name of SECTIONS) {
44 const written = sections[name]?.join('\n').trim()
45 if (written !== undefined) layer[name] = name === 'styles' ? (FENCE.exec(written)?.[1] ?? written).trim() : written
46 }
47
48 return layer
49}
50
51export function layerDesign(layers: readonly DesignLayer[]): Design {
52 const design: Design = { name: '', webfonts: '', tokens: {}, writing: '', illustrations: '', styles: '' }
53 for (const { tokens, ...rest } of layers) {
54 Object.assign(design, rest)
55 Object.assign(design.tokens, tokens)
56 }
57
58 return design
59}
60
61export function designCss(design: Design, sheet: string): string {
62 const tokens = Object.entries(design.tokens).map(([key, value]) => ` --${key}: ${value};`)
63
64 return [`:root {\n${tokens.join('\n')}\n}`, sheet.trim(), design.styles.replace(/<\/style/gi, '<\\/style')].filter(Boolean).join('\n\n')
65}
66
67export function hashOf(text: string): string {
68 let hash = 5381
69 for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
70
71 return hash.toString(36)
72}
73hooks/history.ts 102 lines1import type { RoundEntry, Run, Seat } from '../types'
2import type { AnyPage } from './page'
3import { closedEntry, parseSeat } from './seats'
4
5export type HistoryEntry = { id: string; kind: 'panel' | 'pingpong'; folder: string; question: string; state: 'live' | 'done' | 'failed'; startedAt?: number; headline?: string }
6export type History = { stamp: string; runs: HistoryEntry[] }
7
8const QUESTION_MAX = 160
9const HEADLINE_MAX = 200
10const REBUILT = 'Rebuilt from the saved answers. How the run ended was not recorded.'
11const SEAT_LINE = /^\d+\. (\w+)·(\w+)( contrarian)?((?: \+[\w:./-]+)*) \(/gm
12
13const oneLine = (text: string, max: number) => {
14 const line = text.replace(/`|\*\*/g, '').replace(/\s+/g, ' ').trim()
15
16 return line.length > max ? `${line.slice(0, max - 1)}…` : line
17}
18
19export function historyEntry(data: AnyPage, folder: string): HistoryEntry {
20 const isFailed = data.kind === 'pingpong' && data.isFailed
21
22 return {
23 id: data.id,
24 kind: data.kind ?? 'panel',
25 folder,
26 question: oneLine(data.question, QUESTION_MAX),
27 state: data.isLive ? 'live' : isFailed ? 'failed' : 'done',
28 ...(data.startedAt === undefined ? {} : { startedAt: data.startedAt }),
29 ...(data.verdict === undefined ? {} : { headline: oneLine(data.verdict.headline, HEADLINE_MAX) }),
30 }
31}
32
33export function sortHistory(runs: readonly HistoryEntry[]): HistoryEntry[] {
34 return [...runs].sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0) || b.folder.localeCompare(a.folder))
35}
36
37export function withEntry(history: History, entry: HistoryEntry): History {
38 return { ...history, runs: sortHistory([...history.runs.filter(run => run.folder !== entry.folder), entry]) }
39}
40
41export function historyScript(history: History): string {
42 return `window.__panelHistory&&window.__panelHistory(${JSON.stringify(history.runs)})\n`
43}
44
45const body = (file: string | undefined) => (file ?? '').replace(/^# .*\n+/, '').trim()
46
47export function savedRun(files: Readonly<Record<string, string>>, startedAt?: number): Run | undefined {
48 const [header = '', asked] = (files['question.md'] ?? '').split(/\n## (?:Question|Task)\n+/)
49 const head = /^(Panel|Ping-pong) (\w+)/.exec(header)
50 const seats = [...header.matchAll(SEAT_LINE)].flatMap((line): Seat[] => {
51 const seat = parseSeat(`${line[1]}:${line[2]}${line[3] === undefined ? '' : ':contrarian'}${(line[4] ?? '').replace(/ /g, '')}`)
52
53 return seat ? [{ ...seat, status: 'done' }] : []
54 })
55 if (!head?.[2] || asked === undefined || seats.length === 0) return undefined
56 const names = Object.keys(files)
57 const common = { id: head[2], question: asked.trim(), outcome: REBUILT, ...(startedAt === undefined ? {} : { startedAt }) }
58
59 if (head[1] === 'Ping-pong') {
60 const named = (start: string) => body(files[names.find(name => name.startsWith(start)) ?? ''])
61 const numbered = (what: string) => {
62 const found: string[] = []
63 while (files[`round-${found.length + 1}-${what}.md`] !== undefined) found.push(body(files[`round-${found.length + 1}-${what}.md`]))
64
65 return found
66 }
67 const candidate = body(files['candidate.md'])
68 const challenges = numbered('challenge')
69
70 return {
71 ...common,
72 kind: 'pingpong',
73 seats,
74 phase: candidate === '' ? 'failed' : 'done',
75 round: challenges.length,
76 rounds: Math.max(1, challenges.length),
77 challenges,
78 drafts: [named('draft-a-'), named('draft-b-')],
79 plans: candidate === '' ? [] : [candidate, ...numbered('plan')],
80 }
81 }
82
83 const answers = new Map<string, string>()
84 for (const name of names) {
85 const at = /^(?:round-(\d+)-)?seat-(\d+)-/.exec(name)
86 if (at) answers.set(`${at[1] ?? 1}-${at[2]}`, body(files[name]))
87 }
88 const rounds = Math.max(1, ...[...answers.keys()].map(key => Number(key.split('-')[0])))
89 const transcript: RoundEntry[][] = []
90 for (let round = 1; round <= rounds; round += 1) {
91 transcript.push(
92 seats.map((seat, index) => {
93 const answer = answers.get(`${round}-${index + 1}`)
94
95 return closedEntry({ kind: 'panel', round, rounds, transcript }, answer === undefined ? { ...seat, status: 'failed' } : { ...seat, answer }, index)
96 }),
97 )
98 }
99
100 return { ...common, kind: 'panel', seats, phase: 'done', round: rounds, rounds, challenges: [], transcript }
101}
102hooks/help.ts 73 lines1export type HelpTone = 'heading' | 'command' | 'option' | 'quiet' | 'error' | 'warning'
2export type HelpSpan = { text: string; tone?: HelpTone }
3
4const INLINE = /(?<=^|[\s(])\/[a-z][\w-]*(?=[\s.,;:)]|$)|\b[a-z][\w-]*=[^\s\],;)]*|\b[a-z]+:[a-z]+(?::[a-z]+)*(?:\+[\w-]+)*|(?<=\[)[:+][a-z][\w-]*|(?<=\s)\+[a-z][\w-]*|(?<=^|\s)--(?=\s|$)|[[\]]/g
5const ROW = /^( {2})(\S+)( {2,})(?:(built-in|user|project)( +))?(.*)$/
6const HEADING_PREFIX = /^([^:]{1,30}): /
7const USAGE = /^([\w-]+: )?Usage: /
8const NUMBERED = /^(\d+\.)( )([^(]+?)( \(.*)?$/
9const PATH = /^[~/]\S*\//
10const WARNING = /^\s*[!⚠] /
11const LABELLED = /^([^\s/~].{0,29}?)( {2,})(\S.*)$/
12
13function inline(text: string, tone?: HelpTone): HelpSpan[] {
14 const spans: HelpSpan[] = []
15 let from = 0
16 for (const hit of text.matchAll(INLINE)) {
17 const token = hit[0]
18 if (hit.index > from) spans.push({ text: text.slice(from, hit.index), tone })
19 spans.push({ text: token, tone: token.startsWith('/') ? 'command' : token === '--' || token === '[' || token === ']' ? 'quiet' : 'option' })
20 from = hit.index + token.length
21 }
22 if (from < text.length) spans.push({ text: text.slice(from), tone })
23
24 return spans
25}
26
27function heading(line: string, next: string | undefined): HelpSpan[] | undefined {
28 if (line === '' || next === undefined || !(next.startsWith(' ') || NUMBERED.test(next))) return undefined
29 if (PATH.test(line)) return [{ text: line, tone: 'heading' }]
30 if (/^[\s/]/.test(line)) return undefined
31 const labelled = LABELLED.exec(line)
32 if (labelled !== null) return [{ text: labelled[1] ?? '', tone: 'heading' }, { text: labelled[2] ?? '' }, { text: labelled[3] ?? '', tone: 'quiet' }]
33 if (line.length <= 40) return [{ text: line, tone: 'heading' }]
34 if (line.endsWith(':')) return inline(line, 'heading')
35 const prefix = HEADING_PREFIX.exec(line)?.[1]
36
37 return prefix === undefined ? undefined : [{ text: `${prefix}:`, tone: 'heading' }, ...inline(line.slice(prefix.length + 1))]
38}
39
40export function helpLines(text: string): HelpSpan[][] {
41 const lines = text.split('\n')
42 const usageAt = lines.findIndex(line => USAGE.test(line))
43
44 return lines.map((line, index) => {
45 if (index < usageAt) return line === '' ? [] : [{ text: line, tone: 'error' }]
46 if (index === usageAt) {
47 const label = USAGE.exec(line)?.[1] ?? ''
48
49 return [...(label === '' ? [] : [{ text: label, tone: 'quiet' as const }]), { text: 'Usage:', tone: 'heading' }, ...inline(line.slice(label.length + 'Usage:'.length))]
50 }
51 const titled = heading(line, lines[index + 1])
52 if (titled !== undefined) return titled
53 if (WARNING.test(line)) return [{ text: line, tone: 'warning' }]
54 const numbered = NUMBERED.exec(line)
55 if (numbered !== null) {
56 const [, number = '', gap = '', label = '', rest = ''] = numbered
57
58 return [{ text: number, tone: 'quiet' }, { text: gap }, { text: label, tone: 'option' }, ...(rest === '' ? [] : [{ text: rest }])]
59 }
60 const row = ROW.exec(line)
61 if (row === null) return line === '' ? [] : inline(line)
62 const [, indent = '', name = '', gap = '', source, sourceGap = '', rest = ''] = row
63
64 return [
65 { text: indent },
66 { text: name, tone: 'option' },
67 { text: gap },
68 ...(source === undefined ? [] : [{ text: source, tone: 'quiet' as const }, { text: sourceGap }]),
69 ...inline(rest),
70 ]
71 })
72}
73types/index.d.ts 76 lines1export type Role = 'panelist' | 'contrarian'
2export type SeatStatus = 'starting' | 'working' | 'done' | 'failed'
3
4export type Seat = {
5 label: string
6 model: string
7 effort: string
8 role: Role
9 skills: string[]
10 folderSkills?: FolderSkill[]
11 unknownSkills?: string[]
12 status: SeatStatus
13 agentId?: string
14 answer?: string
15 startedAt?: number
16 durationMs?: number
17 tokens?: number
18 steps?: number
19 activity?: string
20 isInterrupted?: boolean
21}
22
23export type FolderSkill = { name: string; path: string }
24
25export type PresetSource = 'project' | 'user' | 'built-in'
26
27export type Preset = {
28 seats: string[]
29 source: PresetSource
30 description?: string
31 brief?: string
32 rounds?: number
33 skillDirs?: string[]
34}
35
36export type PresetMap = Record<string, Preset>
37
38export type RunKind = 'panel' | 'pingpong'
39export type Phase = 'drafting' | 'discussing' | 'merging' | 'challenging' | 'integrating' | 'done' | 'failed'
40export type RoundState = 'answered' | 'unchanged' | 'failed' | 'interrupted' | 'absent'
41export type RoundEntry = { state: RoundState; text: string; isMoved?: boolean }
42
43export type Run = {
44 id: string
45 kind: RunKind
46 question: string
47 seats: Seat[]
48 phase: Phase
49 round: number
50 rounds: number
51 candidate?: string
52 challenges: string[]
53 drafts?: string[]
54 plans?: string[]
55 claim?: string
56 isReported?: boolean
57 brief?: string
58 dir?: string
59 page?: string
60 saveNote?: string
61 notes?: string[]
62 warnings?: string[]
63 startedAt?: number
64 endedAt?: number
65 transcript?: RoundEntry[][]
66 briefText?: string
67 outcome?: string
68 isStopping?: boolean
69}
70
71declare module 'claude-code' {
72 interface PluginState {
73 'agent-panel': { runs: Run[] }
74 }
75}
76