A band above the prompt, a run pane and toasts showing what a request-pipeline run is doing: its lane, stage, MSPs, agents and verdicts.

Spindle is a Claude Code mod that shows what a request-pipeline run is doing: its lane, stage and review round, the MSPs mitosis is building with their acceptance proof, the agents at work and each round's verdict. It draws a band above the prompt, a run pane and toasts, read from the run's files and the session's own events, and it writes nothing.
At the prompt of a Claude Code terminal session:
/plugin install spindle --marketplace SatanshuMishra/spindle
From a shell:
claude plugin install spindle --marketplace SatanshuMishra/spindle
To load a clone by hand for one session:
claude --plugin-dir <clone>/plugins/spindle
/spindle shows or hides the run pane./spindle status prints the run as text lines in the transcript.scripts/check.sh
It runs claude plugin validate --strict on the marketplace and on the mod, claude plugin test plugins/spindle, and tsc -p plugins/spindle once the engine has laid its types in plugins/spindle/.claude-plugin/types (it says the type-check was skipped otherwise). CI runs the same script on every push and pull request.
.claude-plugin/marketplace.json the marketplace, listing the one plugin
plugins/spindle/
.claude-plugin/plugin.json the mod's manifest
hooks/hooks.json names the hooks module
hooks/register.tsx wires the hooks
hooks/run/ reads the run's files and the session's events into the run view
hooks/draw/ draws the band, the run pane and the crew
types/index.d.ts the $.state contract
tests/ the harness, fixture run homes and the plugin tests
scripts/check.sh the repository checks
.github/workflows/check.yml CI
MIT, see LICENSE. The pixel crab art, costumes and motion derive from the savvy-progress plugin of claude-kit (https://github.com/johnnyvizz/claude-kit), used under the MIT License, Copyright (c) 2026 johnnyvizz.
hooks/register.tsx 43 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { drawBand } from './draw/band'
5import { PANE, drawPane, togglePane } from './draw/pane'
6import { startPolling } from './run/poll'
7import { agentSpawnSignals, toolCallSignals, turnCompleteSignals, turnStepSignals } from './run/signals'
8import { summary } from './run/summary'
9import { activitySignature } from './run/view'
10
11const viewState = atom({ plugin: 'spindle', key: 'view' } as const, null)
12const dismissedState = atom({ plugin: 'spindle', key: 'dismissedAt' } as const, null)
13
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 const started = await next(e)
17 await $.command.register({
18 name: 'spindle',
19 description: 'Show or hide the pipeline run pane; /spindle status prints the run as text',
20 })
21 await startPolling($)
22 return started
23 })
24
25 on('tool.call', toolCallSignals).catch(($, e, next) => next(e))
26 on('agent.spawn', agentSpawnSignals).catch(($, e, next) => next(e))
27 on('turn.step', turnStepSignals)
28 on('turn.complete', turnCompleteSignals)
29
30 on('command.run', { command: 'spindle' }, async ($, e) => {
31 const view = await read($, viewState)
32 if (e.args.trim() === 'status') {
33 return { text: summary(view, await $.clock.now()) }
34 }
35 await update($, dismissedState, () => (view !== null && view.isFinished ? activitySignature(view) : null))
36 return { text: await togglePane($) }
37 })
38
39 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
40
41 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => drawBand($, e, () => next(e)))
42}
43hooks/draw/band.tsx 439 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderElement, RenderInput, UiPressArgument } from 'claude-code'
3
4import type { Finish, Lane, Role, RunView } from '../../types'
5import { LANE_NAME } from '../run/state'
6import { activityNow, currentActivity, elapsed } from '../run/summary'
7import { activitySignature, isFinishSettled } from '../run/view'
8import { CRAB_CSS, celebrationSize, celebrationSvg, colorOf, costumeOf, crab } from './crew'
9import type { CelebrationLook } from './crew'
10import { cardSlot, cardTrigger, hint, tallestCard } from './hint'
11import type { Card } from './hint'
12import { stageSummary, togglePane } from './pane'
13import { ACCENT, CHIP_H, DONE, FONT, STAGE_TABLE, THEME_CSS, TRACK, chipSvg, chipWidthOf, noise, stageIndex, stageInfo, svg, xml } from './text'
14
15const viewState = atom({ plugin: 'spindle', key: 'view' } as const, null)
16const dismissedState = atom({ plugin: 'spindle', key: 'dismissedAt' } as const, null)
17const finishSeenState = atom({ plugin: 'spindle', key: 'finishSeen' } as const, null)
18
19const BAR_H = 16
20const CELL = 3
21const TEXTURE_W = 1600
22const CROP_FROM_START = 'xMinYMid slice'
23const CROP_FROM_END = 'xMaxYMid slice'
24const TEXTURE_DENSITY = 0.65
25const TICK_W = 2
26const TICK_H = 8
27const CRAB_W = 24
28const CRAB_H = 22
29const CELEBRATION_PARTNERS: readonly Role[] = ['captain', 'builder', 'judge']
30const CELEBRATION_SIZE = 3
31const CELEBRATION_FORM = { scale: 0.8, step: 26, inset: 0, hopPx: 3 } as const
32const BAR_SPARE_COLUMNS = 12
33const BAR_COLUMNS = 60
34const DOT_PX = 10
35const DOT = '●'
36const ICON = '◉'
37const DISMISS = '✕'
38const DISMISS_HINT = 'Hide until an MSP moves to a new activity'
39const COPY_HINT = 'Press to copy the name'
40const COPY_FAILED = "Couldn't copy the name here"
41const COUNT_HINT = 'Show the Pipeline pane'
42const PROGRESS_SCOPE = 'spindle-band-progress'
43const STAGE_SCOPE = 'spindle-band-stage'
44const LANE_SCOPE = 'spindle-band-lane'
45const LANE_MEANING: Readonly<Record<Lane, string>> = {
46 full: 'Full lane: interview, spec, build, review until green',
47 standard: 'Standard lane: brief, one build, one review round',
48 direct: 'Direct lane: a hand fix after its parent run was cleaned up',
49}
50
51type Worker = { role: Role; startedAt: number }
52
53export function workingOf(view: RunView): readonly Worker[] {
54 return [
55 ...view.agents.filter(agent => agent.isRunning).map(agent => ({ role: agent.role, startedAt: agent.startedAt })),
56 ...(view.buildingLanes > 0 ? [{ role: 'builder' as const, startedAt: Number.NEGATIVE_INFINITY }] : []),
57 ...(view.isPullRequestRunning ? [{ role: 'courier' as const, startedAt: Number.POSITIVE_INFINITY }] : []),
58 ]
59}
60
61export function leadOf(view: RunView): Role {
62 const working = workingOf(view)
63 const lead = working.reduce<Worker | null>((best, one) => (best === null || one.startedAt >= best.startedAt ? one : best), null)
64 return lead?.role ?? 'captain'
65}
66
67const roundPart = (view: RunView) =>
68 view.stage === 'review' || view.stage === 'fix' ? Math.min(0.9, view.round * 0.25) : 0
69
70const isFinishedDone = (finish: Finish | null) => finish?.isFullyDone === true
71
72const progressOf = (view: RunView) =>
73 isFinishedDone(view.finish) ? 1 : (Math.max(0, stageIndex(view.stage)) + roundPart(view)) / STAGE_TABLE.length
74
75export function pillText(view: RunView): string {
76 if (view.finish !== null) return view.finish.word
77 const stage = stageInfo(view.stage)
78 if (stage === null) return 'stage ?'
79 return view.round > 0 ? `${stage.name} · R${view.round}` : stage.name
80}
81
82const pillWidthOf = (label: string) => Math.round(18 + label.length * 6.4)
83
84const laneOf = (view: RunView) => (view.lane === null ? null : LANE_NAME[view.lane])
85
86const nameHintOf = (view: RunView) => {
87 const lane = laneOf(view)
88 return lane === null ? view.slug : `${view.slug} · ${lane} lane`
89}
90
91const countHintOf = (working: number) => `${COUNT_HINT} · ${working} working now`
92
93const mspLinesOf = (view: RunView, now: number): readonly string[] =>
94 view.msps.map(msp => {
95 const activity = currentActivity(msp)
96 return `${msp.label}: ${activity === undefined ? 'status unknown' : activityNow(activity, now)}`
97 })
98
99const dotColorOf = (view: RunView) => (isFinishedDone(view.finish) ? DONE : (stageInfo(view.stage)?.color ?? ACCENT))
100
101const barColorOf = (view: RunView) => (isFinishedDone(view.finish) ? DONE : ACCENT)
102
103const lineWordsOf = (view: RunView) => (view.finish === null ? pillText(view) : view.finish.headline)
104
105type BandRoom = {
106 columns: number
107 fixed: readonly number[]
108 slug: number
109 icon: number | null
110 lane: number | null
111 pill: number
112 barMin: number
113}
114
115type BandPlan = { isSlugShown: boolean; isLaneShown: boolean; isBarShown: boolean; barColumns: number }
116
117const present = (parts: readonly (number | null)[]): number[] => parts.filter((part): part is number => part !== null)
118
119const rowColumns = (parts: readonly (number | null)[]) => {
120 const drawn = present(parts)
121 return drawn.reduce((sum, part) => sum + part, 0) + Math.max(0, drawn.length - 1)
122}
123
124function planBand(room: BandRoom): BandPlan {
125 const fits = (parts: readonly (number | null)[]) => rowColumns(parts) <= room.columns
126 const isBarShown = fits([...room.fixed, room.icon, room.barMin])
127 const middle = isBarShown ? room.barMin : room.pill
128 const isSlugShown = fits([...room.fixed, room.slug, middle])
129 const name = isSlugShown ? room.slug : room.icon
130 const isLaneShown = isSlugShown && isBarShown && room.lane !== null && fits([...room.fixed, name, room.lane, room.barMin])
131 const others = [...room.fixed, name, isLaneShown ? room.lane : null]
132 const barColumns = isBarShown ? room.columns - rowColumns(others) - 1 : 0
133 return { isSlugShown, isLaneShown, isBarShown, barColumns }
134}
135
136const TWINKLE_CSS = `<style>
137.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}
138.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
139@keyframes tw{0%,100%{opacity:1}50%{opacity:0.3}}
140@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
141</style>`
142
143function barSvg(view: RunView): string {
144 const cols = Math.floor(TEXTURE_W / CELL)
145 const rows = Math.floor(BAR_H / CELL)
146 const dots = Array.from({ length: cols }, (_, c) =>
147 Array.from({ length: rows }, (_, r) =>
148 noise(c, r) < TEXTURE_DENSITY
149 ? `<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`
150 : '',
151 ).join(''),
152 ).join('')
153 return `<svg xmlns="http://www.w3.org/2000/svg" width="${TEXTURE_W}" height="${BAR_H}" viewBox="0 0 ${TEXTURE_W} ${BAR_H}" preserveAspectRatio="${CROP_FROM_START}">${view.isFinished ? '' : TWINKLE_CSS}<clipPath id="spindle-bar-start"><rect width="${TEXTURE_W + BAR_H}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath><g data-part="bar" fill="${barColorOf(view)}" clip-path="url(#spindle-bar-start)">${dots}</g></svg>`
154}
155
156const trackHalfSvg = (crop: string, x: number) =>
157 `<svg xmlns="http://www.w3.org/2000/svg" width="${TEXTURE_W}" height="${BAR_H}" viewBox="0 0 ${TEXTURE_W} ${BAR_H}" preserveAspectRatio="${crop}"><rect data-part="track" x="${x}" width="${TEXTURE_W + BAR_H}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${TRACK}"/></svg>`
158
159const TRACK_START_SVG = trackHalfSvg(CROP_FROM_START, 0)
160
161const TRACK_END_SVG = trackHalfSvg(CROP_FROM_END, -BAR_H)
162
163function pillSvg(view: RunView): string {
164 const label = pillText(view)
165 const W = pillWidthOf(label)
166 return svg(
167 W,
168 BAR_H,
169 `<g data-part="pill"><rect width="${W}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${barColorOf(view)}"/>
170<text x="${W / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(label)}</text></g>`,
171 )
172}
173
174const TICK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${TICK_W}" height="${TICK_H}" viewBox="0 0 ${TICK_W} ${TICK_H}">${THEME_CSS}<rect class="sv-tk" width="${TICK_W}" height="${TICK_H}" rx="${TICK_W / 2}"/></svg>`
175
176function dotSvg(view: RunView): string {
177 const half = DOT_PX / 2
178 return svg(
179 DOT_PX,
180 DOT_PX,
181 `<circle${view.isFinished ? '' : ' class="sv-live"'} cx="${half}" cy="${half}" r="4" fill="${dotColorOf(view)}"/>`,
182 )
183}
184
185function crabSvg(view: RunView): string {
186 const lead = leadOf(view)
187 return `<svg xmlns="http://www.w3.org/2000/svg" width="${CRAB_W}" height="${CRAB_H}" viewBox="0 0 ${CRAB_W} ${CRAB_H}" role="img" aria-label="${xml(costumeOf(lead))} crab">
188${CRAB_CSS}${crab(0, -1, costumeOf(lead), colorOf(lead), false, !view.isFinished, 0.8)}
189</svg>`
190}
191
192const celebrationCrewOf = (view: RunView): readonly Role[] => {
193 const lead = leadOf(view)
194 return [lead, ...CELEBRATION_PARTNERS.filter(role => role !== lead)].slice(0, CELEBRATION_SIZE)
195}
196
197const celebrationLookOf = (view: RunView): CelebrationLook => ({ ...CELEBRATION_FORM, crew: celebrationCrewOf(view) })
198
199const blockBar = (view: RunView, blocks: number) => {
200 const filled = Math.round(blocks * progressOf(view))
201 return `${'█'.repeat(filled)}${'░'.repeat(blocks - filled)}`
202}
203
204function terminalLine(view: RunView, plan: BandPlan, time: string): string {
205 const lane = laneOf(view)
206 const pill = lineWordsOf(view)
207 const middle = plan.isBarShown ? `${blockBar(view, plan.barColumns - [...pill].length - 1)} ${pill}` : pill
208 return [
209 plan.isSlugShown ? DOT : null,
210 plan.isSlugShown ? view.slug : null,
211 plan.isLaneShown && lane !== null ? `[${lane}]` : null,
212 middle,
213 time,
214 ]
215 .filter((part): part is string => part !== null)
216 .join(' ')
217}
218
219const isEnginePlaceholder = (drawn: unknown) =>
220 typeof drawn !== 'object' || drawn === null || (drawn as { type?: unknown }).type === 'engine'
221
222const isDismissed = (marker: string | null, view: RunView) => marker !== null && marker === activitySignature(view)
223
224type BandElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
225
226type DesktopElements = BandElements & Pick<Elements['desktop'], 'Svg'>
227
228async function copyName($: EngineInterface, slug: string, surface: UiPressArgument['surface']): Promise<void> {
229 const copied = await $.ui.copy({ text: slug, surface })
230 await $.ui.toast(copied.isCopied ? `Copied ${slug}` : COPY_FAILED)
231}
232
233function countButton($: EngineInterface, ui: BandElements, view: RunView): RenderElement {
234 const { Button } = ui
235 return (
236 <Button key="spindle-crew" plain onPress={() => void togglePane($)}>
237 {`×${workingOf(view).length}`}
238 </Button>
239 )
240}
241
242function dismissButton($: EngineInterface, ui: BandElements, view: RunView): RenderElement {
243 const { Button } = ui
244 return (
245 <Button
246 key="spindle-dismiss"
247 plain
248 role="dismiss"
249 onPress={async () => {
250 await update($, dismissedState, () => activitySignature(view))
251 }}
252 >
253 {DISMISS}
254 </Button>
255 )
256}
257
258function progressBar(ui: DesktopElements, view: RunView): RenderElement {
259 const { Box, Svg } = ui
260 const share = progressOf(view)
261 const pill = pillText(view)
262 const ticks = STAGE_TABLE.map((stage, k) => (
263 <Box key={`spindle-tick-${k}`} width={0} flexGrow={1} flexDirection="row" alignItems="center">
264 {k > 0 && k / STAGE_TABLE.length > share + 0.01 ? (
265 <Svg source={TICK_SVG} alt={`start of ${stage.name}`} width={TICK_W} height={TICK_H} />
266 ) : null}
267 </Box>
268 ))
269 return (
270 <Box key="spindle-bar" position="relative" flexDirection="row" flexGrow={1} minWidth={0} overflow="hidden">
271 <Box key="spindle-track" position="absolute" top={0} bottom={0} left={0} right={0} flexDirection="row">
272 <Box key="spindle-track-start" width={0} flexGrow={1} overflow="hidden">
273 <Svg source={TRACK_START_SVG} alt="progress track, left end" width={TEXTURE_W} height={BAR_H} />
274 </Box>
275 <Box key="spindle-track-end" width={0} flexGrow={1} overflow="hidden">
276 <Svg source={TRACK_END_SVG} alt="progress track, right end" width={TEXTURE_W} height={BAR_H} />
277 </Box>
278 </Box>
279 <Box key="spindle-ticks" position="absolute" top={0} bottom={0} left={0} right={0} flexDirection="row">
280 <Box key="spindle-ticks-offset" flexShrink={0}>
281 <Svg source={svg(pillWidthOf(pill), BAR_H, '')} alt="space for the stage label" width={pillWidthOf(pill)} height={BAR_H} />
282 </Box>
283 {ticks}
284 </Box>
285 <Box key="spindle-bar-filled" position="relative" width={0} flexGrow={share} overflow="hidden">
286 <Svg source={barSvg(view)} alt={`${Math.round(share * 100)}% of the run done`} width={TEXTURE_W} height={BAR_H} />
287 </Box>
288 <Box key="spindle-pill-holder" position="relative" flexShrink={0}>
289 <Svg key="spindle-pill" source={pillSvg(view)} alt={`${view.slug}: ${pill}`} width={pillWidthOf(pill)} height={BAR_H} />
290 </Box>
291 <Box key="spindle-bar-rest" width={0} flexGrow={1 - share} />
292 </Box>
293 )
294}
295
296function desktopBand(
297 $: EngineInterface,
298 ui: DesktopElements,
299 view: RunView,
300 room: { columns: number; now: number; below: RenderElement | null },
301): RenderElement {
302 const { Box, Button, Text, Svg } = ui
303 const { columns, now, below } = room
304 const lane = laneOf(view)
305 const pill = pillText(view)
306 const cards: readonly Card[] = [
307 { scope: PROGRESS_SCOPE, lines: mspLinesOf(view, now) },
308 { scope: STAGE_SCOPE, lines: [stageSummary(view)] },
309 { scope: LANE_SCOPE, lines: view.lane === null ? [] : [LANE_MEANING[view.lane]] },
310 ]
311 const progress =
312 columns >= BAR_COLUMNS
313 ? [cardTrigger(ui, PROGRESS_SCOPE, progressBar(ui, view), 'grow')]
314 : [
315 cardTrigger(ui, PROGRESS_SCOPE, <Svg key="spindle-pill" source={pillSvg(view)} alt={`${view.slug}: ${pill}`} width={pillWidthOf(pill)} height={BAR_H} />),
316 <Box key="spindle-spacer" flexGrow={1} />,
317 ]
318 return (
319 <Box flexDirection="column">
320 <Box key="spindle-row" flexDirection="row" alignItems="center" gap="sm">
321 {cardTrigger(ui, STAGE_SCOPE, <Svg source={dotSvg(view)} alt={stageSummary(view)} width={DOT_PX} height={DOT_PX} />)}
322 <Box key="spindle-slug-room" flexShrink={1} minWidth={0}>
323 <Button key="spindle-slug" plain onPress={press => void copyName($, view.slug, press.surface)}>
324 <Text wrap="truncate-end">{view.slug}</Text>
325 </Button>
326 </Box>
327 {lane === null
328 ? null
329 : cardTrigger(ui, LANE_SCOPE, <Svg source={chipSvg(lane)} alt={`${lane} lane`} width={chipWidthOf(lane)} height={CHIP_H} />)}
330 {progress}
331 <Text dimColor tabular wrap="truncate">
332 {elapsed(view.startedAt, now)}
333 </Text>
334 {view.finish?.isCelebrated ? (
335 <Svg
336 key="spindle-celebration"
337 source={celebrationSvg(celebrationLookOf(view), false)}
338 alt={`Celebration: ${view.slug} finished clean`}
339 {...celebrationSize(celebrationLookOf(view))}
340 />
341 ) : (
342 <Svg source={crabSvg(view)} alt={`${costumeOf(leadOf(view))} crab`} width={CRAB_W} height={CRAB_H} />
343 )}
344 {countButton($, ui, view)}
345 {dismissButton($, ui, view)}
346 </Box>
347 {below}
348 {cardSlot(ui, 'spindle-cards', cards, { top: -tallestCard(cards), left: 0 })}
349 </Box>
350 )
351}
352
353function settledBand(
354 $: EngineInterface,
355 ui: DesktopElements,
356 view: RunView,
357 finish: Finish,
358 room: { now: number; below: RenderElement | null },
359): RenderElement {
360 const { Box, Text, Svg } = ui
361 return (
362 <Box flexDirection="column">
363 <Box key="spindle-row" flexDirection="row" alignItems="center" gap="sm">
364 <Svg source={dotSvg(view)} alt={stageSummary(view)} width={DOT_PX} height={DOT_PX} />
365 <Box key="spindle-finish-room" flexShrink={1} minWidth={0} flexGrow={1}>
366 <Text wrap="truncate-end">{`${view.slug} · ${finish.headline}`}</Text>
367 </Box>
368 <Text dimColor tabular wrap="truncate">
369 {elapsed(view.startedAt, room.now)}
370 </Text>
371 {dismissButton($, ui, view)}
372 </Box>
373 {room.below}
374 </Box>
375 )
376}
377
378function terminalBand($: EngineInterface, ui: BandElements, view: RunView, room: { columns: number; now: number; below: RenderElement | null }) {
379 const { Box, Button, Text } = ui
380 const { columns, now, below } = room
381 const time = elapsed(view.startedAt, now)
382 const working = workingOf(view).length
383 const lane = laneOf(view)
384 const pill = lineWordsOf(view)
385 const plan = planBand({
386 columns,
387 fixed: [DOT.length, time.length, `×${working}`.length, DISMISS.length],
388 slug: [...view.slug].length,
389 icon: ICON.length,
390 lane: lane === null ? null : `[${lane}]`.length,
391 pill: [...pill].length,
392 barMin: [...pill].length + 1 + BAR_SPARE_COLUMNS,
393 })
394 const icon = hint(
395 ui,
396 'spindle-name-hint',
397 <Button key="spindle-name" plain onPress={press => void copyName($, view.slug, press.surface)}>
398 {ICON}
399 </Button>,
400 [nameHintOf(view), COPY_HINT],
401 )
402 return (
403 <Box flexDirection="column">
404 <Box flexDirection="row" gap="sm">
405 {plan.isSlugShown ? null : <Text>{DOT}</Text>}
406 {plan.isSlugShown ? null : icon}
407 <Text>{terminalLine(view, plan, time)}</Text>
408 {hint(ui, 'spindle-crew-card', countButton($, ui, view), [countHintOf(working)], { edge: 'end' })}
409 {hint(ui, 'spindle-dismiss-hint', dismissButton($, ui, view), [DISMISS_HINT], { edge: 'end' })}
410 </Box>
411 {below}
412 </Box>
413 )
414}
415
416export async function drawBand(
417 $: EngineInterface,
418 e: RenderInput<'AbovePrompt'>,
419 beneath: () => Promise<RenderElement> | RenderElement,
420): Promise<RenderElement> {
421 const [view, dismissedAt, seen, now] = await Promise.all([
422 read($, viewState),
423 read($, dismissedState),
424 read($, finishSeenState),
425 $.clock.now(),
426 ])
427 if (view === null || e.props.hasSurvey || isDismissed(dismissedAt, view)) return beneath()
428 const drawn = await Promise.resolve(beneath()).catch(() => null)
429 const below = isEnginePlaceholder(drawn) ? null : drawn
430 const ui = $.ui.resolve(e)
431 const room = { columns: e.props.bodyColumns || 100, now, below }
432 if (e.surface === 'desktop' && 'Svg' in ui) {
433 return view.finish !== null && isFinishSettled(seen, view)
434 ? settledBand($, ui, view, view.finish, room)
435 : desktopBand($, ui, view, room)
436 }
437 return terminalBand($, ui, view, room)
438}
439hooks/draw/pane.tsx 852 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderElement, RenderInput } from 'claude-code'
3
4import type { Activity, Finish, MspRow, MspStatus, Outcome, PastRun, Role, RoundRow, RunView, WorkerRole, WorkerRow } from '../../types'
5import { leavePastRun, readPastRun } from '../run/past'
6import { LANE_NAME } from '../run/state'
7import { NO_RUN, activityNow, currentActivity, elapsed, modelName, unplannedPulls } from '../run/summary'
8import { isFinishSettled } from '../run/view'
9import { ROLE_COLOR, celebrationSize, celebrationSvg, colorOf, costumeOf, crab } from './crew'
10import type { CelebrationLook } from './crew'
11import { cardSlot, cardTrigger } from './hint'
12import type { Card } from './hint'
13import {
14 ACCENT,
15 CELL_PX,
16 CHIP_H,
17 DONE,
18 FAIL,
19 FONT,
20 LANE_COLORS,
21 STAGE_TABLE,
22 TRACK,
23 chipSvg,
24 chipWidthOf,
25 columnsOf,
26 fitText,
27 stageIndex,
28 stageInfo,
29 statusMark,
30 svg,
31 textWidth,
32 xml,
33} from './text'
34import type { Mark, StageInfo } from './text'
35
36export const PANE = 'spindle-run'
37export const PANE_TITLE = 'Pipeline'
38
39const viewState = atom({ plugin: 'spindle', key: 'view' } as const, null)
40const selectedState = atom({ plugin: 'spindle', key: 'selectedMsp' } as const, null)
41const finishSeenState = atom({ plugin: 'spindle', key: 'finishSeen' } as const, null)
42const pastRunsState = atom({ plugin: 'spindle', key: 'pastRuns' } as const, null)
43const pastViewState = atom({ plugin: 'spindle', key: 'pastView' } as const, null)
44const pastSelectedState = atom({ plugin: 'spindle', key: 'pastSelectedMsp' } as const, null)
45
46const TITLE = 'Pipeline Insights'
47const PROOF_HINT = ['Acceptance test failed without the work (red)', 'and passed with it (green)']
48const VERDICT_LEGEND = 'Red: blocker · yellow: advisory · grey: other candidate'
49const MAIN_THREAD = 'run by the main thread'
50const COST_NOT_KEPT = 'not kept'
51const BACK_TO_SESSION = "Back to this session's run"
52const RAIL_NAMES_COLUMNS = 60
53const RAIL_MARK = 24
54const STRIP_W = 1600
55const LINK_H = 2
56const LANE_BAR_H = 4
57const VERDICT_ROOM = 200
58const AVATAR_W = 44
59const AVATAR_H = 36
60const MARK_PX = 14
61const PROOF_PX = 10.5
62const PROOF_WORD_W = Math.ceil(textWidth('proof', PROOF_PX)) + 4
63const PROOF_W = PROOF_WORD_W + 26
64const PROOF_H = 14
65const CREW_CRAB_PX = 18
66const WORKER_CRAB_PX = 32
67const CONTEXT_W = 48
68const CONTEXT_H = 4
69const PIP = 8
70const PIP_STEP = 10
71const VERDICT_CHIP_H = 18
72const MAX_HOTKEYS = 9
73const CELEBRATION: CelebrationLook = {
74 crew: ['captain', 'scribe', 'builder', 'detective', 'judge', 'courier'],
75 scale: 1.1,
76 step: 38,
77 inset: 2,
78 hopPx: 5,
79}
80
81const ROLE_NAME: Readonly<Record<WorkerRole, string>> = {
82 captain: 'Captain',
83 scribe: 'Scribe',
84 builder: 'Builder',
85 detective: 'Detective',
86 judge: 'Judge',
87 courier: 'Courier',
88 plain: 'Agent',
89 diagnosis: 'Diagnosis',
90 smoke: 'Smoke',
91}
92
93const ROLE_LOOK: Readonly<Record<WorkerRole, Role>> = {
94 captain: 'captain',
95 scribe: 'scribe',
96 builder: 'builder',
97 detective: 'detective',
98 judge: 'judge',
99 courier: 'courier',
100 plain: 'plain',
101 diagnosis: 'detective',
102 smoke: 'courier',
103}
104
105const MSP_STATUS: Readonly<Record<MspStatus, { word: string; mark: Mark }>> = {
106 planned: { word: 'Planned', mark: 'pending' },
107 building: { word: 'Building', mark: 'running' },
108 built: { word: 'Built', mark: 'done' },
109 review: { word: 'In review', mark: 'running' },
110 fixing: { word: 'Fixing', mark: 'running' },
111 green: { word: 'Green', mark: 'done' },
112 blocked: { word: 'Draft · blocked', mark: 'failed' },
113 pr: { word: 'PR open', mark: 'done' },
114 failed: { word: 'Failed', mark: 'failed' },
115 unknown: { word: 'Unknown', mark: 'pending' },
116}
117
118const VERDICT_COLOR: Readonly<Record<Outcome, string>> = {
119 green: DONE,
120 loop: ACCENT,
121 diagnose: ROLE_COLOR.verdict,
122 blocked: FAIL,
123 'hard-failure': FAIL,
124 unknown: ROLE_COLOR.continuity,
125}
126
127type PaneElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
128
129const laneWordOf = (view: RunView) => (view.lane === null ? null : `${LANE_NAME[view.lane]} lane`)
130
131const stageName = (view: RunView) => view.finish?.word ?? (stageInfo(view.stage)?.name ?? '?')
132
133type Shown = { isPast: boolean; pastRuns: readonly PastRun[] }
134
135const costText = (view: RunView, shown: Shown) => (shown.isPast ? COST_NOT_KEPT : `~$${view.costUsd.toFixed(2)}`)
136
137const isCrew = (worker: WorkerRow) => worker.role !== 'captain'
138
139const liveCrewOf = (view: RunView) => view.workers.filter(worker => worker.isLive && isCrew(worker))
140
141const mspsAt = (view: RunView, stage: StageInfo) => view.msps.filter(msp => msp.stage === stage.id).length
142
143export const stageSummary = (view: RunView) => {
144 const counts = STAGE_TABLE.filter(stage => mspsAt(view, stage) > 0).map(stage => `${mspsAt(view, stage)} at ${stage.name}`)
145 return counts.length === 0 ? `Stage ${stageName(view)}` : `Stage ${stageName(view)} · ${counts.join(', ')}`
146}
147
148const currentIndexOf = (view: RunView) => (view.finish?.isFullyDone === true ? STAGE_TABLE.length : stageIndex(view.stage))
149
150const mspStatusText = (msp: MspRow) => {
151 const word = MSP_STATUS[msp.status].word
152 if (msp.pr !== '') return `${word} ${msp.pr}`
153 return msp.round > 0 ? `${word} · R${msp.round}` : word
154}
155
156const activityText = (msp: MspRow, now: number) => {
157 const activity = currentActivity(msp)
158 return activity === undefined ? 'status unknown' : activityNow(activity, now)
159}
160
161const mspLabel = (msp: MspRow, now: number) => `${msp.label} · ${activityText(msp, now)}`
162
163const laneColorOf = (i: number) => LANE_COLORS[i % LANE_COLORS.length] ?? ACCENT
164
165const lanesDoneShare = (msp: MspRow) => (msp.lanesTotal > 0 ? Math.min(1, msp.lanesDone / msp.lanesTotal) : 0)
166
167const proofMarks = (msp: MspRow) =>
168 msp.proof === null ? '' : `proof ${msp.proof.isRedSeen ? '●' : '○'}${msp.proof.isGreenSeen ? '●' : '○'}`
169
170const proofWords = (msp: MspRow) =>
171 `Proof: ${msp.proof?.isRedSeen ? 'failed without the work' : 'without the work not seen yet'}, ${msp.proof?.isGreenSeen ? 'passed with it' : 'with the work not seen yet'}`
172
173const proofVerdict = (msp: MspRow) =>
174 msp.proof?.isRedSeen && msp.proof.isGreenSeen ? 'Both seen: the work is proven' : 'Not proven yet'
175
176const lanesWords = (msp: MspRow) => `${msp.lanesDone} of ${msp.lanesTotal} lanes built`
177
178const selectedOf = (view: RunView, stored: string | null) =>
179 stored !== null && view.msps.some(msp => msp.label === stored) ? stored : null
180
181const roundOrder = (worker: WorkerRow) => worker.round ?? Number.NEGATIVE_INFINITY
182
183function listedWorkers(view: RunView, selected: string | null): readonly WorkerRow[] {
184 if (selected === null) return view.workers.filter(worker => worker.isLive)
185 const own = view.workers.filter(worker => worker.msp === selected)
186 const finished = own.filter(worker => !worker.isLive)
187 const newestFirst = [...finished].sort((a, b) => roundOrder(b) - roundOrder(a))
188 return [...own.filter(worker => worker.isLive), ...newestFirst]
189}
190
191const workingTitle = (selected: string | null) => (selected === null ? 'Working now' : `${selected} · workers`)
192
193const rightText = (worker: WorkerRow, now: number) =>
194 [
195 worker.model === null ? '' : modelName(worker.model),
196 worker.isLive && worker.startedAt !== null ? elapsed(worker.startedAt, now) : '',
197 ]
198 .filter(part => part !== '')
199 .join(' · ')
200
201const contextPercent = (worker: WorkerRow) => Math.round((worker.contextShare ?? 0) * 100)
202
203const contextHint = (worker: WorkerRow) => `Context ${contextPercent(worker)}%`
204
205const roundOf = (view: RunView, worker: WorkerRow): RoundRow | undefined =>
206 view.rounds.find(round => round.n === worker.round && round.msp === worker.msp)
207
208const roundActivityOf = (view: RunView, worker: WorkerRow): Activity | undefined =>
209 view.msps
210 .find(msp => msp.label === worker.msp)
211 ?.activities.find(activity => activity.kind === 'round' && activity.round === worker.round)
212
213const firstKindOf = (view: RunView, worker: WorkerRow): string | undefined => roundActivityOf(view, worker)?.kinds[0]
214
215const isVerdictRow = (view: RunView, worker: WorkerRow) =>
216 worker.role === 'judge' && !worker.isLive && roundOf(view, worker) !== undefined
217
218const verdictWords = (worker: WorkerRow) => `Round ${worker.round ?? '?'}`
219
220const workerKey = (worker: WorkerRow, i: number) => `worker-${worker.msp ?? 'run'}-${i}`
221
222type RailMark = 'done' | 'now' | 'to come'
223
224const railMarkOf = (view: RunView, i: number): RailMark => {
225 const current = currentIndexOf(view)
226 return current === -1 || i > current ? 'to come' : i < current ? 'done' : 'now'
227}
228
229function railMarkSvg(stage: StageInfo, mark: RailMark): string {
230 const c = RAIL_MARK / 2
231 const body =
232 mark === 'done'
233 ? `<circle cx="${c}" cy="${c}" r="7" fill="${DONE}"/><path d="M${c - 3.2} ${c}l2.2 2.2 4.2-4.6" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
234 : mark === 'now'
235 ? `<circle class="sv-live" cx="${c}" cy="${c}" r="10" fill="${stage.color}" opacity=".25"/><circle cx="${c}" cy="${c}" r="6" fill="${stage.color}"/>`
236 : `<circle cx="${c}" cy="${c}" r="5" class="sv-k sv-mstroke" stroke-width="1.2"/>`
237 return svg(RAIL_MARK, RAIL_MARK, body)
238}
239
240const stripSvg = (H: number, color: string, y = 0, h = H) =>
241 `<svg xmlns="http://www.w3.org/2000/svg" width="${STRIP_W}" height="${H}" viewBox="0 0 ${STRIP_W} ${H}" preserveAspectRatio="none"><rect y="${y}" width="${STRIP_W}" height="${h}" fill="${color}"/></svg>`
242
243const linkSvg = (color: string) => stripSvg(RAIL_MARK, color, RAIL_MARK / 2 - LINK_H / 2, LINK_H)
244
245function avatarSvg(msp: MspRow, i: number, isSelected: boolean): string {
246 const { mark } = MSP_STATUS[msp.status]
247 const isWorking = currentActivity(msp)?.isRunning ?? false
248 return svg(
249 AVATAR_W,
250 AVATAR_H,
251 `${isSelected ? `<rect data-selected="true" x="6" y="0" width="${AVATAR_W - 6}" height="${AVATAR_H}" rx="7" fill="none" stroke="${laneColorOf(i)}" stroke-width="2"/>` : ''}<rect x="0" y="0" width="3" height="${AVATAR_H}" rx="1.5" fill="${laneColorOf(i)}"/>
252${crab(8, 1, 'builder', laneColorOf(i), mark === 'pending', isWorking, 1.1)}`,
253 )
254}
255
256function markSvg(msp: MspRow, i: number): string {
257 const half = MARK_PX / 2
258 return svg(MARK_PX, MARK_PX, statusMark(half, half, MSP_STATUS[msp.status].mark, laneColorOf(i)))
259}
260
261function proofSvg(msp: MspRow): string {
262 const x = PROOF_WORD_W
263 const red = msp.proof?.isRedSeen
264 ? `<circle cx="${x + 5}" cy="7" r="3.5" fill="${FAIL}"/>`
265 : `<circle cx="${x + 5}" cy="7" r="3.5" fill="none" class="sv-mstroke" stroke-width="1.2"/>`
266 const green = msp.proof?.isGreenSeen
267 ? `<circle cx="${x + 21}" cy="7" r="3.5" fill="${DONE}"/>`
268 : `<circle cx="${x + 21}" cy="7" r="3.5" fill="none" class="sv-mstroke" stroke-width="1.2"/>`
269 return svg(
270 PROOF_W,
271 PROOF_H,
272 `<text class="sv-m" x="0" y="10" font-family="${FONT}" font-size="${PROOF_PX}">proof</text>${red}<path d="M${x + 10} 7h6" class="sv-mstroke" stroke-width="1.2"/>${green}`,
273 )
274}
275
276const finishColor = (finish: Finish) =>
277 finish.isCelebrated || finish.state === 'closed-done'
278 ? { color: DONE }
279 : finish.state === 'stopped-critical'
280 ? { color: FAIL }
281 : { dimColor: true }
282
283function desktopFinish(ui: DesktopElements, view: RunView, finish: Finish, isSettled: boolean): RenderElement {
284 const { Box, Text, Svg } = ui
285 return (
286 <Box key="pane-finish" flexDirection="column" gap="xs">
287 {finish.isCelebrated ? (
288 <Svg
289 key="finish-celebration"
290 source={celebrationSvg(CELEBRATION, isSettled)}
291 alt={`Celebration: ${view.slug} finished clean`}
292 {...celebrationSize(CELEBRATION)}
293 />
294 ) : null}
295 <Text weight="semibold" {...finishColor(finish)}>
296 {finish.headline}
297 </Text>
298 {finish.details.map((detail, i) => (
299 <Text key={`finish-detail-${i}`} dimColor size="footnote">
300 {detail}
301 </Text>
302 ))}
303 </Box>
304 )
305}
306
307function crabSvg(px: number, role: Role, isWalking: boolean, isDim: boolean): string {
308 return svg(px, px, crab(0, 0, costumeOf(role), colorOf(role), isDim, isWalking, px / 30))
309}
310
311function contextSvg(worker: WorkerRow): string {
312 return svg(
313 CONTEXT_W,
314 CONTEXT_H,
315 `<rect class="sv-k" x="0" y="0" width="${CONTEXT_W}" height="${CONTEXT_H}" rx="2"/>
316<rect x="0" y="0" width="${Math.round((CONTEXT_W * contextPercent(worker)) / 100)}" height="${CONTEXT_H}" rx="2" class="sv-mfill"/>`,
317 )
318}
319
320const VERDICT_CHIP_PAD = 18
321const CHIP_GAP = 6
322const PIP_ROW_GAP = 4
323
324type Spot = { x: number; y: number }
325
326type VerdictLayout = { W: number; H: number; chip: Spot & { w: number; word: string }; pips: readonly Spot[] }
327
328const pipsSpan = (count: number) => (count === 0 ? 0 : count * PIP_STEP - (PIP_STEP - PIP))
329
330const pipsPerRow = (room: number) => Math.max(0, Math.floor((room + PIP_STEP - PIP) / PIP_STEP))
331
332const pipGrid = (count: number, perRow: number, top: number): Spot[] =>
333 Array.from({ length: count }, (_, i) => ({ x: (i % perRow) * PIP_STEP, y: top + Math.floor(i / perRow) * PIP_STEP }))
334
335function verdictLayout(maxW: number, round: RoundRow): VerdictLayout {
336 const chipW = Math.min(maxW, Math.ceil(textWidth(round.outcome, 11) + VERDICT_CHIP_PAD))
337 const word = fitText(round.outcome, 11, chipW - VERDICT_CHIP_PAD)
338 const candidates = Math.max(round.candidates ?? 0, round.blockers + round.advisories)
339 const beside = pipsPerRow(maxW - chipW - CHIP_GAP)
340 if (candidates === 0) return { W: chipW, H: VERDICT_CHIP_H, chip: { x: 0, y: 0, w: chipW, word }, pips: [] }
341 if (beside >= 1) {
342 const rows = Math.ceil(candidates / beside)
343 const top = rows === 1 ? (VERDICT_CHIP_H - PIP) / 2 : 0
344 const pips = pipGrid(candidates, beside, top)
345 const chipX = pipsSpan(Math.min(candidates, beside)) + CHIP_GAP
346 return { W: chipX + chipW, H: Math.max(VERDICT_CHIP_H, pipsSpan(rows)), chip: { x: chipX, y: 0, w: chipW, word }, pips }
347 }
348 const below = Math.max(1, pipsPerRow(maxW))
349 const rows = Math.ceil(candidates / below)
350 const top = VERDICT_CHIP_H + PIP_ROW_GAP
351 const pips = pipGrid(candidates, below, top)
352 return {
353 W: Math.max(chipW, pipsSpan(Math.min(candidates, below))),
354 H: top + pipsSpan(rows),
355 chip: { x: 0, y: 0, w: chipW, word },
356 pips,
357 }
358}
359
360function verdictSvg(layout: VerdictLayout, round: RoundRow): string {
361 const { W, H, chip } = layout
362 const pips = layout.pips.map(({ x, y }, i) => {
363 const kind = i < round.blockers ? 'blocker' : i < round.blockers + round.advisories ? 'advisory' : 'other'
364 const paint =
365 kind === 'blocker' ? `fill="${FAIL}"` : kind === 'advisory' ? `fill="${ROLE_COLOR.verdict}"` : 'class="sv-k"'
366 return `<rect data-pip="${kind}" x="${x}" y="${y}" width="${PIP}" height="${PIP}" rx="2" ${paint}/>`
367 }).join('')
368 return svg(
369 W,
370 H,
371 `${pips}<rect x="${chip.x}" y="${chip.y}" width="${chip.w}" height="${VERDICT_CHIP_H}" rx="9" fill="${VERDICT_COLOR[round.outcome]}"/>
372<text x="${chip.x + chip.w / 2}" y="${chip.y + 12.5}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#fff">${xml(chip.word)}</text>`,
373 )
374}
375
376export async function togglePane($: EngineInterface): Promise<string> {
377 const panes = await $.ui.panes()
378 if (panes.some(pane => pane.id === PANE)) {
379 await $.ui.close({ id: PANE })
380 return 'Run pane closed.'
381 }
382 await $.ui.open({ id: PANE, title: PANE_TITLE })
383 return 'Run pane opened.'
384}
385
386async function toggleSelection($: EngineInterface, label: string, isPast: boolean): Promise<void> {
387 const toggled = (current: string | null) => (current === label ? null : label)
388 if (isPast) await update($, pastSelectedState, toggled)
389 else await update($, selectedState, toggled)
390}
391
392const hotkeyOf = (i: number) => (i < MAX_HOTKEYS ? { hotkey: String(i + 1) } : {})
393
394function desktopMspButton($: EngineInterface, ui: PaneElements, msp: MspRow, i: number, now: number, isSelected: boolean, isPast: boolean) {
395 const { Button, Text } = ui
396 const parts = [
397 <Text weight="semibold" wrap="truncate-end" {...(isSelected ? { color: ACCENT } : {})}>
398 {msp.label}
399 </Text>,
400 ' ',
401 <Text dimColor={!isSelected} size="footnote" wrap="truncate-end">
402 {activityText(msp, now)}
403 </Text>,
404 ]
405 return (
406 <Button
407 key={`msp-${msp.label}`}
408 plain
409 {...hotkeyOf(i)}
410 onPress={async () => {
411 await toggleSelection($, msp.label, isPast)
412 }}
413 >
414 {parts}
415 </Button>
416 )
417}
418
419function terminalMspButton($: EngineInterface, ui: PaneElements, msp: MspRow, i: number, now: number, isPast: boolean) {
420 const { Button } = ui
421 return (
422 <Button
423 key={`msp-${msp.label}`}
424 plain
425 {...hotkeyOf(i)}
426 onPress={async () => {
427 await toggleSelection($, msp.label, isPast)
428 }}
429 >
430 {mspLabel(msp, now)}
431 </Button>
432 )
433}
434
435function workingHeading(ui: PaneElements, selected: string | null, count: number) {
436 const { Box, Text } = ui
437 if (selected === null) {
438 return (
439 <Box key="working-heading" flexDirection="row" gap="sm">
440 <Text dimColor>{workingTitle(selected)}</Text>
441 <Text dimColor>{`· ${count}`}</Text>
442 </Box>
443 )
444 }
445 return (
446 <Box key="working-heading" flexShrink={1} minWidth={0}>
447 <Text weight="semibold" wrap="truncate-end">
448 {workingTitle(selected)}
449 </Text>
450 </Box>
451 )
452}
453
454const roundHeading = (worker: WorkerRow) => (worker.round === null ? 'Build' : `Round ${worker.round}`)
455
456const mspTag = (worker: WorkerRow, selected: string | null) => (selected === null ? worker.msp : null)
457
458function sectionHeadings(selected: string | null, listed: readonly WorkerRow[]): { working: string; done: string } | null {
459 if (selected === null) return null
460 const live = listed.filter(worker => worker.isLive).length
461 return { working: `Working · ${live}`, done: `Done · ${listed.length - live}` }
462}
463
464function backButton($: EngineInterface, ui: PaneElements, shown: Shown) {
465 const { Button } = ui
466 if (!shown.isPast) return null
467 return (
468 <Button
469 key="past-back"
470 onPress={async () => {
471 await leavePastRun($)
472 }}
473 >
474 {BACK_TO_SESSION}
475 </Button>
476 )
477}
478
479function pastRunsList($: EngineInterface, ui: PaneElements, runs: readonly PastRun[]) {
480 const { Box, Text, Button } = ui
481 if (runs.length === 0) return null
482 return (
483 <Box key="pane-past" flexDirection="column">
484 <Text dimColor>{`Past runs · ${runs.length}`}</Text>
485 {runs.map(run => (
486 <Button
487 key={`past-${run.slug}`}
488 plain
489 onPress={async () => {
490 await readPastRun($, run.slug)
491 }}
492 >
493 {run.slug}
494 </Button>
495 ))}
496 </Box>
497 )
498}
499
500function sectionHeading(ui: PaneElements, key: string, words: string) {
501 const { Text } = ui
502 return (
503 <Text key={key} dimColor weight="semibold">
504 {words}
505 </Text>
506 )
507}
508
509type Room = { columns: number; now: number; isSettled: boolean }
510
511type DesktopElements = PaneElements & Pick<Elements['desktop'], 'Svg'>
512
513function rail(ui: DesktopElements, view: RunView, isEveryName: boolean): RenderElement {
514 const { Box, Text, Svg } = ui
515 const current = currentIndexOf(view)
516 const parts = STAGE_TABLE.flatMap((stage, i) => {
517 const mark = railMarkOf(view, i)
518 const isNow = mark === 'now'
519 const name = isEveryName || isNow ? (
520 <Text size="caption" wrap="truncate-end" {...(isNow ? { color: stage.color, weight: 'semibold' as const } : { dimColor: true })}>
521 {stage.name}
522 </Text>
523 ) : null
524 const links = STAGE_TABLE.slice(i + 1, i + 2).map(next => (
525 <Box key={`rail-link-${i}`} flexGrow={1} minWidth={0} overflow="hidden">
526 <Svg source={linkSvg(i < current ? DONE : TRACK)} alt={`${stage.name} to ${next.name}`} width={STRIP_W} height={RAIL_MARK} />
527 </Box>
528 ))
529 return [
530 <Box key={`rail-stage-${i}`} flexDirection="column" alignItems="center" flexShrink={0}>
531 <Svg source={railMarkSvg(stage, mark)} alt={`${stage.name}: ${mark}`} width={RAIL_MARK} height={RAIL_MARK} />
532 {name}
533 </Box>,
534 ...links,
535 ]
536 })
537 return (
538 <Box key="pane-rail" flexDirection="row" alignItems="flex-start">
539 {parts}
540 </Box>
541 )
542}
543
544function lanesBar(ui: DesktopElements, msp: MspRow, i: number): RenderElement {
545 const { Box, Svg } = ui
546 const share = lanesDoneShare(msp)
547 const fill = MSP_STATUS[msp.status].mark === 'failed' ? FAIL : laneColorOf(i)
548 return (
549 <Box key={`msp-bar-${msp.label}`} flexDirection="row" flexGrow={1} minWidth={0}>
550 <Box key={`msp-bar-done-${msp.label}`} width={0} flexGrow={share} overflow="hidden" backgroundColor={fill}>
551 <Svg source={stripSvg(LANE_BAR_H, fill)} alt={`${msp.label}: ${lanesWords(msp)}`} width={STRIP_W} height={LANE_BAR_H} />
552 </Box>
553 {share < 1 ? (
554 <Box key={`msp-bar-rest-${msp.label}`} width={0} flexGrow={1 - share} overflow="hidden" backgroundColor={TRACK}>
555 <Svg source={stripSvg(LANE_BAR_H, TRACK)} alt={`${msp.label}: lanes still to build`} width={STRIP_W} height={LANE_BAR_H} />
556 </Box>
557 ) : null}
558 </Box>
559 )
560}
561
562function desktopPane($: EngineInterface, ui: DesktopElements, view: RunView, selected: string | null, room: Room, shown: Shown) {
563 const { Box, Text, Svg } = ui
564 const { columns, now, isSettled } = room
565 const time = elapsed(view.startedAt, now)
566 const lane = laneWordOf(view)
567 const live = liveCrewOf(view)
568 const crewRole = ROLE_LOOK[live[0]?.role ?? 'captain']
569 const tile = (key: string, name: string, value: RenderElement) => (
570 <Box key={key} flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} borderStyle="round" borderDimColor paddingX={1}>
571 <Text dimColor size="caption">
572 {name}
573 </Text>
574 {value}
575 </Box>
576 )
577 const mspCards = view.msps.flatMap((msp, i): Card[] => [
578 { scope: `pane-msp-${i}`, lines: [mspLabel(msp, now), msp.branch, mspStatusText(msp), lanesWords(msp)] },
579 ...(msp.proof === null ? [] : [{ scope: `pane-proof-${i}`, lines: [...PROOF_HINT, proofVerdict(msp)] }]),
580 ])
581 const mspRow = (msp: MspRow, i: number) => (
582 <Box key={`msp-row-${msp.label}`} flexDirection="row" alignItems="center" gap="sm">
583 {cardTrigger(
584 ui,
585 `pane-msp-${i}`,
586 <Svg key={`msp-avatar-${msp.label}`} source={avatarSvg(msp, i, msp.label === selected)} alt={`${msp.label} crab`} width={AVATAR_W} height={AVATAR_H} />,
587 )}
588 <Box key={`msp-main-${msp.label}`} flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
589 <Box key={`msp-line-${msp.label}`} flexDirection="row" alignItems="center" gap="sm">
590 <Box key={`msp-press-${msp.label}`} flexGrow={1} flexShrink={1} minWidth={0}>
591 {desktopMspButton($, ui, msp, i, now, msp.label === selected, shown.isPast)}
592 </Box>
593 <Svg key={`msp-mark-${msp.label}`} source={markSvg(msp, i)} alt={`${msp.label}: ${MSP_STATUS[msp.status].word}`} width={MARK_PX} height={MARK_PX} />
594 </Box>
595 <Box key={`msp-lanes-${msp.label}`} flexDirection="row" alignItems="center" gap="sm">
596 {lanesBar(ui, msp, i)}
597 {msp.proof === null
598 ? null
599 : cardTrigger(
600 ui,
601 `pane-proof-${i}`,
602 <Svg key={`msp-proof-${msp.label}`} source={proofSvg(msp)} alt={proofWords(msp)} width={PROOF_W} height={PROOF_H} />,
603 )}
604 </Box>
605 </Box>
606 </Box>
607 )
608 const listed = listedWorkers(view, selected)
609 const rightColumns = Math.max(
610 0,
611 ...listed.map(worker => columnsOf(rightText(worker, now))),
612 ...listed.map(worker => (worker.contextShare === null ? 0 : CONTEXT_W / CELL_PX)),
613 )
614 const workerCards = listed.flatMap((worker, i): Card[] => {
615 const round = isVerdictRow(view, worker) ? roundOf(view, worker) : undefined
616 return [
617 { scope: `pane-words-${i}`, lines: [round === undefined ? worker.words : verdictWords(worker)] },
618 ...(round === undefined
619 ? []
620 : [{ scope: `pane-verdict-${i}`, lines: [worker.words, ...(roundActivityOf(view, worker)?.summary ?? []), VERDICT_LEGEND] }]),
621 ...(worker.contextShare === null
622 ? []
623 : [{ scope: `pane-context-${i}`, lines: [contextHint(worker), ...(worker.model === null ? [] : [modelName(worker.model)])] }]),
624 ]
625 })
626 const workerRow = (worker: WorkerRow, i: number) => {
627 const key = workerKey(worker, i)
628 const role = ROLE_LOOK[worker.role]
629 const round = isVerdictRow(view, worker) ? roundOf(view, worker) : undefined
630 const kind = round === undefined ? undefined : firstKindOf(view, worker)
631 const words = round === undefined ? worker.words : verdictWords(worker)
632 const right = rightText(worker, now)
633 const tag = mspTag(worker, selected)
634 const layout = round === undefined ? undefined : verdictLayout(VERDICT_ROOM, round)
635 return (
636 <Box key={key} flexDirection="row" alignItems="center" gap="sm">
637 <Svg
638 key={`${key}-crab`}
639 source={crabSvg(WORKER_CRAB_PX, role, worker.isLive, !worker.isLive)}
640 alt={`${ROLE_NAME[worker.role]} crab`}
641 width={WORKER_CRAB_PX}
642 height={WORKER_CRAB_PX}
643 />
644 <Box key={`${key}-words`} flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
645 <Box key={`${key}-role`} flexDirection="row" gap="xs" minWidth={0}>
646 <Text weight="semibold" wrap="truncate-end">
647 {ROLE_NAME[worker.role]}
648 </Text>
649 {tag === null ? null : (
650 <Text dimColor wrap="truncate-end">
651 {`· ${tag}`}
652 </Text>
653 )}
654 </Box>
655 {cardTrigger(
656 ui,
657 `pane-words-${i}`,
658 <Text dimColor size="footnote" wrap="truncate-end">
659 {words}
660 </Text>,
661 'shrink',
662 )}
663 {round === undefined || layout === undefined
664 ? null
665 : cardTrigger(
666 ui,
667 `pane-verdict-${i}`,
668 <Svg key={`${key}-verdict`} source={verdictSvg(layout, round)} alt={worker.words} width={layout.W} height={layout.H} />,
669 )}
670 {kind === undefined ? null : (
671 <Text size="footnote" wrap="truncate-end">
672 {kind}
673 </Text>
674 )}
675 </Box>
676 <Box key={`${key}-right`} flexDirection="column" alignItems="flex-start" width={rightColumns} flexShrink={0}>
677 {right === '' ? null : (
678 <Text dimColor size="footnote" tabular>
679 {right}
680 </Text>
681 )}
682 {worker.contextShare === null
683 ? null
684 : cardTrigger(ui, `pane-context-${i}`, <Svg source={contextSvg(worker)} alt={contextHint(worker)} width={CONTEXT_W} height={CONTEXT_H} />)}
685 </Box>
686 </Box>
687 )
688 }
689 const selectedMsp = view.msps.find(msp => msp.label === selected)
690 const isMainThreadOnly = selectedMsp !== undefined && !listed.some(worker => worker.isLive)
691 const rowsWithHeadings = (worker: WorkerRow, i: number) => {
692 const previous = listed[i - 1]
693 const isNewRound = selected !== null && !worker.isLive && (previous === undefined || previous.isLive || previous.round !== worker.round)
694 return [
695 ...(isNewRound
696 ? [
697 <Text key={`round-head-${worker.round ?? 'build'}`} dimColor size="caption" weight="semibold">
698 {roundHeading(worker)}
699 </Text>,
700 ]
701 : []),
702 workerRow(worker, i),
703 ]
704 }
705 const liveRows = listed.flatMap((worker, i) => (worker.isLive ? rowsWithHeadings(worker, i) : []))
706 const doneRows = listed.flatMap((worker, i) => (worker.isLive ? [] : rowsWithHeadings(worker, i)))
707 const sections = sectionHeadings(selected, listed)
708 const cards: readonly Card[] = [{ scope: 'pane-slug', lines: [view.slug] }, ...mspCards, ...workerCards]
709 return (
710 <Box flexDirection="column" gap="md">
711 <Box key="pane-title" flexDirection="column">
712 <Text weight="bold" size="heading">
713 {TITLE}
714 </Text>
715 <Box key="pane-thread" flexDirection="row" alignItems="center" gap="sm">
716 {cardTrigger(
717 ui,
718 'pane-slug',
719 <Text dimColor wrap="truncate-end">
720 {view.slug}
721 </Text>,
722 'shrink',
723 )}
724 {lane === null ? null : <Svg key="pane-lane" source={chipSvg(lane)} alt={lane} width={chipWidthOf(lane)} height={CHIP_H} />}
725 </Box>
726 </Box>
727 {backButton($, ui, shown)}
728 {view.finish === null ? null : desktopFinish(ui, view, view.finish, isSettled)}
729 <Box key="pane-tiles" flexDirection="row" gap="sm">
730 {tile('tile-cost', 'Cost', <Text weight="semibold" tabular wrap="truncate-end">{costText(view, shown)}</Text>)}
731 {tile(
732 'tile-agents',
733 'Agents',
734 <Box flexDirection="row" alignItems="center" gap="xs">
735 <Svg source={crabSvg(CREW_CRAB_PX, crewRole, true, false)} alt={`${costumeOf(crewRole)} crab`} width={CREW_CRAB_PX} height={CREW_CRAB_PX} />
736 <Text weight="semibold" tabular>
737 {String(live.length)}
738 </Text>
739 </Box>,
740 )}
741 {tile('tile-time', 'Time', <Text weight="semibold" tabular wrap="truncate-end">{time}</Text>)}
742 </Box>
743 {rail(ui, view, columns >= RAIL_NAMES_COLUMNS)}
744 <Box key="pane-msps" flexDirection="column" gap="xs">
745 <Text dimColor>{`MSPs · ${view.msps.length}`}</Text>
746 {view.msps.map(mspRow)}
747 {unplannedPulls(view).map(pull => (
748 <Box key={`pull-row-${pull.number}`} flexDirection="row" alignItems="center" gap="sm">
749 <Text weight="semibold">{`Pull request #${pull.number}`}</Text>
750 <Text dimColor wrap="truncate-end">
751 {pull.head}
752 </Text>
753 </Box>
754 ))}
755 </Box>
756 <Box key="pane-workers" flexDirection="column" gap="xs">
757 {workingHeading(ui, selected, listed.length)}
758 {sections === null ? null : sectionHeading(ui, 'working-section', sections.working)}
759 {isMainThreadOnly && selectedMsp !== undefined ? (
760 <Text key="main-thread-note" dimColor size="footnote">{`${activityText(selectedMsp, now)} · ${MAIN_THREAD}`}</Text>
761 ) : null}
762 {liveRows}
763 {sections === null ? null : sectionHeading(ui, 'done-section', sections.done)}
764 {doneRows}
765 </Box>
766 {pastRunsList($, ui, shown.pastRuns)}
767 {cardSlot(ui, 'pane-cards', cards, { top: 0, left: 0 })}
768 </Box>
769 )
770}
771
772const terminalWorkerLine = (view: RunView, worker: WorkerRow, now: number) => {
773 const round = isVerdictRow(view, worker) ? roundOf(view, worker) : undefined
774 const verdict =
775 round === undefined
776 ? ''
777 : `${round.outcome} · ${round.blockers} blockers, ${round.advisories} advisories${firstKindOf(view, worker) === undefined ? '' : ` · ${firstKindOf(view, worker)}`}`
778 return [
779 ROLE_NAME[worker.role],
780 round === undefined ? worker.words : verdictWords(worker),
781 verdict,
782 rightText(worker, now),
783 worker.contextShare === null ? '' : `context ${contextPercent(worker)}%`,
784 ]
785 .filter(part => part !== '')
786 .join(' ')
787}
788
789function terminalPane($: EngineInterface, ui: PaneElements, view: RunView, selected: string | null, now: number, shown: Shown) {
790 const { Box, Text } = ui
791 const time = elapsed(view.startedAt, now)
792 const lane = laneWordOf(view)
793 const listed = listedWorkers(view, selected)
794 const sections = sectionHeadings(selected, listed)
795 const workerLine = (worker: WorkerRow, i: number) => <Text key={workerKey(worker, i)}>{terminalWorkerLine(view, worker, now)}</Text>
796 const mspExtra = (msp: MspRow) =>
797 [`${msp.lanesDone}/${msp.lanesTotal}`, proofMarks(msp), mspStatusText(msp)].filter(part => part !== '').join(' ')
798 return (
799 <Box flexDirection="column">
800 <Text bold>{TITLE}</Text>
801 <Text>{lane === null ? view.slug : `${view.slug} [${lane}]`}</Text>
802 {backButton($, ui, shown)}
803 {view.finish === null ? null : <Text key="finish-headline">{view.finish.headline}</Text>}
804 {(view.finish?.details ?? []).map((detail, i) => (
805 <Text key={`finish-detail-${i}`}>{detail}</Text>
806 ))}
807 <Text>{`Cost ${costText(view, shown)} · Agents ${liveCrewOf(view).length} · Time ${time}`}</Text>
808 <Text>{stageSummary(view)}</Text>
809 <Text dimColor>{`MSPs · ${view.msps.length}`}</Text>
810 {view.msps.map((msp, i) => (
811 <Box key={`msp-row-${msp.label}`} flexDirection="row" gap={1}>
812 {terminalMspButton($, ui, msp, i, now, shown.isPast)}
813 <Text dimColor>{mspExtra(msp)}</Text>
814 </Box>
815 ))}
816 {unplannedPulls(view).map(pull => (
817 <Text key={`pull-row-${pull.number}`}>{`Pull request #${pull.number} ${pull.head}`}</Text>
818 ))}
819 {workingHeading(ui, selected, listed.length)}
820 {sections === null ? null : <Text key="working-section">{sections.working}</Text>}
821 {listed.flatMap((worker, i) => (worker.isLive ? [workerLine(worker, i)] : []))}
822 {sections === null ? null : <Text key="done-section">{sections.done}</Text>}
823 {listed.flatMap((worker, i) => (worker.isLive ? [] : [workerLine(worker, i)]))}
824 {pastRunsList($, ui, shown.pastRuns)}
825 </Box>
826 )
827}
828
829export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
830 const ui = $.ui.resolve(e)
831 const { Box, Text } = ui
832 const [live, past, listed] = await Promise.all([read($, viewState), read($, pastViewState), read($, pastRunsState)])
833 const view = past ?? live
834 const pastRuns = (listed?.runs ?? []).filter(run => run.slug !== past?.slug)
835 if (view === null) {
836 return (
837 <Box flexDirection="column">
838 <Text dimColor>{NO_RUN}</Text>
839 {pastRunsList($, ui, pastRuns)}
840 </Box>
841 )
842 }
843 const shown: Shown = { isPast: past !== null, pastRuns }
844 const now = await $.clock.now()
845 const selected = selectedOf(view, shown.isPast ? await read($, pastSelectedState) : await read($, selectedState))
846 const isSettled = isFinishSettled(await read($, finishSeenState), view)
847 if (e.surface === 'desktop' && 'Svg' in ui) {
848 return desktopPane($, ui, view, selected, { columns: e.props.bodyColumns || 60, now, isSettled }, shown)
849 }
850 return terminalPane($, ui, view, selected, now, shown)
851}
852hooks/run/poll.ts 151 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { EventFacts, FileFacts, RunIdentity } from '../../types'
5import { eventLogOf, readEvents, recordOf } from './events'
6import { cacheOf, fetchChanged, isGone, readRunFiles } from './home'
7import type { FileCache, FileText } from './home'
8import { eventRunFacts, inferFacts } from './infer'
9import { ledgerOf, locate, sessionFile } from './session'
10import type { RunLocation, SessionFile } from './session'
11import { storePastRuns } from './past'
12import { NO_SIGNALS } from './state'
13import { refresh } from './view'
14
15const filesState = atom({ plugin: 'spindle', key: 'files' } as const, null)
16const signalsState = atom({ plugin: 'spindle', key: 'signals' } as const, NO_SIGNALS)
17const viewState = atom({ plugin: 'spindle', key: 'view' } as const, null)
18
19export const POLL_MS = 2000
20
21type Read = { facts: FileFacts | null; cache: FileCache; isOtherRun: boolean; mitosis: string | null; location: RunLocation | null }
22
23async function failedRead($: EngineInterface, path: string): Promise<FileText> {
24 return (await isGone($, path)) ? { kind: 'absent' } : { kind: 'unreadable' }
25}
26
27async function readSession(
28 $: EngineInterface,
29 where: SessionFile,
30 hasHome: boolean,
31 cache: FileCache,
32): Promise<{ text: FileText; cache: FileCache }> {
33 if (!hasHome) {
34 try {
35 const text = await $.fs.read(where.path)
36 return { text: typeof text === 'string' ? { kind: 'text', text } : { kind: 'unreadable' }, cache: {} }
37 } catch {
38 return { text: await failedRead($, where.path), cache: {} }
39 }
40 }
41 try {
42 const stat = await $.fs.stat(where.path)
43 const fetched = await fetchChanged($, [{ path: where.path, mtimeMs: stat.mtimeMs }], cache)
44 return { text: fetched[0]?.text ?? { kind: 'unreadable' }, cache: cacheOf(fetched) }
45 } catch {
46 return { text: await failedRead($, where.path), cache: {} }
47 }
48}
49
50const locationOf = (known: FileFacts, mitosis: string): RunLocation => ({
51 slug: known.slug,
52 repo: known.repo,
53 home: known.home,
54 runDir: known.runDir,
55 ledger: known.repo === null ? null : ledgerOf(mitosis, known.repo),
56})
57
58const isSameLocated = (known: RunIdentity, location: RunLocation) =>
59 known.slug === location.slug && (known.home === null || known.home === location.home)
60
61export async function readGoneRun(
62 $: EngineInterface,
63 mitosis: string,
64 location: RunLocation,
65 events: EventFacts,
66 cache: FileCache,
67): Promise<{ facts: FileFacts | null; cache: FileCache }> {
68 const record = recordOf(mitosis, location)
69 if (record !== null && !(await isGone($, `${record}/home`))) {
70 const run = await readRunFiles($, { ...location, home: `${record}/home`, runDir: `${record}/run` }, cache)
71 return { facts: { ...inferFacts({ ...run.files, location }), source: 'record', events }, cache: run.cache }
72 }
73 if (events.log !== 'absent') return { facts: { ...eventRunFacts(location, events), source: 'events', events }, cache: {} }
74 return { facts: null, cache: {} }
75}
76
77async function readRun(
78 $: EngineInterface,
79 mitosis: string,
80 location: RunLocation,
81 events: EventFacts,
82 cache: FileCache,
83): Promise<{ facts: FileFacts | null; cache: FileCache }> {
84 if (location.home !== null && (await isGone($, location.home))) return readGoneRun($, mitosis, location, events, cache)
85 const run = await readRunFiles($, location, cache)
86 return { facts: { ...inferFacts(run.files), source: 'home', events }, cache: run.cache }
87}
88
89async function isHomeGoneSince($: EngineInterface, known: FileFacts): Promise<boolean> {
90 return known.source === 'home' && known.home !== null && (await isGone($, known.home))
91}
92
93async function readFacts($: EngineInterface, cache: FileCache, isFinished: boolean): Promise<Read> {
94 const where = await sessionFile($)
95 if (where === null) return { facts: null, cache: {}, isOtherRun: false, mitosis: null, location: null }
96 const known = await read($, filesState)
97 const session = await readSession($, where, known?.home != null, cache)
98 const location =
99 session.text.kind === 'unreadable'
100 ? known === null
101 ? null
102 : locationOf(known, where.mitosis)
103 : locate(session.text.kind === 'text' ? session.text.text : null, where.mitosis)
104 const found = { mitosis: where.mitosis, location }
105 if (location === null) return { facts: null, cache: session.cache, isOtherRun: false, ...found }
106 const isOtherRun = known !== null && !isSameLocated(known, location)
107 if (isFinished && known !== null && !isOtherRun && !(await isHomeGoneSince($, known))) {
108 if (known.events.end !== null) return { facts: known, cache: session.cache, isOtherRun, ...found }
109 const finished = await readEvents($, eventLogOf(where.mitosis, location), cache)
110 const events = finished.events.log === 'unreadable' ? known.events : finished.events
111 return { facts: { ...known, events }, cache: { ...session.cache, ...finished.cache }, isOtherRun, ...found }
112 }
113 const logged = await readEvents($, eventLogOf(where.mitosis, location), cache)
114 const run = await readRun($, where.mitosis, location, logged.events, cache)
115 return { facts: run.facts, cache: { ...session.cache, ...logged.cache, ...run.cache }, isOtherRun, ...found }
116}
117
118async function startOtherRun($: EngineInterface, facts: FileFacts | null): Promise<void> {
119 if (facts?.home == null) return
120 const signals = await read($, signalsState)
121 if (signals.finishedRun != null) {
122 await update($, signalsState, current => ({ ...current, finishedRun: null }))
123 return
124 }
125 await update($, signalsState, () => NO_SIGNALS)
126}
127
128export async function startPolling($: EngineInterface): Promise<void> {
129 let polling: { cache: FileCache; isBusy: boolean } = { cache: {}, isBusy: false }
130 const pass = async () => {
131 if (polling.isBusy) return
132 polling = { ...polling, isBusy: true }
133 try {
134 const shown = await read($, viewState)
135 const { facts, cache, isOtherRun, mitosis, location } = await readFacts($, polling.cache, shown?.isFinished === true)
136 polling = { ...polling, cache }
137 if (isOtherRun) await startOtherRun($, facts)
138 const previous = await read($, filesState)
139 if (JSON.stringify(previous) !== JSON.stringify(facts)) await update($, filesState, () => facts)
140 await storePastRuns($, mitosis, location)
141 await refresh($)
142 } finally {
143 polling = { ...polling, isBusy: false }
144 }
145 }
146 await pass()
147 $.clock.every(POLL_MS, () => {
148 void pass()
149 })
150}
151hooks/run/signals.ts 198 lines1import { atom, read, update } from 'claude-code'
2import type { Hook } from 'claude-code'
3
4import type { AgentRow, FileFacts, Lane, Role, Signals, Stage } from '../../types'
5import { isPipelineClean, isRunScript, isShipping, simpleCommands } from './command'
6import { contextMax, contextTokens, stepCost } from './cost'
7import { STAGES, isRunStarted } from './infer'
8import { NO_SIGNALS } from './state'
9import { refresh } from './view'
10import type { ViewDollar } from './view'
11import { agentWords, mspOfAgent } from './workers'
12
13const MAX_AGENTS = 200
14
15const signalsState = atom({ plugin: 'spindle', key: 'signals' } as const, NO_SIGNALS)
16const filesState = atom({ plugin: 'spindle', key: 'files' } as const, null)
17
18type CallSignal = {
19 stage: Stage | null
20 lane: Lane | null
21 isPullRequest: boolean
22 isClean: boolean
23 isRunEntry: boolean
24}
25
26const NO_CALL: CallSignal = { stage: null, lane: null, isPullRequest: false, isClean: false, isRunEntry: false }
27
28const ROLES: readonly (readonly [RegExp, Role])[] = [
29 [/^interphase:(scout|spec-reviewer)$/, 'scribe'],
30 [/^(?:[^:]+:)?(code-reviewer|security-reviewer|conformance-auditor)$/, 'detective'],
31 [/^(?:[^:]+:)?validator$/, 'judge'],
32]
33
34export const roleOf = (type: string): Role => ROLES.find(([pattern]) => pattern.test(type))?.[1] ?? 'plain'
35
36const textOf = (value: unknown) => (typeof value === 'string' ? value : '')
37
38function laneOf(args: string): Lane | null {
39 const trimmed = args.trim()
40 if (trimmed.endsWith('Interview me first.')) return 'full'
41 if (trimmed.endsWith('Work autonomously.')) return 'standard'
42 return null
43}
44
45function bashSignal(text: string): CallSignal {
46 const commands = simpleCommands(text) ?? []
47 const cleans = commands.filter(isPipelineClean)
48 if (cleans.length > 0) return { ...NO_CALL, stage: 'clean', isClean: cleans.some(command => command.decidesStatus) }
49 const runs = commands.filter(isRunScript)
50 if (runs.length === 0) return NO_CALL
51 return { ...NO_CALL, stage: runs.some(isShipping) ? 'ship' : 'build' }
52}
53
54function callSignal(tool: string, input: Readonly<Record<string, unknown>>): CallSignal {
55 if (tool === 'Skill') {
56 const skill = textOf(input.skill)
57 if (skill.startsWith('interphase:')) {
58 return { ...NO_CALL, stage: 'spec', lane: laneOf(textOf(input.args)), isRunEntry: true }
59 }
60 if (skill === 'pipeline:run') return { ...NO_CALL, stage: 'spec', isRunEntry: true }
61 if (skill === 'lighthouse:test') return { ...NO_CALL, stage: 'integrate' }
62 return NO_CALL
63 }
64 if (tool === 'Bash') return bashSignal(textOf(input.command))
65 if (tool.endsWith('create_pull_request')) return { ...NO_CALL, stage: 'ship', isPullRequest: true }
66 return NO_CALL
67}
68
69const laterStage = (known: Stage | null, reached: Stage | null): Stage | null => {
70 if (reached === null) return known
71 if (known === null) return reached
72 return STAGES.indexOf(reached) > STAGES.indexOf(known) ? reached : known
73}
74
75function pullNumber(result: unknown): string | null {
76 const text = typeof result === 'string' ? result : (JSON.stringify(result) ?? '')
77 return /\/pull\/(\d+)/.exec(text)?.[1] ?? /"number":(\d+)/.exec(text)?.[1] ?? null
78}
79
80function exitedClean(ran: { deny?: string; isError?: boolean; result?: unknown }): boolean {
81 if (ran.deny !== undefined || ran.isError === true) return false
82 const result = ran.result
83 const exitCode = typeof result === 'object' && result !== null ? (result as { exitCode?: unknown }).exitCode : undefined
84 return exitCode === undefined || exitCode === 0
85}
86
87async function change($: ViewDollar, apply: (signals: Signals) => Signals): Promise<void> {
88 await update($, signalsState, apply)
89 await refresh($)
90}
91
92export const toolCallSignals: Hook<'tool.call'> = async ($, e, next) => {
93 const input = e as unknown as Readonly<Record<string, unknown>>
94 const signal = callSignal(String(e.tool), input)
95 if (signal.stage === null) return next(e)
96 const at = await $.clock.now()
97 const files = signal.isRunEntry ? await read($, filesState) : null
98 const afterDone = (signals: Signals): Signals =>
99 signal.isRunEntry && signals.isCleanDone
100 ? { ...NO_SIGNALS, finishedRun: files === null ? null : { slug: files.slug, home: files.home, signals } }
101 : signals
102 await change($, current => {
103 const signals = afterDone(current)
104 return {
105 ...signals,
106 callStage: laterStage(signals.callStage, signal.stage),
107 lane: signals.lane ?? signal.lane,
108 firstCallAt: signals.firstCallAt ?? at,
109 isPullRequestRunning: signals.isPullRequestRunning || signal.isPullRequest,
110 }
111 })
112 const ran = await next(e).catch(async (error: unknown) => {
113 if (signal.isPullRequest) await change($, signals => ({ ...signals, isPullRequestRunning: false }))
114 throw error
115 })
116 if (signal.isClean && exitedClean(ran)) {
117 await change($, signals => ({ ...signals, isCleanDone: true }))
118 }
119 if (signal.isPullRequest) {
120 const head = input.head
121 const number = ran.deny === undefined ? pullNumber(ran.result) : null
122 await change($, signals => ({
123 ...signals,
124 isPullRequestRunning: false,
125 pulls:
126 typeof head === 'string' && number !== null
127 ? [...signals.pulls.filter(pull => pull.head !== head), { head, number }]
128 : signals.pulls,
129 }))
130 }
131 return ran
132}
133
134export const agentSpawnSignals: Hook<'agent.spawn'> = async ($, e, next) => {
135 const started = await next(e)
136 if (started.deny !== undefined) return started
137 const at = await $.clock.now()
138 const agent: AgentRow = {
139 id: started.agentId ?? e.tool_use_id,
140 role: roleOf(e.subagentType),
141 type: e.subagentType,
142 description: e.description,
143 prompt: e.prompt,
144 model: started.model,
145 startedAt: at,
146 contextTokens: 0,
147 contextMax: contextMax(started.model),
148 isRunning: true,
149 msp: null,
150 round: null,
151 }
152 await change($, signals => ({
153 ...signals,
154 agents: [...signals.agents.filter(one => one.id !== agent.id), agent].slice(-MAX_AGENTS),
155 }))
156 return started
157}
158
159export const turnStepSignals: Hook<'turn.step'> = async function* ($, e, next) {
160 const result = yield* next(e)
161 const usage = result.usage
162 if (usage === null) return result
163 const model = usage.model || e.model
164 const [signals, files] = await Promise.all([read($, signalsState), read($, filesState)])
165 const isRunKnown = isRunStarted(files, signals)
166 const agentId = e.agentId
167 await change($, current => ({
168 ...current,
169 costUsd: isRunKnown ? current.costUsd + stepCost(model, usage) : current.costUsd,
170 agents:
171 agentId === undefined
172 ? current.agents
173 : current.agents.map(agent =>
174 agent.id === agentId
175 ? { ...agent, model, contextTokens: contextTokens(usage), contextMax: contextMax(model) }
176 : agent,
177 ),
178 }))
179 return result
180}
181
182function finishedAgent(agent: AgentRow, files: FileFacts | null): AgentRow {
183 const msp = files === null ? null : mspOfAgent(`${agent.prompt}\n${agent.description}`, files.msps, files.worktrees)
184 return { ...agent, isRunning: false, prompt: '', msp: msp?.label ?? null, round: agentWords(agent, msp).round }
185}
186
187export const turnCompleteSignals: Hook<'turn.complete'> = async ($, e, next) => {
188 const agentId = e.agentId
189 if (agentId !== undefined) {
190 const files = await read($, filesState)
191 await change($, signals => ({
192 ...signals,
193 agents: signals.agents.map(agent => (agent.id === agentId && agent.isRunning ? finishedAgent(agent, files) : agent)),
194 }))
195 }
196 return next(e)
197}
198hooks/run/summary.ts 90 lines1import type { Activity, AgentRow, MspRow, PullRow, RoundRow, RunView, WorkerRow } from '../../types'
2
3export const NO_RUN = 'No pipeline run in this session.'
4
5const MODEL = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/
6
7export function modelName(model: string): string {
8 const match = MODEL.exec(model)
9 if (match === null) return model
10 const [, family = '', major = '', minor] = match
11 const name = family.charAt(0).toUpperCase() + family.slice(1)
12 return minor === undefined ? `${name} ${major}` : `${name} ${major}.${minor}`
13}
14
15export function elapsed(startedAt: number | null, now: number): string {
16 if (startedAt === null) return '?'
17 const minutes = Math.max(0, Math.floor((now - startedAt) / 60_000))
18 if (minutes < 60) return `${minutes}m`
19 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
20}
21
22export const plural = (count: number, word: string) =>
23 `${count} ${count === 1 ? word : word.endsWith('y') ? `${word.slice(0, -1)}ies` : `${word}s`}`
24
25export const currentActivity = (msp: MspRow): Activity | undefined => (msp.current === null ? undefined : msp.activities[msp.current])
26
27export const activityNow = (activity: Activity, now: number): string =>
28 activity.isRunning ? `${activity.words} · ${elapsed(activity.startedAt, now)}` : activity.words
29
30export const summaryNow = (activity: Activity, now: number): readonly string[] =>
31 activity.kind === 'round' && activity.isRunning && activity.state !== 'diagnosing'
32 ? activity.summary.map((line, i) => (i === 0 ? `${line} · ${elapsed(activity.startedAt, now)}` : line))
33 : activity.summary
34
35const mspLine = (msp: MspRow) =>
36 [
37 `msp ${msp.label} ${msp.status} ${msp.lanesDone}/${msp.lanesTotal}`,
38 `proof ${msp.proof?.isRedSeen ? 'red' : 'none'},${msp.proof?.isGreenSeen ? 'green' : 'none'}`,
39 `branch ${msp.branch}`,
40 `pr ${msp.pr === '' ? 'none' : msp.pr}`,
41 `round ${msp.round}`,
42 ].join(' ')
43
44const nowLine = (msp: MspRow, now: number) => {
45 const activity = currentActivity(msp)
46 return `msp ${msp.label} now ${activity === undefined ? 'status unknown' : activityNow(activity, now)}`
47}
48
49const activityLines = (msp: MspRow, now: number) =>
50 msp.activities.map(
51 activity =>
52 `activity ${msp.label} ${activity.name}${activity.kind === 'round' ? `: ${summaryNow(activity, now).join(' / ')}` : ''}`,
53 )
54
55const workerLine = (worker: WorkerRow) =>
56 `worker ${worker.msp ?? '-'} ${worker.role} ${worker.words} · ${worker.isLive ? 'live' : 'done'}`
57
58const roundLine = (round: RoundRow) =>
59 `round ${round.n} ${round.msp} candidates ${round.candidates ?? '?'} blockers ${round.blockers} advisories ${round.advisories} outcome ${round.outcome}`
60
61const pullLine = (pull: PullRow) => `pull #${pull.number} ${pull.head}`
62
63export const unplannedPulls = (view: RunView): readonly PullRow[] => (view.msps.length === 0 ? view.pulls : [])
64
65const agentLine = (agent: AgentRow) =>
66 `agent ${agent.id} ${agent.role} ${agent.type} ${modelName(agent.model)} ${agent.isRunning ? 'running' : 'done'}`
67
68export function summary(view: RunView | null, now: number): string {
69 if (view === null) return NO_RUN
70 return [
71 `run ${view.slug} repo ${view.repo ?? '?'}`,
72 ...(view.follows === null ? [] : [`follows ${view.follows}`]),
73 `lane ${view.lane ?? 'unknown'}`,
74 `stage ${view.stage} round ${view.round} finished ${view.isFinished ? 'yes' : 'no'}`,
75 `lifecycle ${view.lifecycle}`,
76 ...unplannedPulls(view).map(pullLine),
77 `elapsed ${elapsed(view.startedAt, now)}`,
78 ...view.msps.map(mspLine),
79 ...view.msps.map(msp => nowLine(msp, now)),
80 ...view.msps.flatMap(msp => activityLines(msp, now)),
81 ...view.workers.map(workerLine),
82 ...view.rounds.map(roundLine),
83 ...view.agents.map(agentLine),
84 `builders ${view.buildingLanes}`,
85 `cost ${view.costUsd.toFixed(4)}`,
86 `agents ${view.agents.filter(agent => agent.isRunning).length} live ${view.agents.length} total`,
87 ...(view.finish === null ? [] : [`finish ${view.finish.state} ${view.finish.isCelebrated ? 'celebrated' : 'plain'}`]),
88 ].join('\n')
89}
90hooks/run/view.ts 84 lines1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface } from 'claude-code'
3
4import type { Finish, FinishKey, FinishSeen, RunView } from '../../types'
5import { buildView, isSameRun } from './infer'
6import { NO_SIGNALS } from './state'
7import { currentActivity } from './summary'
8import { changes } from './toasts'
9
10export type ViewDollar = Pick<CoreEngineInterface, 'state' | 'ui' | 'clock'>
11
12export const SETTLE_MS = 5000
13
14const filesState = atom({ plugin: 'spindle', key: 'files' } as const, null)
15const signalsState = atom({ plugin: 'spindle', key: 'signals' } as const, NO_SIGNALS)
16const viewState = atom({ plugin: 'spindle', key: 'view' } as const, null)
17const dismissedState = atom({ plugin: 'spindle', key: 'dismissedAt' } as const, null)
18const selectedState = atom({ plugin: 'spindle', key: 'selectedMsp' } as const, null)
19const finishSeenState = atom({ plugin: 'spindle', key: 'finishSeen' } as const, null)
20
21const isSameFinish = (one: FinishKey | null, other: FinishKey | null): boolean =>
22 one !== null && other !== null && one.state === other.state && one.isCelebrated === other.isCelebrated
23
24const isSeenRun = (seen: FinishSeen | null, view: RunView): seen is FinishSeen =>
25 seen !== null && isSameRun(seen, view) && isSameFinish(seen.finish, view.finish)
26
27export const isFinishSettled = (seen: FinishSeen | null, view: RunView): boolean =>
28 !(isSeenRun(seen, view) && seen.isLive && !seen.isSettled)
29
30const isSettleDue = (seen: FinishSeen | null, now: number): seen is FinishSeen =>
31 seen !== null && !seen.isSettled && now >= seen.at + SETTLE_MS
32
33const finishKeyOf = (finish: Finish): FinishKey => ({ state: finish.state, isCelebrated: finish.isCelebrated })
34
35function seenAfter(seen: FinishSeen | null, before: RunView | null, after: RunView | null, now: number): FinishSeen | null {
36 if (after === null || after.finish === null) return null
37 if (isSeenRun(seen, after)) return seen
38 const finish = finishKeyOf(after.finish)
39 const hadCelebrated = seen !== null && isSameRun(seen, after) && seen.hasCelebrated
40 const isTurn = before !== null && isSameRun(before, after) && !isSameFinish(before.finish, finish)
41 const isLive = isTurn && !(finish.isCelebrated && hadCelebrated)
42 return { slug: after.slug, home: after.home, finish, at: now, isLive, isSettled: !isLive, hasCelebrated: hadCelebrated || finish.isCelebrated }
43}
44
45export const activitySignature = (view: RunView): string =>
46 JSON.stringify(
47 view.msps.map(msp => {
48 const activity = currentActivity(msp)
49 return [msp.label, activity?.words ?? '', activity?.state ?? '']
50 }),
51 )
52
53const isOtherActivity = (before: RunView | null, after: RunView | null) =>
54 before === null || after === null
55 ? before !== after
56 : !isSameRun(before, after) || activitySignature(before) !== activitySignature(after)
57
58const isSelectionGone = (selected: string, before: RunView | null, after: RunView | null) =>
59 after === null || (before !== null && !isSameRun(before, after)) || !after.msps.some(msp => msp.label === selected)
60
61export async function refresh($: ViewDollar): Promise<void> {
62 const [files, signals, previous, seen, now] = await Promise.all([
63 read($, filesState),
64 read($, signalsState),
65 read($, viewState),
66 read($, finishSeenState),
67 $.clock.now(),
68 ])
69 if (isSettleDue(seen, now)) await update($, finishSeenState, current => (current === null ? null : { ...current, isSettled: true }))
70 const next = buildView(files, signals)
71 if (JSON.stringify(next) === JSON.stringify(previous)) return
72 let before: RunView | null = previous
73 const after = await update($, viewState, current => {
74 before = current
75 return buildView(files, signals)
76 })
77 await update($, finishSeenState, current => seenAfter(current, before, after, now))
78 if (isOtherActivity(before, after)) await update($, dismissedState, () => null)
79 const selected = await read($, selectedState)
80 if (selected !== null && isSelectionGone(selected, before, after)) await update($, selectedState, () => null)
81 if (after === null) return
82 changes(before, after).forEach(text => $.ui.toast(text))
83}
84hooks/run/state.ts 29 lines1import type { Lane, Signals } from '../../types'
2
3export const NO_SIGNALS: Signals = {
4 lane: null,
5 callStage: null,
6 isCleanDone: false,
7 firstCallAt: null,
8 agents: [],
9 isPullRequestRunning: false,
10 pulls: [],
11 costUsd: 0,
12}
13
14export type Json = Record<string, unknown>
15
16export const isObject = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value)
17
18export function parseJson(text: string): unknown {
19 try {
20 return JSON.parse(text)
21 } catch {
22 return undefined
23 }
24}
25
26export const LANES: readonly Lane[] = ['full', 'standard', 'direct']
27
28export const LANE_NAME: Readonly<Record<Lane, string>> = { full: 'Full', standard: 'Standard', direct: 'Direct' }
29hooks/draw/crew.ts 175 lines1import type { Role } from '../../types'
2
3export const CLAY = '#D97757'
4export const INK = '#1F1E1D'
5export const CRAB_BOX = 30
6
7export const ROLE_COLOR = {
8 interphase: '#7F77DD',
9 mitosis: '#1D9E75',
10 review: '#D85A30',
11 verdict: '#BA7517',
12 ship: '#378ADD',
13 continuity: '#888780',
14} as const
15
16export type Costume = 'captain' | 'scribe' | 'builder' | 'detective' | 'judge' | 'courier' | 'plain'
17
18type Rect = { x: number; y: number; w: number; h: number; fill: string; cls: string }
19type Palette = Readonly<Record<string, string>>
20
21const rect = (x: number, y: number, w: number, h: number, fill: string, cls = 'bd'): Rect => ({ x, y, w, h, fill, cls })
22
23const stamp = (x: number, y: number, rows: readonly string[], map: Palette, cls?: string): Rect[] =>
24 rows.flatMap((row, dy) =>
25 [...row].flatMap((ch, dx) => {
26 const fill = map[ch]
27 return fill ? [rect(x + dx, y + dy, 1, 1, fill, cls)] : []
28 }),
29 )
30
31const crabBody = (armFront = 0, armCls?: string): Rect[] => [
32 rect(7, 10, 16, 12, CLAY),
33 rect(3, 14, 4, 4, CLAY),
34 rect(23, 14 + armFront, 4, 4, CLAY, armCls),
35 rect(9, 12, 2, 2, INK),
36 rect(19, 12, 2, 2, INK),
37 rect(7, 22, 2, 4, CLAY, 'la'),
38 rect(17, 22, 2, 4, CLAY, 'la'),
39 rect(11, 22, 2, 4, CLAY, 'lb'),
40 rect(21, 22, 2, 4, CLAY, 'lb'),
41]
42
43export const CRAB_CSS = `<style>
44.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
45.run .bd{animation:bob .5s steps(1) infinite -.125s}
46.run g{transform-box:fill-box}
47@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
48@keyframes blink{50%{opacity:.15}}
49@keyframes page{50%{transform:scaleX(-1)}}
50.c-scribe.run .it{transform-origin:0 100%;animation:scrib .5s ease-in-out infinite}
51@keyframes scrib{25%{transform:rotate(-14deg)}75%{transform:rotate(10deg)}}
52.c-builder.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
53@keyframes twist{50%{transform:rotate(-35deg)}}
54.c-detective.run .it{animation:scan 1s steps(1) infinite}
55.c-detective.run .gl{animation:blink 1s steps(1) infinite -.5s}
56@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
57.c-judge.run .it{transform-origin:0 100%;animation:gavel 1s cubic-bezier(.6,0,.4,1) infinite}
58@keyframes gavel{0%,40%{transform:rotate(-40deg)}55%{transform:rotate(8deg)}70%,100%{transform:rotate(0)}}
59.c-captain.run .pg{transform-origin:0 50%;animation:page 2s ease-in-out infinite}
60.c-courier.run .it{animation:hop .5s steps(1) infinite}
61@keyframes hop{50%{transform:translate(1px,-2px)}}
62@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
63</style>`
64
65const COSTUMES: Readonly<Record<Costume, (t: string) => Rect[]>> = {
66 scribe: t => [
67 ...crabBody(-4),
68 ...stamp(6, 5, ['......bbbbbb......', '...bbbbbbbbbbbb...', '..bbbbbbbbbbbbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: t, d: '#3E3A73' }),
69 rect(14, 4, 2, 1, '#3E3A73'),
70 ...stamp(24, 0, ['...w', '..ww', '.wwg', 'wwg.', 'wg..', 'k...', 'k...'], { w: '#F8F6F1', g: '#D2D1C8', k: INK }, 'it'),
71 ],
72 builder: t => [
73 ...crabBody(),
74 ...stamp(6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' }),
75 rect(13, 5, 4, 2, t),
76 ...stamp(0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it'),
77 ],
78 detective: t => [
79 ...crabBody(-4, 'it'),
80 ...stamp(6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' }),
81 rect(6, 9, 18, 1, t),
82 ...stamp(23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it'),
83 rect(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'),
84 rect(25, 6, 1, 4, '#7A4A26', 'it'),
85 rect(24, 2, 1, 1, '#fff', 'gl'),
86 ],
87 judge: t => [
88 ...crabBody(-4),
89 ...stamp(5, 3, ['....wwwwwwwwww....', '..wwgwwgwwgwwgww..', '.wwwwwwwwwwwwwwww.', 'wgwwgww....wwgwwgw', 'ww..............ww', 'wg..............gw', 'ww..............ww', 'wg..............gw'], { w: '#F4F3EE', g: '#D2D1C8' }),
90 rect(13, 18, 4, 2, t),
91 ...stamp(23, 3, ['.bbbbb', '.bbbbb', '...h..', '...h..', '...h..', '...h..', '...h..'], { b: '#7A4A26', h: '#A0703F' }, 'it'),
92 ],
93 captain: () => [
94 ...crabBody(),
95 ...stamp(5, 5, ['....nnnnnnnnnn....', '..nnnnnnnnnnnnnn..', '.wwwwwwwwwwwwwwww.', '.nnnnnnnggnnnnnnn.', 'kkkkkkkkkkkkkkkkkk.'], { n: '#2B3A55', w: '#F4F3EE', g: '#F5C542', k: INK }),
96 ...stamp(9, 17, ['nnnnn.nnnnn', 'nllll.lllln', 'nnnnn.nnnnn'], { n: '#2B3A55', l: '#C9CCD2' }),
97 rect(15, 17, 5, 3, '#3A4D70', 'pg'),
98 ],
99 courier: t => [
100 ...crabBody(-4, 'it'),
101 ...stamp(6, 6, ['...cccccccccccc...', '..cccccccccccccc..', '.cccccccccccccccc.', 'bbbbbbbbbbbbbbbbbbbbbb'], { c: t, b: '#1F4E7A' }),
102 ...stamp(22, 5, ['eeeeeeee', 'ewe..ewe', 'e.weew.e', 'e..ww..e', 'eeeeeeee'], { e: '#F8F6F1', w: '#C9CCD2' }, 'it'),
103 ],
104 plain: () => crabBody(),
105}
106
107const ROLE_LOOK: Readonly<Record<Role, { costume: Costume; color: string }>> = {
108 captain: { costume: 'captain', color: ROLE_COLOR.continuity },
109 scribe: { costume: 'scribe', color: ROLE_COLOR.interphase },
110 builder: { costume: 'builder', color: ROLE_COLOR.mitosis },
111 detective: { costume: 'detective', color: ROLE_COLOR.review },
112 judge: { costume: 'judge', color: ROLE_COLOR.verdict },
113 courier: { costume: 'courier', color: ROLE_COLOR.ship },
114 plain: { costume: 'plain', color: ROLE_COLOR.continuity },
115}
116
117export const costumeOf = (role: Role): Costume => ROLE_LOOK[role].costume
118
119export const colorOf = (role: Role): string => ROLE_LOOK[role].color
120
121const LEGS = new Set(['bd', 'la', 'lb'])
122
123export function crab(
124 x: number,
125 y: number,
126 costume: Costume,
127 color: string,
128 dim = false,
129 isWalking = false,
130 scale = 1.1,
131 mirror = false,
132): string {
133 const rects = COSTUMES[costume](color)
134 const rectsOf = (cls: string) =>
135 rects
136 .filter(one => one.cls === cls)
137 .map(one => `<rect x="${one.x}" y="${one.y}" width="${one.w}" height="${one.h}" fill="${one.fill}"/>`)
138 .join('')
139 const group = (cls: string) => `<g class="${cls}">${rectsOf(cls)}</g>`
140 const props = [...new Set(rects.map(one => one.cls))].filter(cls => !LEGS.has(cls))
141 const body = `<g class="bd">${rectsOf('bd')}${props.map(group).join('')}</g>`
142 const place = mirror
143 ? `translate(${x + CRAB_BOX * scale},${y}) scale(${-scale},${scale})`
144 : `translate(${x},${y}) scale(${scale})`
145 return `<g transform="${place}" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
146}
147
148export type CelebrationLook = { crew: readonly Role[]; scale: number; step: number; inset: number; hopPx: number }
149
150const HOP_SECONDS = 0.6
151const HOP_STAGGER_SECONDS = 0.2
152
153export const celebrationSize = (look: CelebrationLook) => ({
154 width: look.step * look.crew.length,
155 height: Math.ceil(CRAB_BOX * look.scale) + look.hopPx,
156})
157
158const hopCss = (look: CelebrationLook) => `<style>
159.hop{animation-name:hop-${look.hopPx};animation-duration:${HOP_SECONDS}s;animation-timing-function:ease-out;animation-iteration-count:1;animation-fill-mode:both}
160${look.crew.map((_, i) => `.hop-${i}{animation-delay:${(i * HOP_STAGGER_SECONDS).toFixed(1)}s}`).join('')}
161@keyframes hop-${look.hopPx}{0%,100%{transform:translateY(0)}45%{transform:translateY(-${look.hopPx}px)}}
162@media (prefers-reduced-motion: reduce){.hop{animation:none}}
163</style>`
164
165export function celebrationSvg(look: CelebrationLook, isSettled: boolean): string {
166 const { width, height } = celebrationSize(look)
167 const crabs = look.crew
168 .map(
169 (role, i) =>
170 `<g class="hop hop-${i}">${crab(i * look.step + look.inset, look.hopPx, costumeOf(role), colorOf(role), false, false, look.scale)}</g>`,
171 )
172 .join('')
173 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${isSettled ? '' : hopCss(look)}${crabs}</svg>`
174}
175hooks/draw/hint.tsx 102 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import { columnsOf } from './text'
4
5export type HintElements = Pick<Elements['terminal'], 'Box' | 'Text'>
6
7export type CardSizing = 'fixed' | 'shrink' | 'grow'
8
9export type HintPlace = {
10 side?: 'above' | 'below'
11 edge?: 'start' | 'end'
12 size?: CardSizing
13}
14
15export const CARD_BACKGROUND = '#2a2a29'
16export const CARD_TEXT = '#ffffff'
17const CARD_PAD = 1
18const CARD_SPARE = 2
19
20const cardColumns = (lines: readonly string[]) => Math.max(0, ...lines.map(columnsOf)) + 2 * CARD_PAD + CARD_SPARE
21
22const sizingOf = (size: CardSizing) =>
23 size === 'grow' ? { flexGrow: 1, flexShrink: 1, minWidth: 0 } : size === 'shrink' ? { flexShrink: 1, minWidth: 0 } : { flexShrink: 0 }
24
25export function hint(
26 ui: HintElements,
27 key: string,
28 visible: RenderElement | readonly RenderElement[],
29 lines: readonly string[],
30 { side = 'above', edge = 'start', size = 'fixed' }: HintPlace = {},
31): RenderElement {
32 const { Box, Text } = ui
33 const vertical = side === 'above' ? { top: -lines.length } : { bottom: -lines.length }
34 const horizontal = edge === 'start' ? { left: 0 } : { right: 0 }
35 return (
36 <Box key={key} flexDirection="row" alignItems="center" gap="xs" {...sizingOf(size)}>
37 {visible}
38 {lines.length === 0 ? null : (
39 <Box
40 position="absolute"
41 {...vertical}
42 {...horizontal}
43 width={cardColumns(lines)}
44 display="none"
45 hover={{ display: 'flex' }}
46 flexDirection="column"
47 backgroundColor={CARD_BACKGROUND}
48 paddingX={CARD_PAD}
49 >
50 {lines.map(line => (
51 <Text color={CARD_TEXT}>{line}</Text>
52 ))}
53 </Box>
54 )}
55 </Box>
56 )
57}
58
59export type Card = { scope: string; lines: readonly string[] }
60
61export type SlotPlace = { top: number; left: number }
62
63export function cardTrigger(
64 ui: Pick<HintElements, 'Box'>,
65 scope: string,
66 visible: RenderElement | readonly RenderElement[],
67 size: CardSizing = 'fixed',
68): RenderElement {
69 const { Box } = ui
70 return (
71 <Box key={scope} hover={{ scope }} flexDirection="row" alignItems="center" {...sizingOf(size)}>
72 {visible}
73 </Box>
74 )
75}
76
77export const tallestCard = (cards: readonly Card[]) => Math.max(0, ...cards.map(card => card.lines.length))
78
79export function cardSlot(ui: HintElements, key: string, cards: readonly Card[], place: SlotPlace): RenderElement {
80 const { Box, Text } = ui
81 return (
82 <Box key={key} position="absolute" {...place} flexDirection="column">
83 {cards
84 .filter(card => card.lines.length > 0)
85 .map(card => (
86 <Box
87 key={`${card.scope}-card`}
88 display="none"
89 hover={{ scope: card.scope, display: 'flex' }}
90 flexDirection="column"
91 backgroundColor={CARD_BACKGROUND}
92 paddingX={CARD_PAD}
93 >
94 {card.lines.map(line => (
95 <Text color={CARD_TEXT}>{line}</Text>
96 ))}
97 </Box>
98 ))}
99 </Box>
100 )
101}
102hooks/draw/text.ts 94 lines1import type { Stage } from '../../types'
2import { CRAB_CSS, ROLE_COLOR } from './crew'
3
4export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif"
5export const ACCENT = '#8f8cf4'
6export const DONE = '#5fbf8f'
7export const FAIL = '#D0453F'
8export const TRACK = 'rgba(128,128,120,.24)'
9export const LANE_COLORS = ['#1D9E75', '#378ADD', '#BA7517', '#7F77DD', '#D85A30'] as const
10
11export type StageInfo = { id: Stage; name: string; color: string }
12
13export const STAGE_TABLE: readonly StageInfo[] = [
14 { id: 'spec', name: 'Spec', color: ROLE_COLOR.interphase },
15 { id: 'build', name: 'Build', color: ROLE_COLOR.mitosis },
16 { id: 'review', name: 'Review', color: ROLE_COLOR.review },
17 { id: 'fix', name: 'Fix', color: ROLE_COLOR.verdict },
18 { id: 'ship', name: 'Ship', color: ROLE_COLOR.ship },
19 { id: 'integrate', name: 'Integrate', color: ROLE_COLOR.ship },
20 { id: 'clean', name: 'Clean-up', color: ROLE_COLOR.continuity },
21]
22
23export const THEME_CSS = `<style>
24.sv-t{fill:#77776f}.sv-s{fill:#85857f}.sv-m{fill:#93938d}.sv-k{fill:rgba(128,128,120,.24)}.sv-tile{fill:rgba(128,128,120,.12)}
25.sv-tk{fill:#93938d;opacity:.6}.sv-mstroke{stroke:#93938d}.sv-mfill{fill:#93938d}
26.sv-live{animation:sv-p 1.6s ease-in-out infinite}@keyframes sv-p{50%{opacity:.3}}
27@media (prefers-reduced-motion: reduce){.sv-live{animation:none}}
28</style>`
29
30const ESCAPES: Readonly<Record<string, string>> = { '&': '&', '<': '<', '>': '>', '"': '"' }
31
32export const xml = (value: unknown) => String(value).replace(/[&<>"]/g, ch => ESCAPES[ch] ?? ch)
33
34export const noise = (x: number, y: number) => {
35 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
36 return s - Math.floor(s)
37}
38
39export const charEm = (ch: string) => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56)
40
41export const textWidth = (s: string, size: number) => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
42
43export const CELL_PX = 8
44
45export const TEXT_PX = 13
46
47export const columnsOf = (s: string) => Math.max([...s].length, Math.ceil(textWidth(s, TEXT_PX) / CELL_PX))
48
49const FIT_SLACK = 0.01
50
51export function fitText(s: string, size: number, maxW: number): string {
52 if (textWidth(s, size) <= maxW + FIT_SLACK) return s
53 const chars = [...s]
54 const kept = chars.findIndex((_, i) => textWidth(chars.slice(0, i + 1).join('') + '…', size) > maxW + FIT_SLACK)
55 return chars.slice(0, kept === -1 ? chars.length : kept).join('') + '…'
56}
57
58export const stageIndex = (stage: Stage | 'unknown') => STAGE_TABLE.findIndex(one => one.id === stage)
59
60export const stageInfo = (stage: Stage | 'unknown'): StageInfo | null => STAGE_TABLE[stageIndex(stage)] ?? null
61
62export type Mark = 'running' | 'done' | 'failed' | 'pending'
63
64export function statusMark(x: number, y: number, status: Mark, color: string): string {
65 if (status === 'running') return `<circle class="sv-live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
66 if (status === 'done') {
67 return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
68 }
69 if (status === 'failed') {
70 return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="${FAIL}" stroke-width="1.8" stroke-linecap="round"/>`
71 }
72 return `<circle cx="${x}" cy="${y}" r="5" fill="none" class="sv-mstroke" stroke-width="1.4"/>`
73}
74
75export const svg = (W: number, H: number, body: string) =>
76 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${THEME_CSS}${CRAB_CSS}${body}</svg>`
77
78export const CHIP_H = 16
79const CHIP_PX = 10.5
80const CHIP_PAD = 12
81const SEMIBOLD_WIDTH = 1.08
82
83export const chipWidthOf = (label: string) => Math.ceil(textWidth(label, CHIP_PX) * SEMIBOLD_WIDTH) + CHIP_PAD
84
85export function chipSvg(label: string): string {
86 const W = chipWidthOf(label)
87 return svg(
88 W,
89 CHIP_H,
90 `<rect class="sv-k" y="1" width="${W}" height="${CHIP_H - 2}" rx="4"/>
91<text class="sv-s" x="${W / 2}" y="${CHIP_H / 2 + 3.5}" text-anchor="middle" font-family="${FONT}" font-size="${CHIP_PX}" font-weight="600">${xml(label)}</text>`,
92 )
93}
94hooks/run/past.ts 61 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { PastRun, PastRuns } from '../../types'
5import { eventLogOf, readEvents } from './events'
6import { listing } from './home'
7import { buildView } from './infer'
8import { readGoneRun } from './poll'
9import { ledgerOf, sessionFile } from './session'
10import type { RunLocation } from './session'
11import { NO_SIGNALS } from './state'
12
13export const MAX_PAST_RUNS = 10
14
15const pastRunsState = atom({ plugin: 'spindle', key: 'pastRuns' } as const, null)
16const pastViewState = atom({ plugin: 'spindle', key: 'pastView' } as const, null)
17const pastSelectedState = atom({ plugin: 'spindle', key: 'pastSelectedMsp' } as const, null)
18
19const recordsOf = (mitosis: string, repo: string) => `${mitosis}/${repo}/records`
20
21async function listPastRuns($: EngineInterface, mitosis: string, repo: string, shownSlug: string | null): Promise<PastRuns | null> {
22 const entries = await listing($, recordsOf(mitosis, repo))
23 if (entries === null) return null
24 const runs: readonly PastRun[] = entries
25 .filter(entry => entry.kind === 'dir' && entry.name !== shownSlug)
26 .map(entry => ({ slug: entry.name, mtimeMs: entry.mtimeMs }))
27 .sort((a, b) => b.mtimeMs - a.mtimeMs)
28 .slice(0, MAX_PAST_RUNS)
29 return { repo, runs }
30}
31
32export async function storePastRuns($: EngineInterface, mitosis: string | null, location: RunLocation | null): Promise<void> {
33 const repo = location?.repo ?? null
34 const next = mitosis === null || repo === null ? null : await listPastRuns($, mitosis, repo, location?.slug ?? null)
35 const previous = await read($, pastRunsState)
36 if (JSON.stringify(previous) !== JSON.stringify(next)) await update($, pastRunsState, () => next)
37}
38
39const pastLocationOf = (mitosis: string, repo: string, slug: string): RunLocation => ({
40 slug,
41 repo,
42 home: `${mitosis}/${repo}/${slug}`,
43 runDir: `${mitosis}/runs/${repo}/${slug}`,
44 ledger: ledgerOf(mitosis, repo),
45})
46
47export async function readPastRun($: EngineInterface, slug: string): Promise<void> {
48 const [where, past] = await Promise.all([sessionFile($), read($, pastRunsState)])
49 if (where === null || past === null) return
50 const location = pastLocationOf(where.mitosis, past.repo, slug)
51 const logged = await readEvents($, eventLogOf(where.mitosis, location), {})
52 const run = await readGoneRun($, where.mitosis, location, logged.events, {})
53 await update($, pastSelectedState, () => null)
54 await update($, pastViewState, () => buildView(run.facts, NO_SIGNALS))
55}
56
57export async function leavePastRun($: EngineInterface): Promise<void> {
58 await update($, pastSelectedState, () => null)
59 await update($, pastViewState, () => null)
60}
61