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

<!-- README.md -->
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.
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.
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.
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. 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.
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.
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:
~/.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..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.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.
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.
hooks/register.ts 204 lines1// 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}
204hooks/mod/ask.ts 89 lines1// 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}
89hooks/mod/pane.tsx 559 lines1// 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}
559hooks/mod/messages.ts 67 lines1// 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}
67hooks/mod/numbers.ts 53 lines1// 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}.`
53hooks/mod/calm.tsx 84 lines1// 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}
84hooks/mod/cards.tsx 356 lines1// 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}
356hooks/mod/settings.tsx 325 lines1// 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}
325types/index.d.ts 177 lines1// 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