SLOPSHOPPER

wheelhouse

Lets a director who is not a developer run AI agents that build their product.

panecommandtoastpromptprocesstimer
v0.1.1Apache-2.0updated 2026-10-26where-is-brett/wheelhouse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wheelhouse
│ ┃ Wheelhouse ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ [ STOP ]Halts every builder at once. │ wheelhouse │ │ ┃ Couldn't read the latest at 08:53. ⏺ Read(src/auth.ts) │ That wasn't recorded. Try again, or type │ │ ⎿ Read 6 lines │ it in the chat. │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ⏺ Bash(bun test) │ wheelhouse │ │ ⎿ 3 pass, 1 fail │ That wasn't recorded. Try again, or type │ │ │ it in the chat. │ │ ● Done. refresh now re╰────────────────────────────────────────────╯ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wheelhouse-go │ ⎿ wheelhouse: That wasn't recorded. Try again, or type it in the c │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wheelhouse
[ STOP ]Halts every builder at once. Couldn't read the latest at 08:53.
README

<!-- README.md -->

Wheelhouse

Direct AI agents building your product, in plain words, with checks they can't skip.

Wheelhouse is for a product owner who isn't a developer. You say what you want in plain words. A front desk asks you short questions about what is yours to decide, each with buttons to click. Behind it, agents build the work and other agents review it, and nothing reaches the main version of your product until it has passed checks the agents can't skip. You make the product decisions; the agents do the building.

v0.1.1 is an early release for trying Wheelhouse out. It hasn't yet been used from start to finish on a real product.

How it works

  1. You ask. In a Claude Desktop conversation, you write what you want: "Add a page where customers can book a time."
  2. The front desk checks with you. It asks only what is really yours to decide, as a card with clickable answers, and decides the rest itself, telling you so.
  3. Agents build it. A foreman hands each piece of work to builder agents, and to reviewer agents who check it.
  4. Nothing ships unchecked. The work reaches your main product only after its checks pass and a reviewer has looked. Anything that changes the safety rules needs your click as well.
  5. You get a short briefing of what's done, what's under way and what needs you. Type "stop" at any time and every builder halts until you click to carry on.

A card looks like this (an illustration):

Approve · Add the booking page to your product? It lets customers pick a time from your calendar. The checks passed and a reviewer has looked. [ Approve ] [ Change it ]

And a briefing opens like this:

Since you were last here at 09:10: the booking page is ready for you to look at. One thing needs you. Nothing is broken.

What it costs. Wheelhouse is free and open source (Apache 2.0). The agents run on your own Claude plan, so their work comes out of your plan's usage, and Wheelhouse's board shows this week's usage, including the share of your weekly allowance.

What it protects. Agents never hold your GitHub or GitLab keys, your clicks are signed so an agent can't approve for you, and on a Mac the agents' commands run fenced off from the rest of your computer. What it doesn't protect you from is in SECURITY.md.

How to take it out again. Type /wheelhouse:uninstall in a conversation on your product. What it removes, and what it keeps, is in docs/data.md.

Install

You need Node.js (a free program; download the version marked LTS), Claude Desktop, signed in, and about 15 minutes. A GitHub or GitLab account helps; setup shows you where to make one if you have neither.

  1. Open a session on your product's folder. For a new product, start a new session in Claude Desktop, and in its folder picker make an empty folder under Projects in your home folder, named after the product; open the session on it. For a product you already have, open the session on its folder. For a product you set up with Wheelhouse on another computer, make an empty folder here too: setup brings the product into it.
  2. Check for Node.js. Wheelhouse needs Node.js 22 or later. If this computer has none or an older one, or a Wheelhouse session refuses every tool with a message naming a Wheelhouse script and "node: command not found", install the LTS from nodejs.org, then start a new conversation. Wheelhouse also says so itself, in plain words, when a conversation starts.
  3. Install Wheelhouse. Paste this message into the session and press Enter:
   Please run these two commands in this folder, one after the other, and tell me when both have finished:
   claude plugin marketplace add "where-is-brett/wheelhouse#v0.1.1" --scope project
   claude plugin install wheelhouse@wheelhouse --scope project

