SLOPSHOPPER

spindle

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.

newpanebandguardcommandtoast
v0.2.6MITupdated 2026-10-22SatanshuMishra/spindle/plugins/spindle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spindle
│ ┃ Pipeline ✕ › fix the failing auth test and add an audit log call │ ┃ No pipeline run in this session. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spindle │ ⎿ spindle: Run pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pipeline
No pipeline run in this session.
README

Spindle

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.

Install

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

Use

  • /spindle shows or hides the run pane.
  • /spindle status prints the run as text lines in the transcript.

Checks

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.

Layout

.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

License

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.

Source 21 files
hooks/register.tsx 43 lines
1import { 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}
43
hooks/draw/band.tsx 439 lines
1import { 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}
439
hooks/draw/pane.tsx 852 lines
1import { 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}
852
hooks/run/poll.ts 151 lines
1import { 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}
151
hooks/run/signals.ts 198 lines
1import { 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}
198
hooks/run/summary.ts 90 lines
1import 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}
90
hooks/run/view.ts 84 lines
1import { 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}
84
hooks/run/state.ts 29 lines
1import 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' }
29
hooks/draw/crew.ts 175 lines
1import 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}
175
hooks/draw/hint.tsx 102 lines
1import 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}
102
hooks/draw/text.ts 94 lines
1import 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>> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
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}
94
hooks/run/past.ts 61 lines
1import { 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