SLOPSHOPPER

Agent Panel

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

panerowsguardcommandtoaststatus
★ 3v0.5.4MITupdated 2026-10-20koomai/claude-kit/plugins/agent-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-panel
│ ┃ Agent panel ✕ › fix the failing auth test and add an audit log call │ ┃ No panels yet. Try /panel or /pingpong. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /panel │ Usage: /panel [seats or preset] [rounds=N] [brief=name] [skills=dir │ [as=name] -- <question> │ │ Seats, space separated: model:effort[:contrarian][+skill] │ model opus, sonnet, haiku, fable │ effort low, medium, high, xhigh, max │ +skill loads a skill into the seat: a folder under skills= │ the "Default skill folders" setting, else a session skill by the na │ /skills shows; repeat for more │ :contrarian the seat attacks the obvious answer instead of │ answering │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent panel
No panels yet. Try /panel or /pingpong.
Command output
Usage: /panel [seats or preset] [rounds=N] [brief=name] [skills=dir] [as=name] -- <question> Seats, space separated: model:effort[:contrarian][+skill] model opus, sonnet, haiku, fable effort low, medium, high, xhigh, max +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 :contrarian the seat attacks the obvious answer instead of answering brief=name every seat reviews what you name through that brief rounds=N rounds, 1 to 5 (no rounds: 1); round 1 is independent, in each later round every seat reads the others and replies; 2 is usual; a round where no seat changes anything ends it skills=dir folders of <skill>/SKILL.md for this run's +skill names, comma separated as=name also saves this run's seats and options as a preset of that name Presets (no seats: "quick") deep built-in opus:xhigh opus:high sonnet:high quick built-in sonnet:medium sonnet:low haiku:high duo built-in opus:high sonnet:high review built-in opus:xhigh sonnet:high opus:high:contrarian You have no saved presets yet. See yours: /panel-preset list, or /panel-preset show <name> for one Save one: /panel-preset save <name> <seats> [scope=project], or as=<name> on any run Remove one: /panel-preset delete <name> [scope=project] Presets save to ~/.claude/agent-panel/presets.json, for you in every project; scope=project saves to .claude/agent-panel/presets.json in this project, to share through git. Briefs (none found) Add your own as .claude/agent-panel/briefs/<name>.md in the project or under your home folder. Examples /panel -- Is the retry in the queue worker safe? /panel deep -- Should we split this table before or after the migration? /panel opus:xhigh sonnet:high opus:high:contrarian -- Should we split this table? /panel sonnet:high+laravel-php
README

<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

Terms

TermMeaning
RunOne panel or one ping-pong, from the start to the result.
PanelA run that you start with /panel. All seats answer the same question.
Ping-pongA run that you start with /pingpong. Two seats make a plan together.
SeatOne subagent in a run. A seat has a model, an effort level and, optionally, skills.
RoundOne step in which each seat writes an answer.
SynthesisThe one answer that Claude makes from the answers of all seats.
VerdictThe result that Claude records on the page: the synthesis of a panel, or the summary of a plan.
PresetA set of seats and options, saved with a name.
BriefReview instructions that all seats in a panel use.
PaneThe live view of runs in the Claude Code terminal.
PageThe HTML file that shows a run and its verdict.

Install

Do these steps in a Claude Code terminal session:

  1. Type this command:
   /plugin install agent-panel --marketplace koomai/claude-kit
  1. Type y to add the marketplace.
  2. Select a scope.

The plugin needs only Claude Code.

Instructions for Claude

This section is for Claude. If a user asks you to install this plugin or to explain it, do the steps in this section.

Install the plugin

  1. Run this command in the shell:
   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.

  1. Make sure that the command shows no error. If it shows an error, stop and give the error to the user.
  2. Tell the user to restart Claude Code. The commands and tools are available only after the restart.

Learn the commands

  1. Read the sections of this README from "How a run works" to "Presets".
  2. Tell the user to type /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.
  3. Explain the commands to the user. Start with these three examples, and change them to the work of the user:
   /panel -- <a question about their code>
   /panel review brief=bughunt -- <the changes on their branch>
   /pingpong -- <a task that needs a plan>
  1. Tell the user that seats are read-only, and that each round runs all seats again.

Start a run for the user

You cannot type slash commands. To start a run, use the tools of the plugin:

ToolRequired fieldOptional fields
panelquestionseats, preset, rounds, brief
pingpongtaskseats (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.

How a run works

  1. You start a run with /panel or /pingpong. You can also ask Claude in plain English, for example "get an agent panel on this".
  2. Each seat starts as a subagent in the background. To see the progress of each seat, type /panel-view.
  3. When all seats are done, their answers come into the session as a new message.
  4. Claude examines the claims in the answers against the code.
  5. For a panel, Claude writes the synthesis. For a ping-pong, Claude merges the two plans and starts the challenge rounds.
  6. Claude records the verdict on the page of the run. Then Claude gives you a link to the page.

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.

Commands

CommandWhat it does
/panelStarts 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.
/pingpongStarts 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-presetSaves, lists, shows and deletes presets. It also lists the skills in your skill folders.
/panel-stopStops 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-viewOpens the pane.
/panel-designShows 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.

Options

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.

OptionCommandsWhat it does
rounds=NbothSets 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/panelAll seats use this brief.
skills=dir[,dir]bothThe plugin looks for +skill names in these folders first.
as=namebothSaves the seats and options of this run as a preset with this name.

Seats

Write a seat as model:effort[:contrarian][+skill]. The parts in square brackets are optional.

PartValuesWhat it does
modelopus, sonnet, haiku, fableThe Claude model of the seat.
effortlow, medium, high, xhigh, maxHow much the seat thinks before it answers.
:contrariannoneThe seat argues against the obvious answer, or it answers PASS. In a discussion, it attacks the position that the other seats agree on.
+skilla skill nameThe 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.

Skills

The plugin looks for a +name skill in these places, in this order:

  1. The folders in skills=<folder>[,<folder>] on the command, or the folders in the preset.
  2. The folders in the "Default skill folders" setting.
  3. The skills of the session, by the name that /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:

  • If the skill tells the seat to ask the user, the seat writes its assumption.
  • If the skill tells the seat to change files or run commands, the seat recommends the change.
  • The plugin does not apply frontmatter such as 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.

Briefs

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:

  1. .claude/agent-panel/briefs/<name>.md in the project.
  2. ~/.claude/agent-panel/briefs/<name>.md.
  3. The briefs of the plugin.

Discussion rounds

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

  • What it changes, and what it examined.
  • What it still does not agree with.
  • What all the answers missed.
  • Its full position.

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:

  • You type /panel-stop.
  • You interrupt a seat.

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.

Ping-pong planning

/pingpong opus:xhigh sonnet:high rounds=2 -- Plan the billing migration

A ping-pong has two seats:

  • Seat A is the challenger. It is the first seat on the command.
  • Seat B is the integrator. It is the second seat on the command.

A ping-pong has these steps:

  1. Seat A and seat B each write a plan. Each seat does not see the other plan.
  2. Claude merges the two plans into one plan.
  3. In each round, seat A challenges the plan. Then seat B changes the plan for each correction that the evidence supports. Seat B also tells which corrections it rejected, and why.
  4. If seat A has no important correction, it answers PASS. The rounds then stop.
  5. Claude examines the final plan and records a summary. The summary gives the plan in brief, the changes from the rounds and the decisions that are still open.

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.

The pane

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:

  • Its status.
  • Its time.
  • The number of tokens that it used, under its time. The number grows while the seat works.
  • The text that it writes now.

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.

The discussion page

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:

  1. The question.
  2. The headline of the verdict, after Claude records it.
  3. What each seat did in each round.
  4. The discussion, round by round. The page divides each reply into its labelled parts. Each reply links to the seats that it answers.

Two controls change how the discussion shows:

  • "Positions only" hides the evidence and the risks. To show them for one reply, click its "Show …" button.
  • "Side by side" shows one column for each seat.

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.

The plan 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:

  1. The two first plans.
  2. The merged plan.
  3. For each round, the challenge and the corrections that seat B rejected.

The first plans and the old plans are closed. Click one to open it.

The verdict

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.

History

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.

Page design

One file controls how the page looks and how Claude writes on it: page/design.md. The file has these parts:

PartWhat it controls
FrontmatterThe 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:

  1. Type /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.
  2. Edit the copy.
  3. Delete the parts that you do not change. These parts then continue to use the design of the plugin.
  4. Start a run, or open a page from the pane. The plugin then draws all saved pages again with the new design.

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.

Saved answers and settings

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.

SettingDefaultWhat it does
Runs folder~/.claude/agent-panel/runsThe 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 foldersnoneFolders 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

Presets

The plugin has four presets. They are in presets.json:

PresetSeatsUsed when
quicksonnet:medium sonnet:low haiku:high/panel has no seats (the default)
duoopus:high sonnet:high/pingpong has no seats (the default)
deepopus:xhigh opus:high sonnet:highyou name it
reviewopus:xhigh sonnet:high opus:high:contrarianyou name it

To save a preset, use one of these methods:

  • Type /panel-preset save <name> <seats>.
  • Add 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

Where presets go

CommandFileWho can use the preset
/panel-preset save …~/.claude/agent-panel/presets.jsonYou, in all projects.
/panel-preset save … scope=project.claude/agent-panel/presets.json in the projectAll 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:

  1. The project file.
  2. Your user file.
  3. The presets of the plugin.

A project file can come from a cloned repository. Thus, the plugin applies two limits to it:

  • A project preset cannot replace a preset of the plugin.
  • The plugin ignores the skillDirs of a project preset.

The preset file

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.
  • You can put more than one preset on a command. The plugin adds their seats together. It uses the brief and the rounds of the first preset that sets them.

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

Asking in plain English

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:

ToolWhat it does
panelStarts a panel.
pingpongStarts a ping-pong.
pingpong_mergeGives the merged plan back to the ping-pong, which then starts the rounds.
verdictRecords the verdict on the page.

What the plugin does on your machine

What it writes

The plugin writes only these files:

FileWhen
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.htmlWhen 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 folderWhen a run starts or ends, when Claude records the verdict, and when you open a page from the pane
~/.claude/agent-panel/presets.jsonWhen you save or delete a preset, or add as=<name> to a run
.claude/agent-panel/presets.json in the projectWhen you save or delete a preset with scope=project
~/.claude/agent-panel/design.md or .claude/agent-panel/design.md in the projectOnly 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.

What it reads

  • The 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.
  • Its presets, briefs and design files in ~/.claude/agent-panel/ and in .claude/agent-panel/ in the project.
  • The SKILL.md files in the skill folders that you name with skills= or the "Default skill folders" setting.
  • The name of the project folder, for the folder name in the runs folder.
  • The list of slash commands, to find the session skills that +skill can name.

Which programs it runs

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

What it sends, and where

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:

  • To its own seats, with 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.
  • To the main chat, as a prompt that Claude answers. The plugin submits a prompt when bot
Source 8 files
hooks/register.tsx 1787 lines
1import 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 lines
1import 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}
792
hooks/pane.ts 271 lines
1import 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}
271
hooks/page.ts 631 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
631
hooks/design.ts 73 lines
1import { 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}
73
hooks/history.ts 102 lines
1import 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}
102
hooks/help.ts 73 lines
1export 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}
73
types/index.d.ts 76 lines
1export 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