Claude asks before it runs each one: allow it. The first adds Wheelhouse's own catalogue, at release v0.1.1; the second installs Wheelhouse from that catalogue, for this project only. Both name Wheelhouse's catalogue, so nothing else with a similar name can be installed by mistake. (A later release, from Wheelhouse's releases page, goes in place of v0.1.1.) If you prefer a terminal, the same two commands work there, run in the product's folder.

  1. Start a new session on the same folder. Wheelhouse loads, and setup starts.
  2. If the commands don't work, use the plugin browser instead: click the + button beside the message box, then Add plugins (or Plugins, then Browse plugins). Add the catalogue where-is-brett/wheelhouse, choose Wheelhouse from it, choose this project when asked where, then start a new session on the folder.

Setup goes one step at a time. Each step shows you a card listing exactly what it will change, and nothing changes until you click Go ahead. You can stop at any card and carry on later: setup picks up where it left off. To take Wheelhouse out of a project again, type /wheelhouse:uninstall in a session on it.

Where your conversations, transcripts and records go, and what uninstall removes and keeps, is in docs/data.md.

What Wheelhouse protects you from, what it doesn't, and how to report a problem are in SECURITY.md. Setup switches these protections on by itself; on a Mac you can also choose Make it stricter when it asks how to connect to GitHub or GitLab.

On GitHub Copilot

Wheelhouse also runs in VS Code with GitHub Copilot, with nothing else installed. This is its lean tier ("Lean"): you need VS Code with Copilot, and git. You don't need Node.js, a code host, administrator rights or anything else, and Wheelhouse installs nothing. Everything stays on your computer, and Copilot builds in your own chat, one feature at a time.

Status: v0.1.1 is an early release for trying Wheelhouse out. Where this page says v0.1.1, use the latest release's name.

Open your product's folder in VS Code, open Copilot's chat in agent mode, and paste this line:

Set up Wheelhouse from https://github.com/where-is-brett/wheelhouse at release v0.1.1

Copilot asks you before it runs each command, as it always does. What the line does, one step at a time:

  1. Checks git is here. Without it, setup stops and tells you where to get it. Nothing has changed.
  2. Gets Wheelhouse with git. It fetches the release into ~/.copilot/wheelhouse/<tag>/, copies Wheelhouse's skills, agents and instructions into ~/.copilot/, and checks every copied file against the fingerprint the release lists for it. A file that doesn't match stops setup.
  3. Asks the computer what it can run, and says in one sentence which tier this is.
  4. Sets up your project. It adds Wheelhouse's VS Code settings to .vscode/settings.json (your own file is only changed after you click Approve), makes the record folder .wheelhouse/record/ and the board wheelhouse/board.md, and makes one commit of the files it added.
  5. Asks where the copy goes: a folder you choose, such as a company drive, holding a bare copy of your project's history.
  6. Compares your versions of VS Code and Copilot with the list of versions Wheelhouse has tested, and says when yours haven't been tested yet.

Then the chat follows Wheelhouse's own playbook for Lean: the record, the board, cards, building, merging, undo and the copy, all with git. The board's first lines say plainly what is and isn't enforced on your computer. At Lean nothing enforces STOP, an approval is a click in the chat that the chat records itself, and the VS Code settings are conveniences, not controls. Lean is not for projects with real users.

For the chat reading this: check git --version first; then git clone --depth 1 --branch <tag> https://github.com/where-is-brett/wheelhouse.git "$HOME/.copilot/wheelhouse/<tag>", and follow adapters/copilot/skills/setup-lean/SKILL.md in that clone, step by step.

For developers

Status: v0.1.1 is an early release for trying Wheelhouse out, with its tests passing. The design is in docs/specs/2026-10-17-wheelhouse-design.md, and where the work stands is in HANDOVER.md.

Source 9 files
hooks/register.ts 204 lines
1// hooks/register.ts
2// Wheelhouse's mod. It signs nothing and runs no shell: every call it makes goes to the
3// plugin's bridge, `node <plugin root>/scripts/mod/bridge.js`, through ask (hooks/mod/ask.ts),
4// with one JSON request, and the bridge answers one JSON line. The prompts the bridge's answers
5// hold, and the settings view's Ask the front desk, are handed to the front desk as prompts.
6//
7// This file only wires the hooks, each passing on with next(e) and never changing the message,
8// but for Wheelhouse traffic in the front desk's session (hooks/mod/messages.ts):
9//   session.start    registers /wheelhouse-go, /wheelhouse and /wheelhouse-digest, sends the session's usage figures
10//                    and starts the pane's ticks (hooks/mod/pane.tsx), which hand on the prompts
11//                    their answers hold
12//   session.measure  sends the usage figures again when the allowance or the cost moved
13//   prompt.submit    keeps Wheelhouse traffic out of the front desk and ticks instead, the tick
14//                    saying one was received; a phoned "stop" sends stop; a reply code and an
15//                    option sends reply
16//   session.receive  keeps Wheelhouse traffic out of the front desk and ticks instead, the same
17//   turn.start       marks the session busy, for the tick
18//   turn.complete    marks it idle again
19//   command.run      /wheelhouse-go sends go and answers with the bridge's words; /wheelhouse opens the pane;
20//                    /wheelhouse-digest answers with the bridge's briefing
21//   ui.render        draws the pane, with each card's buttons, STOP or Lift STOP and the settings
22//                    view (hooks/mod/cards.tsx, hooks/mod/settings.tsx)
23// A typed reply or "stop" is read past any reminder blocks the app puts before it (ownWords),
24// and the bridge is sent what follows them.
25// A message counts as the director's only when it came from the prompt (`composer`) or the
26// phone (`bridge`); every other origin never reaches the bridge. A press counts as the
27// director's under the origin `pane`, which only a button's press handler sends.
28import type { Register, EngineInterface, PromptSubmitInput, SessionRateLimit, SessionCost } from 'claude-code'
29import { ask, send, FAILED, NODE_MISSING, nodeNotFound, type Answer } from './mod/ask'
30import { drawPane, openPane, startPane, turnEnded, turnStarted } from './mod/pane'
31import { keepOut, KEPT_OUT, ownWords } from './mod/messages'
32
33const DIRECTOR = ['composer', 'bridge']
34// The options a reply code can answer: the signed labels of scripts/hooks/prompt-record.js,
35// kept in step by tests/scripts/mod/validate.test.js. The bridge refuses every other option.
36const LABELS = ['Approve', 'Confirm', 'Lift STOP', 'Do it', 'Push it', 'Change it', 'End it', 'Merge the fix']
37// The code alphabet of scripts/state/card-lines.js, kept in step by the same test.
38const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'
39// A reply is four characters from the code alphabet and one of those labels, in any case, with
40// any spacing and one closing full stop; the bridge does the real parse. So an ordinary message
41// that starts with a four-letter word sends nothing.
42const REPLY = new RegExp(`^[${CODE_ALPHABET}]{4}\\s+(?:${LABELS.map((l) => l.split(' ').join('\\s+')).join('|')})\\s*\\.?$`, 'i')
43// A reply to a question card names one of the question's own options, which the mod can't know.
44// So a message whose first word is a code as a card shows it (four capitals from the alphabet),
45// then a space and an option of at most OPTION_CHARS characters with no control characters, is
46// sent too, the way scripts/mod/reply.js parseReply reads it. An ordinary message can look like
47// this, so the bridge's refusal is never shown for it.
48const OPTION_CHARS = 80
49const CODED = new RegExp(`^[${CODE_ALPHABET}]{4} (.+)$`)
50// "stop" as the stop-word hook (scripts/hooks/stop-word.js) reads it: the word, in any case,
51// with any closing full stops or exclamation marks.
52const STOP = /^stop[.!]*$/i
53
54// The session's usage figures. A reading that can't be kept says nothing: the next one comes
55// at the next measure.
56async function sendUsage($: EngineInterface, rateLimits: readonly SessionRateLimit[], cost: SessionCost | undefined) {
57  try {
58    const windows = rateLimits.map(({ kind, percentUsed, resetsAt }) =>
59      resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt })
60    await ask($, {
61      cmd: 'usage',
62      session: await $.session.id(),
63      cwd: await $.session.root(),
64      ...(cost === undefined ? {} : { usd: cost.usd }),
65      ...(windows.length === 0 ? {} : { windows }),
66    })
67  } catch {
68    // Usage is informing only.
69  }
70}
71
72// Whether the message is a code and an option, as parseReply reads it: whitespace folded, one
73// closing full stop dropped, the option short and free of control characters.
74function codedReply(text: string): boolean {
75  let words = text.replace(/\s+/g, ' ').trim()
76  if (words.endsWith('.')) words = words.slice(0, -1).trimEnd()
77  const option = CODED.exec(words)?.[1]
78  return option !== undefined && option.length <= OPTION_CHARS && !/[\p{Cc}\p{Cf}]/u.test(option)
79}
80
81// What a submitted message asks of the bridge, or null for nothing. A `quiet` request is shown
82// only when it made a record.
83function requestFor(e: PromptSubmitInput): { request: Record<string, unknown>, quiet: boolean } | null {
84  const origin = e.origin.kind
85  if (!DIRECTOR.includes(origin) || e.isMeta || e.command) return null
86  // The app's reminder blocks before the typed line are read past; a remainder that still holds
87  // a reminder tag is not the director's own words.
88  const own = ownWords(e.text)
89  if (own === null) return null
90  const text = own.trim()
91  // A typed "stop" is the stop-word hook's; only a phoned one is sent.
92  if (STOP.test(text)) return origin === 'bridge' ? { request: { cmd: 'stop', origin }, quiet: false } : null
93  if (REPLY.test(text)) return { request: { cmd: 'reply', origin, text: own }, quiet: false }
94  if (codedReply(text)) return { request: { cmd: 'reply', origin, text: own }, quiet: true }
95  return null
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    try {
101      await $.command.register({ name: 'wheelhouse-go', description: 'Lift STOP' })
102    } catch {
103      // Without the command, a reply code still lifts STOP.
104    }
105    try {
106      await $.command.register({ name: 'wheelhouse', description: 'Open Wheelhouse\'s pane' })
107    } catch {
108      // Without the command, the pane still opens at the session's start.
109    }
110    try {
111      await $.command.register({ name: 'wheelhouse-digest', description: 'Show the briefing, with no Claude turn' })
112    } catch {
113      // Without the command, the front desk still gives the briefing.
114    }
115    // Usage is informing only, so the session starts without waiting for it.
116    void (async () => {
117      try {
118        const { rateLimits, cost } = await $.session.usage()
119        await sendUsage($, rateLimits, cost)
120      } catch {
121        // The next measure sends it again.
122      }
123      // With no Node.js no hook runs, so every tool call is refused with a shell's error; this is
124      // the one place Wheelhouse can say why in plain words.
125      if (nodeNotFound()) $.ui.toast(NODE_MISSING)
126    })()
127    try {
128      await startPane($, e)
129    } catch {
130      // The pane is informing only.
131    }
132    return next(e)
133  }).catch(($, e, next) => next(e))
134
135  on('session.measure', async ($, e, next) => {
136    if (e.changed.includes('rateLimits') || e.changed.includes('cost')) await sendUsage($, e.rateLimits, e.cost)
137    return next(e)
138  }).catch(($, e, next) => next(e))
139
140  on('turn.start', async ($, e, next) => {
141    await turnStarted($, e)
142    return next(e)
143  }).catch(($, e, next) => next(e))
144
145  on('turn.complete', async ($, e, next) => {
146    await turnEnded($, e)
147    return next(e)
148  }).catch(($, e, next) => next(e))
149
150  // Wheelhouse traffic is kept out before anything else; the director's messages never are.
151  on('prompt.submit', async ($, e, next) => {
152    if (await keepOut($, e.origin.kind, e.text, 'prompt')) return { drop: KEPT_OUT }
153    const asked = requestFor(e)
154    if (asked !== null) {
155      try {
156        await send($, { ...asked.request, cwd: await $.session.root() }, asked.quiet)
157      } catch {
158        if (!asked.quiet) $.ui.toast(FAILED)
159      }
160    }
161    return next(e)
162  }).catch(($, e, next) => next(e))
163
164  // The bridge's words, refusals included, are the command's output; only no answer at all
165  // is a toast too.
166  on('command.run', { command: 'wheelhouse-go' }, async ($, e) => {
167    let answer: Answer | null = null
168    try {
169      answer = await ask($, { cmd: 'go', origin: e.origin.kind, cwd: await $.session.root() })
170    } catch {
171      answer = null
172    }
173    if (answer === null) $.ui.toast(FAILED)
174    return { text: answer?.says ?? FAILED }
175  })
176
177  // A delivery for one of the session's agents is not the front desk's conversation.
178  on('session.receive', async ($, e, next) => {
179    if (e.agentId === undefined && await keepOut($, e.origin.kind, e.text, 'receive')) return { consumed: KEPT_OUT }
180    return next(e)
181  }).catch(($, e, next) => next(e))
182
183  on('command.run', { command: 'wheelhouse' }, async ($) => openPane($))
184
185  // The bridge's text is the command's output, its own words when it has no briefing included
186  // (or its refusal's, should it refuse); only no answer at all is a toast too.
187  on('command.run', { command: 'wheelhouse-digest' }, async ($, e) => {
188    let answer: Answer | null = null
189    try {
190      answer = await ask($, { cmd: 'digest', origin: e.origin.kind, cwd: await $.session.root() })
191    } catch {
192      answer = null
193    }
194    const words = answer?.text ?? answer?.says
195    if (words === undefined) $.ui.toast(FAILED)
196    return { text: words ?? FAILED }
197  })
198
199  // The pane's id, PANE_ID, written out so the validator can name the hook; scripts/mod/validate.js
200  // checks it is PANE_ID.
201  on('ui.render', { component: 'Pane', requestId: 'wheelhouse' }, async ($, e) => drawPane($, e))
202    .catch(($, e, next) => next(e))
203}
204
hooks/mod/ask.ts 89 lines
1// hooks/mod/ask.ts
2// The mod's one call out: the plugin's bridge, `node <plugin root>/scripts/mod/bridge.js`,
3// run through the engine's process call with one JSON request on standard input. The bridge
4// answers one JSON line. Those commands run outside the session's sandbox, so the bridge can
5// sign (check 32). scripts/mod/validate.js checks that this is the mod's only such call and that
6// it runs nothing but the bridge.
7import type { EngineInterface, PluginState } from 'claude-code'
8import { ASK_MS, NOT_RECORDED, NODE_MISSING } from './numbers'
9
10// The pane's model as the bridge sends it (scripts/mod/status.js), declared in types/index.d.ts.
11export type PaneModel = PluginState['wheelhouse']['pane']['model']
12
13// The toast for a press the bridge gave no answer to.
14export const FAILED = NOT_RECORDED
15
16// The toast for a computer with no Node.js, and whether the bridge's last start found none: the
17// engine's process call says "Executable not found" (check 32) when `node` is not on the PATH.
18export { NODE_MISSING }
19let nodeAbsent = false
20export const nodeNotFound = () => nodeAbsent
21const notFound = (error: unknown) => /Executable not found|ENOENT/.test(String(error instanceof Error ? error.message : error))
22
23// The bridge's answer line, as scripts/mod/bridge.js lineOf writes it.
24export type Answer = {
25  ok: boolean,
26  says?: string,
27  role?: string,
28  every?: number,
29  pane?: PaneModel,
30  glance?: string,
31  status?: string,
32  submit?: string[],
33  // The ids the courier's wake in `submit` marked sent, and its stamp, which `unsent` hands back.
34  woken?: string[],
35  wokeAt?: string,
36  rows?: Record<string, unknown>[],
37  groups?: Record<string, unknown>[],
38  text?: string,
39}
40
41const isRecord = (value: unknown): value is Record<string, unknown> =>
42  value !== null && typeof value === 'object' && !Array.isArray(value)
43
44// The answer's fields the mod reads, each kept only when it is of its type, so an answer that
45// isn't what the bridge writes can't break the mod.
46function answerOf(line: unknown): Answer | null {
47  if (!isRecord(line) || typeof line.ok !== 'boolean') return null
48  const answer: Answer = { ok: line.ok }
49  for (const key of ['says', 'role', 'glance', 'status', 'text', 'wokeAt'] as const) {
50    if (typeof line[key] === 'string') answer[key] = line[key]
51  }
52  if (typeof line.every === 'number' && Number.isFinite(line.every) && line.every >= 0) answer.every = line.every
53  if (isRecord(line.pane)) answer.pane = line.pane as PaneModel
54  if (Array.isArray(line.submit) && line.submit.every((item) => typeof item === 'string')) answer.submit = line.submit
55  if (Array.isArray(line.woken) && line.woken.every((item) => typeof item === 'string')) answer.woken = line.woken
56  if (Array.isArray(line.rows) && line.rows.every(isRecord)) answer.rows = line.rows
57  if (Array.isArray(line.groups) && line.groups.every(isRecord)) answer.groups = line.groups
58  return answer
59}
60
61// The bridge's answer, or null when it couldn't start, exited non-zero, timed out or printed
62// something that isn't its one line.
63export async function ask($: EngineInterface, request: Record<string, unknown>, { timeoutMs = ASK_MS }: { timeoutMs?: number } = {}): Promise<Answer | null> {
64  try {
65    const run = await $.process.run(['node', `${$.plugin.root}/scripts/mod/bridge.js`], {
66      stdin: JSON.stringify(request),
67      timeoutMs,
68    })
69    nodeAbsent = false
70    if (run.exitCode !== 0) return null
71    return answerOf(JSON.parse(run.stdout.trim()))
72  } catch (error) {
73    if (notFound(error)) nodeAbsent = true
74    return null
75  }
76}
77
78// A request the director made: the bridge's words are a toast, and so is a failure. A `quiet`
79// request is one an ordinary message could be: only a record made is shown.
80export async function send($: EngineInterface, request: Record<string, unknown>, quiet = false) {
81  const answer = await ask($, request)
82  if (quiet) {
83    if (answer?.ok === true && answer.says !== undefined) $.ui.toast(answer.says)
84    return
85  }
86  if (answer?.says !== undefined) $.ui.toast(answer.says)
87  else if (answer === null || !answer.ok) $.ui.toast(FAILED)
88}
89
hooks/mod/pane.tsx 559 lines
1// hooks/mod/pane.tsx
2// The side pane and the status line, drawn from the bridge's tick. The mod decides nothing here:
3// the bridge says the session's role, how often to tick, what the pane shows and what the status
4// line says; the mod asks, keeps the answer in the session's state and draws it.
5//
6// A tick sends { cmd: 'tick', session, cwd, idle, surface }. In the front desk's session its
7// answer holds the pane's model (scripts/mod/status.js), the glance and the status line; any
8// other session's answer, a session still in setup's included, holds only its role and `every`.
9// One tick runs at a time: a tick a press asks for while one runs follows it, once. A message
10// kept out as traffic (hooks/mod/messages.ts) asks for a tick that says it was received,
11// { received: { origin, via } } and never its text, so the bridge notes it. Those that come
12// while a tick runs share the one follow-up tick, which lists them ({ received: [...] }), the
13// newest MAX_WAITING of them.
14// Each prompt an answer's `submit` holds (the courier's wake in the front desk's session, the
15// watcher's lines in a Shape 2 foreman's) is handed on in order, once: the bridge has marked it
16// handed, so the next answer doesn't hold it again. A courier's wake that can't be handed on is
17// sent back as { cmd: 'unsent', session, cwd, ids, at } with the ids and the stamp the answer's
18// `woken` and `wokeAt` named, so the bridge undoes its marks and the next idle tick wakes again.
19// Its toast shows once for a run of such wakes in a session: again only after one is handed on or
20// a turn starts. A watcher's line that can't be handed on is not tried again.
21//
22// The pane has two views: the board, with each card's buttons (hooks/mod/cards.tsx), and the
23// settings (hooks/mod/settings.tsx). STOP or Lift STOP heads both. Everything is drawn by the
24// functions of hooks/mod/calm.tsx.
25import type { EngineInterface, RenderInput, SessionStartInput, TurnStartInput, TurnCompleteInput } from 'claude-code'
26import { ask, type Answer, type PaneModel } from './ask'
27import { PANE_ID, TICK_MS, STALE_TICKS, TICK_ASK_MS, STOP_HALTS, ONLY_YOU_LIFT, NOTHING_NEEDS_YOU, NOT_HANDED, NOT_HERE, SETTING_UP, staleLine as staleWords } from './numbers'
28import { body, footnote, group, heading, small, status, title } from './calm'
29import { drawCard, drawStop, pressingNow, releaseAll, submitAll, NOT_HANDED_FOREMAN } from './cards'
30import { drawSettings, forgetAsks, showSettings } from './settings'
31
32// Whether every button, STOP included, is held back while a turn runs. Buttons stay live during a
33// turn, since each press says at once that it was taken (design section 17.1); check 41 turns this
34// on if a press made during a turn is lost in Desktop, and then they are drawn as "Wait for Claude
35// to finish, or type stop" (cards.tsx WAIT) while a turn runs.
36export const HIDE_WHILE_BUSY: boolean = false
37
38const FRONT_DESK = 'front-desk'
39const FOREMAN = 'foreman'
40const SETUP = 'setup'
41const NOT_OPENED = 'The pane didn\'t open. Type /wheelhouse to try again.'
42const FIRST_READING = 'Reading how the project stands…'
43// The pane's title while what it shows is old: no figures, so none is taken for current.
44const PLAIN_TITLE = 'Wheelhouse'
45// What /wheelhouse says, and opens nothing, where the last answered tick gave the session no pane
46// (NOT_HERE and SETTING_UP are the set words, in numbers.ts).
47const NO_PROJECT = 'This folder isn\'t a Wheelhouse project, so it has no pane'
48
49const PANE = { plugin: 'wheelhouse', key: 'pane' } as const
50const TICK = { plugin: 'wheelhouse', key: 'tick' } as const
51const CARD = { plugin: 'wheelhouse', key: 'card' } as const
52const BUSY = { plugin: 'wheelhouse', key: 'busy' } as const
53const SURFACE = { plugin: 'wheelhouse', key: 'surface' } as const
54const ROLE = { plugin: 'wheelhouse', key: 'role' } as const
55const PICTURED = { plugin: 'wheelhouse', key: 'pictured' } as const
56const OPENED = { plugin: 'wheelhouse', key: 'opened' } as const
57const VIEW = { plugin: 'wheelhouse', key: 'view' } as const
58const SETTINGS = { plugin: 'wheelhouse', key: 'settings' } as const
59const UNHANDED = { plugin: 'wheelhouse', key: 'unhanded' } as const
60
61// The module's own: the timer, whether a tick runs and whether a press or traffic asked for one
62// meanwhile. A reload starts them over, with the session.start that follows it. Whether the pane
63// has been opened is the session's, kept in its state, so a reload never opens a pane the
64// director closed.
65let timer: { every: number, cancel: () => void } | null = null
66let running = false
67let again = false
68const waiting: Received[] = []
69const MAX_WAITING = 50
70// The ticks' prompts being handed on. A prompt waits behind a running turn, so they are handed
71// on apart from the tick, which goes on ticking, and each tick's after the last tick's.
72let handing: Promise<unknown> = Promise.resolve()
73
74// A message kept out as traffic: the origin it arrived under and the hook that saw it.
75export type Received = { origin: string, via: 'prompt' | 'receive' }
76
77const isRecord = (value: unknown): value is Record<string, unknown> =>
78  value !== null && typeof value === 'object' && !Array.isArray(value)
79const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [])
80// The lines of what is building: each is a line of words, or, from the bridge now, { text, see }.
81const buildingLines = (value: unknown): string[] => (Array.isArray(value)
82  ? value.map((item) => (typeof item === 'string' ? item : isRecord(item) && typeof item.text === 'string' ? item.text : '')).filter((line) => line !== '')
83  : [])
84const records = (value: unknown): Record<string, unknown>[] => (Array.isArray(value) ? value.filter(isRecord) : [])
85
86// "14:01" for `$.clock.now()`'s milliseconds, in the computer's own time zone: the mod's Date
87// carries it, as node's does for the bridge.
88function timeOf(ms: number): string {
89  const d = new Date(ms)
90  const two = (n: number) => String(n).padStart(2, '0')
91  return `${two(d.getHours())}:${two(d.getMinutes())}`
92}
93
94function stopTimer() {
95  timer?.cancel()
96  timer = null
97}
98
99function startTimer($: EngineInterface, every: number) {
100  const interval = $.clock.every(every, () => { void tick($) })
101  timer = { every, cancel: () => interval.cancel() }
102}
103
104// The timer after a tick (ruling 28): the answer's `every` when it is above 0, restarted when it
105// changed; 0 stops it; no `every` (a failed or late tick) leaves it, or starts it at TICK_MS.
106function schedule($: EngineInterface, every: number | undefined) {
107  if (every === undefined) {
108    if (timer === null) startTimer($, TICK_MS)
109    return
110  }
111  if (every === 0) {
112    stopTimer()
113    return
114  }
115  if (timer?.every !== every) {
116    stopTimer()
117    startTimer($, every)
118  }
119}
120
121// Opens the pane, or retitles it, once the session has a surface to draw on. The first
122// ui.open of a session can run before the app has attached one; the call is then left for a
123// later tick, and a no-surface refusal, should one come regardless, is quiet. Any other refusal
124// passes on. Returns whether the pane was opened.
125async function openOnSurface($: EngineInterface, title: string): Promise<boolean> {
126  if ((await $.session.surfaces()).length === 0) return false
127  try {
128    await $.ui.open({ id: PANE_ID, title })
129    return true
130  } catch (error) {
131    if (isNoSurface(error)) return false
132    throw error
133  }
134}
135
136function isNoSurface(error: unknown) {
137  const { code, message } = error as { code?: unknown, message?: unknown }
138  return code === 'no-surface' || (typeof message === 'string' && message.includes('no-surface'))
139}
140
141async function paneIsOpen($: EngineInterface) {
142  return (await $.ui.panes()).some((pane) => pane.id === PANE_ID)
143}
144
145// While what the pane shows is old, the status line this session set is cleared and the open
146// pane's title carries no figures, so neither shows old figures as current; the pane says how old.
147async function showOld($: EngineInterface, session: string) {
148  const [role, opened] = await Promise.all([$.state.get(ROLE), $.state.get(OPENED)])
149  if (role.value !== FRONT_DESK || opened.value !== session) return
150  $.ui.status(undefined)
151  if (await paneIsOpen($)) await openOnSurface($, PLAIN_TITLE)
152}
153
154// What an answered tick shows. The session's first front-desk answer opens the pane; later ones,
155// a reload's included, retitle it only while it is open, so a pane the director closed stays
156// closed. A session whose role is no longer the front desk's (a foreman known only from its
157// first prompt, or a project whose folder setup is working on again) closes the pane and clears
158// the status line; when the pane was open it forgets that it opened it, so a front-desk answer
159// after it (setup finished) opens the pane again, while a pane the director closed stays closed.
160// The picture, and whether the answer held one, are kept before the role, so /wheelhouse never
161// reads a front-desk role with no picture as a folder outside a project.
162async function show($: EngineInterface, answer: Answer, session: string, now: number) {
163  const before = (await $.state.get(ROLE)).value
164  if (answer.role === FRONT_DESK && answer.pane !== undefined) await $.state.set(PANE, { model: answer.pane, readAt: now })
165  if (answer.role !== undefined) {
166    await $.state.set(PICTURED, answer.pane !== undefined)
167    await $.state.set(ROLE, answer.role)
168  }
169  if (answer.role === FRONT_DESK && answer.pane !== undefined) {
170    const title = answer.glance ?? PLAIN_TITLE
171    if ((await $.state.get(OPENED)).value !== session) {
172      // Kept once the pane is open, or the open was refused: with no surface yet, the next tick
173      // opens it; any other refusal is not retried every tick.
174      try {
175        if (await openOnSurface($, title)) await $.state.set(OPENED, session)
176      } catch (error) {
177        await $.state.set(OPENED, session)
178        throw error
179      }
180    } else if (await paneIsOpen($)) {
181      await openOnSurface($, title)
182    }
183    $.ui.status(answer.status)
184  } else if (answer.role !== undefined && answer.role !== FRONT_DESK && before === FRONT_DESK) {
185    // Only a pane the mod itself closes is forgotten: one the director closed stays closed.
186    if (await paneIsOpen($)) {
187      await $.ui.close({ id: PANE_ID })
188      await $.state.set(OPENED, null)
189    }
190    $.ui.status(undefined)
191  }
192}
193
194async function runTick($: EngineInterface, received?: Received | Received[]) {
195  let every: number | undefined
196  try {
197    const [session, cwd, busy, surface] = await Promise.all([$.session.id(), $.session.root(), $.state.get(BUSY), $.state.get(SURFACE)])
198    const answer = await ask($, { cmd: 'tick', session, cwd, idle: busy.value !== true, surface: surface.value ?? null, ...(received === undefined ? {} : { received }) }, { timeoutMs: TICK_ASK_MS })
199    const now = await $.clock.now()
200    if (answer === null || !answer.ok) {
201      await $.state.set(TICK, { at: now, failed: true })
202      await showOld($, session)
203      return
204    }
205    every = answer.every
206    const prompts = answer.submit ?? []
207    if (prompts.length > 0) {
208      handing = handing.then(() => (answer.role === FOREMAN ? submitAll($, prompts, NOT_HANDED_FOREMAN) : handWake($, answer, session, cwd))).catch(() => undefined)
209    }
210    await $.state.set(TICK, { at: now, failed: false })
211    await show($, answer, session, now)
212  } catch {
213    // A tick that can't finish shows nothing new; the next one tries again.
214  } finally {
215    schedule($, every)
216  }
217}
218
219// The courier's wake, handed on. One that can't be is sent back with the ids and the stamp the
220// answer named, so the bridge undoes its marks; its toast shows only for the first of a run.
221async function handWake($: EngineInterface, answer: Answer, session: string, cwd: string) {
222  const unhanded = (await $.state.get(UNHANDED)).value === session
223  if (await submitAll($, answer.submit ?? [], NOT_HANDED, unhanded)) {
224    await $.state.set(UNHANDED, null)
225    return
226  }
227  await $.state.set(UNHANDED, session)
228  const ids = answer.woken ?? []
229  if (ids.length > 0 && answer.wokeAt !== undefined) await ask($, { cmd: 'unsent', session, cwd, ids, at: answer.wokeAt })
230}
231
232// Whether the last answered tick said this is the front desk's session; false before any has.
233export async function isFrontDesk($: EngineInterface): Promise<boolean> {
234  return (await $.state.get(ROLE)).value === FRONT_DESK
235}
236
237// Whether the pane's model is older than STALE_TICKS ticks.
238async function isOld($: EngineInterface) {
239  const [kept, now] = await Promise.all([$.state.get(PANE), $.clock.now()])
240  return kept.value !== undefined && now - kept.value.readAt > STALE_TICKS * TICK_MS
241}
242
243// One tick, unless the last one is still running. Then a press's or traffic's tick (`queued`)
244// runs once that one ends, however many asked; the timer's waits for its next fire. A tick that
245// runs long may leave the figures old: the status line and the title then stop showing them, and
246// the pane is drawn again, so its line saying how old they are comes without a tick's answer.
247export async function tick($: EngineInterface, queued = false, received?: Received | Received[]) {
248  if (running) {
249    if (queued) {
250      if (received === undefined) again = true
251      else {
252        waiting.push(...(Array.isArray(received) ? received : [received]))
253        if (waiting.length > MAX_WAITING) waiting.splice(0, waiting.length - MAX_WAITING)
254      }
255    }
256    try {
257      if (await isOld($)) {
258        await showOld($, await $.session.id())
259        $.ui.invalidate('ui.render')
260      }
261    } catch {
262      // The next time the timer fires asks again.
263    }
264    return
265  }
266  running = true
267  try {
268    await runTick($, received)
269  } finally {
270    running = false
271  }
272  if (waiting.length > 0) {
273    const listed = waiting.splice(0)
274    again = false
275    await tick($, false, listed.length === 1 ? listed[0] : listed)
276  } else if (again) {
277    again = false
278    await tick($)
279  }
280}
281
282// The session's start: the pane opens with the session's first front-desk answer. The first
283// tick isn't waited for, so the session starts at once.
284export async function startPane($: EngineInterface, e: SessionStartInput) {
285  stopTimer()
286  again = false
287  waiting.length = 0
288  try {
289    await $.state.set(SURFACE, e.surface)
290  } catch {
291    // The tick goes without it.
292  }
293  try {
294    await releaseAll($)
295    await forgetAsks($)
296  } catch {
297    // The buttons stay as they were.
298  }
299  void tick($)
300}
301
302// Whether the session is busy: only the main loop's turns count, not a subagent's.
303export async function turnStarted($: EngineInterface, e: TurnStartInput) {
304  if (e.agentId === undefined) {
305    await $.state.set(BUSY, true)
306    await $.state.set(UNHANDED, null)
307  }
308}
309
310export async function turnEnded($: EngineInterface, e: TurnCompleteInput) {
311  if (e.agentId === undefined) await $.state.set(BUSY, false)
312}
313
314// Why the session has no pane, from the last answered tick, or null when it has one or no tick
315// has answered yet: another role's session, a folder setup is working on, or a front desk outside
316// a project (its answer held no picture). A tick that failed since leaves the reason as it was.
317async function noPane($: EngineInterface, kept: unknown): Promise<string | null> {
318  const [role, pictured] = await Promise.all([$.state.get(ROLE), $.state.get(PICTURED)])
319  if (role.value === undefined) return null
320  if (role.value === SETUP) return SETTING_UP
321  if (role.value !== FRONT_DESK) return NOT_HERE
322  return kept === undefined && pictured.value === false ? NO_PROJECT : null
323}
324
325// /wheelhouse: the director asked, so the pane opens at any width, titled with the glance. While
326// what it shows is old, the title carries no figures and the answer says how old they are. A
327// session the bridge gave no pane opens none, and the answer says why.
328export async function openPane($: EngineInterface): Promise<{ text: string }> {
329  try {
330    const [pane, last, now] = await Promise.all([$.state.get(PANE), $.state.get(TICK), $.clock.now()])
331    const kept = pane.value
332    const why = await noPane($, kept)
333    if (why !== null) return { text: why }
334    const glance = typeof kept?.model.glance === 'string' ? kept.model.glance : PLAIN_TITLE
335    const stale = staleLine(kept, last.value, now)
336    // With no surface yet the pane can't open, and the director who asked is told so.
337    if (!(await openOnSurface($, stale === null ? glance : PLAIN_TITLE))) throw new Error('no surface is attached')
338    return { text: stale ?? glance }
339  } catch {
340    $.ui.toast(NOT_OPENED)
341    return { text: NOT_OPENED }
342  }
343}
344
345// The line above everything when what is shown is old: the last tick failed, or no tick has
346// answered for more than STALE_TICKS ticks.
347function staleLine(kept: { readAt: number } | undefined, last: { at: number, failed: boolean } | undefined, now: number): string | null {
348  const failed = last?.failed === true
349  // With nothing read yet, only when the failure was: the set line without its showing.
350  if (kept === undefined) return failed ? staleWords(timeOf(last.at), '').split(' Showing')[0] : null
351  if (!failed && now - kept.readAt <= STALE_TICKS * TICK_MS) return null
352  return staleWords(timeOf(failed ? last.at : now), timeOf(kept.readAt))
353}
354
355
356const STOP_IS_ON = 'STOP is on'
357const text = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
358const count = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null)
359// The "hh:mm" in a line such as "Since you were last here at 09:10."
360const timeIn = (line: unknown): string | null => (typeof line === 'string' ? /\b(\d{2}:\d{2})\b/.exec(line)?.[1] ?? null : null)
361
362// When STOP began, in words: the bridge's STOP line ("STOP is on, since 19 October 2026") without
363// its lead, else the start the model carries, as a time.
364function stopSince(model: PaneModel, line: string | null): string | null {
365  const rest = line?.replace(/^STOP is on,?\s*/, '') ?? ''
366  if (rest !== '') return rest.charAt(0).toUpperCase() + rest.slice(1)
367  const at = isRecord(model.stop) && typeof model.stop.since === 'string' ? Date.parse(model.stop.since) : NaN
368  return Number.isNaN(at) ? null : `Since ${timeOf(at)}`
369}
370
371// The next card's subject, with its optional mark: "Next: how files are named · optional · 3 min".
372function nextLine(card: Record<string, unknown>): string {
373  const parts = isRecord(card.parts) ? card.parts : {}
374  const subject = text(parts.ask) ?? text(card.header) ?? String(card.text).split('\n')[0]
375  const minutes = count(parts.optional)
376  return `Next: ${subject}${minutes !== null && minutes > 0 ? ` · optional · ${minutes} min` : ''}`
377}
378
379// The pane, top to bottom (design section 23.3): the line saying what is shown is old; STOP, or
380// the STOP is on group; the verdict; the card, with the next card's subject and Back and Next;
381// what is being built; launch; health and this week; and the foot, whose Settings is the only button on the board view (the settings view has its own Done). Each is a group, and an empty
382// one is left out. Every line is plain Text: a card's text is drawn whole, as written, and the
383// bridge has made every other line one line of plain words. Or the settings view.
384export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
385  const t = $.ui.resolve(e)
386  const { Box } = t
387  const [pane, last, shown, now, view, pressing] = await Promise.all([$.state.get(PANE), $.state.get(TICK), $.state.get(CARD), $.clock.now(), $.state.get(VIEW), pressingNow($)])
388  // Busy is read only while it holds the buttons back, so a turn's start and end redraw nothing else.
389  const live = !HIDE_WHILE_BUSY || (await $.state.get(BUSY)).value !== true
390  const after = () => tick($, true)
391  const kept = pane.value
392  const model: PaneModel = isRecord(kept?.model) ? kept.model : {}
393  const stale = staleLine(kept, last.value, now)
394  const staleNode = stale !== null && status(t, stale, 'warning')
395  // Before the first picture, STOP heads the pane, as STOP needs only the session's folder. Once a
396  // tick has given the session no pane (another role's, setup's included, or a front desk outside
397  // a project), a pane /wheelhouse opened before it says why, with no STOP, as /wheelhouse would.
398  if (kept === undefined) {
399    const why = await noPane($, kept)
400    if (why !== null) {
401      return (
402        <Box flexDirection="column">
403          {body(t, why)}
404        </Box>
405      )
406    }
407    return (
408      <Box flexDirection="column">
409        <Box flexDirection="row" gap="sm">
410          {await drawStop($, e, {}, pressing, live, after)}
411          {footnote(t, STOP_HALTS)}
412        </Box>
413        {body(t, stale ?? FIRST_READING)}
414      </Box>
415    )
416  }
417
418  const health = records(model.health).filter((line) => typeof line.text === 'string')
419  const stopLine = health.find((line) => line.of === 'stop')?.text as string | undefined ?? null
420  const cards = records(model.cards).filter((card) => typeof card.text === 'string')
421  const ids = cards.map((card, i) => (typeof card.id === 'string' ? card.id : `#${i}`))
422  const at = Math.max(0, ids.indexOf(shown.value ?? ''))
423  const card = cards[at]
424  const before = ids[at - 1]
425  const later = ids[at + 1]
426  const control = await drawStop($, e, model, pressing, live, after)
427  const stopOn = isRecord(model.stop) && model.stop.on === true
428
429  // STOP, outlined with what it does beside it; or, while it is on, the group that leads the pane.
430  const stopPart = stopOn
431    ? group(t, (
432      <Box flexDirection="column" gap="xs">
433        {title(t, STOP_IS_ON)}
434        {stopSince(model, stopLine) !== null && body(t, stopSince(model, stopLine) as string)}
435        {control}
436        {footnote(t, ONLY_YOU_LIFT)}
437      </Box>
438    ))
439    : (
440      <Box flexDirection="row" gap="sm">
441        {control}
442        {footnote(t, STOP_HALTS)}
443      </Box>
444    )
445
446  if (view.value === 'settings') {
447    return (
448      <Box flexDirection="column" gap="sm">
449        {staleNode}
450        {stopPart}
451        {await drawSettings($, e, (await $.state.get(SETTINGS)).value, pressing, live, now)}
452      </Box>
453    )
454  }
455
456  const verdict = text(model.verdict) ?? text(model.glance) ?? PLAIN_TITLE
457  const since = text(model.since)
458
459  // The card, with its place: a card of parts says it in its own header row, one with none above it.
460  const cardPart = card !== undefined
461    ? (
462      <Box flexDirection="column" gap="xs">
463        {!isRecord(card.parts) && footnote(t, `Card ${at + 1} of ${cards.length}`)}
464        {await drawCard($, e, card, `${at + 1} of ${cards.length}`, pressing, live, after)}
465        {later !== undefined && footnote(t, nextLine(cards[at + 1]))}
466        {live && cards.length > 1 && (
467          <Box flexDirection="row" gap="sm">
468            {before !== undefined && small(t, 'back', 'Back', () => $.state.set(CARD, before))}
469            {later !== undefined && small(t, 'next', 'Next', () => $.state.set(CARD, later))}
470          </Box>
471        )}
472      </Box>
473    )
474    : status(t, NOTHING_NEEDS_YOU, 'success')
475
476  const building = (Array.isArray(model.building) ? model.building : [])
477    .map((item) => (typeof item === 'string' ? { text: item, see: null } : isRecord(item) ? { text: text(item.text), see: text(item.see) } : { text: null, see: null }))
478    .filter((item) => item.text !== null)
479  const buildingPart = building.length > 0 && group(t, (
480    <Box flexDirection="column" gap="xs">
481      {heading(t, 'Being built')}
482      {building.map((item) => (
483        <Box flexDirection="column">
484          {body(t, item.text as string)}
485          {item.see !== null && footnote(t, item.see)}
486        </Box>
487      ))}
488    </Box>
489  ))
490
491  const launch = isRecord(model.launch) ? model.launch : null
492  const resolved = count(launch?.resolved)
493  const total = count(launch?.total)
494  const gained = count(launch?.gained)
495  const upNext = launch !== null && Array.isArray(launch.next) ? records(launch.next).filter((n) => typeof n.text === 'string') : []
496  const launchPart = launch !== null && resolved !== null && total !== null && group(t, (
497    <Box flexDirection="column" gap="xs">
498      {heading(t, 'Launch')}
499      {body(t, `${resolved} of ${total} blockers cleared`)}
500      {gained !== null && gained > 0 && footnote(t, `+${gained} since ${timeIn(since) ?? 'you were last here'}`)}
501      {upNext.length > 0 && footnote(t, 'What\'s next')}
502      {upNext.map((n) => body(t, n.yours === true ? `${n.text as string} (yours)` : (n.text as string)))}
503    </Box>
504  ))
505
506  const note = isRecord(model.healthNote) ? model.healthNote : {}
507  const watch = health.filter((line) => line.of !== 'stop' && (line.level === 'watch' || line.level === 'stop'))
508  const ideas = isRecord(model.ideas) ? model.ideas : null
509  const ideasCount = count(ideas?.count)
510  const fresh = count(ideas?.fresh)
511  const protection = text(note.protection)
512  const week = text(note.week)
513  const usage = text(model.usage)
514  const missing = strings(model.missing)
515  const healthRows = [
516    note.allWell === true && status(t, 'All well', 'success'),
517    ...watch.map((line) => status(t, 'To watch', 'warning', line.text as string)),
518    protection !== null && body(t, `Protection: ${protection}`),
519    // The week's share once: the health note's, else the usage line of a model without one.
520    (week ?? usage) !== null && body(t, (week ?? usage) as string),
521    ideasCount !== null && ideasCount > 0 && body(t, `${ideasCount} ${ideasCount === 1 ? 'idea' : 'ideas'}${fresh !== null && fresh > 0 ? ` · ${fresh} new since yesterday` : ''}`),
522    ...missing.map((line) => footnote(t, line)),
523  ].filter((row) => row !== false)
524  const healthPart = healthRows.length > 0 && group(t, (
525    <Box flexDirection="column" gap="xs">
526      {heading(t, 'Health and this week')}
527      {healthRows}
528    </Box>
529  ))
530
531  const updated = text(model.updated)
532  const footPart = group(t, (
533    <Box flexDirection="column" gap="xs">
534      {live && (
535        <Box flexDirection="row" gap="sm">
536          {small(t, 'settings', 'Settings', () => showSettings($))}
537        </Box>
538      )}
539      {updated !== null && footnote(t, `Updated ${updated}`)}
540    </Box>
541  ))
542
543  return (
544    <Box flexDirection="column" gap="sm">
545      {staleNode}
546      {stopPart}
547      <Box flexDirection="column">
548        {title(t, verdict)}
549        {since !== null && footnote(t, since)}
550      </Box>
551      {cardPart}
552      {buildingPart}
553      {launchPart}
554      {healthPart}
555      {footPart}
556    </Box>
557  )
558}
559
hooks/mod/messages.ts 67 lines
1// hooks/mod/messages.ts
2// Wheelhouse traffic between sessions. Another Wheelhouse session's message (from the SDK or a
3// peer, its own first line led by MESSAGE_LEAD) is information, never an instruction (design
4// section 4). In the front desk's session it is kept out of the conversation and only starts a
5// tick, so the pane shows what changed; the message itself never reaches the bridge. Before any
6// tick has answered, and in every other role, every message passes on as it came. A message led
7// by the foreman's lead is never traffic (ruling 34): an app-started foreman learns its role from
8// its first prompt, which comes after a first tick that answered as the front desk's. The same
9// reading past the app's reminders finds the director's own typed words (ownWords).
10import type { EngineInterface } from 'claude-code'
11import { FOREMAN_LEAD, MESSAGE_LEAD } from './numbers'
12import { isFrontDesk, tick, type Received } from './pane'
13
14// The reason the engine shows for a dropped prompt: a hook that answers without passing a prompt on
15// is shown a worse line (probed, check 41-8), so the drop's own words are plain ones.
16export const KEPT_OUT = 'Wheelhouse: an update from another session, shown in the pane'
17
18// The origins another session's message arrives under. The director's (`composer`, `bridge`),
19// a task's notification and a plugin's own prompt are never traffic.
20const TRAFFIC_ORIGINS = ['sdk', 'peer']
21
22// The reminder blocks alone, as scripts/lib/own-words.js reads them (kept in step by
23// tests/scripts/mod/validate.test.js).
24const BLOCKS = /^(?:<system-reminder>\n(?:(?:(?!<\/system-reminder>)[\s\S])*\n)?<\/system-reminder>(?:\n|$)\n*)*/
25
26// What the Claude app puts before another session's message (check 41), read past only at the
27// start and only whole: any number of system reminders, each with its opening and closing tags
28// on lines of their own and any blank lines after it; then the line the app writes before a
29// peer's message; then the opening tag of the message's wrapper, on a line of its own. Whichever
30// session the tag names, the message's own first line is the one after.
31const WRAPPER = new RegExp([
32  BLOCKS.source,
33  String.raw`(?:Another Claude session sent a message:\n)?`,
34  String.raw`(?:<cross-session-message(?: [a-z-]+="[^"\n]*")*>\n)?`,
35].join(''))
36
37// The director's own words: the text after any leading reminder blocks the app put before the
38// typed line, or null when what is left still holds a reminder tag, which is not their words.
39// With no leading block, the text as it came. It never says who typed: only the origin does.
40export function ownWords(text: string): string | null {
41  const lead = BLOCKS.exec(text)?.[0] ?? ''
42  if (lead === '') return text
43  const rest = text.slice(lead.length)
44  return /<\/?system-reminder>/.test(rest) ? null : rest
45}
46
47// The message's own first line: the text's first line, or the first after the app's wrapper.
48function firstLine(text: string): string {
49  const own = text.slice(text.match(WRAPPER)?.[0].length ?? 0)
50  return own.split('\n', 1)[0] ?? ''
51}
52
53export function isWheelhouseTraffic(origin: string, text: string): boolean {
54  if (!TRAFFIC_ORIGINS.includes(origin)) return false
55  const first = firstLine(text)
56  return MESSAGE_LEAD.test(first) && !first.startsWith(FOREMAN_LEAD)
57}
58
59// Whether the message is kept out: Wheelhouse traffic reaching the front desk's session. A
60// message kept out starts a tick, or, while one runs, one tick once it ends, as a press does. The
61// tick says that a message was received, by which hook and from which origin, never its text.
62export async function keepOut($: EngineInterface, origin: string, text: string, via: Received['via']): Promise<boolean> {
63  if (!isWheelhouseTraffic(origin, text) || !(await isFrontDesk($))) return false
64  void tick($, true, { origin, via }).catch(() => undefined)
65  return true
66}
67
hooks/mod/numbers.ts 53 lines
1// hooks/mod/numbers.ts
2// The mod's copies of the numbers and words it needs, named once here. The mod can't import
3// scripts/, so tests/scripts/mod/validate.test.js keeps each in step with scripts/lib/mod-numbers.js
4// and scripts/mod/bridge.js.
5
6// The id of the side pane the mod draws.
7export const PANE_ID = 'wheelhouse'
8// How often the front desk's pane ticks.
9export const TICK_MS = 15000
10// How many ticks a pane may go unanswered before it says it is out of date.
11export const STALE_TICKS = 3
12// The bridge's own limit on a request, and on a Shape 2 foreman's tick.
13export const BRIDGE_MS = 5000
14export const WATCH_BRIDGE_MS = 45000
15// How long a change can be undone (UNDO_MS in scripts/lib/mod-numbers.js).
16export const UNDO_MS = 60000
17// The words every prompt the pane submits starts with.
18export const PANE_LEAD = 'Wheelhouse pane:'
19// A message from a Wheelhouse session starts with this.
20export const MESSAGE_LEAD = /^Wheelhouse(?: [a-z]+)?:/
21// The foreman's lead (FOREMAN_PROMPT_LEAD in scripts/foreman/start.js). It matches MESSAGE_LEAD,
22// but a prompt it leads is never traffic: the foreman talks to no one.
23export const FOREMAN_LEAD = 'Wheelhouse foreman:'
24// How long the mod waits for the bridge: its limit and the time node takes to start. A tick can't
25// tell a foreman's from a front desk's before the answer, so every tick waits a foreman's time.
26export const ASK_MS = BRIDGE_MS + 1500
27export const TICK_ASK_MS = WATCH_BRIDGE_MS + 5000
28// The Calm interface's set words (scripts/lib/calm-words.js), written as the pane says them.
29export const STOP_HALTS = 'Halts every builder at once.'
30export const ONLY_YOU_LIFT = 'Only you can lift it.'
31export const NOTHING_NEEDS_YOU = 'Nothing needs you.'
32export const RECORDING = 'Recording…'
33export const WHY = 'Why?'
34export const HIDE_WHY = 'Hide why'
35export const SHOW_ME = 'Show me'
36export const WHAT_CHANGED = 'What changed:'
37export const SUGGEST = 'Suggest a change…'
38export const WHAT_WOULD_YOU_CHANGE = 'What would you change?'
39export const SENT_TO_FRONT_DESK = 'Sent to the front desk. A new proposal will follow.'
40export const NOT_HANDED = 'That didn\'t reach the front desk. Type it in the chat instead.'
41export const NOT_HERE = 'This pane lives in the front desk\'s chat. Type /wheelhouse there.'
42// The toast at a session's start on a computer with no Node.js, where no hook can run (check 35):
43// scripts/hooks/node-floor.cjs's missingMessage(), kept in step by tests/scripts/hooks/node-floor.test.js.
44export const NODE_MISSING = 'Wheelhouse needs a free program called Node.js, version 22 or later, and this computer doesn\'t have it. Download the version marked LTS from https://nodejs.org and install it, then start a new conversation. Nothing else needs to change, and nothing has been lost.'
45export const NOT_RECORDED = 'That wasn\'t recorded. Try again, or type it in the chat.'
46export const SETTING_UP = 'Setup is still running. This pane opens when it finishes.'
47// The line shown when the latest state could not be read: when it failed and which reading is shown.
48export const staleLine = (failedAt: string, shownAt: string) => `Couldn't read the latest at ${failedAt}. Showing ${shownAt}'s.`
49// The note for a suggestion made at a time (hh:mm).
50export const suggestedAt = (hhmm: string) => `You suggested a change at ${hhmm}. A new proposal is on its way, and nothing goes live meanwhile.`
51// The note under a card's buttons where the pane has no box to type in (the phone): the change goes in the chat.
52export const typeYourChange = (n: number | string) => `Type your change in the chat, and I'll know it's about card ${n}.`
53
hooks/mod/calm.tsx 84 lines
1// hooks/mod/calm.tsx
2// The Calm interface's drawing kit (design sections 23.2 and 23.3). Each function takes the
3// surface's resolved table, `$.ui.resolve(e)`, and draws one piece, so every file of the mod draws
4// alike. No other file sets a colour or a size: a colour is a theme key, never a literal.
5import type { EngineInterface } from 'claude-code'
6
7export type Table = ReturnType<EngineInterface['ui']['resolve']>
8// The theme keys a word may take beside its plain words.
9export type Level = 'success' | 'warning' | 'error'
10// Whatever a group holds: elements, or lists of them.
11type Children = unknown
12
13// The five type styles.
14export function title(t: Table, text: string) {
15  return <t.Text size="title" weight="semibold">{text}</t.Text>
16}
17export function heading(t: Table, text: string) {
18  return <t.Text size="heading" weight="semibold">{text}</t.Text>
19}
20export function body(t: Table, text: string) {
21  return <t.Text size="body">{text}</t.Text>
22}
23export function footnote(t: Table, text: string) {
24  return <t.Text size="footnote" dimColor>{text}</t.Text>
25}
26// The checking line and the reply code only.
27export function caption(t: Table, text: string) {
28  return <t.Text size="caption" mono dimColor>{text}</t.Text>
29}
30
31// A rounded, quiet outline round its children, and its note under it in footnote.
32export function group(t: Table, children: Children, note?: string) {
33  return (
34    <t.Box flexDirection="column">
35      <t.Box flexDirection="column" borderStyle="round" borderColor="subtle" padding="sm">
36        {children}
37      </t.Box>
38      {note !== undefined && footnote(t, note)}
39    </t.Box>
40  )
41}
42
43// The four shapes. A press is the caller's; the key names the button for the engine.
44export function filled(t: Table, key: string, label: string, onPress: () => unknown) {
45  return <t.Button key={key} label={label} variant="primary" onPress={onPress} />
46}
47export function outlined(t: Table, key: string, label: string, onPress: () => unknown) {
48  return (
49    <t.Button key={key} label={label} variant="secondary" onPress={onPress}>
50      <t.Text color="suggestion">{label}</t.Text>
51    </t.Button>
52  )
53}
54// Outlined at the footnote's size: the type table gives a Button no size of its own, so its label does.
55export function small(t: Table, key: string, label: string, onPress: () => unknown) {
56  return (
57    <t.Button key={key} label={label} variant="secondary" onPress={onPress}>
58      <t.Text size="footnote" color="suggestion">{label}</t.Text>
59    </t.Button>
60  )
61}
62export function link(t: Table, href: string, label: string) {
63  return <t.Link href={href} label={`${label} ↗`} />
64}
65
66// STOP: outlined, its label in error, and never beside a filled button.
67export function stop(t: Table, onPress: () => unknown) {
68  return (
69    <t.Button key="stop" label="STOP" variant="secondary" onPress={onPress}>
70      <t.Text color="error">STOP</t.Text>
71    </t.Button>
72  )
73}
74
75// A word in success, warning or error beside its plain words.
76export function status(t: Table, word: string, level: Level, words?: string) {
77  return (
78    <t.Box flexDirection="row" gap="sm">
79      <t.Text color={level}>{word}</t.Text>
80      {words !== undefined && words !== '' && body(t, words)}
81    </t.Box>
82  )
83}
84
hooks/mod/cards.tsx 356 lines
1// hooks/mod/cards.tsx
2// The board view's buttons: each card's, and STOP or Lift STOP on the pane's top line. The mod
3// decides nothing here: a card gets exactly the buttons the bridge gave it (scripts/mod/buttons.js),
4// a press sends the card, its hash and the label pressed, and what the bridge says is shown.
5//
6// A press says at once that it was taken (a toast, and "Recording…" in place of the button), so
7// buttons stay live during a turn. One press is recorded at a time: while one is, every other
8// button but STOP is drawn as plain text, so a double press sends once. STOP is never held back.
9// The origin `pane` is written only inside the press… functions below and in settings.tsx, each
10// passed as a Button's onPress, an Input's onSubmit or a Select's onSelect; scripts/mod/validate.js checks it.
11import type { EngineInterface, PluginState, RenderInput } from 'claude-code'
12import { ask, FAILED, type Answer, type PaneModel } from './ask'
13import { body, caption, filled, footnote, heading, link, outlined, small, stop as stopButton } from './calm'
14import type { Table } from './calm'
15import { HIDE_WHY, NOT_HANDED, PANE_LEAD, RECORDING, SHOW_ME, SUGGEST, WHAT_WOULD_YOU_CHANGE, WHY, suggestedAt, typeYourChange, WHAT_CHANGED } from './numbers'
16
17// A card of the pane's model. It comes from another process, so each field is read as anything.
18type Card = Record<string, unknown>
19type Pressing = PluginState['wheelhouse']['pressing']
20// What runs once the bridge has answered a press: a tick, or a fresh read of the settings.
21export type After = () => Promise<unknown>
22
23const PRESSING = { plugin: 'wheelhouse', key: 'pressing' } as const
24const STOPPING = { plugin: 'wheelhouse', key: 'stopping' } as const
25
26// Drawn in place of every button while a turn runs, when pane.tsx's HIDE_WHILE_BUSY is on.
27export const WAIT = 'Wait for Claude to finish, or type stop'
28// A Shape 2 foreman's watcher lines that couldn't be handed to the foreman. NOT_HANDED, for a
29// prompt that couldn't be handed to the front desk, is in numbers.ts.
30export const NOT_HANDED_FOREMAN = 'The watcher\'s news didn\'t reach the foreman.'
31const LIFT = 'lift-stop'
32const WHY_OPEN = { plugin: 'wheelhouse', key: 'why' } as const
33const SUGGESTING = { plugin: 'wheelhouse', key: 'suggesting' } as const
34const SUGGEST_BOX = (id: string) => `suggest:${id}`
35// How many lines tall the Suggest a change… box is, so a sentence or two stays in sight.
36const SUGGEST_ROWS = 4
37
38const isRecord = (value: unknown): value is Record<string, unknown> =>
39  value !== null && typeof value === 'object' && !Array.isArray(value)
40
41// The press being recorded, as the drawing read it.
42export async function pressingNow($: EngineInterface): Promise<Pressing> {
43  return (await $.state.get(PRESSING)).value ?? null
44}
45
46// Takes the one press at a time: true when this press is now the one being recorded. Two presses
47// at once both read the same version, and only the first write at it lands.
48export async function claim($: EngineInterface, pressing: NonNullable<Pressing>): Promise<boolean> {
49  const held = await $.state.get(PRESSING)
50  if (held.value !== undefined && held.value !== null) return false
51  return (await $.state.set(PRESSING, pressing, { ifVersion: held.version })).isSet
52}
53
54export async function release($: EngineInterface) {
55  await $.state.set(PRESSING, null)
56}
57
58// A session's start, a reload's included: a press the last copy of the module was recording
59// can't finish, so none is left holding the buttons.
60export async function releaseAll($: EngineInterface) {
61  await $.state.set(PRESSING, null)
62  await $.state.set(STOPPING, false)
63  await $.state.set(SUGGESTING, null)
64}
65
66// Hands the session each prompt, in order, once. Each prompt stands alone: one the engine drops,
67// or a call that rejects, isn't handed, and the ones after it still are. However many aren't, the
68// toast `notHanded` says so once, unless `quiet`. True when every prompt was handed over.
69export async function submitAll($: EngineInterface, prompts: readonly string[], notHanded: string = NOT_HANDED, quiet = false): Promise<boolean> {
70  let handed = true
71  for (const text of prompts) {
72    try {
73      if ((await $.prompt.submit({ text })).drop !== undefined) handed = false
74    } catch {
75      handed = false
76    }
77  }
78  if (!handed && !quiet) $.ui.toast(notHanded)
79  return handed
80}
81
82// A press once taken: said at once, sent with the session's folder, the bridge's words said (or
83// that it couldn't be recorded), the press released so the buttons are live again, `after`
84// started, and each prompt the bridge gave handed to the front desk. Never throws.
85export async function carry($: EngineInterface, request: Record<string, unknown>, taken: string, done: () => Promise<unknown>, after: After) {
86  let answer: Answer | null = null
87  try {
88    $.ui.toast(taken)
89    answer = await ask($, { ...request, cwd: await $.session.root() })
90  } catch {
91    answer = null
92  }
93  try {
94    await done()
95  } catch {
96    // The next session start releases it.
97  }
98  if (answer?.says !== undefined) $.ui.toast(answer.says)
99  else if (answer === null || !answer.ok) $.ui.toast(FAILED)
100  void after().catch(() => undefined)
101  await submitAll($, answer?.submit ?? [])
102}
103
104// A press on one of a card's buttons.
105async function pressButton($: EngineInterface, card: Card, label: string, after: After) {
106  const id = card.id as string
107  if (!(await claim($, { on: id, label }))) return
108  await carry($, { cmd: 'press', origin: 'pane', card: id, hash: card.hash, label }, RECORDING, () => release($), after)
109}
110
111// Why? on a card. One the bridge gave as `why` opens or closes the reasons in place, and opening
112// tells the bridge quietly, never waiting for it and showing nothing. One it gave as `turn` (a
113// card whose parts can't be trusted to show) hands the front desk a prompt naming the card, after
114// the same quiet word, and opens nothing.
115async function pressWhy($: EngineInterface, card: Card, turn: boolean) {
116  const id = card.id as string
117  const cwd = await $.session.root()
118  const quiet = { cmd: 'why', origin: 'pane', cwd, card: id, hash: card.hash }
119  if (turn) {
120    if (!(await claim($, { on: id, label: WHY }))) return
121    void ask($, quiet)
122    try {
123      await submitAll($, [`${PANE_LEAD} the director pressed ${WHY} on card ${typeof card.n === 'number' ? card.n : id} (${id}).`])
124    } finally {
125      await release($)
126    }
127    return
128  }
129  const open = (await $.state.get(WHY_OPEN)).value === id
130  if (!open) void ask($, quiet)
131  await $.state.set(WHY_OPEN, open ? null : id)
132}
133
134// Suggest a change… on a card opens its box (or, where the surface has no box, the note to type
135// the change in the chat). The words already typed for that card stay.
136async function openSuggest($: EngineInterface, card: Card) {
137  const kept = (await $.state.get(SUGGESTING)).value
138  if (kept?.card === card.id) return
139  await $.state.set(SUGGESTING, { card: card.id as string, words: '' })
140}
141
142// The words typed in the box, kept in the session's state while the box is open.
143async function keepWords($: EngineInterface, id: string, words: string) {
144  await $.state.set(SUGGESTING, { card: id, words })
145}
146
147// Cancel: the box closes and its words are gone. Nothing is sent.
148async function closeSuggest($: EngineInterface) {
149  await $.state.set(SUGGESTING, null)
150}
151
152// Send, or Enter in the box (which hands over the words as typed): the words steer a new proposal
153// and never approve anything. Nothing is sent while the words are empty or another press is being
154// recorded. The box closes once the bridge has answered, and its `submit` prompt goes to the front desk.
155async function pressSuggest($: EngineInterface, card: Card, after: After, typed?: string) {
156  const id = card.id as string
157  const kept = (await $.state.get(SUGGESTING)).value
158  const words = (typed ?? (kept?.card === id ? kept.words : '')).trim()
159  if (words === '') return
160  if (!(await claim($, { on: id, label: SUGGEST }))) return
161  await carry($, { cmd: 'suggest', origin: 'pane', card: id, hash: card.hash, words }, RECORDING, async () => {
162    await closeSuggest($)
163    await release($)
164  }, after)
165}
166
167// STOP: never held back by another press, only by its own.
168async function pressStop($: EngineInterface, after: After) {
169  const held = await $.state.get(STOPPING)
170  if (held.value === true) return
171  if (!(await $.state.set(STOPPING, true, { ifVersion: held.version })).isSet) return
172  await carry($, { cmd: 'stop', origin: 'pane' }, RECORDING, () => $.state.set(STOPPING, false), after)
173}
174
175// Lift STOP, carrying the start of the STOP the pane drew: the bridge lifts only that one.
176async function pressLift($: EngineInterface, since: unknown, after: After) {
177  if (!(await claim($, { on: LIFT, label: 'Lift STOP' }))) return
178  await carry($, { cmd: 'go', origin: 'pane', since: typeof since === 'string' ? since : null }, RECORDING, () => release($), after)
179}
180
181// The labels of the buttons the bridge gave a card, in its order, each once.
182function labelsOf(card: Card): string[] {
183  const given = Array.isArray(card.buttons) ? card.buttons : []
184  return [...new Set(given.map((b) => (isRecord(b) && typeof b.label === 'string' ? b.label : '')).filter((label) => label !== ''))]
185}
186
187// STOP's control, which the pane lays out: STOP while STOP is off, Lift STOP while it is on, or
188// the word that the press is being recorded. `live` is false while HIDE_WHILE_BUSY holds every
189// button back.
190export async function drawStop($: EngineInterface, e: RenderInput<'Pane'>, model: PaneModel, pressing: Pressing, live: boolean, after: After) {
191  const t = $.ui.resolve(e)
192  if (!live) return footnote(t, WAIT)
193  const stop = isRecord(model.stop) ? model.stop : {}
194  if (stop.on === true) {
195    if (pressing?.on === LIFT) return body(t, RECORDING)
196    if (pressing !== null) return footnote(t, 'Lift STOP')
197    const since = stop.since
198    return filled(t, LIFT, 'Lift STOP', () => pressLift($, since, after))
199  }
200  if ((await $.state.get(STOPPING)).value === true) return body(t, RECORDING)
201  return stopButton(t, () => pressStop($, after))
202}
203
204const isShape = (shape: unknown): shape is 'filled' | 'outlined' | 'small' | 'link' =>
205  shape === 'filled' || shape === 'outlined' || shape === 'small' || shape === 'link'
206
207// A button the bridge gave a card, as the mod reads it.
208type Given = { label: string, does: string, shape: 'filled' | 'outlined' | 'small' | 'link', href: string | null, corner: boolean }
209
210// The buttons the bridge gave a card, in its order, each label once. A shape the mod doesn't know
211// is drawn small.
212function givenOf(card: Card): Given[] {
213  const given = Array.isArray(card.buttons) ? card.buttons : []
214  const seen = new Set<string>()
215  const out: Given[] = []
216  for (const b of given) {
217    if (!isRecord(b) || typeof b.label !== 'string' || b.label === '' || seen.has(b.label)) continue
218    seen.add(b.label)
219    out.push({
220      label: b.label,
221      does: typeof b.does === 'string' ? b.does : 'record',
222      shape: isShape(b.shape) ? b.shape : 'small',
223      href: typeof b.href === 'string' && /^https:/.test(b.href) ? b.href : null,
224      corner: b.corner === true,
225    })
226  }
227  return out
228}
229
230const text = (value: unknown): string | null => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
231
232// A card with no parts: its text whole as plain text, its note under it, and one button per entry
233// of its buttons, keyed by the card's id and the label. Its Why? has no reasons to open, so it hands
234// the front desk a prompt naming the card, as Why? does on a card failing its check.
235function drawWhole($: EngineInterface, e: RenderInput<'Pane'>, card: Card, pressing: Pressing, live: boolean, after: After) {
236  const { Box, Text, Button } = $.ui.resolve(e)
237  const id = typeof card.id === 'string' ? card.id : null
238  const labels = id === null || !live ? [] : labelsOf(card)
239  return (
240    <Box flexDirection="column">
241      <Text>{card.text as string}</Text>
242      {typeof card.note === 'string' && <Text dimColor>{card.note}</Text>}
243      {labels.length > 0 && (
244        <Box flexDirection="row" flexWrap="wrap">
245          {labels.map((label) => {
246            if (pressing?.on === id && pressing.label === label) return <Text>{RECORDING}</Text>
247            if (pressing !== null) return <Text dimColor>{label}</Text>
248            if (label === WHY) return <Button key={`card:${id}:${label}`} label={label} onPress={() => pressWhy($, card, true)} />
249            return <Button key={`card:${id}:${label}`} label={label} onPress={() => pressButton($, card, label, after)} />
250          })}
251        </Box>
252      )}
253    </Box>
254  )
255}
256
257// One of a card's buttons in the shape the bridge gave it, or its plain word while another press
258// is recorded.
259function drawButton($: EngineInterface, t: Table, card: Card, id: string, b: Given, pressing: Pressing, open: boolean, after: After) {
260  if (b.shape === 'link' && b.href !== null) return link(t, b.href, b.label)
261  if (pressing?.on === id && pressing.label === b.label) return body(t, RECORDING)
262  if (pressing !== null) return footnote(t, b.label)
263  const key = `card:${id}:${b.label}`
264  if (b.does === 'suggest') {
265    const open = () => openSuggest($, card)
266    if (b.shape === 'filled') return filled(t, key, b.label, open)
267    if (b.shape === 'outlined') return outlined(t, key, b.label, open)
268    return small(t, key, b.label, open)
269  }
270  const label = b.does === 'why' && open ? HIDE_WHY : b.label
271  // Each press is written out beside its shape, since scripts/mod/validate.js reads a press only as
272  // the last argument of filled, outlined or small.
273  const why = b.does === 'why' || (b.does === 'turn' && b.label === WHY)
274  const turn = b.does === 'turn'
275  if (b.shape === 'filled') return why ? filled(t, key, label, () => pressWhy($, card, turn)) : filled(t, key, label, () => pressButton($, card, b.label, after))
276  if (b.shape === 'outlined') return why ? outlined(t, key, label, () => pressWhy($, card, turn)) : outlined(t, key, label, () => pressButton($, card, b.label, after))
277  return why ? small(t, key, label, () => pressWhy($, card, turn)) : small(t, key, label, () => pressButton($, card, b.label, after))
278}
279
280// The box for a suggested change, a few lines tall: the words typed so far, Send (plain while there are none or
281// another press is recorded) and Cancel. Where the surface has no Input, the note says to type it
282// in the chat instead.
283function drawSuggest($: EngineInterface, t: Table, typeable: boolean, card: Card, id: string, words: string, pressing: Pressing, after: After) {
284  if (!typeable) return footnote(t, typeYourChange(typeof card.n === 'number' ? card.n : id))
285  const recording = pressing?.on === id && pressing.label === SUGGEST
286  const send = words.trim() === '' || pressing !== null ? (recording ? body(t, RECORDING) : footnote(t, 'Send')) : filled(t, 'suggest-send', 'Send', () => pressSuggest($, card, after))
287  return (
288    <t.Box flexDirection="column" gap="xs">
289      <t.Input
290        key={SUGGEST_BOX(id)}
291        label={WHAT_WOULD_YOU_CHANGE}
292        value={words}
293        rows={SUGGEST_ROWS}
294        autoFocus
295        onInput={(v: string) => keepWords($, id, v)}
296        onSubmit={(v: string) => pressSuggest($, card, after, v)}
297      />
298      <t.Box flexDirection="row" gap="sm">
299        {send}
300        {outlined(t, 'suggest-cancel', 'Cancel', () => closeSuggest($))}
301      </t.Box>
302    </t.Box>
303  )
304}
305
306// A card with parts, part by part (design section 23.3). `place` is "1 of 2". Nothing in its words
307// is a link or a heading but the ask. `suggesting` is the words typed in its Suggest a change… box
308// while that is open, else null.
309function drawParts($: EngineInterface, e: RenderInput<'Pane'>, card: Card, parts: Card, place: string | null, pressing: Pressing, open: boolean, suggesting: string | null, live: boolean, after: After) {
310  const t = $.ui.resolve(e)
311  const { Box } = t
312  const id = typeof card.id === 'string' ? card.id : null
313  const header = text(parts.header) ?? text(card.header)
314  const where = card.revised === true ? 'revised' : place
315  const given = id === null || !live ? [] : givenOf(card)
316  const draw = (b: Given) => drawButton($, t, card, id as string, b, pressing, open, after)
317  const corner = given.find((b) => b.corner)
318  const minutes = typeof parts.optional === 'number' && parts.optional > 0 ? `optional · ${parts.optional} min` : null
319  const smallLine = [text(parts.small), text(card.waited), minutes].filter((x) => x !== null).join(' · ')
320  const options = Array.isArray(parts.options) ? parts.options.filter(isRecord) : []
321  const reasons = [text(parts.why), ...options.map((o) => (text(o.label) === null ? null : `${o.label}: ${text(o.says) ?? ''}`.replace(/: $/, ''))), text(parts.meanwhile)]
322  const row = given.filter((b) => !b.corner && (b.does === 'why' || b.shape === 'link' || b.label === SHOW_ME))
323  const answers = given.filter((b) => !b.corner && !row.includes(b))
324  const checking = [text(parts.line), text(parts.code)].filter((x) => x !== null).join(' · ')
325  const changed = text(parts.changed)
326  return (
327    <Box flexDirection="column" gap="xs">
328      <Box flexDirection="row" justifyContent="space-between">
329        {footnote(t, [header, where].filter((x) => x !== null).join(' · '))}
330        {corner !== undefined && draw(corner)}
331      </Box>
332      {text(parts.ask) !== null && heading(t, parts.ask as string)}
333      {text(parts.what) !== null && text(parts.what) !== text(parts.ask) && body(t, parts.what as string)}
334      {smallLine !== '' && footnote(t, smallLine)}
335      {text(parts.recommendation) !== null && body(t, parts.recommendation as string)}
336      {card.revised === true && changed !== null && body(t, `${WHAT_CHANGED} ${changed}`)}
337      {row.length > 0 && <Box flexDirection="row" gap="sm">{row.map(draw)}</Box>}
338      {open && reasons.filter((r) => r !== null).map((r) => body(t, r as string))}
339      {typeof card.steered === 'string' && footnote(t, suggestedAt(card.steered))}
340      {answers.length > 0 && <Box flexDirection="row" gap="sm" flexWrap="wrap">{answers.map(draw)}</Box>}
341      {suggesting !== null && id !== null && given.some((b) => b.does === 'suggest') && drawSuggest($, t, e.surface !== 'mobile', card, id, suggesting, pressing, after)}
342      {typeof card.note === 'string' && footnote(t, card.note)}
343      {checking !== '' && caption(t, checking)}
344    </Box>
345  )
346}
347
348// A card, drawn as its parts, or as its text whole when it has none. `place` is "1 of 2".
349export async function drawCard($: EngineInterface, e: RenderInput<'Pane'>, card: Card, place: string | null, pressing: Pressing, live: boolean, after: After) {
350  if (!isRecord(card.parts)) return drawWhole($, e, card, pressing, live, after)
351  const open = typeof card.id === 'string' && (await $.state.get(WHY_OPEN)).value === card.id
352  const suggest = (await $.state.get(SUGGESTING)).value
353  const suggesting = typeof card.id === 'string' && suggest?.card === card.id ? suggest.words : null
354  return drawParts($, e, card, card.parts, place, pressing, open, suggesting, live, after)
355}
356
hooks/mod/settings.tsx 325 lines
1// hooks/mod/settings.tsx
2// The pane's settings view, reached by Settings on the board view and left by Done. The bridge
3// sends the rows in groups (scripts/mod/settings-pane.js): each group is drawn as a group with its
4// note under it, and each row by its kind. A `select` row is its name and a Select (on the phone, a
5// Button that opens the choices on their own page); a pick sends the setting's key and the chosen
6// value under the origin `pane`, and the bridge both sets it and signs the director's confirmation.
7// A `change` row is its name, its value and Change…, which hands the front desk a prompt naming the
8// row and asks the bridge nothing. A `value` row is its name and value. A row changed less than a
9// minute ago says so and offers Undo, which sends the choice `undo`; the pane is drawn again when
10// that minute is up, so Undo leaves then.
11import type { EngineInterface, PluginState, RenderInput } from 'claude-code'
12import { ask } from './ask'
13import { body, filled, footnote, group, heading, outlined, small, title } from './calm'
14import type { Table } from './calm'
15import { PANE_LEAD, RECORDING, UNDO_MS } from './numbers'
16import { carry, claim, release, submitAll, type After } from './cards'
17
18type Pressing = PluginState['wheelhouse']['pressing']
19type Settings = PluginState['wheelhouse']['settings']
20type Group = NonNullable<Settings>['groups'][number]
21type Row = NonNullable<Group['rows']>[number]
22type Option = NonNullable<Row['options']>[number]
23
24const VIEW = { plugin: 'wheelhouse', key: 'view' } as const
25const SETTINGS = { plugin: 'wheelhouse', key: 'settings' } as const
26const ASKED = { plugin: 'wheelhouse', key: 'asked' } as const
27const CHOOSING = { plugin: 'wheelhouse', key: 'choosing' } as const
28
29const READING = 'Reading the settings…'
30const NOT_READ = 'Wheelhouse couldn\'t read the settings'
31const CHANGE = 'Change…'
32const UNDO = 'undo'
33const JUST_NOW = 'Changed just now'
34
35const isRecord = (value: unknown): value is Record<string, unknown> =>
36  value !== null && typeof value === 'object' && !Array.isArray(value)
37
38// Reads the settings again. A read that fails keeps the groups last read, under a line saying so.
39export async function readSettings($: EngineInterface) {
40  const before = (await $.state.get(SETTINGS)).value ?? null
41  let answer = null
42  try {
43    answer = await ask($, { cmd: 'settings', cwd: await $.session.root() })
44  } catch {
45    answer = null
46  }
47  if (answer?.ok === true && answer.groups !== undefined) {
48    await $.state.set(SETTINGS, { rows: (answer.rows ?? []) as NonNullable<Settings>['rows'], groups: answer.groups as Group[], text: answer.text ?? null, failed: false })
49  } else {
50    await $.state.set(SETTINGS, { rows: before?.rows ?? [], groups: before?.groups ?? [], text: before?.text ?? null, failed: true })
51  }
52}
53
54// Settings on the board view: the view changes at once and the settings are read. Each row's
55// Change… is live again.
56export async function showSettings($: EngineInterface) {
57  await $.state.set(SETTINGS, null)
58  await $.state.set(ASKED, [])
59  await $.state.set(CHOOSING, null)
60  await $.state.set(VIEW, 'settings')
61  await readSettings($)
62}
63
64export async function showBoard($: EngineInterface) {
65  await $.state.set(CHOOSING, null)
66  await $.state.set(VIEW, 'board')
67}
68
69// On the phone, a row's choices open on their own page (the row's key), or the list again (null).
70async function choose($: EngineInterface, key: string | null) {
71  await $.state.set(CHOOSING, key)
72}
73
74// A pick in a row: a Select's, a choice on the phone's page, or Undo (`undo`). One press is
75// recorded at a time, as a button's is; a pick the bridge refuses (STOP is on) says so, and the
76// read after it draws the row back at the value in force. A pick of the value already in force
77// (`held`) sends nothing; Undo is not a pick of a value.
78async function pressChoice($: EngineInterface, key: string, choice: string, held: string | null | undefined) {
79  if (choice === held) {
80    await choose($, null)
81    return
82  }
83  if (!(await claim($, { on: `setting:${key}`, label: choice }))) return
84  await choose($, null)
85  await carry($, { cmd: 'setting', origin: 'pane', key, choice }, RECORDING, () => release($), () => readSettings($))
86}
87
88// A session's start, a reload's included: every row's Change… is live again and no page of choices
89// is open.
90export async function forgetAsks($: EngineInterface) {
91  await $.state.set(ASKED, [])
92  await $.state.set(CHOOSING, null)
93}
94
95// Marks a row asked, or unmarks it. Written at the version read, again on a miss, so a row
96// another press marked meanwhile is kept. True when the write landed.
97async function mark($: EngineInterface, key: string, on: boolean): Promise<boolean> {
98  for (let tries = 0; tries < 3; tries += 1) {
99    const held = await $.state.get(ASKED)
100    const asked = held.value ?? []
101    if (on && asked.includes(key)) return false
102    const next = on ? [...asked, key] : asked.filter((k) => k !== key)
103    if ((await $.state.set(ASKED, next, { ifVersion: held.version })).isSet) return true
104  }
105  return false
106}
107
108// Change…: a prompt naming the row, in the pane's own words. It goes to the front desk, not the
109// bridge, so it carries no origin of its own. The row is marked asked once the press is taken,
110// and the mark stays until the settings view is opened again (or the session starts again), so
111// its Change… is plain text till then and a double click, however far apart, asks once. The press
112// itself is released once the prompt is handed over, not once its turn starts, so the other
113// buttons stay live while a turn runs. A prompt that can't be handed unmarks the row, so it can be
114// asked again.
115async function askFrontDesk($: EngineInterface, key: string, rowLabel: string) {
116  if (!(await claim($, { on: `setting:${key}`, label: 'ask' }))) return
117  let marked = false
118  try {
119    marked = await mark($, key, true)
120  } catch {
121    marked = false
122  }
123  const handed = marked ? submitAll($, [`${PANE_LEAD} the director wants to change ${rowLabel}.`]) : Promise.resolve(true)
124  try {
125    await release($)
126  } catch {
127    // The next session start releases it.
128  }
129  if (!(await handed)) {
130    try {
131      await mark($, key, false)
132    } catch {
133      // Opening the settings view again unmarks it.
134    }
135  }
136}
137
138// What a row is, as the bridge sent it: a key and a name, else it isn't drawn.
139const drawable = (row: unknown): row is Row => isRecord(row) && typeof row.key === 'string' && typeof row.label === 'string'
140const optionsOf = (row: Row): { value: string, label: string }[] =>
141  (Array.isArray(row.options) ? row.options : [])
142    .filter((o): o is Option => isRecord(o) && typeof o.value === 'string' && o.value !== '')
143    .map((o) => ({ value: o.value as string, label: typeof o.label === 'string' ? o.label : (o.value as string) }))
144
145// The value in force, in words: the chosen option's label, else the words the bridge gave
146// (a temporary setting matching no option), else the value.
147function shownOf(row: Row): string {
148  const chosen = optionsOf(row).find((o) => o.value === row.value)
149  if (chosen !== undefined) return chosen.label
150  if (typeof row.current === 'string') return row.current
151  return typeof row.value === 'string' ? row.value : ''
152}
153
154// When a row's latest change stops being undoable: NaN for a change that can be undone with no
155// time known, null for a row with nothing to undo.
156function undoEnds(row: Row): number | null {
157  if (!isRecord(row.changed) || row.changed.undo !== true) return null
158  const at = typeof row.changed.at === 'number' ? row.changed.at : typeof row.changed.at === 'string' ? Date.parse(row.changed.at) : NaN
159  return at + UNDO_MS
160}
161
162// Whether a row's latest change is under a minute old and can still be undone.
163function justChanged(row: Row, now: number): boolean {
164  const ends = undoEnds(row)
165  return ends !== null && (!Number.isFinite(ends) || now < ends)
166}
167
168// The times a redraw is asked for, so an Undo leaves when its minute is up although nothing else
169// redraws the pane. A reload starts the module over, and its first draw asks again.
170const redraws = new Set<number>()
171function redrawAt($: EngineInterface, due: number, now: number) {
172  if (redraws.has(due)) return
173  redraws.add(due)
174  $.clock.after(Math.max(1, due - now), () => {
175    redraws.delete(due)
176    $.ui.invalidate('ui.render')
177  })
178}
179
180// The settings view: Settings with Done, each group with its note, then (on the phone) a row's
181// choices on their own page. `live` is false while pane.tsx's HIDE_WHILE_BUSY holds every button
182// back; `now` is the time the pane is drawn at.
183export async function drawSettings($: EngineInterface, e: RenderInput<'Pane'>, settings: Settings | undefined, pressing: Pressing, live: boolean, now: number) {
184  const t: Table = $.ui.resolve(e)
185  if (settings === null || settings === undefined) return body(t, READING)
186  const phone = e.surface === 'mobile'
187  const [asked, choosing] = await Promise.all([$.state.get(ASKED), $.state.get(CHOOSING)])
188  const groups = (Array.isArray(settings.groups) ? settings.groups : []).filter(isRecord)
189  const rowsOf = (g: Group) => (Array.isArray(g.rows) ? g.rows : []).filter(drawable)
190
191  // A row that is being recorded, and whether it is an Undo.
192  const recording = (key: string) => pressing?.on === `setting:${key}` && pressing.label !== 'ask'
193  const undoing = (key: string) => pressing?.on === `setting:${key}` && pressing.label === UNDO
194
195  // A row's choices on their own page (the phone's, and a row with no value in force anywhere), each a Button with a tick after the one in force.
196  if (live && typeof choosing.value === 'string') {
197    const open = groups.flatMap(rowsOf).find((row) => row.key === choosing.value)
198    if (open !== undefined) {
199      const key = open.key as string
200      const name = open.label as string
201      return (
202        <t.Box flexDirection="column" gap="sm">
203          {small(t, 'settings-back', '‹ Settings', () => choose($, null))}
204          {heading(t, name)}
205          {optionsOf(open).map((option) => {
206            const label = option.value === open.value ? `${option.label} ✓` : option.label
207            return pressing === null
208              ? outlined(t, `setting:${key}:${option.value}`, label, () => pressChoice($, key, option.value, open.value))
209              : footnote(t, label)
210          })}
211        </t.Box>
212      )
213    }
214  }
215
216  // A row's value where it can't be changed just now: plain dim words, or Recording… for the row
217  // being recorded.
218  const valueOf = (row: Row) => (recording(row.key as string) && !undoing(row.key as string) ? body(t, RECORDING) : footnote(t, shownOf(row)))
219
220  const selectOf = (row: Row) => {
221    const key = row.key as string
222    const name = row.label as string
223    const options = optionsOf(row)
224    if (!live || pressing !== null || options.length === 0) return valueOf(row)
225    if (phone) return outlined(t, `setting:${key}`, `${name}  ${shownOf(row)} ›`, () => choose($, key))
226    // No value in force (a temporary setting matching no option): its words, and Change… opens the choices. No Select, which might pick one of itself.
227    if (typeof row.value !== 'string') {
228      return (
229        <t.Box flexDirection="row" gap="sm">
230          {footnote(t, shownOf(row))}
231          {small(t, `setting:${key}:change`, CHANGE, () => choose($, key))}
232        </t.Box>
233      )
234    }
235    return (
236      <t.Select
237        key={`setting:${key}`}
238        options={options}
239        value={typeof row.value === 'string' ? row.value : undefined}
240        onSelect={(v: string) => pressChoice($, key, v, row.value)}
241      />
242    )
243  }
244
245  const changeOf = (row: Row) => {
246    const key = row.key as string
247    const name = row.label as string
248    if (pressing?.on === `setting:${key}` && pressing.label === 'ask') return body(t, RECORDING)
249    if (!live || pressing !== null || asked.value?.includes(key) === true) return footnote(t, CHANGE)
250    return small(t, `setting:${key}:change`, CHANGE, () => askFrontDesk($, key, name))
251  }
252
253  const undoOf = (row: Row) => {
254    const key = row.key as string
255    const name = row.label as string
256    if (undoing(key)) return body(t, RECORDING)
257    if (!live || pressing !== null) return footnote(t, 'Undo')
258    return small(t, `setting:${key}:undo`, 'Undo', () => pressChoice($, key, UNDO, row.value))
259  }
260
261  const rowOf = (row: Row) => {
262    const name = row.label as string
263    const ends = undoEnds(row)
264    if (row.kind === 'select' && ends !== null && Number.isFinite(ends) && now < ends) redrawAt($, ends, now)
265    const changed = row.kind === 'select' && justChanged(row, now) && (
266      <t.Box flexDirection="row" gap="sm">
267        {footnote(t, JUST_NOW)}
268        {undoOf(row)}
269      </t.Box>
270    )
271    if (row.kind === 'select') {
272      // On the phone the row's Button carries the name and value; elsewhere the name stands beside the Select.
273      const picker = selectOf(row)
274      return (
275        <t.Box flexDirection="column">
276          {phone && live && pressing === null && optionsOf(row).length > 0
277            ? picker
278            : (
279              <t.Box flexDirection="row" gap="sm">
280                {body(t, name)}
281                {picker}
282              </t.Box>
283            )}
284          {changed}
285        </t.Box>
286      )
287    }
288    if (row.kind === 'change') {
289      return (
290        <t.Box flexDirection="column">
291          <t.Box flexDirection="row" gap="sm">
292            {body(t, name)}
293            {changeOf(row)}
294          </t.Box>
295          {typeof row.value === 'string' && row.value !== '' && footnote(t, row.value)}
296        </t.Box>
297      )
298    }
299    return (
300      <t.Box flexDirection="row" gap="sm">
301        {body(t, name)}
302        {typeof row.value === 'string' && row.value !== '' && footnote(t, row.value)}
303      </t.Box>
304    )
305  }
306
307  return (
308    <t.Box flexDirection="column" gap="sm">
309      <t.Box flexDirection="row" gap="sm">
310        {title(t, 'Settings')}
311        {live && filled(t, 'done', 'Done', () => showBoard($))}
312      </t.Box>
313      {settings.failed && body(t, NOT_READ)}
314      {groups.map((g) => group(
315        t,
316        <t.Box flexDirection="column" gap="xs">
317          {typeof g.title === 'string' && heading(t, g.title)}
318          {rowsOf(g).map(rowOf)}
319        </t.Box>,
320        typeof g.note === 'string' && g.note !== '' ? g.note : undefined,
321      ))}
322    </t.Box>
323  )
324}
325
types/index.d.ts 177 lines
1// types/index.d.ts
2// The mod's contract: the values it keeps in the session's `$.state`, under its own name. Claude
3// Code holds every `$.state` key the mod names to this file.
4
5// A button the bridge gave a card (scripts/mod/buttons.js): its label, whether a press records it
6// ('record'), hands the front desk a prompt ('turn'), opens the card's reasons ('why') or opens the
7// box for the director's words ('suggest'), and its shape: 'filled', 'outlined', 'small' or 'link'.
8// A link carries its address in `href`; `corner` marks Not now, drawn in the card's header row.
9// The mod draws the label and sends it; `does` is the bridge's.
10export type WheelhouseCardButton = { label?: string; does?: string; shape?: string; href?: string; corner?: boolean };
11
12// A card's parts as the composer keeps them (scripts/cards/compose.js), in plain words. Every field
13// is read as possibly missing.
14export type WheelhouseCardParts = {
15  kind?: string;
16  header?: string;
17  ask?: string;
18  what?: string;
19  small?: string;
20  recommendation?: string;
21  options?: { label?: string; says?: string; recommended?: boolean }[];
22  why?: string;
23  meanwhile?: string;
24  checks?: string;
25  changed?: string;
26  line?: string | null;
27  code?: string | null;
28  suggest?: boolean;
29  optional?: number;
30  revises?: string;
31};
32
33// A waiting card as the pane's model holds it (scripts/mod/status.js). Its text is drawn whole.
34export type WheelhousePaneCard = {
35  id?: string;
36  n?: number;
37  header?: string;
38  kind?: string;
39  text?: string;
40  hash?: string;
41  buttons?: WheelhouseCardButton[];
42  note?: string | null;
43  blocking?: boolean;
44  asked?: boolean;
45  // The card's parts, or null for a card with none, which is drawn as its text.
46  parts?: WheelhouseCardParts | null;
47  // When the director last suggested a change on it, as HH:MM in the computer's own time, or null.
48  steered?: string | null;
49  // "asked 2 hours ago", or null.
50  waited?: string | null;
51  // Whether it is a revised proposal, replacing a card.
52  revised?: boolean;
53};
54
55// A piece of work being built, and when the director will see it (null for the line "and 2 more").
56export type WheelhouseBuilding = { text?: string; see?: string | null };
57
58// Progress to launch: blockers resolved of all, those resolved since the director's last visit, and
59// what is next, the director's own marked.
60export type WheelhouseLaunch = { resolved?: number; total?: number; gained?: number; next?: { text?: string; yours?: boolean }[] };
61
62// The health group's facts: whether all is well, the protection level (Standard or Locked) and the
63// week's share line. `health` stays the list of lines at watch or stop.
64export type WheelhouseHealthNote = { allWell?: boolean; protection?: string | null; week?: string | null };
65
66// A health line, `of` naming STOP's own line ('stop') or an incident's.
67export type WheelhouseHealthLine = { level?: string; text?: string; of?: string | null };
68
69// The pane's model as the bridge sends it (scripts/mod/status.js paneModel). It comes from
70// another process, so every field is read as possibly missing.
71export type WheelhousePaneModel = {
72  v?: number;
73  project?: string;
74  at?: string | null;
75  glance?: string;
76  // The one-line verdict, and the line saying when the director was last here (null for never).
77  verdict?: string;
78  since?: string | null;
79  launch?: WheelhouseLaunch | null;
80  ideas?: { count?: number; fresh?: number };
81  // When the model was built, as HH:MM.
82  updated?: string | null;
83  stop?: { on?: boolean; since?: string | null };
84  cards?: WheelhousePaneCard[];
85  // Each piece of work as { text, see }; a model an older bridge wrote holds lines of words instead.
86  building?: (WheelhouseBuilding | string)[];
87  health?: WheelhouseHealthLine[];
88  healthNote?: WheelhouseHealthNote;
89  usage?: string | null;
90  finished?: string[];
91  incidents?: string[];
92  watcher?: string | null;
93  missing?: string[];
94};
95
96// A row of the settings view as the bridge sends it (scripts/mod/settings-pane.js settingsRows).
97export type WheelhouseSettingsRow = {
98  key?: string;
99  label?: string;
100  current?: string;
101  from?: string;
102  until?: string;
103  choices?: { id?: string; label?: string; chosen?: boolean }[];
104  here?: boolean;
105  why?: string;
106};
107
108// A row of a settings group (scripts/mod/settings-pane.js settingsGroups). A `select` row has its
109// `options` and the `value` in force, which is null when a temporary setting matches no option;
110// its words are then in `current`. `changed` is there while the change can still be undone.
111export type WheelhouseGroupRow = {
112  key?: string;
113  label?: string;
114  value?: string | null;
115  kind?: 'select' | 'change' | 'value';
116  options?: { value?: string; label?: string }[];
117  current?: string;
118  changed?: { at?: string | number; undo?: boolean };
119};
120
121// A group of the settings view, with its note (empty for none).
122export type WheelhouseSettingsGroup = { id?: string; title?: string; note?: string; rows?: WheelhouseGroupRow[] };
123
124// The press the bridge is recording: where it was made (a card's id, `setting:<key>`, or
125// `lift-stop`) and the label pressed.
126export type WheelhousePressing = { on: string; label: string };
127
128declare module 'claude-code' {
129  interface PluginState {
130    wheelhouse: {
131      // The last pane the bridge answered, and when the mod read it (`$.clock.now()`'s ms).
132      pane: { model: WheelhousePaneModel; readAt: number };
133      // The last tick: when it ended and whether it failed.
134      tick: { at: number; failed: boolean };
135      // The id of the card the pane shows.
136      card: string;
137      // The id of the card whose Why? is open in place, or null.
138      why: string | null;
139      // Whether a turn of the main loop is running.
140      busy: boolean;
141      // Where the session drew at its start, as session.start said; null for none.
142      surface: string | null;
143      // The role the last tick answered: 'front-desk', 'foreman', 'feature' or 'setup'.
144      role: string;
145      // Whether the last answered tick's answer held the pane's model: false for a session given
146      // no pane (a front desk outside a project among them). A failed tick leaves it as it was.
147      pictured: boolean;
148      // The id of the session whose front-desk answer opened the pane: once opened, a tick or a
149      // reload only retitles it while it is open. Null once the mod has closed it because the
150      // session's role is no longer the front desk's, so the next front-desk answer opens it.
151      opened: string | null;
152      // The press the bridge is recording, or null: while one is, every other button but STOP is
153      // drawn as plain text, so a double press sends once.
154      pressing: WheelhousePressing | null;
155      // The card whose Suggest a change… box is open, and the words typed in it so far, or null.
156      // Kept only while the box is open; cleared on Send, on Cancel and at a session's start.
157      suggesting: { card: string; words: string } | null;
158      // Whether a press of STOP is being recorded.
159      stopping: boolean;
160      // The session whose courier wake last couldn't be handed on, or null: its toast has shown, and
161      // shows again only after a wake is handed on or a turn starts.
162      unhanded: string | null;
163      // Which view the pane shows: the board or the settings.
164      view: 'board' | 'settings';
165      // The settings view's rows as the bridge last answered them, its line under them, and
166      // whether the last read failed; null while they are being read.
167      settings: { rows: WheelhouseSettingsRow[]; groups: WheelhouseSettingsGroup[]; text: string | null; failed: boolean } | null;
168      // The keys of the settings rows whose Ask the front desk has been handed over since the
169      // settings view was last opened. Each is drawn as plain text until the view is opened again,
170      // so a double click asks once.
171      asked: string[];
172      // On the phone, the key of the settings row whose choices are open on their own page, or null.
173      choosing: string | null;
174    };
175  }
176}
177