Under-Empire overhaul mod: a scheming Skaven Warlock Engineer voice for prose, with plain professional artefacts, an Underlings pane of running subagents and…

The Skaven overhaul mod. Install it with:
./install.sh --overhaul under-empire
| Feature | What it is |
|---|---|
Warlock Engineer output style | A scheming Skaven voice for prose; code, commands and artefacts stay plain (output-styles/warlock-engineer.md) |
| Reminder hook | A UserPromptSubmit command hook that adds hooks/reminder.txt to every prompt, so the voice holds in long sessions |
| Underlings | A side pane listing running subagents and background tasks as animated Skaven rats (below) |
| Doomwheel | A side pane showing each PR's review process, read from the session and GitHub, as a Skaven Doomwheel (below) |
Claude Code loads one hooks module per plugin, so hooks/register.ts registers every mod in mods/. Each mod keeps its own source and tests in mods/<mod>/, and its $.state keys, prefixed <mod>., in the plugin's one contract, types/index.d.ts.
The engine takes one hook without a matcher per event from a plugin. Underlings holds those on session.start, tool.call, session.append and turn.complete. A later mod hooks those events with a matcher naming what it watches, as Doomwheel does.
A pane that opens whenever subagents or background tasks are running, and shows each one as an animated, pixel-art Skaven underling in a roster.
| Kind of work | Underling |
|---|---|
| Research (explore, investigate, map, plan) | Grey Seer |
| Experiment (spike, prototype, benchmark) | Apprentice |
| Building / coding (implement, fix, refactor) | Tunnel-Sapper |
| Tests and audits (test, lint, scan, review) | Eshin Inspector |
| Anything else | Clanrat |
/underlings opens the pane by hand.On the desktop app the rats are animated SVGs; in a terminal each is a single glyph.
| File | Role |
|---|---|
mods/underlings/register.tsx | The hooks (agent.spawn, turn.step, turn.complete, tool.call, session.append), the output follower and the Pane drawing |
mods/underlings/model.ts | Pure logic: classification, formatting, and parsers for the engine's messages |
mods/underlings/sprites.ts | The 24×24 rats, baked into self-animating SVG (SMIL) |
mods/underlings/row-hit.tsx | Client module: makes a whole roster row one click target |
mods/underlings/tests/ | Unit tests and Pane tests on the terminal and desktop surfaces |
$.fs.read refuses files over 4 MiB, so past that size the tail is read with tail, or with PowerShell's Get-Content -Tail for a Windows path, each falling back to the other. Supported on Windows and Linux; macOS is untested.totalTokens, which replaces it when a foreground subagent completes. Every request resends the whole conversation, so summing requests would count that history once per request.A pane that shows each PR's review process as a Skaven Doomwheel: a wooden wheel with a rat running inside, powered by a warpstone crystal. It reads the session and GitHub, and needs no skill: any PR review process drives it.
gh pr create.<ci-monitor-event>./doomwheel <url> (or /doomwheel owner/name#N). /doomwheel alone opens the pane.Wheels are kept per session. A wheel that is not retired resumes polling when the session starts again.
| Gauge | Meaning | Look |
|---|---|---|
| HEAT | Unresolved review threads + failing checks + 1 for a merge conflict | The crystal glows green at 0, rising to red at 5 or more (a cosmetic threshold) |
| LAPS | Submitted reviews, from any reviewer, with no cap | From lap 7 the wheel slows and the rat sweats. Cosmetic only: nothing halts |
| THREADS | unresolved/total review threads | Red while any are unresolved, green at 0 |
| Phase | When | Wheel |
|---|---|---|
| 📜 Offering laid | PR tracked, Auto-Fix not armed | Still |
| ⚙ Spinning | Auto-Fix on | Turning, with warp-lightning |
| ⚡ Warp-bolt! | A <ci-monitor-event> arrived; settles when the turn ends | Racing, lightning strikes |
| 💥 Cracked | Auto-Fix cut while the PR is open with work left (unresolved threads, failing checks or a conflict) | Cracked and smoking, the rat knocked out |
| ❄ Cold | Auto-Fix off, no open work | The rat sleeps |
| 🏆 Victory | PR merged | The rat stands on the still wheel holding the warpstone aloft, in a gold blaze |
| 🪦 Scrapped | PR closed unmerged | The wheel lies in a grey heap and the rat slinks away |
A cracked wheel goes back to Spinning when Auto-Fix is re-armed, or to Cold once its open work drops to zero. Victory and Scrapped are retired: polling stops, and Clear retired removes them.
| Event | Toast | Native notification | Pane opens |
|---|---|---|---|
| Wheel first appears | — | — | ✓ |
| Auto-Fix armed | ✓ | — | — |
| Cracked | ✓ | ✓ | ✓ |
| Victory | ✓ | — | ✓ |
| Scrapped | — | — | — |
On the desktop app the wheel is an animated SVG. In a terminal it is drawn as a single glyph.
Session signals find the PRs and make the wheel react at once. GitHub polling supplies the true numbers.
| Signal | Source | Read with |
|---|---|---|
| PR raised | A gh pr create Bash or PowerShell call whose output holds the PR URL | tool.call |
| Auto-Fix on / off | The desktop app's set_monitor call (auto_fix, url) and its answer | tool.call |
| Wake | A <ci-monitor-event> message | session.append (tool output is skipped, because event-shaped text there is data) |
| Wake settled | The main turn ending | turn.complete |
| PR state, reviews, threads, checks, mergeability | One gh api graphql query per PR | $.process.run |
Each live PR is polled every 60 seconds, and at once on a wake, on a gh call that names the PR, before a cut of its Auto-Fix and at the end of each turn. If gh is missing or not signed in, the wheel runs on session signals alone, with one dim line on its card saying so.
| File | Role |
|---|---|
mods/doomwheel/register.tsx | The hooks, the polling, the dispatch of events into state, and the Pane drawing |
mods/doomwheel/model.ts | Pure logic: the signal parsers, the poll query and its reader, the reducer that folds events into wheel state, and labels |
mods/doomwheel/sprites.ts | The Doomwheel, baked into self-animating SVG (SMIL) for every phase, heat and lap count |
mods/doomwheel/tests/ | Unit tests, and Pane tests on the terminal and desktop surfaces with gh and the clock mocked |
gh call names a PR by its URL, owner/name#N or its number standing alone, so a call that merely mentions the number also triggers a poll.run_checks.sh validates the plugin and runs its tests (see docs/running-checks.md). After editing, run /reload-plugins in a session. The plugin sets no version, so each commit is a new version (see the README).
hooks/register.ts 11 lines1import type { Register } from 'claude-code'
2
3import { register as registerDoomwheel } from '../mods/doomwheel/register'
4import { register as registerUnderlings } from '../mods/underlings/register'
5
6/** Claude Code loads one hooks module per plugin, so this one registers every mod the overhaul ships. */
7export const register: Register = (on, options) => {
8 registerUnderlings(on, options)
9 registerDoomwheel(on, options)
10}
11mods/doomwheel/register.tsx 291 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { LogEntry, Offering } from '../../types'
5import {
6 HEAT_LIMIT, LOG_GLYPHS, LOG_LIMIT, PHASES, apply, clock, ghTouches, heatColor, heatOf, isRetired, keyOf, lapsColor,
7 parseBash, parseCiEvent, parseMonitor, parsePrRef, pips, pollArgv, readPoll, revive, sanitize, textsIn, threadsGauge,
8} from './model'
9import type { WheelEvent } from './model'
10import { glyphOf, wheelSvg } from './sprites'
11
12const PANE = 'doomwheel'
13const PANE_TITLE = '⚙ Doomwheel'
14const WHEEL_PX = 88
15const CARD_LOG_LINES = 6
16const CARD_CAP = 3
17const POLL_MS = 60_000
18const POLL_TIMEOUT_MS = 30_000
19const LINE = 'rgba(128,128,128,0.35)'
20const SUNK_BG = 'rgba(0,0,0,0.22)'
21const SET_MONITOR_RE = /(^|__)set_monitor$/
22
23// The plugin is under-empire; every key Doomwheel owns there starts `doomwheel.`.
24const offerings = atom({ plugin: 'under-empire', key: 'doomwheel.offerings' } as const, [] as Offering[])
25const log = atom({ plugin: 'under-empire', key: 'doomwheel.log' } as const, [] as LogEntry[])
26
27/** The module's own: started once per load, since /reload-plugins starts the module over. */
28let ticker: Timer | undefined
29const inFlight = new Set<string>()
30
31async function readOfferings($: Engine): Promise<Offering[]> {
32 return (await read($, offerings)).map(revive)
33}
34
35// ---------------------------------------------------------------- dispatch
36
37async function openPane($: Engine) {
38 await $.ui.open({ id: PANE, title: PANE_TITLE }).catch(() => undefined)
39}
40
41async function dispatch($: Engine, event: WheelEvent) {
42 const before = await readOfferings($)
43 const applied = apply(before, event, Date.now())
44 await update($, offerings, () => applied.offerings)
45 if (applied.logs.length > 0) {
46 await update($, log, entries => [...entries, ...applied.logs].slice(-LOG_LIMIT))
47 }
48 if (applied.appeared.length > 0 || applied.alerts.some(alert => alert.openPane)) {
49 await openPane($)
50 }
51 for (const alert of applied.alerts) {
52 $.ui.toast(alert.text)
53 if (alert.notify) await $.ui.notify(alert.text, { title: 'Doomwheel' }).catch(() => undefined)
54 }
55 for (const key of applied.appeared) startPolling($, key, applied.offerings)
56}
57
58// ---------------------------------------------------------------- polling
59
60/** Asks GitHub about one PR, once at a time per PR; what it learns is dispatched like any signal. */
61async function poll($: Engine, repo: string, pr: number) {
62 const key = keyOf(repo, pr)
63 if (inFlight.has(key)) return
64 inFlight.add(key)
65 try {
66 let event: WheelEvent | undefined
67 try {
68 event = readPoll(repo, pr, await $.process.run(pollArgv(repo, pr), { timeoutMs: POLL_TIMEOUT_MS }))
69 } catch {
70 // `gh` could not start (or never answered): the wheel runs on session signals alone.
71 event = { type: 'ghUnavailable', repo, pr }
72 }
73 if (event) await dispatch($, event)
74 } finally {
75 inFlight.delete(key)
76 }
77}
78
79/** Polls each named PR that is still live, without holding up the hook that asked. */
80function pollNow($: Engine, keys: string[], items: Offering[]) {
81 for (const one of items) {
82 if (keys.includes(one.key) && !isRetired(one)) void poll($, one.repo, one.pr).catch(() => undefined)
83 }
84}
85
86async function pollLive($: Engine) {
87 const items = await readOfferings($)
88 pollNow($, items.map(one => one.key), items)
89}
90
91function ensureTicker($: Engine) {
92 ticker ??= $.clock.every(POLL_MS, () => {
93 void pollLive($).catch(() => undefined)
94 })
95}
96
97function startPolling($: Engine, key: string, items: Offering[]) {
98 ensureTicker($)
99 pollNow($, [key], items)
100}
101
102// ---------------------------------------------------------------- signals
103
104function contentText(content: unknown): string {
105 if (typeof content === 'string') return content
106 if (Array.isArray(content)) {
107 return content.map(block => contentText((block as { text?: unknown; content?: unknown }).text ?? (block as { content?: unknown }).content)).join('\n')
108 }
109 return ''
110}
111
112async function afterToolCall($: Engine, e: Record<string, unknown>, ran: unknown) {
113 const answer = ran as { deny?: string; result?: unknown }
114 const tool = String(e.tool ?? '')
115 if (SET_MONITOR_RE.test(tool)) {
116 const event = parseMonitor(e, textsIn(answer.result).join('\n'), answer.deny)
117 if (!event) return
118 // A cut cracks the wheel only with work left, so GitHub is asked first when the PR is named.
119 if (event.type === 'monitor' && event.repo && event.pr && !event.autoFix && !answer.deny) await poll($, event.repo, event.pr)
120 await dispatch($, event)
121 return
122 }
123 if ((tool !== 'Bash' && tool !== 'PowerShell') || answer.deny || !answer.result) return
124 const result = answer.result as { stdout?: unknown }
125 const output = typeof result.stdout === 'string' ? result.stdout : textsIn(answer.result).join('\n')
126 const command = String(e.command ?? '')
127 const event = parseBash(command, output)
128 if (event) await dispatch($, event)
129 const items = await readOfferings($)
130 pollNow($, ghTouches(command, items), items)
131}
132
133/** Clears the retired wheels (merged or closed) and their squeak-scrolls. */
134async function clearRetired($: Engine) {
135 const kept = (await readOfferings($)).filter(one => !isRetired(one))
136 const keys = new Set(kept.map(one => one.key))
137 await update($, offerings, () => kept)
138 await update($, log, entries => entries.filter(entry => keys.has(entry.key)))
139}
140
141// ---------------------------------------------------------------- hooks
142
143// The engine takes one unmatched hook per event from a plugin, and Underlings holds those on the
144// events both mods watch. So each of Doomwheel's hooks there names what it watches in a matcher.
145
146/** Every session start, interactive or not. */
147const ANY_SESSION = { isInteractive: [true, false] }
148/** The tools whose calls carry Doomwheel's signals: shell runs and the desktop app's set_monitor. */
149const WATCHED_TOOLS = { tool: ['Bash', 'PowerShell', SET_MONITOR_RE] } as const
150/** Every door but a tool result's: a genuine wake is its own message, and event-shaped text inside tool output is data. */
151const WAKE_DOORS = { door: ['prompt', 'command', 'response', 'tool-message', 'delivery', 'attachment', 'hook-context', 'note', 'compaction', 'notice'] } as const
152/** Every turn, aborted or not. */
153const ANY_TURN = { isAborted: [true, false] }
154
155export const register: Register = on => {
156 on('session.start', ANY_SESSION, async ($, e, next) => {
157 const started = await next(e)
158 await $.command.register({ name: 'doomwheel', description: 'Show the Doomwheel pane, or add a PR to it: /doomwheel <PR URL>' })
159 // Wheels the session kept from before resume polling; retired ones stay as they are.
160 const items = await readOfferings($).catch(() => [] as Offering[])
161 if (items.some(one => !isRetired(one))) {
162 ensureTicker($)
163 pollNow($, items.map(one => one.key), items)
164 }
165 return started
166 })
167
168 on('command.run', { command: 'doomwheel' }, async ($, e) => {
169 const args = String((e as { args?: unknown }).args ?? '').trim()
170 if (!args) {
171 await $.ui.open({ id: PANE, title: PANE_TITLE })
172 return { text: 'Doomwheel pane opened.' }
173 }
174 const target = parsePrRef(args)
175 if (!target) return { text: 'Doomwheel takes a PR URL, such as https://github.com/owner/name/pull/42, or owner/name#42.' }
176 await dispatch($, { type: 'added', ...target })
177 await openPane($)
178 return { text: `Doomwheel is tracking ${keyOf(target.repo, target.pr)}.` }
179 })
180
181 on('tool.call', WATCHED_TOOLS, async ($, e, next) => {
182 const ran = await next(e)
183 if (!(e as { agentId?: string }).agentId) {
184 await afterToolCall($, e as Record<string, unknown>, ran).catch(() => undefined)
185 }
186 return ran
187 }).catch(($, e, next) => next(e))
188
189 on('session.append', WAKE_DOORS, async ($, e, next) => {
190 const stored = await next(e)
191 if (!e.agentId) {
192 const event = parseCiEvent(contentText(e.message.content))
193 if (event && event.type === 'wake') {
194 await dispatch($, event).catch(() => undefined)
195 startPolling($, keyOf(event.repo, event.pr), await readOfferings($))
196 }
197 }
198 return stored
199 }).catch(($, e, next) => next(e))
200
201 on('turn.complete', ANY_TURN, async ($, e, next) => {
202 if (!e.agentId) {
203 await dispatch($, { type: 'turnEnd' }).catch(() => undefined)
204 await pollLive($).catch(() => undefined)
205 }
206 return next(e)
207 })
208
209 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210 const ui = $.ui.resolve(e)
211 const { Box, Text, Button } = ui
212 const Svg = e.surface === 'terminal' ? undefined : (ui as ElementTable<'desktop'>).Svg
213 const items = await readOfferings($)
214 const entries = await read($, log)
215 const columns = e.props.bodyColumns
216
217 if (items.length === 0) {
218 return (
219 <Box flexDirection="column" paddingX={1}>
220 <Text dimColor>The Doomwheel stands idle. It turns for a PR this session raises, arms Auto-fix on, or names with /doomwheel <PR URL>.</Text>
221 </Box>
222 )
223 }
224
225 const newestFirst = [...items].sort((a, b) => b.updatedAt - a.updatedAt)
226 const shown = newestFirst.slice(0, CARD_CAP)
227 const retired = items.filter(isRetired).length
228 const live = items.length - retired
229 const cell = Math.max(12, Math.floor((columns - 2) / 3))
230
231 const statCell = (label: string, value: string, color?: string) => (
232 <Box flexDirection="column" width={cell} borderStyle="single" borderColor={LINE} paddingX={1}>
233 <Text dimColor>{label}</Text>
234 <Text bold color={color}>{value}</Text>
235 </Box>
236 )
237
238 const card = (item: Offering) => {
239 const phase = PHASES[item.phase]
240 const heat = heatOf(item)
241 const threads = threadsGauge(item)
242 const lines = entries.filter(entry => entry.key === item.key).slice(-CARD_LOG_LINES)
243 return (
244 <Box key={`card-${item.key}`} flexDirection="column" borderStyle="single" borderColor={phase.color} marginTop={1}>
245 <Box flexDirection="row" gap={2} paddingX={1} paddingY={1}>
246 {Svg
247 ? <Svg source={wheelSvg({ phase: item.phase, heat, laps: item.laps }, WHEEL_PX)} alt={`Doomwheel: ${phase.label}`} width={WHEEL_PX} height={WHEEL_PX} />
248 : <Text color={phase.color}>{glyphOf(item)}</Text>}
249 <Box flexDirection="column" flexGrow={1} justifyContent="center">
250 <Text bold wrap="truncate-end">{`Offering #${item.pr} · ${item.repo}`}</Text>
251 <Text wrap="wrap"><Text color={phase.color} bold>{phase.label}</Text><Text dimColor> — {phase.mood}</Text></Text>
252 <Text dimColor>{`Auto-fix ${item.autoFix ? 'on' : 'off'}`}</Text>
253 {item.ghUnavailable ? <Text dimColor wrap="wrap">gh is missing or not signed in — the wheel turns on session signals alone</Text> : null}
254 </Box>
255 </Box>
256 <Box flexDirection="row">
257 {statCell('HEAT', `${pips(heat, HEAT_LIMIT)} ${heat}`, heatColor(heat))}
258 {statCell('LAPS', String(item.laps), lapsColor(item.laps))}
259 {statCell('THREADS', threads.text, threads.color)}
260 </Box>
261 <Box flexDirection="column" paddingX={1} paddingY={1}>
262 <Text dimColor>SQUEAK-SCROLL</Text>
263 <Box flexDirection="column" borderStyle="round" borderColor={LINE} backgroundColor={SUNK_BG} paddingX={1}>
264 {lines.length === 0
265 ? <Text dimColor>Nothing squeaked yet.</Text>
266 : lines.map((entry, i) => (
267 <Text key={`log-${item.key}-${i}`} wrap="wrap"><Text dimColor>{clock(entry.at)} </Text><Text color={entry.kind === 'crack' ? PHASES.cracked.color : undefined}>{LOG_GLYPHS[entry.kind] ?? '·'} {sanitize(entry.text)}</Text></Text>
268 ))}
269 </Box>
270 </Box>
271 </Box>
272 )
273 }
274
275 return (
276 <Box flexDirection="column" width={columns}>
277 <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
278 <Text bold>{`⚙ ${live} wheel${live === 1 ? '' : 's'} turning for the Council`}</Text>
279 {retired > 0 ? <Button key="clear-retired" plain label={`Clear ${retired} retired`} onPress={() => clearRetired($)} /> : null}
280 </Box>
281 {shown.map(card)}
282 {newestFirst.length > shown.length ? <Text dimColor>{` + ${newestFirst.length - shown.length} older offerings`}</Text> : null}
283 </Box>
284 )
285 }).catch(($, e, next) => {
286 const { Box, Text } = $.ui.resolve(e)
287 const reason = next.error.message ?? next.error.kind
288 return <Box flexDirection="column" paddingX={1}><Text color="error">Doomwheel could not draw this pane ({next.error.kind}): {reason.slice(0, 300)}</Text></Box>
289 })
290}
291mods/underlings/register.tsx 681 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface as Engine, Register, RenderElement, SessionMessage, TurnStepChunk } from 'claude-code'
3
4import type { Status, TxEntry, Underling } from '../../types'
5import {
6 ROLES, appendText, classify, formatElapsed, formatTokens, isKilled, parseExitMarker, parseOutputPath,
7 parseTaskNotification, sanitize, shortModel, summarizeJson, summarizeResult, summarizeToolInput, tailCommands,
8 tailLines, usageTotal, TRANSCRIPT_LIMIT,
9} from './model'
10import { FINISHED_GREY, spriteSvg } from './sprites'
11
12const PANE = 'underlings'
13const PANE_TITLE = '🐀 Underlings'
14const FLUSH_MS = 500
15const OUTPUT_LINES = 400
16const MAX_READ_BYTES = 4 * 1024 * 1024
17const ROW_ROWS = 3
18/** Rows each roster group shows until its Show all toggle opens it. */
19const GROUP_CAP = 5
20/** Rows the detail panel takes besides its transcript or output: borders, header, stat cells and headings. */
21const DETAIL_CHROME_ROWS = 19
22/** Most rows a detail's transcript or output takes, so an open accordion never dwarfs the roster. */
23const DETAIL_MAX_ROOM = 30
24const AVATAR_COLUMNS = 6
25const ROW_AVATAR_PX = 44
26const DETAIL_AVATAR_PX = 72
27const OK = '#5fbf5a'
28const BAD = '#e0574a'
29const STOPPED = '#d9a441'
30const ACCENT = '#d97757'
31const LINE = 'rgba(128,128,128,0.35)'
32const HOVER_BG = 'rgba(128,128,128,0.10)'
33const SUNK_BG = 'rgba(0,0,0,0.22)'
34const EDGE_PX = 3
35const EDGE_HEIGHT_PX = 240
36
37// The plugin is under-empire; every key Underlings owns there starts `underlings.`.
38const list = atom({ plugin: 'under-empire', key: 'underlings.list' } as const, [] as Underling[])
39const selected = atom({ plugin: 'under-empire', key: 'underlings.selected' } as const, null as string | null)
40const now = atom({ plugin: 'under-empire', key: 'underlings.now' } as const, 0)
41const expanded = atom({ plugin: 'under-empire', key: 'underlings.expanded' } as const, [] as string[])
42
43const transcriptFamily = atom({ plugin: 'under-empire', key: 'underlings.transcript' } as const, [] as TxEntry[])
44const outputFamily = atom({ plugin: 'under-empire', key: 'underlings.output' } as const, '')
45
46// Working copies held between flushes; $.state stays the source of truth across reloads.
47const transcripts = new Map<string, TxEntry[]>()
48const dirtyTranscripts = new Set<string>()
49const toolInputs = new Map<string, string>()
50/** Each subagent's context size at its latest model request, awaiting the next flush. */
51const pendingTokens = new Map<string, number>()
52const followedSizes = new Map<string, number>()
53let isTicking = false
54let rotation = 0
55const outputPaths = new Map<string, string>()
56const tickers: Array<{ cancel: () => void }> = []
57
58// ---------------------------------------------------------------- tracker
59
60async function addUnderling($: Engine, item: Underling) {
61 let isFirst = false
62 await update($, list, items => {
63 if (items.some(one => one.id === item.id)) return items
64 isFirst = !items.some(one => one.status === 'running')
65 return [...items, item]
66 })
67 if (isFirst) await $.ui.open({ id: PANE, title: PANE_TITLE })
68}
69
70async function patchUnderling($: Engine, id: string, patch: Partial<Underling>) {
71 await update($, list, items => items.map(one => (one.id === id ? { ...one, ...patch } : one)))
72}
73
74async function finishUnderling($: Engine, id: string, status: Status, exitCode?: number) {
75 const items = await read($, list)
76 const item = items.find(one => one.id === id)
77 if (!item || item.status !== 'running') return
78 await patchUnderling($, id, { status, endedAt: Date.now(), exitCode: exitCode ?? item.exitCode })
79 if (item.type === 'bash') {
80 await pollOutput($, id)
81 followedSizes.delete(id)
82 }
83}
84
85// ---------------------------------------------------------------- subagent transcripts
86
87async function loadTranscript($: Engine, id: string): Promise<TxEntry[]> {
88 const held = transcripts.get(id)
89 if (held) return held
90 const stored = await read($, memberOf(transcriptFamily, { requestId: id }))
91 transcripts.set(id, stored)
92 return stored
93}
94
95function setTranscript(id: string, entries: TxEntry[]) {
96 transcripts.set(id, entries.slice(-TRANSCRIPT_LIMIT))
97 dirtyTranscripts.add(id)
98}
99
100async function onChunk($: Engine, agentId: string, chunk: TurnStepChunk) {
101 const entries = await loadTranscript($, agentId)
102 if (chunk.kind === 'text' || chunk.kind === 'thinking') {
103 setTranscript(agentId, appendText(entries, chunk.kind, sanitize(chunk.text)))
104 } else if (chunk.kind === 'tool') {
105 toolInputs.set(chunk.id, '')
106 setTranscript(agentId, [...entries, { kind: 'tool', id: chunk.id, name: chunk.name, arg: '' }])
107 } else if (chunk.kind === 'input') {
108 const call = [...entries].reverse().find(one => one.kind === 'tool')
109 if (call?.kind === 'tool') toolInputs.set(call.id, (toolInputs.get(call.id) ?? '') + chunk.json)
110 } else if (chunk.kind === 'stop') {
111 setTranscript(agentId, entries.map(one => (
112 one.kind === 'tool' && !one.arg && toolInputs.has(one.id)
113 ? { ...one, arg: summarizeJson(toolInputs.get(one.id) ?? '') }
114 : one
115 )))
116 const total = usageTotal(chunk.usage)
117 if (total > 0) pendingTokens.set(agentId, total)
118 }
119}
120
121async function onToolResult($: Engine, agentId: string, toolUseId: string, result: unknown, isError: boolean) {
122 const entries = await loadTranscript($, agentId)
123 if (!entries.some(one => one.kind === 'tool' && one.id === toolUseId)) return
124 toolInputs.delete(toolUseId)
125 setTranscript(agentId, entries.map(one => (
126 one.kind === 'tool' && one.id === toolUseId ? { ...one, result: summarizeResult(result, isError), isError } : one
127 )))
128}
129
130async function backfillTranscript($: Engine, id: string) {
131 const held = await loadTranscript($, id)
132 if (held.length > 0) return
133 const answer = await $.session.messages({ agentId: id })
134 if (!Array.isArray(answer)) return
135 const rows = answer as SessionMessage[]
136 const entries: TxEntry[] = []
137 for (const row of rows) {
138 if (row.role === 'assistant' && row.text) entries.push({ kind: 'text', text: sanitize(row.text) })
139 for (const use of row.toolUses) {
140 const outcome = row.toolResults?.find(r => r.tool_use_id === use.tool_use_id)
141 entries.push({
142 kind: 'tool', id: use.tool_use_id, name: use.tool, arg: summarizeToolInput(use.input),
143 result: outcome ? summarizeResult(outcome.text, outcome.isError) : 'done', isError: outcome?.isError,
144 })
145 }
146 }
147 setTranscript(id, entries)
148 await flush($)
149}
150
151async function flush($: Engine) {
152 for (const id of [...dirtyTranscripts]) {
153 dirtyTranscripts.delete(id)
154 const entries = transcripts.get(id) ?? []
155 await update($, memberOf(transcriptFamily, { requestId: id }), () => entries)
156 }
157 if (pendingTokens.size > 0) {
158 const latest = new Map(pendingTokens)
159 pendingTokens.clear()
160 await update($, list, items => items.map(one => (
161 latest.has(one.id) ? { ...one, tokens: latest.get(one.id) } : one
162 )))
163 }
164}
165
166/** Starts the flush timer once per module load; /reload-plugins may not raise session.start again. */
167async function ensureTicker($: Engine) {
168 if (tickers.length > 0) return
169 tickers.push($.clock.every(FLUSH_MS, () => tick($)))
170}
171
172async function tick($: Engine) {
173 // A tick still running when the next comes due is left to finish; ticks never queue behind each other.
174 if (isTicking) return
175 isTicking = true
176 try {
177 await flush($)
178 const items = await read($, list)
179 if (!items.some(one => one.status === 'running')) return
180 await pollRound($, items)
181 await advanceClock($)
182 } finally {
183 isTicking = false
184 }
185}
186
187async function advanceClock($: Engine) {
188 // Elapsed times show whole seconds, so the pane redraws once a second, not on every tick.
189 const second = Math.floor(Date.now() / 1000) * 1000
190 if ((await read($, now)) !== second) await update($, now, () => second)
191}
192
193/**
194 * Reads at most two output files a tick, however many tasks run: the open task's, and one other
195 * running task's in turn. Task notifications finish most tasks; the rotation catches any whose
196 * notification never came.
197 */
198async function pollRound($: Engine, items: Underling[]) {
199 const chosenId = await read($, selected)
200 const chosen = items.find(one => one.id === chosenId && one.type === 'bash')
201 if (chosen) await pollOutput($, chosen.id)
202 const others = items.filter(one => one.type === 'bash' && one.status === 'running' && one.id !== chosenId)
203 if (others.length === 0) return
204 rotation = (rotation + 1) % others.length
205 const next = others[rotation]
206 if (next) await pollOutput($, next.id)
207}
208
209// ---------------------------------------------------------------- background output
210
211async function readText($: Engine, path: string, size: number): Promise<string> {
212 if (size <= MAX_READ_BYTES) return $.fs.read(path)
213 // $.fs.read refuses files past 4 MiB, so a host command reads the tail.
214 for (const argv of tailCommands(path, OUTPUT_LINES)) {
215 const ran = await $.process.run(argv).catch(() => undefined)
216 if (ran?.exitCode === 0) return ran.stdout
217 }
218 throw new Error(`No command could read the tail of ${path}`)
219}
220
221async function pollOutput($: Engine, id: string) {
222 const path = outputPaths.get(id) ?? (await read($, list)).find(one => one.id === id)?.outputPath
223 if (!path) return
224 try {
225 const { size } = await $.fs.stat(path)
226 if (size === followedSizes.get(id)) return
227 const text = tailLines(sanitize(await readText($, path, size)), OUTPUT_LINES)
228 // Recorded only once read, so a failed read is tried again on the next poll.
229 followedSizes.set(id, size)
230 await update($, memberOf(outputFamily, { requestId: id }), () => text)
231 const exitCode = parseExitMarker(text)
232 if (exitCode !== undefined) await finishUnderling($, id, exitCode === 0 ? 'done' : 'failed', exitCode)
233 else if (isKilled(text)) await finishUnderling($, id, 'stopped')
234 } catch {
235 // The file appears a moment after the task starts, or no command could read it; the next poll tries again.
236 }
237}
238
239// ---------------------------------------------------------------- tool calls and rows
240
241async function afterToolCall($: Engine, e: { tool: string; tool_use_id: string; agentId?: string } & Record<string, unknown>, ran: unknown) {
242 const answer = ran as { deny?: string; result?: Record<string, unknown>; isError?: boolean }
243 if (e.agentId) {
244 await onToolResult($, e.agentId, e.tool_use_id, answer.deny ?? answer.result, Boolean(answer.deny) || answer.isError === true)
245 }
246 if (answer.deny || !answer.result) return
247 if (e.tool === 'Bash' && typeof answer.result.backgroundTaskId === 'string') {
248 const id = answer.result.backgroundTaskId
249 const command = sanitize(String(e.command ?? ''))
250 const description = typeof e.description === 'string' ? e.description : undefined
251 await addUnderling($, {
252 id, type: 'bash', title: description ?? command.slice(0, 80), kind: classify(description, command),
253 status: 'running', startedAt: Date.now(), toolUseId: e.tool_use_id, command,
254 })
255 const path = outputPaths.get(e.tool_use_id)
256 if (path) await patchUnderling($, id, { outputPath: path })
257 }
258 if (e.tool === 'Agent') {
259 const result = answer.result
260 const agentId = typeof result.agentId === 'string' ? result.agentId : undefined
261 if (!agentId) return
262 const model = typeof result.resolvedModel === 'string' ? result.resolvedModel : undefined
263 // agent.spawn adds the row; this catches a subagent whose spawn hook was passed over.
264 const description = typeof e.description === 'string' ? e.description : undefined
265 const agentType = typeof e.subagent_type === 'string' ? e.subagent_type : undefined
266 await addUnderling($, {
267 id: agentId, type: 'subagent', title: sanitize(description || agentType || 'Subagent'),
268 kind: classify(description, agentType), status: 'running', startedAt: Date.now(),
269 toolUseId: e.tool_use_id, agentType, model, tokens: 0,
270 })
271 if (model) await patchUnderling($, agentId, { model })
272 if (result.status === 'completed') {
273 if (typeof result.totalTokens === 'number') await patchUnderling($, agentId, { tokens: result.totalTokens })
274 await finishUnderling($, agentId, 'done')
275 }
276 }
277}
278
279function contentText(content: unknown): string {
280 if (typeof content === 'string') return content
281 if (Array.isArray(content)) return content.map(block => contentText((block as { text?: unknown; content?: unknown }).text ?? (block as { content?: unknown }).content)).join('\n')
282 return ''
283}
284
285async function onRow($: Engine, door: string, blocks: ReadonlyArray<Record<string, unknown>>) {
286 if (door === 'tool-result') {
287 for (const block of blocks) {
288 if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue
289 const path = parseOutputPath(contentText(block.content))
290 if (!path) continue
291 outputPaths.set(block.tool_use_id, path)
292 const items = await read($, list)
293 const task = items.find(one => one.toolUseId === block.tool_use_id && one.type === 'bash')
294 if (task) await patchUnderling($, task.id, { outputPath: path })
295 }
296 return
297 }
298 const notice = parseTaskNotification(blocks.map(block => contentText(block.text ?? block.content)).join('\n'))
299 if (notice) {
300 await finishUnderling($, notice.taskId, noticeStatus(notice.status, notice.exitCode), notice.exitCode)
301 }
302}
303
304async function select($: Engine, id: string) {
305 const current = await read($, selected)
306 await update($, selected, () => (current === id ? null : id))
307 if (current !== id) await $.ui.scroll({ to: { key: `detail-${id}` }, in: PANE, block: 'nearest' }).catch(() => undefined)
308 const item = (await read($, list)).find(one => one.id === id)
309 if (current !== id && item?.type === 'subagent') await backfillTranscript($, id)
310}
311
312async function toggleGroup($: Engine, label: string) {
313 await update($, expanded, open => (open.includes(label) ? open.filter(one => one !== label) : [...open, label]))
314}
315
316async function clearFinished($: Engine) {
317 const items = await read($, list)
318 const kept = items.filter(one => one.status === 'running')
319 await update($, list, () => kept)
320 const current = await read($, selected)
321 if (current && !kept.some(one => one.id === current)) await update($, selected, () => null)
322}
323
324async function stopUnderling($: Engine, id: string) {
325 const ran = await $.tool.call({ tool: 'TaskStop', task_id: id } as never).catch((err: unknown) => ({ deny: String(err) }))
326 const answer = ran as { deny?: string; isError?: boolean; text?: string; result?: unknown }
327 if (!answer.deny && !answer.isError) {
328 await finishUnderling($, id, 'stopped')
329 return
330 }
331 const detail = answer.deny ?? answer.text ?? (typeof answer.result === 'string' ? answer.result : JSON.stringify(answer.result ?? null))
332 // The engine no longer knows the task: it ended without a marker or notice we could read.
333 if (detail.includes('No task found')) {
334 await finishUnderling($, id, 'stopped')
335 return
336 }
337 $.ui.toast(`Could not stop ${id}: ${sanitize(detail).slice(0, 300)}`)
338}
339
340// ---------------------------------------------------------------- drawing helpers (no $)
341
342function noticeStatus(status: string, exitCode: number | undefined): Status {
343 if (status === 'killed' || status === 'stopped') return 'stopped'
344 return status === 'completed' && (exitCode === undefined || exitCode === 0) ? 'done' : 'failed'
345}
346
347function turnStatus(reason: string): Status {
348 if (reason === 'answer') return 'done'
349 return reason === 'aborted' ? 'stopped' : 'failed'
350}
351
352function statusMark(status: Status): { glyph: string; color: string } {
353 if (status === 'done') return { glyph: '✓', color: OK }
354 if (status === 'failed') return { glyph: '✕', color: BAD }
355 if (status === 'stopped') return { glyph: '■', color: STOPPED }
356 return { glyph: '●', color: ACCENT }
357}
358
359/** `#rrggbb` plus an alpha byte: a tint that sits on either theme's background. */
360function tint(color: string, alpha: number): string {
361 return `${color}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`
362}
363
364/** A solid bar taller than any row; the row's edge Box clips it to the row's own height. */
365function edgeSvg(color: string): string {
366 return `<svg xmlns="http://www.w3.org/2000/svg" width="${EDGE_PX}" height="${EDGE_HEIGHT_PX}"><rect width="${EDGE_PX}" height="${EDGE_HEIGHT_PX}" fill="${color}"/></svg>`
367}
368
369function truncate(text: string, max: number): string {
370 return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
371}
372
373function entryLines(entry: TxEntry, width: number): number {
374 if (entry.kind === 'tool') return entry.result ? 2 : 1
375 return Math.max(1, Math.ceil(entry.text.length / Math.max(20, width)))
376}
377
378function fitTranscript(entries: TxEntry[], rows: number, width: number): TxEntry[] {
379 const shown: TxEntry[] = []
380 let used = 0
381 for (let i = entries.length - 1; i >= 0 && used < rows; i--) {
382 const entry = entries[i]
383 if (!entry) continue
384 used += entryLines(entry, width)
385 shown.unshift(entry)
386 }
387 return shown
388}
389
390// ---------------------------------------------------------------- hooks
391
392export const register: Register = on => {
393 on('session.start', async ($, e, next) => {
394 const started = await next(e)
395 await $.command.register({ name: 'underlings', description: 'Show the Underlings pane: running subagents and background tasks' })
396 await ensureTicker($)
397 return started
398 })
399
400 on('command.run', { command: 'underlings' }, async $ => {
401 await ensureTicker($)
402 await tick($)
403 await $.ui.open({ id: PANE, title: PANE_TITLE })
404 return { text: 'Underlings pane opened.' }
405 })
406
407 on('agent.spawn', async ($, e, next) => {
408 await ensureTicker($)
409 const spawned = await next(e)
410 if (spawned.deny === undefined && spawned.agentId) {
411 await addUnderling($, {
412 id: spawned.agentId, type: 'subagent', title: sanitize(e.description || e.subagentType),
413 kind: classify(e.description, e.subagentType), status: 'running', startedAt: Date.now(),
414 toolUseId: e.tool_use_id, agentType: e.subagentType, model: spawned.model, tokens: 0,
415 })
416 }
417 return spawned
418 }).catch(($, e, next) => next(e))
419
420 on('turn.step', async function* ($, e, next) {
421 if (!e.agentId) return yield* next(e)
422 const stream = next(e)
423 for await (const chunk of stream) {
424 await onChunk($, e.agentId, chunk)
425 yield chunk
426 }
427 return stream.result
428 })
429
430 on('turn.complete', async ($, e, next) => {
431 if (e.agentId) await finishUnderling($, e.agentId, turnStatus(e.reason))
432 return next(e)
433 })
434
435 on('tool.call', async ($, e, next) => {
436 await ensureTicker($).catch(() => undefined)
437 const ran = await next(e)
438 await afterToolCall($, e as never, ran).catch(() => undefined)
439 return ran
440 }).catch(($, e, next) => next(e))
441
442 on('session.append', async ($, e, next) => {
443 const stored = await next(e)
444 if (!e.agentId) await onRow($, e.door, e.message.content).catch(() => undefined)
445 return stored
446 }).catch(($, e, next) => next(e))
447
448 on('ui.message', { requestId: PANE }, async ($, e, next) => {
449 const pick = (e.data as { pick?: unknown } | null)?.pick
450 if (typeof pick === 'string') await select($, pick)
451 return next(e)
452 }).catch(($, e, next) => next(e))
453
454 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
455 const ui = $.ui.resolve(e)
456 const { Box, Text, Button, Code } = ui
457 const Svg = e.surface === 'terminal' ? undefined : (ui as ElementTable<'desktop'>).Svg
458 const Client = e.surface === 'terminal' || e.surface === 'desktop' ? (ui as ElementTable<'desktop'>).Client : undefined
459 const items = await read($, list)
460 const chosenId = await read($, selected)
461 const openGroups = await read($, expanded)
462 const clock = (await read($, now)) || Date.now()
463 const columns = e.props.bodyColumns
464 const bodyRows = e.props.scroll?.bodyRows ?? 40
465 const running = items.filter(one => one.status === 'running')
466 const totalTokens = items.reduce((sum, one) => sum + (one.tokens ?? 0), 0)
467
468 // Animated sprites are ~4x the size of a still frame and the pane redraws every second while
469 // anything runs, so only the open detail's rat animates; roster rats hold their first frame.
470 const avatar = (item: Underling, px: number, animate: boolean) => (Svg
471 ? <Svg source={spriteSvg(item.kind, item.status, px, animate)} alt={ROLES[item.kind].role} width={px} height={px} />
472 : <Text color={ROLES[item.kind].color}>{item.status === 'running' ? '🐀' : statusMark(item.status).glyph}</Text>)
473
474 if (items.length === 0) {
475 return <Box flexDirection="column" paddingX={1}><Text dimColor>No underlings are digging.</Text></Box>
476 }
477
478 const newestFirst = (a: Underling, b: Underling) => b.startedAt - a.startedAt
479 const subagents = running.filter(one => one.type === 'subagent').sort(newestFirst)
480 const shells = running.filter(one => one.type === 'bash').sort(newestFirst)
481 const finished = items.filter(one => one.status !== 'running')
482 .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
483 const chosen = items.find(one => one.id === chosenId)
484 const infoColumns = Math.max(12, columns - AVATAR_COLUMNS - 3)
485
486 const row = (item: Underling) => {
487 const role = ROLES[item.kind]
488 const mark = statusMark(item.status)
489 const isChosen = item.id === chosenId
490 // Once finished, only the ✓ / ✕ mark keeps its colour; the role and selection turn grey.
491 const rowColor = item.status === 'running' ? role.color : FINISHED_GREY
492 const elapsed = formatElapsed((item.endedAt ?? clock) - item.startedAt)
493 const meta = item.type === 'subagent'
494 ? `${shortModel(item.model)} · ${formatTokens(item.tokens ?? 0)} tok`
495 : item.command ?? ''
496 const title = truncate(item.title, Math.max(4, infoColumns - elapsed.length - 2))
497 return (
498 <Box key={`row-${item.id}`} position="relative" flexDirection="row" height={ROW_ROWS} overflow="hidden"
499 backgroundColor={isChosen ? tint(rowColor, 0.12) : undefined}>
500 <Box flexDirection="column" width={1} height={ROW_ROWS} overflow="hidden">
501 {isChosen && Svg ? <Svg source={edgeSvg(rowColor)} alt="" width={EDGE_PX} height={EDGE_HEIGHT_PX} /> : null}
502 {isChosen && !Svg ? [0, 1, 2].map(line => <Text key={`edge-${item.id}-${line}`} color={rowColor}>▎</Text>) : null}
503 </Box>
504 <Box key={`avatar-${item.id}`} width={AVATAR_COLUMNS} height={ROW_ROWS} alignItems="center" justifyContent="center">
505 {avatar(item, ROW_AVATAR_PX, false)}
506 </Box>
507 <Box flexDirection="column" flexGrow={1} height={ROW_ROWS} paddingLeft={1} paddingRight={1} overflow="hidden">
508 <Box flexDirection="row" justifyContent="space-between">
509 {Client
510 ? <Text bold wrap="truncate-end">{title}</Text>
511 : <Button key={`pick-${item.id}`} plain label={title} onPress={() => select($, item.id)} />}
512 <Text dimColor>{elapsed}</Text>
513 </Box>
514 <Text wrap="truncate-end">{item.status === 'running' ? <Text color={role.color}>{role.role}</Text> : <Text dimColor>{role.role}</Text>}<Text dimColor> • </Text><Text color={mark.color}>{mark.glyph}</Text><Text dimColor> {item.status}</Text></Text>
515 <Text dimColor wrap="truncate-end">{truncate(meta, infoColumns)}</Text>
516 </Box>
517 {Client && (
518 <Box position="absolute" top={0} left={0} width={columns} height={ROW_ROWS}>
519 <Client key={`hit-${item.id}`} module="./row-hit.tsx" props={{ id: item.id, rows: ROW_ROWS, hoverColor: isChosen ? 'transparent' : HOVER_BG }} width={columns} height={ROW_ROWS} />
520 </Box>
521 )}
522 </Box>
523 )
524 }
525
526 // A capped group still shows the open row, so its accordion never hides behind "+ N more".
527 const shownOf = (label: string, group: Underling[]) => {
528 if (openGroups.includes(label)) return group
529 const first = group.slice(0, GROUP_CAP)
530 return chosen && group.includes(chosen) && !first.includes(chosen) ? [...first, chosen] : first
531 }
532 const section = (label: string, group: Underling[], action?: RenderElement) => {
533 if (group.length === 0) return null
534 const isOpen = openGroups.includes(label)
535 const toggle = group.length > GROUP_CAP
536 ? <Button key={`toggle-${label}`} plain label={isOpen ? 'Show fewer' : `Show all ${group.length}`} onPress={() => toggleGroup($, label)} />
537 : null
538 return (
539 <Box key={`section-${label}`} flexDirection="column" marginTop={1}>
540 <Box flexDirection="row" justifyContent="space-between" paddingRight={1}>
541 <Text dimColor bold>{` ${label.toUpperCase()} · ${group.length}`}</Text>
542 <Box flexDirection="row" gap={2}>
543 {toggle}
544 {action ?? null}
545 </Box>
546 </Box>
547 {shownOf(label, group).map(item => (item.id === chosen?.id
548 ? <Box key={`open-${item.id}`} flexDirection="column">{row(item)}{detail(item, chosenEntries, chosenOutput)}</Box>
549 : row(item)))}
550 {!isOpen && group.length > shownOf(label, group).length ? <Text dimColor>{` + ${group.length - shownOf(label, group).length} more`}</Text> : null}
551 </Box>
552 )
553 }
554
555 const statCell = (label: string, value: string, width: number, extra?: RenderElement) => (
556 <Box flexDirection="column" width={width} borderStyle="single" borderColor={LINE} paddingX={1}>
557 <Text dimColor>{label.toUpperCase()}</Text>
558 <Text bold>{value}</Text>
559 {extra ?? null}
560 </Box>
561 )
562
563 const detail = (item: Underling, entries: TxEntry[], output: string) => {
564 const role = ROLES[item.kind]
565 const mark = statusMark(item.status)
566 const isSub = item.type === 'subagent'
567 const isRunning = item.status === 'running'
568 // Once finished the panel's chrome turns grey; status marks, output and transcript keep their colours.
569 const tone = isRunning ? role.color : FINISHED_GREY
570 const cell = Math.max(10, Math.floor((columns - 2) / 3))
571 // The open row and its panel together fit the window, so revealing the panel shows it whole.
572 const room = Math.max(6, Math.min(DETAIL_MAX_ROOM, bodyRows - DETAIL_CHROME_ROWS - ROW_ROWS))
573 const share = Math.min(1, (item.tokens ?? 0) / 300_000)
574 const lines = output ? output.split('\n').length : 0
575 const tokenBar = (
576 <Box width={cell - 4} height={1} backgroundColor={SUNK_BG}>
577 <Box width={Math.max(share > 0 ? 1 : 0, Math.round((cell - 4) * share))} height={1} backgroundColor={tone} />
578 </Box>
579 )
580 let heading = 'OUTPUT'
581 if (isSub) heading = isRunning ? 'LIVE TRANSCRIPT' : 'TRANSCRIPT'
582 else if (isRunning) heading = 'OUTPUT · READ FROM FILE EVERY 0.5S'
583 const exitCode = item.exitCode ?? parseExitMarker(output)
584 let exitText = String(exitCode)
585 if (exitCode === undefined) exitText = isRunning ? '—' : '?'
586 if (exitCode === undefined && isKilled(output)) exitText = 'killed'
587 return (
588 <Box key={`detail-${item.id}`} flexDirection="column" borderStyle="single" borderColor={tone} marginBottom={1}>
589 <Box position="relative" flexDirection="row" gap={2} paddingX={2} paddingY={1} backgroundColor={tint(tone, 0.1)}>
590 <Box position="absolute" top={0} right={0}>
591 <Button key="close-detail" plain label="✕" onPress={() => update($, selected, () => null)} />
592 </Box>
593 {avatar(item, DETAIL_AVATAR_PX, true)}
594 <Box flexDirection="column" flexGrow={1} justifyContent="flex-end">
595 <Text><Text color={tone} backgroundColor={tint(tone, 0.18)}> {role.role} </Text><Text dimColor> {isSub ? `Subagent · ${item.agentType ?? ''}` : 'Background task'}</Text></Text>
596 <Text bold>{item.title}</Text>
597 <Text><Text color={mark.color}>{mark.glyph}</Text><Text dimColor> {item.status}</Text></Text>
598 </Box>
599 </Box>
600 <Box flexDirection="row">
601 {statCell('Running for', formatElapsed((item.endedAt ?? clock) - item.startedAt), cell)}
602 {isSub ? statCell('Model', shortModel(item.model), cell) : statCell('Lines out', String(lines), cell)}
603 {isSub ? statCell('Tokens', formatTokens(item.tokens ?? 0), cell, tokenBar) : statCell('Exit', exitText, cell)}
604 </Box>
605 <Box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
606 {isSub ? null : <Code source={sanitize(`$ ${item.command ?? ''}`)} language="bash" />}
607 <Box flexDirection="row" justifyContent="space-between">
608 <Text><Text color={mark.color}>●</Text><Text dimColor> {heading}</Text></Text>
609 {isSub ? null : <Text dimColor>{lines} lines</Text>}
610 </Box>
611 {isSub
612 ? (
613 <Box flexDirection="column" borderStyle="round" borderColor={LINE} backgroundColor={SUNK_BG} paddingX={1}>
614 <TranscriptView entries={entries} rows={room} width={columns - 6} color={role.color} ui={ui} />
615 </Box>
616 )
617 : (
618 <Box flexDirection="column" borderStyle="round" borderColor={LINE} backgroundColor={SUNK_BG} paddingX={1}>
619 <OutputView text={sanitize(tailLines(output, room))} ui={ui} />
620 </Box>
621 )}
622 {isRunning ? <Button key="stop-detail" label="Stop" onPress={() => stopUnderling($, item.id)} /> : null}
623 </Box>
624 </Box>
625 )
626 }
627
628 const chosenEntries = chosen?.type === 'subagent' ? await read($, memberOf(transcriptFamily, { requestId: chosen.id })) : []
629 const chosenOutput = chosen?.type === 'bash' ? await read($, memberOf(outputFamily, { requestId: chosen.id })) : ''
630
631 const tree = (
632 <Box flexDirection="column" width={columns}>
633 <Box flexDirection="row" paddingX={1}>
634 <Text bold>🐀 {running.length} underlings digging · {formatTokens(totalTokens)} tokens</Text>
635 </Box>
636 {section('Subagents', subagents)}
637 {section('Background tasks', shells)}
638 {section('Finished', finished, <Button key="clear-finished" plain label="Clear" onPress={() => clearFinished($)} />)}
639 </Box>
640 )
641 return tree
642 }).catch(($, e, next) => {
643 const { Box, Text } = $.ui.resolve(e)
644 const reason = next.error.message ?? next.error.kind
645 return <Box flexDirection="column" paddingX={1}><Text color="error">Underlings could not draw this pane ({next.error.kind}): {reason.slice(0, 300)}</Text></Box>
646 })
647}
648
649function OutputView({ text, ui }: { text: string; ui: ElementTable }) {
650 const { Box, Text } = ui
651 if (!text) return <Text dimColor>(no output yet)</Text>
652 return (
653 <Box flexDirection="column">
654 {text.split('\n').map(line => <Text wrap="wrap">{line || ' '}</Text>)}
655 </Box>
656 )
657}
658
659function TranscriptView({ entries, rows, width, color, ui }: {
660 entries: TxEntry[]; rows: number; width: number; color: string; ui: ElementTable
661}) {
662 const { Box, Text } = ui
663 const shown = fitTranscript(entries, rows, width)
664 if (shown.length === 0) return <Text dimColor>Waiting for the first response…</Text>
665 return (
666 <Box flexDirection="column">
667 {shown.map((entry, i) => {
668 if (entry.kind !== 'tool') {
669 return entry.kind === 'thinking' ? <Text dimColor italic>✻ {entry.text}</Text> : <Text>{entry.text}</Text>
670 }
671 return (
672 <Box key={`tx-${i}`} flexDirection="column">
673 <Text wrap="truncate-end"><Text color={color} bold>{entry.name}</Text> <Text dimColor>{entry.arg}</Text>{entry.result === undefined ? <Text dimColor> running…</Text> : null}</Text>
674 {entry.result === undefined ? null : <Text dimColor color={entry.isError ? 'error' : undefined} wrap="truncate-end"> ⎿ {entry.result}</Text>}
675 </Box>
676 )
677 })}
678 </Box>
679 )
680}
681mods/doomwheel/model.ts 478 lines1import type { LogEntry, LogKind, Offering, Phase } from '../../types'
2
3// Pure logic: reads the signals a session leaves about its PRs (tool calls, their output, wake
4// messages) and what GitHub says when polled, and folds them into wheel state. Nothing here talks
5// to the engine, so every rule is unit-testable.
6
7export const LOG_LIMIT = 80
8/** Heat at which the crystal glows red. Cosmetic: nothing halts there. */
9export const HEAT_LIMIT = 5
10/** The lap from which the wheel slows and the rat sweats. Cosmetic: nothing halts there. */
11export const TIRED_LAPS = 7
12/** More new reviews than this in one poll are logged as one line, not one each. */
13const REVIEW_LINES_LIMIT = 3
14
15const PR_URL_RE = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
16const REPO_PR_RE = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/
17const GH_RE = /(^|[\s;&|(])gh(\.exe)?\s/
18const GH_PR_CREATE_RE = /\bgh\s+pr\s+create\b/
19const CI_EVENT_RE = /<ci-monitor-event>/
20const CI_WATCHING_RE = /watching ([\w.-]+\/[\w.-]+) PR #(\d+)/
21const CI_FAILED_RE = /(\d+) CI checks? failed/
22const CI_COMMENTS_RE = /(\d+) new review comments?/
23const CI_CONFLICT_RE = /merge conflict/i
24/** What `gh` prints when it has no usable login. */
25const GH_AUTH_RE = /gh auth login|not logged in|authentication|GH_TOKEN/i
26/** `gh` exits 4 when authentication is needed. */
27const GH_AUTH_EXIT = 4
28const FAILED_CONCLUSIONS = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
29const FAILED_STATES = new Set(['FAILURE', 'ERROR'])
30const ANSI_CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g
31const ANSI_OSC = /\u001b\][^\u0007\u001b]*(\u0007|\u001b\\)/g
32const OTHER_ESCAPE = /\u001b[@-_]?/g
33const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g
34
35/** One poll of a PR on GitHub: everything the gauges and phases are drawn from. */
36export type Snapshot = {
37 state: 'OPEN' | 'MERGED' | 'CLOSED'
38 /** Submitted reviews, oldest first. */
39 reviews: Array<{ author: string; state: string }>
40 unresolvedThreads: number
41 totalThreads: number
42 failingChecks: number
43 conflict: boolean
44}
45
46export type WheelEvent =
47 | { type: 'raised'; repo: string; pr: number }
48 | { type: 'added'; repo: string; pr: number }
49 | { type: 'monitor'; repo?: string; pr?: number; autoFix: boolean; denied?: string }
50 | { type: 'wake'; repo: string; pr: number; failedChecks: number; newComments: number; conflict: boolean }
51 | { type: 'poll'; repo: string; pr: number; snapshot: Snapshot }
52 | { type: 'ghUnavailable'; repo: string; pr: number }
53 | { type: 'turnEnd' }
54
55/** `notify` also raises a native notification; `openPane` brings the pane up. */
56export type Alert = { key: string; text: string; notify: boolean; openPane: boolean }
57/** `appeared` holds the keys of offerings this event created. */
58export type Applied = { offerings: Offering[]; logs: LogEntry[]; alerts: Alert[]; appeared: string[] }
59
60// ---------------------------------------------------------------- parsers
61
62/** Every string inside a tool result, however deeply the tool nested it. */
63export function textsIn(value: unknown): string[] {
64 if (typeof value === 'string') return [value]
65 if (Array.isArray(value)) return value.flatMap(textsIn)
66 if (value && typeof value === 'object') return Object.values(value).flatMap(textsIn)
67 return []
68}
69
70function repoPr(match: RegExpMatchArray | null): { repo: string; pr: number } | undefined {
71 const repo = match?.[1]
72 const pr = Number(match?.[2])
73 return repo && Number.isInteger(pr) && pr > 0 ? { repo, pr } : undefined
74}
75
76/** A PR named as its URL or as `owner/name#N`, as `/doomwheel <url>` takes it. */
77export function parsePrRef(text: string): { repo: string; pr: number } | undefined {
78 return repoPr(text.match(PR_URL_RE)) ?? repoPr(text.match(REPO_PR_RE))
79}
80
81/** A Bash or PowerShell call: `gh pr create` that printed its PR URL. */
82export function parseBash(command: string, output: string): WheelEvent | undefined {
83 if (!GH_PR_CREATE_RE.test(command)) return undefined
84 const created = repoPr(output.match(PR_URL_RE))
85 return created ? { type: 'raised', ...created } : undefined
86}
87
88/** The tracked offerings a `gh` command names, by URL, `owner/name#N` or a bare PR number. */
89export function ghTouches(command: string, offerings: Offering[]): string[] {
90 if (!GH_RE.test(` ${command}`)) return []
91 return offerings
92 .filter(one => command.includes(one.url) || command.includes(one.key)
93 || new RegExp(`(^|[\\s/#'"=])${one.pr}($|[\\s'"/])`).test(command))
94 .map(one => one.key)
95}
96
97/** A call to the desktop app's `set_monitor`: its input says on or off, its URL (or answer) says which PR. */
98export function parseMonitor(input: Record<string, unknown>, resultText: string, denied?: string): WheelEvent | undefined {
99 if (typeof input.auto_fix !== 'boolean') return undefined
100 const url = typeof input.url === 'string' ? input.url : ''
101 const target = repoPr(url.match(PR_URL_RE)) ?? repoPr(resultText.match(REPO_PR_RE))
102 return { type: 'monitor', ...target, autoFix: input.auto_fix, denied }
103}
104
105/** A `<ci-monitor-event>` wake from the desktop app. */
106export function parseCiEvent(text: string): WheelEvent | undefined {
107 if (!CI_EVENT_RE.test(text)) return undefined
108 const target = repoPr(text.match(CI_WATCHING_RE))
109 if (!target) return undefined
110 return {
111 type: 'wake', ...target,
112 failedChecks: Number(text.match(CI_FAILED_RE)?.[1] ?? 0),
113 newComments: Number(text.match(CI_COMMENTS_RE)?.[1] ?? 0),
114 conflict: CI_CONFLICT_RE.test(text),
115 }
116}
117
118// ---------------------------------------------------------------- polling
119
120const PR_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
121 repository(owner: $owner, name: $name) {
122 pullRequest(number: $number) {
123 state
124 mergeable
125 reviews(first: 100) { nodes { state author { login } } }
126 reviewThreads(first: 100) { totalCount nodes { isResolved } }
127 commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) {
128 nodes { __typename ... on CheckRun { conclusion } ... on StatusContext { state } }
129 } } } } }
130 }
131 }
132}`
133
134/** The one `gh` call a poll makes: the PR's state, reviews, threads, checks and mergeability. */
135export function pollArgv(repo: string, pr: number): string[] {
136 const [owner = '', name = ''] = repo.split('/')
137 return ['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${pr}`]
138}
139
140type Nodes<T> = { nodes?: Array<T | null> | null } | null | undefined
141
142function nodes<T>(list: Nodes<T>): T[] {
143 return (list?.nodes ?? []).filter((one): one is T => one !== null && one !== undefined)
144}
145
146/** Reads the GraphQL answer `pollArgv` asks for; undefined when it is not one. */
147export function parseSnapshot(stdout: string): Snapshot | undefined {
148 let parsed: unknown
149 try {
150 parsed = JSON.parse(stdout)
151 } catch {
152 return undefined
153 }
154 type Pull = {
155 state?: string
156 mergeable?: string
157 reviews?: Nodes<{ state?: string; author?: { login?: string } | null }>
158 reviewThreads?: { totalCount?: number } & Nodes<{ isResolved?: boolean }>
159 commits?: Nodes<{ commit?: { statusCheckRollup?: { contexts?: Nodes<{ conclusion?: string | null; state?: string }> } | null } }>
160 }
161 const pull = (parsed as { data?: { repository?: { pullRequest?: Pull | null } | null } })?.data?.repository?.pullRequest
162 if (!pull || (pull.state !== 'OPEN' && pull.state !== 'MERGED' && pull.state !== 'CLOSED')) return undefined
163 const reviews = nodes(pull.reviews)
164 .filter(review => review.state !== 'PENDING')
165 .map(review => ({ author: review.author?.login ?? 'ghost', state: review.state ?? 'COMMENTED' }))
166 const threads = nodes(pull.reviewThreads)
167 const contexts = nodes(nodes(pull.commits)[0]?.commit?.statusCheckRollup?.contexts)
168 return {
169 state: pull.state,
170 reviews,
171 unresolvedThreads: threads.filter(thread => !thread.isResolved).length,
172 totalThreads: pull.reviewThreads?.totalCount ?? threads.length,
173 failingChecks: contexts.filter(check => FAILED_CONCLUSIONS.has(check.conclusion ?? '') || FAILED_STATES.has(check.state ?? '')).length,
174 conflict: pull.mergeable === 'CONFLICTING',
175 }
176}
177
178/**
179 * What one poll's `gh` run says: a snapshot, or that `gh` has no login. Any other failure says
180 * nothing, and the next poll tries again. A `gh` that cannot start at all is the caller's to report.
181 */
182export function readPoll(repo: string, pr: number, ran: { exitCode: number; stdout: string; stderr: string }): WheelEvent | undefined {
183 if (ran.exitCode === 0) {
184 const snapshot = parseSnapshot(ran.stdout)
185 return snapshot ? { type: 'poll', repo, pr, snapshot } : undefined
186 }
187 if (ran.exitCode === GH_AUTH_EXIT || GH_AUTH_RE.test(ran.stderr)) return { type: 'ghUnavailable', repo, pr }
188 return undefined
189}
190
191// ---------------------------------------------------------------- reducer
192
193export function keyOf(repo: string, pr: number): string {
194 return `${repo}#${pr}`
195}
196
197/** A wheel whose PR is merged or closed; polling stops and Clear retired removes it. */
198export function isRetired(one: Pick<Offering, 'phase'>): boolean {
199 return one.phase === 'victory' || one.phase === 'scrapped'
200}
201
202/** Unresolved review threads, failing checks, and one for a merge conflict. */
203export function heatOf(one: Pick<Offering, 'unresolvedThreads' | 'failingChecks' | 'conflict'>): number {
204 return one.unresolvedThreads + one.failingChecks + (one.conflict ? 1 : 0)
205}
206
207function hasOpenWork(one: Offering): boolean {
208 return heatOf(one) > 0
209}
210
211/** An offering as stored, with any field an older Doomwheel never wrote filled in. */
212export function revive(one: Offering): Offering {
213 return {
214 ...one,
215 phase: one.phase in PHASES ? one.phase : 'cold',
216 laps: one.laps ?? 0,
217 unresolvedThreads: one.unresolvedThreads ?? 0,
218 totalThreads: one.totalThreads ?? 0,
219 failingChecks: one.failingChecks ?? 0,
220 conflict: one.conflict ?? false,
221 }
222}
223
224function fresh(repo: string, pr: number, at: number, phase: Phase, autoFix: boolean): Offering {
225 return {
226 key: keyOf(repo, pr), repo, pr, url: `https://github.com/${repo}/pull/${pr}`, phase, autoFix,
227 laps: 0, unresolvedThreads: 0, totalThreads: 0, failingChecks: 0, conflict: false,
228 createdAt: at, updatedAt: at,
229 }
230}
231
232function latestLive(offerings: Offering[]): Offering | undefined {
233 return offerings.filter(one => !isRetired(one)).sort((a, b) => b.updatedAt - a.updatedAt)[0]
234}
235
236function plural(count: number, word: string): string {
237 return `${count} ${word}${count === 1 ? '' : 's'}`
238}
239
240function wakeReasons(failedChecks: number, newComments: number, conflict: boolean): string {
241 const parts = []
242 if (failedChecks) parts.push(`${plural(failedChecks, 'trial-pit')} failed`)
243 if (newComments) parts.push(`${plural(newComments, 'new review comment')}`)
244 if (conflict) parts.push('a merge conflict')
245 return parts.length > 0 ? parts.join(', ') : 'something stirs'
246}
247
248const REVIEW_WORDS: Record<string, string> = {
249 APPROVED: 'approves', CHANGES_REQUESTED: 'demands changes', COMMENTED: 'comments', DISMISSED: 'was dismissed',
250}
251
252class Fold {
253 offerings: Offering[]
254 readonly at: number
255 logs: LogEntry[] = []
256 alerts: Alert[] = []
257
258 constructor(offerings: Offering[], at: number) {
259 this.offerings = offerings
260 this.at = at
261 }
262
263 find(key: string | undefined): Offering | undefined {
264 return key ? this.offerings.find(one => one.key === key) : latestLive(this.offerings)
265 }
266
267 ensure(repo: string, pr: number, phase: Phase, autoFix: boolean): Offering {
268 const found = this.find(keyOf(repo, pr))
269 if (found) return found
270 const made = fresh(repo, pr, this.at, phase, autoFix)
271 this.offerings = [...this.offerings, made]
272 return made
273 }
274
275 patch(key: string, patch: Partial<Offering>): Offering {
276 this.offerings = this.offerings.map(one => (one.key === key ? { ...one, ...patch, updatedAt: this.at } : one))
277 return this.offerings.find(one => one.key === key) as Offering
278 }
279
280 log(key: string, kind: LogKind, text: string) {
281 this.logs.push({ at: this.at, key, kind, text })
282 }
283
284 alert(key: string, text: string, notify: boolean, openPane: boolean) {
285 this.alerts.push({ key, text, notify, openPane })
286 }
287
288 crack(one: Offering) {
289 this.patch(one.key, { phase: 'cracked' })
290 this.log(one.key, 'crack', `The wheel CRACKS! Auto-fix cut with ${plural(heatOf(one), 'wound')} still open`)
291 this.alert(one.key, `💥 Doomwheel cracked on #${one.pr}: Auto-fix is off with work left. Your decision is needed.`, true, true)
292 }
293}
294
295function applyMonitor(fold: Fold, e: Extract<WheelEvent, { type: 'monitor' }>) {
296 const target = e.repo && e.pr ? fold.ensure(e.repo, e.pr, 'offering', false) : fold.find(undefined)
297 if (!target) return
298 if (e.denied) {
299 fold.log(target.key, 'warn', `The man-thing refused to ${e.autoFix ? 'arm' : 'cut'} the wheel`)
300 return
301 }
302 if (isRetired(target)) {
303 fold.patch(target.key, { autoFix: e.autoFix })
304 return
305 }
306 if (e.autoFix) {
307 fold.patch(target.key, { autoFix: true, phase: 'armed' })
308 fold.log(target.key, 'armed', 'The Doomwheel spins! Auto-fix armed — warp-lightning crackles')
309 fold.alert(target.key, `⚙ Doomwheel armed for #${target.pr}`, false, false)
310 return
311 }
312 const cut = fold.patch(target.key, { autoFix: false })
313 fold.log(cut.key, 'disarmed', 'Auto-fix cut')
314 if (hasOpenWork(cut)) {
315 if (cut.phase !== 'cracked') fold.crack(cut)
316 } else {
317 fold.patch(cut.key, { phase: 'cold' })
318 fold.log(cut.key, 'disarmed', 'No wounds left open — the wheel-rats rest')
319 }
320}
321
322function logChanges(fold: Fold, before: Offering, after: Snapshot) {
323 const { key } = before
324 const newReviews = after.reviews.slice(before.laps)
325 if (newReviews.length > REVIEW_LINES_LIMIT) {
326 fold.log(key, 'review', `${plural(newReviews.length, 'review')} submitted — lap ${after.reviews.length}`)
327 } else {
328 newReviews.forEach((review, i) => {
329 const lap = before.laps + i + 1
330 fold.log(key, 'review', `${review.author} ${REVIEW_WORDS[review.state] ?? review.state.toLowerCase()} — lap ${lap}`)
331 if (lap === TIRED_LAPS) fold.log(key, 'tired', `The wheel-rat pants and sweats — lap ${lap}`)
332 })
333 }
334 if (after.unresolvedThreads > before.unresolvedThreads) {
335 fold.log(key, 'threads', `Unresolved threads rise to ${after.unresolvedThreads}/${after.totalThreads}`)
336 } else if (after.unresolvedThreads < before.unresolvedThreads) {
337 fold.log(key, 'threads', after.unresolvedThreads === 0
338 ? 'Every thread resolved — the Council is appeased'
339 : `Unresolved threads fall to ${after.unresolvedThreads}/${after.totalThreads}`)
340 }
341 if (after.failingChecks > 0 && before.failingChecks === 0) {
342 fold.log(key, 'checks', `The trial-pits run red: ${plural(after.failingChecks, 'failing check')}`)
343 } else if (after.failingChecks === 0 && before.failingChecks > 0) {
344 fold.log(key, 'checks', 'The trial-pits recover — all green')
345 }
346 if (after.conflict && !before.conflict) fold.log(key, 'conflict', 'A merge conflict! Rival tunnels collide')
347 else if (!after.conflict && before.conflict) fold.log(key, 'conflict', 'The merge conflict is cleared')
348}
349
350function applyPoll(fold: Fold, e: Extract<WheelEvent, { type: 'poll' }>) {
351 const before = fold.ensure(e.repo, e.pr, 'offering', false)
352 if (isRetired(before)) return
353 const { snapshot } = e
354 logChanges(fold, before, snapshot)
355 const now = fold.patch(before.key, {
356 laps: snapshot.reviews.length, unresolvedThreads: snapshot.unresolvedThreads, totalThreads: snapshot.totalThreads,
357 failingChecks: snapshot.failingChecks, conflict: snapshot.conflict, polledAt: fold.at, ghUnavailable: undefined,
358 })
359 if (snapshot.state === 'MERGED') {
360 fold.patch(now.key, { phase: 'victory' })
361 fold.log(now.key, 'victory', 'VICTORY! The offering is merged — the warpstone is ours!')
362 fold.alert(now.key, `🏆 Doomwheel victory: #${now.pr} is merged`, false, true)
363 } else if (snapshot.state === 'CLOSED') {
364 fold.patch(now.key, { phase: 'scrapped' })
365 fold.log(now.key, 'scrapped', 'Scrapped. The offering is closed unmerged — the wheel collapses')
366 } else if (now.phase === 'cracked' && !hasOpenWork(now)) {
367 fold.patch(now.key, { phase: 'cold' })
368 fold.log(now.key, 'disarmed', 'The last wound closes — the cracked wheel cools')
369 }
370}
371
372/** Folds one event into the offerings; returns the new list, the log lines, the alerts and the offerings it created. */
373export function apply(offerings: Offering[], e: WheelEvent, at: number): Applied {
374 const fold = new Fold(offerings, at)
375 switch (e.type) {
376 case 'raised': {
377 const made = fold.ensure(e.repo, e.pr, 'offering', false)
378 fold.log(made.key, 'raised', `An offering is laid before the Council: PR #${e.pr}`)
379 break
380 }
381 case 'added': {
382 const known = fold.find(keyOf(e.repo, e.pr))
383 if (known) break
384 const made = fold.ensure(e.repo, e.pr, 'offering', false)
385 fold.log(made.key, 'raised', `The man-thing drags PR #${e.pr} before the wheel`)
386 break
387 }
388 case 'monitor':
389 applyMonitor(fold, e)
390 break
391 case 'wake': {
392 const target = fold.ensure(e.repo, e.pr, 'armed', true)
393 if (!isRetired(target)) fold.patch(target.key, { phase: 'waking', autoFix: true })
394 fold.log(target.key, 'wake', `Warp-bolt! The app squeaks: ${wakeReasons(e.failedChecks, e.newComments, e.conflict)}`)
395 break
396 }
397 case 'poll':
398 applyPoll(fold, e)
399 break
400 case 'ghUnavailable': {
401 const target = fold.find(keyOf(e.repo, e.pr))
402 if (target && !target.ghUnavailable) fold.patch(target.key, { ghUnavailable: true })
403 break
404 }
405 case 'turnEnd':
406 for (const one of fold.offerings.filter(o => o.phase === 'waking')) {
407 fold.patch(one.key, { phase: one.autoFix ? 'armed' : 'cold' })
408 }
409 break
410 }
411 const known = new Set(offerings.map(one => one.key))
412 return {
413 offerings: fold.offerings,
414 logs: fold.logs,
415 alerts: fold.alerts,
416 appeared: fold.offerings.map(one => one.key).filter(key => !known.has(key)),
417 }
418}
419
420// ---------------------------------------------------------------- presentation
421
422export const PHASES: Record<Phase, { label: string; mood: string; color: string }> = {
423 offering: { label: 'Offering laid', mood: 'The wheel waits to be armed', color: '#d9a441' },
424 armed: { label: 'Spinning', mood: 'Auto-fix armed — the wheel-rats run', color: '#7dff5a' },
425 waking: { label: 'Warp-bolt!', mood: 'The app squeaked — the wheel races', color: '#b8ff3a' },
426 cracked: { label: 'Cracked', mood: 'Auto-fix cut with work left — the man-thing must decide', color: '#e0574a' },
427 cold: { label: 'Cold', mood: 'Auto-fix off — the wheel-rats rest', color: '#8c8984' },
428 victory: { label: 'Victory', mood: 'Merged — the warpstone is held aloft', color: '#ffc83d' },
429 scrapped: { label: 'Scrapped', mood: 'Closed unmerged — the wheel lies in a heap', color: '#8c8984' },
430}
431
432const HEAT_COLORS = ['#5fbf5a', '#e8c547', '#ff9a2e', '#e0574a']
433const TIRED_COLOR = '#d9a441'
434const GOOD = '#5fbf5a'
435const BAD = '#e0574a'
436
437/** Crystal green when cool, red at `HEAT_LIMIT` or more. */
438export function heatColor(heat: number): string {
439 const share = Math.min(1, Math.max(0, heat) / HEAT_LIMIT)
440 return HEAT_COLORS[Math.round(share * (HEAT_COLORS.length - 1))] ?? GOOD
441}
442
443/** Plain until the rat tires, then amber. */
444export function lapsColor(laps: number): string | undefined {
445 return laps >= TIRED_LAPS ? TIRED_COLOR : undefined
446}
447
448/** `unresolved/total`, red while any is unresolved, green at none; a dash until the first poll. */
449export function threadsGauge(one: Offering): { text: string; color?: string } {
450 if (one.polledAt === undefined) return { text: '—' }
451 return { text: `${one.unresolvedThreads}/${one.totalThreads}`, color: one.unresolvedThreads > 0 ? BAD : GOOD }
452}
453
454export function pips(filled: number, total: number): string {
455 const lit = Math.max(0, Math.min(total, filled))
456 return '▰'.repeat(lit) + '▱'.repeat(Math.max(0, total - lit))
457}
458
459export function clock(at: number): string {
460 const d = new Date(at)
461 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
462}
463
464export const LOG_GLYPHS: Record<LogKind, string> = {
465 raised: '📜', armed: '⚙', disarmed: '❄', wake: '⚡', review: '🔁', tired: '💧', threads: '🧵', checks: '🧪',
466 conflict: '⚔', crack: '💥', victory: '🏆', scrapped: '🪦', warn: '!',
467}
468
469/** Strips escape sequences and control characters, so text from GitHub or a tool cannot move the cursor or recolour the pane. */
470export function sanitize(text: string): string {
471 return text
472 .replace(ANSI_OSC, '')
473 .replace(ANSI_CSI, '')
474 .replace(OTHER_ESCAPE, '')
475 .replace(/[\r\n\t]+/g, ' ')
476 .replace(CONTROL, '')
477}
478mods/doomwheel/sprites.ts 250 lines1import type { Phase } from '../../types'
2import { PHASES, TIRED_LAPS, heatColor } from './model'
3
4// The Doomwheel: a wooden wheel with a rat running inside it, powered by a warpstone crystal.
5// One SVG per look, animated by SMIL, so the surface spins it without the plugin redrawing.
6// The crystal glows with the PR's heat, and the rat tires as review laps pile up.
7
8const CX = 32
9const CY = 37
10const RIM = 21
11const TILE_BG = '#151413'
12const WOOD = '#7a4e2a'
13const WOOD_DARK = '#4a2e18'
14const SPOKE = '#a87442'
15/** The rat is drawn small and scaled up about its own feet, so it stays on the wheel's floor. */
16const RAT_SCALE = 1.4
17const RAT_FEET = [32, 54] as const
18const IRON = '#5d6670'
19const FUR = '#8a7a68'
20const PINK = '#e09aa6'
21const EYE = '#ff4433'
22const BOLT = '#7dff5a'
23const SMOKE = '#6a6770'
24const SWEAT = '#6fc3ff'
25const GOLD = '#ffc83d'
26/** How far the wheel drops on a victory, to leave room for the rat standing on top. */
27const VICTORY_DROP = 6
28/** A fresh rat's seconds per turn; a rat worn right out turns the wheel this much slower again. */
29const SPIN: Partial<Record<Phase, number>> = { armed: 4, waking: 1.1 }
30const TIRED_SLOWDOWN = 1.5
31/** Laps past the tired one over which the rat slows to its slowest. */
32const TIRING_LAPS = 5
33
34export type Look = {
35 phase: Phase
36 heat: number
37 laps: number
38}
39
40type Pose = 'running' | 'idle' | 'asleep' | 'knockedOut'
41
42/** Blends two `#rrggbb` colours: `amount` of `a` over `b`. */
43export function mix(a: string, b: string, amount: number): string {
44 const channel = (hex: string, i: number) => Number.parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
45 return `#${[0, 1, 2].map(i => Math.round(channel(a, i) * amount + channel(b, i) * (1 - amount)).toString(16).padStart(2, '0')).join('')}`
46}
47
48/** 0 for a fresh rat, rising from the tired lap to 1 a few laps later. */
49export function fatigue(laps: number): number {
50 if (laps < TIRED_LAPS) return 0
51 return Math.min(1, (laps - TIRED_LAPS + 1) / TIRING_LAPS)
52}
53
54/** Seconds per turn of the wheel, slower once the rat tires; undefined when it stands still. */
55export function spinSeconds(look: Look): number | undefined {
56 const base = SPIN[look.phase]
57 if (base === undefined) return undefined
58 return Number((base * (1 + TIRED_SLOWDOWN * fatigue(look.laps))).toFixed(2))
59}
60
61function isTired(look: Look): boolean {
62 return look.laps >= TIRED_LAPS
63}
64
65function poseOf(look: Look): Pose {
66 if (look.phase === 'cracked') return 'knockedOut'
67 if (look.phase === 'cold') return 'asleep'
68 return spinSeconds(look) === undefined ? 'idle' : 'running'
69}
70
71function spokes(seconds: number | undefined): string {
72 const lines = Array.from({ length: 8 }, (_, i) => {
73 const angle = (i * Math.PI) / 4
74 const x = (CX + Math.cos(angle) * (RIM - 2)).toFixed(2)
75 const y = (CY + Math.sin(angle) * (RIM - 2)).toFixed(2)
76 return `<line x1="${CX}" y1="${CY}" x2="${x}" y2="${y}" stroke="${SPOKE}" stroke-width="1.8"/>`
77 }).join('')
78 const turn = seconds
79 ? `<animateTransform attributeName="transform" type="rotate" from="0 ${CX} ${CY}" to="360 ${CX} ${CY}" dur="${seconds}s" repeatCount="indefinite"/>`
80 : ''
81 const rungs = `<circle cx="${CX}" cy="${CY}" r="${RIM - 2}" fill="none" stroke="${IRON}" stroke-width="0.8" stroke-dasharray="1.2 3.2"/>`
82 return `<g>${lines}${rungs}<circle cx="${CX}" cy="${CY}" r="3" fill="${IRON}"/>${turn}</g>`
83}
84
85function rim(isCracked: boolean): string {
86 const wood = `<circle cx="${CX}" cy="${CY}" r="${RIM}" fill="none" stroke="${WOOD}" stroke-width="3.6"/>`
87 const band = `<circle cx="${CX}" cy="${CY}" r="${RIM}" fill="none" stroke="${IRON}" stroke-width="1" stroke-dasharray="2 5"/>`
88 const cracks = isCracked
89 ? `<polyline points="18,24 22,28 20,31 25,35" fill="none" stroke="#111" stroke-width="1.1"/><polyline points="45,47 41,44 43,41" fill="none" stroke="#111" stroke-width="1.1"/>`
90 : ''
91 const frame = `<polygon points="${CX - 9},63 ${CX},${CY} ${CX + 9},63" fill="none" stroke="${WOOD_DARK}" stroke-width="2"/>`
92 return `${frame}${wood}${band}${cracks}`
93}
94
95function legs(isRunning: boolean): string {
96 const a = `<path d="M29 53l-2 3M34 53l2 3" stroke="${FUR}" stroke-width="1.2"/>`
97 const b = `<path d="M29 53l1 3M34 53l-1 3" stroke="${FUR}" stroke-width="1.2"/>`
98 if (!isRunning) return a
99 const flip = (values: string) => `<animate attributeName="visibility" values="${values}" dur="0.32s" calcMode="discrete" repeatCount="indefinite"/>`
100 return `<g>${a}${flip('visible;hidden')}</g><g visibility="hidden">${b}${flip('hidden;visible')}</g>`
101}
102
103function eyeFor(pose: Pose): string {
104 if (pose === 'knockedOut') return '<path d="M37.4 47.8l1.2 1.2M38.6 47.8l-1.2 1.2" stroke="#111" stroke-width="0.6"/>'
105 if (pose === 'asleep') return '<path d="M37.4 48.6h1.3" stroke="#111" stroke-width="0.6"/>'
106 return `<circle cx="38" cy="48.4" r="0.6" fill="${EYE}"/>`
107}
108
109/** Drops beading off a tired rat's brow. */
110function sweat(): string {
111 const drop = (x: number, y: number, begin: string) => `<path d="M${x} ${y}q-0.9 1.3 0 1.8q0.9-0.5 0-1.8z" fill="${SWEAT}" opacity="0">`
112 + `<animate attributeName="opacity" values="0;1;0" dur="0.9s" begin="${begin}" repeatCount="indefinite"/>`
113 + `<animateTransform attributeName="transform" type="translate" values="0 0;1.5 2.5" dur="0.9s" begin="${begin}" repeatCount="indefinite"/></path>`
114 return `${drop(39.6, 44.2, '0s')}${drop(36.4, 43.4, '0.45s')}`
115}
116
117function rat(look: Look, pose: Pose): string {
118 const isRunning = pose === 'running'
119 const tail = `<path d="M27 52q-5 0-6-5" fill="none" stroke="${PINK}" stroke-width="1"/>`
120 const body = `<ellipse cx="31.5" cy="51" rx="5" ry="3.1" fill="${FUR}"/>`
121 const head = `<circle cx="37" cy="49" r="2.6" fill="${FUR}"/><circle cx="35.8" cy="46.6" r="1.1" fill="${PINK}"/><circle cx="39.4" cy="49.6" r="0.7" fill="${PINK}"/>`
122 const bob = isRunning
123 ? '<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.7;0 0" dur="0.32s" repeatCount="indefinite"/>'
124 : ''
125 const snore = pose === 'asleep'
126 ? `<text x="40" y="44" font-size="4" font-family="monospace" fill="#a8e4ff">z<animate attributeName="opacity" values="0;1;0" dur="2.4s" repeatCount="indefinite"/></text>`
127 : ''
128 const drops = isRunning && isTired(look) ? sweat() : ''
129 const [fx, fy] = RAT_FEET
130 const grow = `translate(${(fx * (1 - RAT_SCALE)).toFixed(1)} ${(fy * (1 - RAT_SCALE)).toFixed(1)}) scale(${RAT_SCALE})`
131 return `<g transform="${grow}"><g>${tail}${legs(isRunning)}${body}${head}${eyeFor(pose)}${bob}</g>${drops}${snore}</g>`
132}
133
134function gem(x: number, y: number, fill: string): string {
135 return `<polygon points="${x},${y - 5} ${x + 4},${y} ${x},${y + 5} ${x - 4},${y}" fill="${fill}" stroke="#111" stroke-width="0.6"/>`
136 + `<polygon points="${x},${y - 3.5} ${x + 1.6},${y} ${x},${y + 2}" fill="#ffffff" opacity="0.35"/>`
137}
138
139function crystal(phase: Phase, heat: string): string {
140 const mount = `<rect x="29" y="11.5" width="6" height="2.5" fill="${IRON}"/>`
141 if (phase === 'offering' || phase === 'cold') return `${mount}${gem(32, 7, heat)}`
142 const pulse = `<circle cx="32" cy="7" r="6" fill="${heat}" opacity="0.25"><animate attributeName="opacity" values="0.1;0.45;0.1" dur="1.4s" repeatCount="indefinite"/></circle>`
143 return `${pulse}${mount}${gem(32, 7, heat)}`
144}
145
146function bolts(phase: Phase): string {
147 if (phase !== 'armed' && phase !== 'waking') return ''
148 const isWaking = phase === 'waking'
149 const width = isWaking ? 1.4 : 0.9
150 const flicker = (values: string, dur: string) => `<animate attributeName="opacity" values="${values}" dur="${dur}" repeatCount="indefinite"/>`
151 const left = `<polyline points="29,9 22,12 25,14 15,20" fill="none" stroke="${BOLT}" stroke-width="${width}">${flicker('1;0.1;0.8;0;1', '0.7s')}</polyline>`
152 const right = `<polyline points="35,9 42,12 39,15 49,21" fill="none" stroke="${BOLT}" stroke-width="${width}">${flicker('0;1;0.2;0.9;0', '0.9s')}</polyline>`
153 if (!isWaking) return left + right
154 const strike = `<polyline points="32,12 30,20 34,24 31,33" fill="none" stroke="#efffd0" stroke-width="1.6">${flicker('1;0;1;0;0;1', '0.5s')}</polyline>`
155 const flash = `<rect x="0" y="0" width="64" height="64" fill="${BOLT}" opacity="0">${flicker('0;0.18;0;0;0.1;0', '1.1s')}</rect>`
156 return left + right + strike + flash
157}
158
159function smoke(): string {
160 const puff = (x: number, begin: string) => `<circle cx="${x}" cy="18" r="2" fill="${SMOKE}" opacity="0">`
161 + `<animate attributeName="cy" values="18;1" dur="2.4s" begin="${begin}" repeatCount="indefinite"/>`
162 + `<animate attributeName="r" values="1.5;5" dur="2.4s" begin="${begin}" repeatCount="indefinite"/>`
163 + `<animate attributeName="opacity" values="0.7;0" dur="2.4s" begin="${begin}" repeatCount="indefinite"/></circle>`
164 const flame = `<path d="M30 12q2-5 2-8q2 3 2 8z" fill="#ff9a2e"><animate attributeName="opacity" values="1;0.4;1" dur="0.4s" repeatCount="indefinite"/></path>`
165 return flame + puff(26, '0s') + puff(36, '0.8s') + puff(31, '1.6s')
166}
167
168/** The rat stands upright on the still wheel's top, the warpstone held aloft in a gold blaze. */
169function victory(): string {
170 const top = CY - RIM + VICTORY_DROP
171 const rays = Array.from({ length: 8 }, (_, i) => {
172 const angle = (i * Math.PI) / 4
173 const x1 = (CX + Math.cos(angle) * 6).toFixed(2)
174 const y1 = (6 + Math.sin(angle) * 6).toFixed(2)
175 const x2 = (CX + Math.cos(angle) * 11).toFixed(2)
176 const y2 = (6 + Math.sin(angle) * 11).toFixed(2)
177 return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${GOLD}" stroke-width="1"/>`
178 }).join('')
179 const blaze = `<circle cx="${CX}" cy="6" r="9" fill="${GOLD}" opacity="0.3"><animate attributeName="opacity" values="0.2;0.55;0.2" dur="1.6s" repeatCount="indefinite"/></circle>`
180 + `<g opacity="0.8">${rays}<animate attributeName="opacity" values="0.4;1;0.4" dur="1.6s" repeatCount="indefinite"/></g>`
181 const tail = `<path d="M31 ${top - 1}q-6 1-7 -4" fill="none" stroke="${PINK}" stroke-width="1"/>`
182 const legs = `<path d="M30.5 ${top - 3}l-1 3M33.5 ${top - 3}l1 3" stroke="${FUR}" stroke-width="1.3"/>`
183 const body = `<ellipse cx="${CX}" cy="${top - 6}" rx="3" ry="4" fill="${FUR}"/>`
184 const head = `<circle cx="${CX}" cy="${top - 11.5}" r="2.5" fill="${FUR}"/><circle cx="30" cy="${top - 13.5}" r="1" fill="${PINK}"/><circle cx="34" cy="${top - 13.5}" r="1" fill="${PINK}"/>`
185 + `<circle cx="31.2" cy="${top - 11.8}" r="0.5" fill="${EYE}"/><circle cx="32.8" cy="${top - 11.8}" r="0.5" fill="${EYE}"/>`
186 const arms = `<path d="M30 ${top - 8}l-2 -6M34 ${top - 8}l2 -6" stroke="${FUR}" stroke-width="1.2"/>`
187 return `${blaze}${tail}${legs}${body}${head}${arms}${gem(CX, 6, GOLD)}`
188}
189
190/** The wheel lies collapsed in a grey heap, its crystal dull, while the rat slinks off. */
191function scrapped(): string {
192 const heap = `<ellipse cx="26" cy="56" rx="17" ry="4" fill="none" stroke="${WOOD}" stroke-width="3"/>`
193 + `<ellipse cx="26" cy="56" rx="17" ry="4" fill="none" stroke="${IRON}" stroke-width="1" stroke-dasharray="2 5"/>`
194 const broken = '<path d="M14 52l9 -6M20 57l12 -9M30 53l8 -8M12 58l6 2" stroke="#a87442" stroke-width="1.6"/>'
195 const dull = gem(24, 48, '#6b6a66')
196 const slink = '<animateTransform attributeName="transform" type="translate" values="0 0;5 0" dur="3s" repeatCount="indefinite"/>'
197 + '<animate attributeName="opacity" values="1;1;0" dur="3s" repeatCount="indefinite"/>'
198 const rat = `<g><path d="M46 57q-4 1-6 -1" fill="none" stroke="${PINK}" stroke-width="0.9"/>`
199 + `<ellipse cx="50" cy="57" rx="4" ry="2" fill="${FUR}"/><circle cx="54.5" cy="57.5" r="1.8" fill="${FUR}"/>`
200 + `<circle cx="53.6" cy="55.8" r="0.8" fill="${PINK}"/><circle cx="55.2" cy="57.3" r="0.4" fill="${EYE}"/>${slink}</g>`
201 return `${heap}${broken}${dull}${rat}`
202}
203
204const cache = new Map<string, string>()
205
206/**
207 * The wheel on its dark tile. It spins while Auto-fix is armed, slower once the rat tires, and
208 * throws warp-lightning on a wake. The crystal glows green to red with the PR's heat. Cut with
209 * work left, it cracks and smokes; cold, the rat sleeps. On a merge the rat stands on top holding
210 * the warpstone aloft; closed unmerged, the wheel lies in a grey heap.
211 */
212export function wheelSvg(look: Look, sizePx: number): string {
213 const heat = heatColor(look.heat)
214 const pose = poseOf(look)
215 const seconds = spinSeconds(look)
216 const cacheKey = `${look.phase}:${pose}:${heat}:${seconds}:${isTired(look)}:${sizePx}`
217 const hit = cache.get(cacheKey)
218 if (hit) return hit
219 const tone = PHASES[look.phase].color
220 const glow = mix(tone, '#1a1918', 0.25)
221 const edge = mix(tone, '#3d3a35', 0.5)
222 const isGrey = look.phase === 'cold' || look.phase === 'scrapped'
223 const filter = isGrey ? '<filter id="dw-grey"><feColorMatrix type="saturate" values="0.15"/></filter>' : ''
224 const open = isGrey ? '<g filter="url(#dw-grey)" opacity="0.85">' : '<g>'
225 const isCracked = pose === 'knockedOut'
226 let scene: string
227 if (look.phase === 'victory') {
228 scene = `<g transform="translate(0 ${VICTORY_DROP})">${rim(false)}${spokes(undefined)}</g>${victory()}`
229 } else if (look.phase === 'scrapped') {
230 scene = scrapped()
231 } else {
232 scene = `${rim(isCracked)}${spokes(seconds)}${rat(look, pose)}${crystal(look.phase, heat)}${bolts(look.phase)}${isCracked ? smoke() : ''}`
233 }
234 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${sizePx}" height="${sizePx}">`
235 + `<defs><radialGradient id="dw-tile" cx="50%" cy="55%" r="70%"><stop offset="0" stop-color="${glow}"/><stop offset="1" stop-color="${TILE_BG}"/></radialGradient>${filter}</defs>`
236 + `<rect width="64" height="64" fill="${TILE_BG}"/><rect x="0.6" y="0.6" width="62.8" height="62.8" rx="5" fill="url(#dw-tile)" stroke="${edge}" stroke-width="1.2"/>`
237 + `${open}${scene}</g></svg>`
238 cache.set(cacheKey, svg)
239 return svg
240}
241
242const GLYPHS: Record<Phase, string> = {
243 offering: '📜', armed: '⚙', waking: '⚡', cracked: '💥', cold: '❄', victory: '🏆', scrapped: '🪦',
244}
245
246/** The wheel in a terminal, where no SVG draws. */
247export function glyphOf(look: Pick<Look, 'phase'>): string {
248 return GLYPHS[look.phase]
249}
250mods/underlings/model.ts 164 lines1import type { Kind, TxEntry } from '../../types'
2
3export const ROLES: Record<Kind, { role: string; color: string }> = {
4 research: { role: 'Grey Seer', color: '#b07cff' },
5 experiment: { role: 'Apprentice', color: '#7dff5a' },
6 build: { role: 'Tunnel-Sapper', color: '#ff8a3d' },
7 test: { role: 'Eshin Inspector', color: '#86b9ff' },
8 other: { role: 'Clanrat', color: '#b3a594' },
9}
10
11const KIND_PATTERNS: ReadonlyArray<[Kind, RegExp]> = [
12 ['test', /\b(test|tests|pytest|vitest|jest|lint|sonar|scan|review|audit|checks?|verify)\b/],
13 ['experiment', /\b(spike|prototype|experiment|poc|benchmark|try)\b/],
14 ['research', /\b(research|explore|investigate|map|search|find|analy[sz]e|plan)\b/],
15 ['build', /\b(build|implement|write|refactor|fix|compile|tsc|scaffold)\b/],
16]
17
18export function classify(...texts: ReadonlyArray<string | undefined>): Kind {
19 const haystack = texts.filter(Boolean).join(' ').toLowerCase()
20 return KIND_PATTERNS.find(([, pattern]) => pattern.test(haystack))?.[0] ?? 'other'
21}
22
23export function formatElapsed(ms: number): string {
24 const total = Math.max(0, Math.floor(ms / 1000))
25 const hours = Math.floor(total / 3600)
26 const minutes = Math.floor(total / 60) % 60
27 const seconds = String(total % 60).padStart(2, '0')
28 return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`
29}
30
31export function formatTokens(n: number): string {
32 if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`
33 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
34 return String(n)
35}
36
37export function shortModel(model: string | undefined): string {
38 if (!model) return '—'
39 return model.replace(/^claude-/, '').replace(/-(\d+)-(\d+)(-\d{8})?$/, ' $1.$2')
40}
41
42export function usageTotal(usage: {
43 input_tokens: number
44 output_tokens: number
45 cache_read_input_tokens: number
46 cache_creation_input_tokens: number
47} | null | undefined): number {
48 if (!usage) return 0
49 return usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
50}
51
52const OUTPUT_PATH = /Output is being written to: (.+?\.output)(?=\.?\s|\.?$)/
53const EXIT_MARKER = /\[exited with code (-?\d+)\]\s*$/
54const KILLED_MARKER = /\[killed\]\s*$/
55const NOTIFICATION_ID = /<task-id>([^<]+)<\/task-id>/
56const NOTIFICATION_STATUS = /<status>([^<]+)<\/status>/
57const NOTIFICATION_EXIT = /exit code (-?\d+)/
58const WINDOWS_PATH = /^[A-Za-z]:[\\/]|^\\\\/
59
60export function parseOutputPath(text: string): string | undefined {
61 return OUTPUT_PATH.exec(text)?.[1]
62}
63
64export function parseExitMarker(text: string): number | undefined {
65 const match = EXIT_MARKER.exec(text)
66 return match ? Number(match[1]) : undefined
67}
68
69/** A stopped task's output file ends with `[killed]` in place of an exit code. */
70export function isKilled(text: string): boolean {
71 return KILLED_MARKER.test(text)
72}
73
74/**
75 * The commands that print a file's last lines, best first: PowerShell's `Get-Content -Tail` for a
76 * Windows path (drive letter or UNC share), `tail` otherwise, and the other one as the fallback.
77 */
78export function tailCommands(path: string, lines: number): string[][] {
79 const tail = ['tail', '-n', String(lines), path]
80 const quoted = path.replace(/'/g, "''")
81 const powershell = ['powershell', '-NoProfile', '-Command', `Get-Content -LiteralPath '${quoted}' -Tail ${lines}`]
82 return WINDOWS_PATH.test(path) ? [powershell, tail] : [tail, powershell]
83}
84
85export type TaskNotification = { taskId: string; status: string; exitCode?: number }
86
87export function parseTaskNotification(text: string): TaskNotification | undefined {
88 const taskId = NOTIFICATION_ID.exec(text)?.[1]
89 const status = NOTIFICATION_STATUS.exec(text)?.[1]
90 if (!taskId || !status) return undefined
91 const exit = NOTIFICATION_EXIT.exec(text)?.[1]
92 return { taskId, status, exitCode: exit === undefined ? undefined : Number(exit) }
93}
94
95const ANSI_CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g
96const ANSI_OSC = /\u001b\][^\u0007\u001b]*(\u0007|\u001b\\)/g
97const OTHER_ESCAPE = /\u001b[@-_]?/g
98const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
99
100/**
101 * Terminal output and model text made drawable: the surfaces refuse a `Code` (and so the whole
102 * tree) that holds a control character, so colour codes go, carriage returns become line breaks,
103 * tabs become spaces, and any other control character is dropped. Line breaks are kept.
104 */
105export function sanitize(text: string): string {
106 return text
107 .replace(ANSI_OSC, '')
108 .replace(ANSI_CSI, '')
109 .replace(OTHER_ESCAPE, '')
110 .replace(/\r\n?/g, '\n')
111 .replace(/\t/g, ' ')
112 .replace(CONTROL, '')
113}
114
115export function tailLines(text: string, count: number): string {
116 if (count <= 0) return ''
117 const lines = text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n')
118 return lines.slice(-count).join('\n')
119}
120
121const ARG_FIELDS = ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'path', 'prompt']
122
123export function summarizeToolInput(input: unknown): string {
124 if (!input || typeof input !== 'object') return ''
125 const record = input as Record<string, unknown>
126 const field = ARG_FIELDS.find(name => typeof record[name] === 'string')
127 const value = field ? String(record[field]) : Object.values(record).find(v => typeof v === 'string')
128 return typeof value === 'string' ? sanitize(value).replace(/\s+/g, ' ').slice(0, 160) : ''
129}
130
131export function summarizeJson(json: string): string {
132 try {
133 return summarizeToolInput(JSON.parse(json))
134 } catch {
135 return ''
136 }
137}
138
139export function summarizeResult(result: unknown, isError: boolean): string {
140 if (isError) return 'error'
141 if (typeof result === 'string') return firstLine(result)
142 if (result && typeof result === 'object') {
143 const record = result as Record<string, unknown>
144 if (typeof record.stdout === 'string') return firstLine(record.stdout) || 'done'
145 if (typeof record.numFiles === 'number') return `${record.numFiles} files`
146 if (typeof record.numLines === 'number') return `${record.numLines} lines`
147 }
148 return 'done'
149}
150
151function firstLine(text: string): string {
152 return sanitize(text).trim().split('\n')[0]?.slice(0, 120) ?? ''
153}
154
155export const TRANSCRIPT_LIMIT = 300
156
157export function appendText(entries: TxEntry[], kind: 'text' | 'thinking', text: string): TxEntry[] {
158 const last = entries.at(-1)
159 if (last && last.kind === kind) {
160 return [...entries.slice(0, -1), { kind, text: last.text + text }]
161 }
162 return [...entries, { kind, text }].slice(-TRANSCRIPT_LIMIT)
163}
164mods/underlings/sprites.ts 255 lines1import type { Kind, Status } from '../../types'
2import { ROLES } from './model'
3
4// 24×24 Skaven, drawn procedurally per frame, then baked into one SVG whose frames SMIL switches,
5// so the surface animates it without the plugin redrawing.
6
7export const FRAMES = 12
8export const FRAME_MS = 170
9const N = 24
10
11const PAL: Record<string, string> = {
12 k: '#16110d', f: '#8a7a68', F: '#5e5045', p: '#e09aa6', w: '#efe6cf', e: '#ff4433',
13 y: '#e8c547', o: '#ff9a2e', b: '#7a4e2a', B: '#4a2e18', g: '#7dff5a', G: '#2fbf1f', c: '#c27a3a',
14 S: '#b4bcc6', s: '#5d6670', l: '#a8e4ff', r: '#5a3d26', m: '#24342a', a: '#8a3f22', d: '#2e3340', h: '#8f8f99',
15 H: '#d9cfb0', q: '#6a6770', Q: '#cfcbd4', P: '#b07cff',
16}
17
18const HEAD = [
19 '..kk........kk..',
20 '.kppk......kppk.',
21 '.kpppk....kpppk.',
22 '.kppfkkkkkkfppk.',
23 '..kffffffffffk..',
24 '..kffffffffffk..',
25 '.kfffeffffefffk.',
26 '.kffffffffffffk.',
27 '..kffffFFffffk..',
28 '...kffFFFFffk...',
29 '....kfFppFfk....',
30 '.....kFwwFk.....',
31 '......kwwk......',
32 '.......kk.......',
33]
34
35type Grid = (string | null)[][]
36
37function grid(): Grid {
38 return Array.from({ length: N }, () => Array<string | null>(N).fill(null))
39}
40function put(g: Grid, x: number, y: number, c: string) {
41 const row = g[y]
42 if (row && x >= 0 && x < N) row[x] = c
43}
44function rect(g: Grid, x0: number, y0: number, x1: number, y1: number, c: string) {
45 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) put(g, x, y, c)
46}
47function pts(g: Grid, list: ReadonlyArray<readonly [number, number]>, c: string) {
48 for (const [x, y] of list) put(g, x, y, c)
49}
50function stamp(g: Grid, rows: readonly string[], ox: number, oy: number, recolor: Record<string, string> = {}) {
51 rows.forEach((row, y) => [...row].forEach((ch, x) => {
52 if (ch !== '.') put(g, ox + x, oy + y, recolor[ch] ?? ch)
53 }))
54}
55
56function body(g: Grid, robe: string, trim: string | null) {
57 for (let y = 12; y < N; y++) {
58 const w = Math.min(8, 3 + Math.floor((y - 12) * 0.7))
59 for (let x = 12 - w; x <= 11 + w; x++) put(g, x, y, x === 12 - w || x === 11 + w ? 'k' : robe)
60 }
61 if (trim) rect(g, 6, 17, 17, 17, trim)
62}
63function tail(g: Grid, f: number) {
64 const w = [0, 1, 0, -1][f % 4] ?? 0
65 pts(g, [[5, 23], [4, 22], [3, 21], [2 + w, 20], [2 + w, 19], [3 + w, 18]], 'p')
66}
67function head(g: Grid, f: number, eye: string, recolor?: Record<string, string>) {
68 stamp(g, HEAD, 4, 0, recolor)
69 const blink = f === FRAMES - 1
70 put(g, 9, 6, blink ? 'k' : eye)
71 put(g, 14, 6, blink ? 'k' : eye)
72 if (f % 6 === 3) put(g, 12, 10, 'k')
73}
74
75function greySeer(g: Grid, f: number) {
76 tail(g, f); body(g, 'q', 'P'); head(g, f, f % 8 < 4 ? 'P' : 'e', { f: 'Q', F: 'h' })
77 pts(g, [[4, 4], [3, 3], [2, 2], [2, 1], [3, 0], [4, 0], [3, 4]], 'H')
78 pts(g, [[19, 4], [20, 3], [21, 2], [21, 1], [20, 0], [19, 0], [20, 4]], 'H')
79 pts(g, [[2, 3], [21, 3]], 'B')
80 rect(g, 20, 8, 21, 12, 'w'); rect(g, 19, 13, 22, 13, 'y')
81 pts(g, [[20, 7], [21, 7], [f % 2 ? 21 : 20, 6]], 'o'); put(g, f % 2 ? 21 : 20, 5, 'y')
82 rect(g, 6, 16, 17, 21, 'B'); rect(g, 7, 16, 16, 20, 'w'); rect(g, 11, 16, 12, 20, 'F')
83 for (let i = 0; i <= (f >> 1) % 4; i++) rect(g, 8, 17 + i, 10, 17 + i, 'F')
84 rect(g, 13, 17, 15, 17, 'F'); rect(g, 13, 19, 14, 19, 'F')
85 if (f === 8) { rect(g, 13, 14, 16, 15, 'w'); rect(g, 13, 13, 16, 13, 'k') }
86 if (f === 9) { rect(g, 11, 12, 13, 15, 'w'); rect(g, 11, 11, 13, 11, 'k') }
87 if (f === 10) { rect(g, 8, 14, 10, 15, 'w'); rect(g, 7, 13, 10, 13, 'k') }
88 pts(g, [[6, 18], [17, 18]], 'Q')
89}
90
91function apprentice(g: Grid, f: number) {
92 tail(g, f); body(g, 'm', 'c'); head(g, f, 'g')
93 rect(g, 6, 4, 17, 4, 'c')
94 pts(g, [[9, 4], [10, 4], [13, 4], [14, 4]], f % 2 ? 'g' : 'G')
95 rect(g, 18, 12, 21, 14, 'k'); rect(g, 19, 12, 20, 14, 'l')
96 rect(g, 17, 15, 22, 15, 'k'); rect(g, 16, 16, 23, 20, 'k'); rect(g, 17, 21, 22, 21, 'k')
97 rect(g, 17, 16, 22, 16, 'l'); rect(g, 17, 17, 22, 20, f % 4 < 2 ? 'G' : 'g')
98 pts(g, [[18 + (f % 3), 18], [21 - (f % 2), 19]], f % 4 < 2 ? 'g' : 'G')
99 for (let i = 0; i < 3; i++) {
100 const y = 11 - ((f + i * 2) % 6) - 1
101 if (y >= 3) put(g, 19 + (i % 2), y, 'g')
102 }
103 put(g, 16, 17, 'p')
104 if (f < 2) pts(g, [[3, 2], [2, 3], [3, 4], [2, 5], [3, 6]], 'y')
105}
106
107function sapper(g: Grid, f: number) {
108 tail(g, f); body(g, 'a', 'b'); head(g, f, 'e')
109 rect(g, 9, 0, 14, 0, 'k'); rect(g, 8, 1, 15, 2, 'y'); rect(g, 7, 3, 16, 3, 'o')
110 rect(g, 16, 20, 23, 20, 'S'); rect(g, 18, 21, 21, 22, 's'); rect(g, 16, 23, 23, 23, 'S')
111 const phase = f % 4
112 if (phase === 0) {
113 rect(g, 19, 9, 19, 15, 'b'); rect(g, 17, 6, 21, 8, 'S'); rect(g, 17, 8, 21, 8, 's'); put(g, 19, 16, 'p')
114 } else if (phase === 1) {
115 pts(g, [[16, 16], [17, 15], [18, 14], [19, 13], [20, 12]], 'b'); rect(g, 19, 9, 22, 11, 'S'); put(g, 15, 16, 'p')
116 } else {
117 rect(g, 15, 17, 19, 17, 'b'); rect(g, 19, 16, 21, 19, 'S'); rect(g, 19, 19, 21, 19, 's'); put(g, 14, 17, 'p')
118 const sparks: ReadonlyArray<readonly [number, number]> = phase === 2
119 ? [[17, 18], [23, 18], [18, 15], [23, 16]]
120 : [[16, 14], [23, 14], [22, 13], [17, 16]]
121 pts(g, sparks, phase === 2 ? 'y' : 'o')
122 }
123}
124
125const SWEEP = [8, 9, 10, 11, 10, 9]
126
127function inspector(g: Grid, f: number) {
128 tail(g, f); body(g, 'd', 'h'); head(g, f, 'e')
129 pts(g, [[13, 5], [14, 5], [15, 5], [13, 7], [14, 7], [15, 7], [13, 6], [15, 6]], 'y')
130 pts(g, [[16, 8], [16, 9], [17, 10], [17, 11]], 'y')
131 rect(g, 5, 15, 14, 22, 'b'); rect(g, 6, 16, 13, 22, 'w'); rect(g, 8, 14, 11, 15, 'S')
132 const ticked = Math.floor(f / 3) % 4
133 ;[17, 19, 21].forEach((y, i) => { rect(g, 9, y, 12, y, 'F'); put(g, 7, y, i < ticked ? 'G' : 's') })
134 const px = SWEEP[f % SWEEP.length] ?? 8
135 pts(g, [[px - 1, 17], [px, 17], [px + 1, 17], [px - 1, 18], [px + 1, 18], [px - 1, 19], [px, 19], [px + 1, 19]], 's')
136 put(g, px, 18, 'l')
137 pts(g, [[px + 2, 20], [px + 3, 21]], 'b'); put(g, px + 4, 22, 'p')
138}
139
140function clanrat(g: Grid, f: number) {
141 tail(g, f); body(g, 'r', null); head(g, f, 'e')
142 const isUp = f % 4 < 2
143 rect(g, 18, isUp ? 9 : 11, 18, isUp ? 15 : 17, 'S')
144 put(g, 18, isUp ? 16 : 18, 'b'); put(g, 17, isUp ? 16 : 18, 'p')
145}
146
147const DRAW: Record<Kind, (g: Grid, f: number) => void> = {
148 research: greySeer,
149 experiment: apprentice,
150 build: sapper,
151 test: inspector,
152 other: clanrat,
153}
154
155export function drawFrame(kind: Kind, frame: number): Grid {
156 const g = grid()
157 DRAW[kind](g, frame)
158 return g
159}
160
161/** One `<path>` per colour, each horizontal run of a colour one `M x y h w v 1 h -w z`. */
162export function toPaths(cells: ReadonlyMap<string, string>): string {
163 const byColor = new Map<string, string[]>()
164 for (let y = 0; y < N; y++) {
165 let x = 0
166 while (x < N) {
167 const c = cells.get(`${x},${y}`)
168 if (!c) { x++; continue }
169 let end = x + 1
170 while (end < N && cells.get(`${end},${y}`) === c) end++
171 const runs = byColor.get(c) ?? []
172 runs.push(`M${x} ${y}h${end - x}v1h-${end - x}z`)
173 byColor.set(c, runs)
174 x = end
175 }
176 }
177 return [...byColor].map(([c, runs]) => `<path fill="${PAL[c]}" d="${runs.join('')}"/>`).join('')
178}
179
180function cellsOf(g: Grid): Map<string, string> {
181 const cells = new Map<string, string>()
182 g.forEach((row, y) => row.forEach((c, x) => { if (c) cells.set(`${x},${y}`, c) }))
183 return cells
184}
185
186const cache = new Map<string, string>()
187
188/**
189 * The rat's SVG body (no outer `<svg>`): animated when running and `animate` is set, frozen on
190 * frame 0 otherwise.
191 */
192export function spriteBody(kind: Kind, status: Status, animate = true): string {
193 const isAnimated = status === 'running' && animate
194 const cacheKey = `${kind}:${status}:${isAnimated}`
195 const hit = cache.get(cacheKey)
196 if (hit) return hit
197 const frames = Array.from({ length: isAnimated ? FRAMES : 1 }, (_, f) => cellsOf(drawFrame(kind, f)))
198 const first = frames[0] ?? new Map<string, string>()
199 const common = new Map<string, string>([...first].filter(([at, c]) => frames.every(fr => fr.get(at) === c)))
200 const dur = `${(FRAMES * FRAME_MS) / 1000}s`
201 const keyTimes = Array.from({ length: FRAMES }, (_, i) => (i / FRAMES).toFixed(4)).join(';')
202 const layers = frames.length === 1 ? '' : frames.map((fr, i) => {
203 const own = new Map<string, string>([...fr].filter(([at]) => !common.has(at)))
204 const values = Array.from({ length: FRAMES }, (_, j) => (j === i ? 'visible' : 'hidden')).join(';')
205 return `<g visibility="${i === 0 ? 'visible' : 'hidden'}">${toPaths(own)}`
206 + `<animate attributeName="visibility" values="${values}" keyTimes="${keyTimes}" calcMode="discrete" dur="${dur}" repeatCount="indefinite"/></g>`
207 }).join('')
208 const isFinished = status !== 'running'
209 const filter = isFinished ? '<filter id="u-grey"><feColorMatrix type="saturate" values="0"/></filter>' : ''
210 const wrapOpen = isFinished ? '<g filter="url(#u-grey)" opacity="0.8">' : '<g>'
211 const svgBody = `${filter}${wrapOpen}${toPaths(common)}${layers}</g>`
212 cache.set(cacheKey, svgBody)
213 return svgBody
214}
215
216const TILE_BG = '#151413'
217export const FINISHED_GREY = '#8c8984'
218const OK = '#5fbf5a'
219const BAD = '#e0574a'
220const STOPPED = '#d9a441'
221
222/** Blends two `#rrggbb` colours: `amount` of `a` over `b`. */
223export function mix(a: string, b: string, amount: number): string {
224 const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
225 return `#${[0, 1, 2].map(i => Math.round(channel(a, i) * amount + channel(b, i) * (1 - amount)).toString(16).padStart(2, '0')).join('')}`
226}
227
228function badge(status: Status): string {
229 if (status === 'done') {
230 return `<circle cx="25.2" cy="25.2" r="3.4" fill="${OK}"/><path d="M23.6 25.3l1.1 1.1 2.2-2.4" fill="none" stroke="#111" stroke-width="0.9"/>`
231 }
232 if (status === 'stopped') {
233 return `<circle cx="25.2" cy="25.2" r="3.4" fill="${STOPPED}"/><rect x="24" y="24" width="2.4" height="2.4" fill="#111"/>`
234 }
235 if (status === 'failed') {
236 return `<circle cx="25.2" cy="25.2" r="3.4" fill="${BAD}"/><path d="M23.8 23.8l2.8 2.8M26.6 23.8l-2.8 2.8" stroke="#111" stroke-width="0.9"/>`
237 }
238 return ''
239}
240
241/**
242 * The rat on its dark tile with the kind's coloured edge while it runs; once stopped, the rat and
243 * tile turn grey and a coloured ✓, ✕ or ■ badge says how it ended.
244 */
245export function spriteSvg(kind: Kind, status: Status, sizePx: number, animate = true): string {
246 const color = status === 'running' ? ROLES[kind].color : FINISHED_GREY
247 const glow = mix(color, '#1a1918', 0.22)
248 const edge = mix(color, '#3d3a35', 0.45)
249 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="${sizePx}" height="${sizePx}">`
250 + `<defs><radialGradient id="u-tile" cx="50%" cy="60%" r="75%"><stop offset="0" stop-color="${glow}"/><stop offset="1" stop-color="${TILE_BG}"/></radialGradient></defs>`
251 + `<rect x="0" y="0" width="30" height="30" fill="#151413"/><rect x="0.4" y="0.4" width="29.2" height="29.2" rx="3" fill="url(#u-tile)" stroke="${edge}" stroke-width="0.8"/>`
252 + `<svg x="3" y="3" width="${N}" height="${N}" viewBox="0 0 ${N} ${N}" shape-rendering="crispEdges">${spriteBody(kind, status, animate)}</svg>`
253 + `${badge(status)}</svg>`
254}
255mods/underlings/row-hit.tsx 27 lines1import type { ClientModule } from 'claude-code'
2
3type HitProps = { id: string; rows: number; hoverColor: string }
4type HitState = { isHovered: boolean }
5
6/**
7 * A transparent region laid over a whole roster row: it tints under the pointer and posts
8 * `{ pick: id }` on a left click, so the avatar and every line of the row open its detail.
9 */
10const RowHit: ClientModule<HitProps, HitState> = (props, surface) => {
11 if (surface.state === undefined) {
12 surface.setState({ isHovered: false })
13 surface.onPointer(event => {
14 if (event.type === 'enter') surface.setState({ isHovered: true })
15 else if (event.type === 'leave') surface.setState({ isHovered: false })
16 else if (event.type === 'down' && event.button === 'left') surface.post({ pick: props.id })
17 })
18 }
19 const { Box } = surface.elements
20 return (
21 <Box width="100%" height={props.rows}
22 backgroundColor={surface.state?.isHovered ? props.hoverColor : undefined} />
23 )
24}
25
26export default RowHit
27types/index.d.ts 84 lines1// The under-empire $.state contract, one self-contained file as the engine requires. Each mod
2// declares its own values under the plugin's name, every key prefixed with the mod's name.
3
4// ---------------------------------------------------------------- underlings
5
6export type Kind = 'research' | 'experiment' | 'build' | 'test' | 'other'
7
8export type Status = 'running' | 'done' | 'failed' | 'stopped'
9
10export type Underling = {
11 /** The subagent's agentId, or the background task id. */
12 id: string
13 type: 'subagent' | 'bash'
14 title: string
15 kind: Kind
16 status: Status
17 startedAt: number
18 endedAt?: number
19 /** The tool call that started it (Agent or Bash). */
20 toolUseId?: string
21 agentType?: string
22 model?: string
23 tokens?: number
24 command?: string
25 outputPath?: string
26 exitCode?: number
27}
28
29export type TxEntry =
30 | { kind: 'text' | 'thinking'; text: string }
31 | { kind: 'tool'; id: string; name: string; arg: string; result?: string; isError?: boolean }
32
33// ---------------------------------------------------------------- doomwheel
34
35/** Where an offering's review process stands. `victory` (merged) and `scrapped` (closed) are retired. */
36export type Phase = 'offering' | 'armed' | 'waking' | 'cracked' | 'cold' | 'victory' | 'scrapped'
37
38export type Offering = {
39 /** `owner/name#pr` */
40 key: string
41 repo: string
42 pr: number
43 url: string
44 phase: Phase
45 autoFix: boolean
46 /** Submitted reviews, from any reviewer, at the latest poll. */
47 laps: number
48 /** Review threads at the latest poll. */
49 unresolvedThreads: number
50 totalThreads: number
51 /** Failing checks on the head commit at the latest poll. */
52 failingChecks: number
53 /** True while GitHub reports a merge conflict. */
54 conflict: boolean
55 /** When GitHub last answered a poll; undefined until it has. */
56 polledAt?: number
57 /** True while `gh` is missing or not signed in, so the wheel runs on session signals alone. */
58 ghUnavailable?: boolean
59 createdAt: number
60 updatedAt: number
61}
62
63export type LogKind =
64 | 'raised' | 'armed' | 'disarmed' | 'wake' | 'review' | 'tired' | 'threads' | 'checks' | 'conflict'
65 | 'crack' | 'victory' | 'scrapped' | 'warn'
66
67export type LogEntry = { at: number; key: string; kind: LogKind; text: string }
68
69declare module 'claude-code' {
70 interface PluginState {
71 'under-empire': {
72 'doomwheel.offerings': Offering[]
73 'doomwheel.log': LogEntry[]
74 'underlings.list': Underling[]
75 'underlings.selected': string | null
76 'underlings.now': number
77 /** Roster groups, by label, showing every row rather than the first few. */
78 'underlings.expanded': string[]
79 'underlings.transcript': StateFamily<TxEntry[]>
80 'underlings.output': StateFamily<string>
81 }
82 }
83}
84