SLOPSHOPPER

crab-band

Your Claude usage limits in the status line, and a band above the prompt with a row for the main session (task list, progress gauge, model, context, time) and…

panebandguardcommandstatusprompt
★ 4v1.3.0MITupdated 2026-10-24MaxHarfen/crab-band/crab-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-band
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ○ Waiting for you │ ┃ Main session · Opus 5.5 · ctx 49% ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Running · 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No subagents in this session yet. ⏺ 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 │ │ › /agents-info │ ⎿ crab-band: Agents pane opened. │ │ ○ Waiting for you · Opus 5.5 · ctx 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ crab-band: 5h ▓▓▓░░░░░░░ 31%

Draws

Band
○ Waiting for you · Opus 5.5 · ctx 49%
Pane · Agents
○ Waiting for you Main session · Opus 5.5 · ctx 49% Running · 0 No subagents in this session yet.
README

crab-band

<img src="docs/crabs-light.gif" alt="Ten pixel crabs dancing in their outfits: wizard, scientist, painter, doll, cowboy, ninja, gardener, musician, explorer and a plain crab" width="100%">

crab-band, an unofficial Claude Code mod (a plugin built on function hooks) that does three things:

  • Usage limits in the status line. Your Claude 5-hour and weekly limits, each with a gauge and a countdown to its reset: 5h ▓▓▓░░░░░░░ 31% ↺2h14m 7d ▓▓▓▓▓░░░░░ 52% ↺4d. It works in the desktop app's Code tab, where a statusLine command is not drawn.
  • A band of crabs above the prompt. It always leads with a row for the main session: the step it is on from its task list, a progress gauge, the model, context and turn time. Below it, a row per running subagent. Each row starts with a pixel crab dressed by model and effort, which dances while its agent works (desktop app). A finished subagent's row stays 30 seconds.
[crab] ● Write the parser · 2/5 ▓▓░░░░ · Opus 5.5 · ctx 24% · 2:13
[crab] ● Count hooks · Haiku 5.5 · ctx 6% · 0:15
  • /agents-info, a pane with big crabs. The same agents on a plain background, about twice the size: the main session, the subagents running now, and every subagent that finished this session (the band keeps a finished one only 30 seconds). /agents-info opens or closes it, /agents-info open only opens it. It runs at once, even typed while Claude is working, without stopping the turn. While the pane is on screen the band steps aside, so each agent has one crab; it comes back when the pane closes or sits behind another tab.

There is also crab-band for claude.ai, a separate Chrome extension that brings the crab to claude.ai in the browser.

The band in the desktop app (an illustration, not a screenshot):

<img src="docs/band-illustration.png" alt="Three band rows: a scientist crab, a musician crab and an explorer crab, each followed by a status dot, the task, the model, context and time" width="520">

Crab outfits:

AgentCrabDanceProps
Fablewizardfloat and twirlwaves its wand, a star blinks
Opus, effort maxscientistthe robot, then the boomthe flask bubbles, then blows up in a puff of smoke
Opus, effort xhighpainterswaystrokes the brush, colours appear one by one
Opus, effort highdolldiscoflips her hair, the skirt flares, a sparkle twinkles, hearts float up
Opus, effort mediumcowboyline dancespins a lasso
Opus, effort lowninjaback flipthe headband flutters, a throwing star spins
Haikugardenersprinklertips the watering can, drops fall
Sonnetmusicianb-boy: toprock, headspin, freeze, hopnotes float up
Exploreexplorermoonwalkglances left and right, the compass needle swings
anything elseplain crabcrab shuffle

The main row reads the task list the model keeps (the desktop app's task list, or TodoWrite in the terminal). It only reads those calls and never changes them. Between turns it rests, dimmed, on the last state.

Requirements

  • A Claude Code build with function-hook mods (an early-access API that may change between releases). Tested on Claude Code 2.1.299.
  • The limits show only on a Claude Pro or Max subscription, after the session's first reply.
  • The crabs are drawn in the desktop app; the terminal shows the same rows as text. In VS Code the band shows only while the session or a subagent is at work, as VS Code's label for it would otherwise cover the slash-command menu.

Install

At the prompt of a terminal session:

/plugin install crab-band --marketplace MaxHarfen/crab-band

Answer y to add the marketplace, then pick a scope (user: every project). From a shell:

claude plugin install crab-band --marketplace MaxHarfen/crab-band

Start a new session afterwards. The status line and the band appear by themselves; /agents-info opens the pane.

crab-band for claude.ai (Chrome extension)

A separate Chrome extension in chrome/ brings the crab to claude.ai in the browser. While Claude answers, a crab dances and walks slowly under the reply, dressed for the model and effort in the model picker, and the 5-hour and 7-day limits show next to the picker. It uses no tokens, asks for no permissions and runs only on claude.ai. It installs on its own, in Chrome, and has nothing to do with the plugin install above. Install it from the Chrome Web Store. chrome/README.md says what it reads and how to build it; chrome/PRIVACY.md is its privacy policy.

Developing

The plugin is crab-band/: hooks/register.tsx holds every hook, hooks/crab.ts the crab art (the ART block) and the props' motion, hooks/moves.ts the dances (the MOVES block). Check it with claude plugin validate crab-band and claude plugin test crab-band. The art and the dances each live in one place: the JSON between // ART:BEGIN and // ART:END in crab-band/hooks/crab.ts, and between // MOVES:BEGIN and // MOVES:END in crab-band/hooks/moves.ts. A routine poses the whole crab, the body, each claw and each leg pair over a 4-second loop, and its box is the view that holds every frame. Edit either block and run python3 docs/make_crab_gif.py docs (it needs Pillow) to redraw the GIFs above from them; python3 docs/make_crab_gif.py --check draws nothing and fails if any frame leaves its routine's box. The GIF script keeps a Python copy of the props' motion only, so a new prop or prop animation needs adding there too.

The Chrome extension imports crab.ts, moves.ts, costume.ts and limits-format.ts from crab-band/hooks/ when it is built, so both draw the same crabs. After changing any of them, also run npm test in chrome/.

License

MIT (see LICENSE).

crab-band is a fan-made mod. It is not made, endorsed or supported by Anthropic.

Credits: the crab is drawn after Clawd, Claude Code's pixel mascot, by Anthropic. crab-band began as a fork of johnnyvizz/claude-kit's savvy-progress, which had the idea of dressing each agent's crab by its model. The outfits, their animations and the dances here are crab-band's own; the parts of the code still adapted from savvy-progress are listed, with its MIT license, in crab-band/NOTICE.md.

Source 8 files
hooks/register.tsx 390 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Main, Run, RunStatus } from '../types'
5import { costumeFor, savvyTier } from './costume'
6import { COSTUME_ACCENT, COSTUME_NAME, crabCss, crabMarkup } from './crab'
7import { isVisible, mainMeta, mainRowText, mainTitle, rowText, runMeta, runTitle } from './format'
8import { formatLimits } from './limits-format'
9import { MOVES, routineOf } from './moves'
10import { PLAN_TOOL, TODO_TOOL, WRAP_TOOL, applyPlanCall, applyTodos, finishAll, isFinished } from './plan'
11
12const IDLE: Main = { model: '', isWorking: false, turnStartedAt: 0, contextTokens: 0, plan: [] }
13
14const runs = atom({ plugin: 'crab-band', key: 'runs' } as const, [])
15const now = atom({ plugin: 'crab-band', key: 'now' } as const, 0)
16const main = atom({ plugin: 'crab-band', key: 'main' } as const, IDLE)
17const paneShown = atom({ plugin: 'crab-band', key: 'paneShown' } as const, false)
18
19const MAX_ROWS = 5
20const MAX_KEPT = 50
21// The agents pane: the same crabs, about twice the size, on a plain background. In the band a grid
22// unit is one px (two device pixels on a Retina screen), so the crab's edges stay even; in the pane,
23// the 30-unit crab is BIG_W px wide.
24const PANE = 'crab-agents'
25const PANE_TITLE = 'Agents'
26const BIG_W = 63
27const BIG_UNIT = BIG_W / 30
28const MAX_FINISHED = 20
29
30const MARK: Record<RunStatus, string> = { running: '●', done: '✓', failed: '✗' }
31const COLOR: Record<RunStatus, string> = { running: '#8f8cf4', done: '#5fbf8f', failed: '#e5484d' }
32
33const costumeOf = (run: Run): string => costumeFor(savvyTier(run.type ?? ''), run.type ?? '', run.model, run.effort)
34const mainCostume = (m: Main): string => costumeFor('other', 'main', m.model, m.effort)
35
36// Every crab is as wide as the union of the dances' boxes, so the marks and text after the crabs line
37// up row after row and the crabs stand on one grid; its height is its own dance's box.
38const VIEW_X = Math.min(...Object.values(MOVES).map(r => r.box[0]))
39const VIEW_W = Math.max(...Object.values(MOVES).map(r => r.box[0] + r.box[2])) - VIEW_X
40
41// One crab as its own SVG, `unit` px per grid unit, dancing while the agent runs. The view holds
42// every frame of the routine, props and all; `phase` (seconds the agent has run) keeps the dance
43// going across the once-a-second redraw.
44const crabSize = (costume: string, unit = 1): [number, number] => {
45  const [, , , h] = routineOf(costume).box
46  return [Math.round(VIEW_W * unit), Math.round(h * unit)]
47}
48const crabSvg = (costume: string, isWalking: boolean, phase: number, unit = 1): string => {
49  const [, y, , h] = routineOf(costume).box
50  const [width, height] = crabSize(costume, unit)
51  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${VIEW_X} ${y} ${VIEW_W} ${h}" shape-rendering="crispEdges">${crabCss(costume)}${crabMarkup(costume, COSTUME_ACCENT[costume] ?? '#888780', isWalking, phase)}</svg>`
52}
53
54// The status line: the 5-hour and weekly limits with their reset countdowns, redrawn once a
55// minute so the countdowns keep moving while idle.
56const LIMITS_TICK_MS = 60_000
57
58async function drawLimits($: EngineInterface, limits: readonly SessionRateLimit[]) {
59  $.ui.status(formatLimits(limits, await $.clock.now()))
60}
61
62// Whether the agents pane is on screen: open, placed and the tab its surface shows. No event says
63// when the person switches tabs, so the tick asks again every second; open, close and a reload
64// ask at once. Written only when it changes, so the band redraws only then.
65async function syncPane($: EngineInterface) {
66  let isShown = false
67  try {
68    isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
69  } catch {
70    return // the record is unreadable: leave the band as it is
71  }
72  if ((await read($, paneShown)) !== isShown) await update($, paneShown, () => isShown)
73}
74
75// Moves the band's clock once a second while something on it moves: the main session at work,
76// a running subagent, or a finished one still lingering (and once more as it leaves). Asks after
77// the pane every second regardless.
78async function tick($: EngineInterface) {
79  await syncPane($)
80  const at = await $.clock.now()
81  const list = await read($, runs)
82  const shown = await read($, now)
83  const m = await read($, main)
84  if (m.isWorking || list.some(r => isVisible(r, at) || isVisible(r, shown))) {
85    await update($, now, () => at)
86  }
87}
88
89export const register: Register = on => {
90  let ticker: Timer | undefined
91  let limitsTicker: Timer | undefined
92  let limits: readonly SessionRateLimit[] = []
93
94  on('session.start', async ($, e, next) => {
95    const started = await next(e)
96
97    // A reload or a resume starts with the limits the engine already has, rather than a blank
98    // line until the next turn ends.
99    try {
100      limits = (await $.session.usage()).rateLimits
101    } catch {
102      limits = []
103    }
104    await drawLimits($, limits)
105    limitsTicker?.cancel()
106    limitsTicker = $.clock.every(LIMITS_TICK_MS, () => void drawLimits($, limits))
107    let model = ''
108    try {
109      model = await $.session.model()
110    } catch {
111      model = ''
112    }
113    await update($, main, () => ({ ...IDLE, model }))
114    ticker?.cancel()
115    ticker = $.clock.every(1000, () => void tick($))
116    // Last, and on its own: a host that refuses the command still gets the status line and the band.
117    // `immediate`: typed while a turn is in flight, it opens the pane at once instead of waiting.
118    try {
119      await $.command.register({
120        name: 'agents-info',
121        description: 'Show or hide the agents pane: the main session and every subagent of this session, with big crabs',
122        argumentHint: '[open]',
123        immediate: true,
124      })
125    } catch {
126      // the pane stays unreachable; everything else works
127    }
128    // A reload keeps the pane up: the band starts aside if it is on screen.
129    await syncPane($)
130    return started
131  })
132
133  on('session.end', async ($, e, next) => {
134    ticker?.cancel()
135    ticker = undefined
136    limitsTicker?.cancel()
137    limitsTicker = undefined
138    return next(e)
139  })
140
141  // A prompt starts the main session's turn. A finished task list is cleared, so a new
142  // request does not show the last one's "All steps done"; an unfinished one stays.
143  on('prompt.submit', async ($, e, next) => {
144    const submitted = await next(e)
145    if (submitted.drop !== undefined || e.isMeta) return submitted
146    const at = await $.clock.now()
147    await update($, main, m => ({
148      ...m,
149      isWorking: true,
150      turnStartedAt: m.isWorking ? m.turnStartedAt : at,
151      plan: isFinished(m.plan) ? [] : m.plan,
152    }))
153    await update($, now, () => at)
154    return submitted
155  }).catch(($, e, next) => next(e))
156
157  on('agent.spawn', async ($, e, next) => {
158    const started = await next(e)
159    if (started.deny !== undefined) return started
160
161    const at = await $.clock.now()
162    const run: Run = {
163      id: started.agentId ?? e.tool_use_id,
164      agentId: started.agentId,
165      description: e.description || e.subagentType,
166      type: e.subagentType,
167      model: started.model,
168      status: 'running',
169      startedAt: at,
170      contextTokens: 0,
171    }
172    await update($, runs, list => [...list.filter(r => r.id !== run.id), run].slice(-MAX_KEPT))
173    await update($, now, () => at)
174    return started
175  }).catch(($, e, next) => next(e)) // bookkeeping only: a failure here never blocks a spawn
176
177  // The task list: read from the calls that keep it, after they went through. Never changes them.
178  on('tool.call', async ($, e, next) => {
179    const result = await next(e)
180    const name = String(e.tool)
181    if (e.agentId || result.deny !== undefined || result.isError) return result
182    if (name !== PLAN_TOOL && name !== WRAP_TOOL && name !== TODO_TOOL) return result
183    const call = e as unknown as Record<string, unknown>
184    await update($, main, m => ({
185      ...m,
186      plan: name === WRAP_TOOL ? finishAll(m.plan) : name === TODO_TOOL ? applyTodos(call.todos) : applyPlanCall(m.plan, call),
187    }))
188    return result
189  }).catch(($, e, next) => next(e))
190
191  // Each model request. The model and effort are known before it is sent, so the crab is dressed
192  // at once (the first answer can stream for a while, task list and all); how full the window is
193  // comes with the reply.
194  on('turn.step', async function* ($, e, next) {
195    const effort = typeof e.effort === 'string' ? e.effort : undefined
196    if (!e.agentId) {
197      await update($, main, m => ({ ...m, model: e.model || m.model, effort: effort ?? m.effort, isWorking: true }))
198    } else {
199      await update($, runs, list =>
200        list.map((r): Run => (r.agentId === e.agentId ? { ...r, model: e.model || r.model, effort: effort ?? r.effort } : r)),
201      )
202    }
203
204    const result = yield* next(e)
205    const usage = result.usage
206    if (!usage) return result
207    const tokens =
208      (usage.input_tokens || 0) +
209      (usage.cache_read_input_tokens || 0) +
210      (usage.cache_creation_input_tokens || 0) +
211      (usage.output_tokens || 0)
212    if (!e.agentId) {
213      await update($, main, m => ({ ...m, model: usage.model || m.model, contextTokens: tokens }))
214      return result
215    }
216    await update($, runs, list =>
217      list.map((r): Run =>
218        r.agentId === e.agentId
219          ? { ...r, model: usage.model || r.model, contextTokens: tokens, status: 'running', endedAt: undefined }
220          : r,
221      ),
222    )
223    return result
224  })
225
226  // The engine's own measurements, after each turn and whenever a limit moves a point: the main
227  // window's fill for the band, the limits for the status line.
228  on('session.measure', async ($, e, next) => {
229    if (e.changed.includes('rateLimits')) {
230      limits = e.rateLimits
231      await drawLimits($, limits)
232    }
233    if (e.context.percent !== undefined) {
234      const percent = e.context.percent
235      await update($, main, m => ({ ...m, contextPercent: percent }))
236    }
237    return next(e)
238  })
239
240  on('turn.complete', async ($, e, next) => {
241    const at = await $.clock.now()
242    if (e.agentId) {
243      const status: RunStatus = e.reason === 'answer' ? 'done' : 'failed'
244      await update($, runs, list => list.map((r): Run => (r.agentId === e.agentId ? { ...r, status, endedAt: at } : r)))
245    } else {
246      await update($, main, m => ({ ...m, isWorking: false }))
247    }
248    await update($, now, () => at)
249    return next(e)
250  })
251
252  // `/agents-info` toggles the pane; `/agents-info open` opens it (or brings it forward) without toggling.
253  on('command.run', { command: 'agents-info' }, async ($, e) => {
254    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
255    if (isOpen && e.args.trim() !== 'open') {
256      await $.ui.close({ id: PANE }) // the ui.close hook below brings the band back
257      return { text: 'Agents pane closed.' }
258    }
259    await $.ui.open({ id: PANE, title: PANE_TITLE })
260    await syncPane($)
261    return { text: 'Agents pane opened.' }
262  })
263
264  // Every close of the pane (this command, the person's close mark, an unload) brings the band back
265  // at once, without waiting for the tick.
266  on('ui.close', async ($, e, next) => {
267    const closed = await next(e)
268    if (e.id === PANE) await update($, paneShown, () => false)
269    return closed
270  }).catch(($, e, next) => next(e))
271
272  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
273    const ui = $.ui.resolve(e)
274    const { Box, Text } = ui
275    const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined
276    const at = await read($, now)
277    const m = await read($, main)
278    const list = await read($, runs)
279    const running = list.filter(r => r.status === 'running').reverse()
280    const finished = list.filter(r => r.status !== 'running').reverse().slice(0, MAX_FINISHED)
281
282    const big = (costume: string, isWalking: boolean, phase: number, key: string) =>
283      Svg && (
284        <Svg
285          key={key}
286          source={crabSvg(costume, isWalking, phase, BIG_UNIT)}
287          alt={`${COSTUME_NAME[costume] ?? 'crab'} crab`}
288          width={crabSize(costume, BIG_UNIT)[0]}
289          height={crabSize(costume, BIG_UNIT)[1]}
290        />
291      )
292    const agent = (r: Run) => (
293      <Box key={`crabs-pane-${r.id}`} flexDirection="row" alignItems="center" gap={2}>
294        {big(costumeOf(r), r.status === 'running', ((r.endedAt ?? at) - r.startedAt) / 1000, `crabs-pane-crab-${r.id}`)}
295        <Box flexDirection="column">
296          <Text bold wrap="truncate-end">
297            {MARK[r.status]} {runTitle(r)}
298          </Text>
299          <Text dimColor wrap="truncate-end">
300            {r.type ? `${r.type} · ` : ''}
301            {runMeta(r, at)}
302          </Text>
303        </Box>
304      </Box>
305    )
306
307    return (
308      <Box flexDirection="column" gap={1}>
309        <Box key="crabs-pane-main" flexDirection="row" alignItems="center" gap={2}>
310          {big(mainCostume(m), m.isWorking, m.turnStartedAt > 0 ? (at - m.turnStartedAt) / 1000 : 0, 'crabs-pane-main-crab')}
311          <Box flexDirection="column">
312            <Text bold wrap="truncate-end">
313              {m.isWorking ? '●' : isFinished(m.plan) ? '✓' : '○'} {mainTitle(m)}
314            </Text>
315            <Text dimColor wrap="truncate-end">
316              Main session{mainMeta(m, at) ? ` · ${mainMeta(m, at)}` : ''}
317            </Text>
318          </Box>
319        </Box>
320        <Text dimColor>Running · {running.length}</Text>
321        {running.map(agent)}
322        {finished.length > 0 && <Text dimColor>Finished · {finished.length}</Text>}
323        {finished.map(agent)}
324        {list.length === 0 && <Text dimColor>No subagents in this session yet.</Text>}
325      </Box>
326    )
327  })
328
329  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
330    if (e.props.hasSurvey) return next(e)
331    // While the agents pane shows the same crabs, the band draws nothing of its own.
332    if (await read($, paneShown)) return next(e)
333
334    const at = await read($, now)
335    const m = await read($, main)
336    const visible = (await read($, runs)).filter(r => isVisible(r, at))
337    // VS Code pins a "From the crab-band plugin" mark on the band and paints it over the slash-command
338    // menu, so there the band shows only while the session or a subagent is at work.
339    if (e.surface === 'vscode' && !m.isWorking && visible.length === 0) return next(e)
340
341    // Other mods' bands draw beneath these rows. The engine's
342    // own placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
343    const drawn = await Promise.resolve(next(e)).catch(() => null)
344    const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
345
346    const ui = $.ui.resolve(e)
347    const { Box, Text } = ui
348    // The desktop draws an SVG; the terminal's table has an Svg that draws nothing, so test the surface too.
349    const Svg = e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined
350    const ordered = [...visible.filter(r => r.status === 'running'), ...visible.filter(r => r.status !== 'running')]
351    // The main row always shows; subagents share what is left of the room.
352    const room = Math.max(0, Math.min(MAX_ROWS, (e.props.maxRows || MAX_ROWS) - 1) - 1)
353    const rows = ordered.slice(0, room)
354    const hidden = ordered.length - rows.length
355    const crab = (costume: string, isWalking: boolean, phase: number, key: string) =>
356      Svg && (
357        <Svg
358          key={key}
359          source={crabSvg(costume, isWalking, phase)}
360          alt={`${COSTUME_NAME[costume] ?? 'crab'} crab`}
361          width={crabSize(costume)[0]}
362          height={crabSize(costume)[1]}
363        />
364      )
365    const mainMark = m.isWorking ? '●' : isFinished(m.plan) ? '✓' : '○'
366    const mainColor = m.isWorking ? COLOR.running : isFinished(m.plan) ? COLOR.done : '#9a9a96'
367
368    return (
369      <Box flexDirection="column">
370        <Box key="crabs-main" flexDirection="row" alignItems="center" gap={1}>
371          {crab(mainCostume(m), m.isWorking, m.turnStartedAt > 0 ? (at - m.turnStartedAt) / 1000 : 0, 'crabs-main-crab')}
372          <Text color={mainColor}>{mainMark} </Text>
373          <Text wrap="truncate-end" dimColor={!m.isWorking}>
374            {mainRowText(m, at)}
375          </Text>
376        </Box>
377        {rows.map(r => (
378          <Box key={`crabs-${r.id}`} flexDirection="row" alignItems="center" gap={1}>
379            {crab(costumeOf(r), r.status === 'running', ((r.endedAt ?? at) - r.startedAt) / 1000, `crabs-crab-${r.id}`)}
380            <Text color={COLOR[r.status]}>{MARK[r.status]} </Text>
381            <Text wrap="truncate-end">{rowText(r, at)}</Text>
382          </Box>
383        ))}
384        {hidden > 0 && <Text dimColor>+{hidden} more · /agents-info</Text>}
385        {below}
386      </Box>
387    )
388  })
389}
390
hooks/costume.ts 43 lines
1// Which outfit an agent wears, by its model and effort, so the crabs tell apart what each agent
2// costs. A savvy-flow worker (savvy-careful, ...) wears its tier's outfit; Explore is the explorer.
3//
4//   Fable                      fable (wizard)
5//   Opus, effort max           heavy (scientist)
6//   Opus, effort xhigh         careful (painter)
7//   Opus, effort high          doll (doll)
8//   Opus, effort medium        medium (cowboy)
9//   Opus, effort low           light (ninja)
10//   Haiku                      haiku (gardener)
11//   Sonnet                     sonnet (musician)
12//
13// An Opus agent whose effort is not known yet (no model request seen) stays a plain crab
14// until its first request reports it.
15export const costumeFor = (tier: string, type: string, model: string, effort?: string): string => {
16  if (tier !== 'other') return tier
17  if (type === 'Explore') return 'explore'
18
19  const m = model.toLowerCase()
20  if (/fable|mythos/.test(m)) return 'fable'
21  if (/opus/.test(m)) {
22    if (effort === 'max') return 'heavy'
23    if (effort === 'xhigh') return 'careful'
24    if (effort === 'high') return 'doll'
25    if (effort === 'medium') return 'medium'
26    if (effort === 'low') return 'light'
27    return 'other'
28  }
29  if (/sonnet/.test(m)) return 'sonnet'
30  if (/haiku/.test(m)) return 'haiku'
31  return 'other'
32}
33
34const SAVVY_TIERS = ['fable', 'heavy', 'careful', 'medium', 'light']
35
36// The savvy tier a subagent type names (`savvy-careful`, or `savvy-flow:savvy-careful` from the plugin),
37// else 'other': the first argument costumeFor takes.
38export const savvyTier = (type: string): string => {
39  const bare = type.replace(/^[^:]*:/, '')
40  const tier = bare.replace(/^savvy-/, '').toLowerCase()
41  return bare.startsWith('savvy-') && SAVVY_TIERS.includes(tier) ? tier : 'other'
42}
43
hooks/crab.ts 939 lines
1// The crabs: a Clawd-style crab (Claude Code's mascot, by Anthropic) on a 30 x 28 grid, in outfits of
2// crab-band's own, and how each one moves.
3//
4// The art is the ART block below: strict JSON between the ART:BEGIN and ART:END lines, so
5// docs/make_crab_gif.py reads the very same pictures. Edit it there and both the plugin and the
6// README GIFs follow. Each picture is a list of rows, one character per pixel ('.' is empty); its
7// legend gives a character's colour ("accent" is the outfit's accent colour). A pixel belongs to a
8// group that moves as one: bd the body (bobs), la / lb the two leg pairs (step in turn), cl / cr the
9// arms, eye the eyes, and each outfit's props (it, star, bub, pop, boom, smoke, pa, pb, pc, loop,
10// flag, can, drop, nt, nt2, needle, hair, skirt, sp, ht).
11
12import { DANCE_SECONDS, keyframes, move, routineCss } from './moves'
13
14type Part = { x: number; y: number; group: string; rows: string[]; legend: Record<string, string> }
15type Art = {
16  // A base legend entry is [colour, group], or [colour, group, underlay]: a body pixel in that colour
17  // drawn beneath it, for a group that moves on its own (the eyes glance without leaving a hole).
18  base: { rows: string[]; legend: Record<string, [string, string] | [string, string, string]> }
19  outfits: Record<string, { parts: Part[] }>
20}
21
22export const ART: Art =
23// ART:BEGIN
24{
25  "base": {
26    "rows": [
27      "..............................",
28      "..............................",
29      "..............................",
30      "..............................",
31      "..............................",
32      "..............................",
33      "..............................",
34      "..............................",
35      "..............................",
36      "..............................",
37      ".......cccccccccccccccc.......",
38      ".......cccccccccccccccc.......",
39      ".......cckkcccccccckkcc.......",
40      ".......cckkcccccccckkcc.......",
41      "...LLLLccccccccccccccccRRRR...",
42      "...LLLLccccccccccccccccRRRR...",
43      "...LLLLccccccccccccccccRRRR...",
44      "...LLLLccccccccccccccccRRRR...",
45      ".......cccccccccccccccc.......",
46      ".......cccccccccccccccc.......",
47      ".......cccccccccccccccc.......",
48      ".......cccccccccccccccc.......",
49      ".......11..22....11..22.......",
50      ".......11..22....11..22.......",
51      ".......11..22....11..22.......",
52      ".......11..22....11..22.......",
53      "..............................",
54      ".............................."
55    ],
56    "legend": {
57      "c": [
58        "#D97757",
59        "bd"
60      ],
61      "k": ["#1F1E1D", "eye", "#D97757"],
62      "L": [
63        "#D97757",
64        "cl"
65      ],
66      "R": [
67        "#D97757",
68        "cr"
69      ],
70      "1": [
71        "#D97757",
72        "la"
73      ],
74      "2": [
75        "#D97757",
76        "lb"
77      ]
78    }
79  },
80  "outfits": {
81    "fable": {
82      "parts": [
83        {
84          "x": 4,
85          "y": 0,
86          "group": "bd",
87          "rows": [
88            "...............tt.....",
89            "..............tt......",
90            ".............ttt......",
91            "............tttt......",
92            "...........tYttt......",
93            ".........ttttttt......",
94            "........tYtttttt......",
95            "......tttttttYttt.....",
96            ".....ttYtttYttttt.....",
97            "BBBBBBBBBBBBBBBBBBBBBB"
98          ],
99          "legend": {
100            "t": "accent",
101            "Y": "#F5C542",
102            "B": "#4E46A8"
103          }
104        },
105        {
106          "x": 25,
107          "y": 9,
108          "group": "it",
109          "rows": [
110            "..n",
111            ".n.",
112            ".n.",
113            "n..",
114            "n.."
115          ],
116          "legend": {
117            "n": "#8A5A33"
118          }
119        },
120        {
121          "x": 26,
122          "y": 5,
123          "group": "star",
124          "rows": [
125            ".y.",
126            "yWy",
127            ".y."
128          ],
129          "legend": {
130            "y": "#F5C542",
131            "W": "#FFF6C8"
132          }
133        }
134      ]
135    },
136    "heavy": {
137      "parts": [
138        {
139          "x": 7,
140          "y": 8,
141          "group": "bd",
142          "rows": [
143            "kkkkkkkkkkkkkkkk",
144            "kggggkkkkkkggggk"
145          ],
146          "legend": {
147            "k": "#3B3B40",
148            "g": "#9AA0A8"
149          }
150        },
151        {
152          "x": 8,
153          "y": 7,
154          "group": "bd",
155          "rows": [
156            ".gg.......gg.",
157            "gvvg.....gvvg",
158            "gvvg.....gvvg",
159            ".gg.......gg."
160          ],
161          "legend": {
162            "g": "#9AA0A8",
163            "v": "#BFE3F7"
164          }
165        },
166        {
167          "x": 2,
168          "y": 8,
169          "group": "bd",
170          "rows": [
171            "..GG..",
172            "..GG..",
173            ".GttG.",
174            "GttttG",
175            "GttttG",
176            ".GGGG."
177          ],
178          "legend": {
179            "G": "#DCE6EE",
180            "t": "accent"
181          }
182        },
183        {
184          "x": 3,
185          "y": 5,
186          "group": "bub",
187          "rows": [
188            "b.",
189            "..",
190            ".b"
191          ],
192          "legend": {
193            "b": "accent"
194          }
195        },
196        {
197          "x": 2,
198          "y": 4,
199          "group": "pop",
200          "rows": [
201            ".y.",
202            "yoy",
203            ".y."
204          ],
205          "legend": {
206            "y": "#F5C542",
207            "o": "#F08A24"
208          }
209        },
210        {
211          "x": 0,
212          "y": 1,
213          "group": "boom",
214          "rows": [
215            "y.y.y.",
216            ".yoooy",
217            "yorroo",
218            ".oorry",
219            "yoooy.",
220            ".y.y.y"
221          ],
222          "legend": {
223            "y": "#F5C542",
224            "o": "#F08A24",
225            "r": "#E5484D"
226          }
227        },
228        {
229          "x": 1,
230          "y": 0,
231          "group": "smoke",
232          "rows": [
233            ".ss.",
234            "ssss",
235            ".ss."
236          ],
237          "legend": {
238            "s": "#B8BEC6"
239          }
240        }
241      ]
242    },
243    "careful": {
244      "parts": [
245        {
246          "x": 7,
247          "y": 7,
248          "group": "bd",
249          "rows": [
250            ".........tt......",
251            "...bbbbbbbbbbbb..",
252            ".bbbbbbbbbbbbbbbb",
253            "..BBBBBBBBBBBBBB."
254          ],
255          "legend": {
256            "t": "accent",
257            "b": "#2E3A59",
258            "B": "#1F2840"
259          }
260        },
261        {
262          "x": 1,
263          "y": 11,
264          "group": "bd",
265          "rows": [
266            ".PPPP.",
267            "PrPbPP",
268            "PPyPP."
269          ],
270          "legend": {
271            "P": "#C98E54",
272            "r": "#E5484D",
273            "b": "#3B82F6",
274            "y": "#F5C542"
275          }
276        },
277        {
278          "x": 25,
279          "y": 7,
280          "group": "it",
281          "rows": [
282            "..p",
283            ".pp",
284            ".g.",
285            ".n.",
286            "n..",
287            "n..",
288            "n.."
289          ],
290          "legend": {
291            "p": "#3B82F6",
292            "g": "#9AA0A8",
293            "n": "#8A5A33"
294          }
295        },
296        {
297          "x": 27,
298          "y": 5,
299          "group": "pa",
300          "rows": [
301            "rrr"
302          ],
303          "legend": {
304            "r": "#E5484D"
305          }
306        },
307        {
308          "x": 26,
309          "y": 3,
310          "group": "pb",
311          "rows": [
312            "yyy."
313          ],
314          "legend": {
315            "y": "#F5C542"
316          }
317        },
318        {
319          "x": 27,
320          "y": 1,
321          "group": "pc",
322          "rows": [
323            "bbb"
324          ],
325          "legend": {
326            "b": "#3B82F6"
327          }
328        }
329      ]
330    },
331    "doll": {
332      "parts": [
333        {
334          "x": 4,
335          "y": 5,
336          "group": "hair",
337          "rows": [
338            "......hhhhhhhhhh......",
339            "....hhhhhhhhhhhhhh....",
340            "...hhhHHhhhhhhhhhhh...",
341            "..hhhHhhhhhhhhhhhhhh..",
342            "..hhHhhhhhhhhhhhhhhh..",
343            ".hhhhhhhhhhhhhhhhhhhh.",
344            ".hHh..............hhd.",
345            ".hHd..............dhh.",
346            ".hHd..............dhh.",
347            ".hHd..............dhh.",
348            ".hHd..............dhh.",
349            ".hHd..............dhh.",
350            ".hHd..............dhh.",
351            ".hHd..............dhh.",
352            "hhd................dhh",
353            "hd..................dh"
354          ],
355          "legend": {
356            "h": "#F2CF5B",
357            "H": "#FBE7A1",
358            "d": "#D4A93A"
359          }
360        },
361        {
362          "x": 19,
363          "y": 2,
364          "group": "bd",
365          "rows": [
366            "aa...aa",
367            "aaa.aaa",
368            "aaaAaaa",
369            "aaa.aaa",
370            "aa...aa"
371          ],
372          "legend": {
373            "a": "accent",
374            "A": "#A3165F"
375          }
376        },
377        {
378          "x": 8,
379          "y": 11,
380          "group": "bd",
381          "rows": [
382            "k"
383          ],
384          "legend": {
385            "k": "#1F1E1D"
386          }
387        },
388        {
389          "x": 21,
390          "y": 11,
391          "group": "bd",
392          "rows": [
393            "k"
394          ],
395          "legend": {
396            "k": "#1F1E1D"
397          }
398        },
399        {
400          "x": 9,
401          "y": 15,
402          "group": "bd",
403          "rows": [
404            "pp"
405          ],
406          "legend": {
407            "p": "#F59BC3"
408          }
409        },
410        {
411          "x": 19,
412          "y": 15,
413          "group": "bd",
414          "rows": [
415            "pp"
416          ],
417          "legend": {
418            "p": "#F59BC3"
419          }
420        },
421        {
422          "x": 26,
423          "y": 0,
424          "group": "sp",
425          "rows": [
426            ".p.",
427            "pwp",
428            ".p."
429          ],
430          "legend": {
431            "w": "#FFFFFF",
432            "p": "#F7A8D0"
433          }
434        },
435        {
436          "x": 0,
437          "y": 5,
438          "group": "ht",
439          "rows": [
440            "aa.aa",
441            "aaaaa",
442            ".aaa.",
443            "..a.."
444          ],
445          "legend": {
446            "a": "accent"
447          }
448        },
449        {
450          "x": 5,
451          "y": 18,
452          "group": "skirt",
453          "rows": [
454            "..AAAAAAAAAAAAAAAA..",
455            "..aalaalaalaalaala..",
456            ".aalaalaalaalaalaaa.",
457            "aalaalaalaalaalaalaa"
458          ],
459          "legend": {
460            "a": "accent",
461            "A": "#A3165F",
462            "l": "#F59BC3"
463          }
464        }
465      ]
466    },
467    "medium": {
468      "parts": [
469        {
470          "x": 4,
471          "y": 5,
472          "group": "bd",
473          "rows": [
474            ".......bbb..bbb.......",
475            "......bbbbbbbbbb......",
476            "......tttttttttt......",
477            ".b....bbbbbbbbbb....b.",
478            "..bbbbbbbbbbbbbbbbbb.."
479          ],
480          "legend": {
481            "b": "#8B5A2B",
482            "t": "accent"
483          }
484        },
485        {
486          "x": 22,
487          "y": 0,
488          "group": "loop",
489          "rows": [
490            "..rrrr..",
491            ".r....r.",
492            "r......r",
493            ".r....r.",
494            "..rrrr.."
495          ],
496          "legend": {
497            "r": "#C8A165"
498          }
499        },
500        {
501          "x": 23,
502          "y": 5,
503          "group": "bd",
504          "rows": [
505            "r..",
506            "r..",
507            ".r.",
508            ".r.",
509            "..r",
510            "..r",
511            "..r",
512            "..r"
513          ],
514          "legend": {
515            "r": "#C8A165"
516          }
517        }
518      ]
519    },
520    "light": {
521      "parts": [
522        {
523          "x": 7,
524          "y": 10,
525          "group": "bd",
526          "rows": [
527            "tttttttttttttttt",
528            "tttttttttttttttt"
529          ],
530          "legend": {
531            "t": "accent"
532          }
533        },
534        {
535          "x": 23,
536          "y": 9,
537          "group": "flag",
538          "rows": [
539            "..tt",
540            "ttt.",
541            ".ttt"
542          ],
543          "legend": {
544            "t": "accent"
545          }
546        },
547        {
548          "x": 0,
549          "y": 9,
550          "group": "it",
551          "rows": [
552            ".s.",
553            "sks",
554            ".s."
555          ],
556          "legend": {
557            "s": "#B8BEC6",
558            "k": "#3B3B40"
559          }
560        }
561      ]
562    },
563    "haiku": {
564      "parts": [
565        {
566          "x": 5,
567          "y": 5,
568          "group": "bd",
569          "rows": [
570            ".......ssssss.......",
571            "......ssSsssSs......",
572            "......tttttttt......",
573            "..ssssssssssssssss..",
574            ".sSssSssSssSssSssSs."
575          ],
576          "legend": {
577            "s": "#E2C27A",
578            "S": "#C9A65A",
579            "t": "accent"
580          }
581        },
582        {
583          "x": 0,
584          "y": 9,
585          "group": "can",
586          "rows": [
587            "c.....",
588            ".c.hh.",
589            "..cGGh",
590            "..GGGG",
591            "..GGGG"
592          ],
593          "legend": {
594            "c": "#3E8E5C",
595            "h": "#3E8E5C",
596            "G": "#4FA36B"
597          }
598        },
599        {
600          "x": 0,
601          "y": 11,
602          "group": "drop",
603          "rows": [
604            "d",
605            ".",
606            "d"
607          ],
608          "legend": {
609            "d": "#5AB0F0"
610          }
611        }
612      ]
613    },
614    "sonnet": {
615      "parts": [
616        {
617          "x": 5,
618          "y": 5,
619          "group": "bd",
620          "rows": [
621            "....kkkkkkkkkkkk....",
622            "..kk............kk..",
623            ".k................k.",
624            ".k................k.",
625            "ttt..............ttt",
626            "tpt..............tpt",
627            "tpt..............tpt",
628            "tpt..............tpt",
629            "ttt..............ttt"
630          ],
631          "legend": {
632            "k": "#76767B",
633            "t": "accent",
634            "p": "#55514C"
635          }
636        },
637        {
638          "x": 25,
639          "y": 2,
640          "group": "nt",
641          "rows": [
642            ".nn",
643            ".n.",
644            "nn.",
645            "nn."
646          ],
647          "legend": {
648            "n": "accent"
649          }
650        },
651        {
652          "x": 0,
653          "y": 5,
654          "group": "nt2",
655          "rows": [
656            ".nn",
657            ".n.",
658            "nn.",
659            "nn."
660          ],
661          "legend": {
662            "n": "accent"
663          }
664        }
665      ]
666    },
667    "explore": {
668      "parts": [
669        {
670          "x": 7,
671          "y": 5,
672          "group": "bd",
673          "rows": [
674            ".....hhhhhh.....",
675            "...hhhhhhhhhh...",
676            "..hhhhhhhhhhhh..",
677            "..tttttttttttt..",
678            "hhhhhhhhhhhhhhhh"
679          ],
680          "legend": {
681            "h": "#D8C49A",
682            "t": "accent"
683          }
684        },
685        {
686          "x": 24,
687          "y": 8,
688          "group": "bd",
689          "rows": [
690            ".YYYY.",
691            "YWWWWY",
692            "YWWWWY",
693            "YWWWWY",
694            "YWWWWY",
695            ".YYYY."
696          ],
697          "legend": {
698            "Y": "#C9A13B",
699            "W": "#F7F4EC"
700          }
701        },
702        {
703          "x": 26,
704          "y": 9,
705          "group": "needle",
706          "rows": [
707            ".r",
708            ".r",
709            "k.",
710            "k."
711          ],
712          "legend": {
713            "r": "#E5484D",
714            "k": "#3B3B40"
715          }
716        }
717      ]
718    },
719    "other": {
720      "parts": []
721    }
722  }
723}
724// ART:END
725
726// Each outfit's accent ("accent" in the art).
727export const COSTUME_ACCENT: Record<string, string> = {
728  fable: '#7F77DD',
729  heavy: '#D85A30',
730  careful: '#BA7517',
731  doll: '#E0218A',
732  medium: '#378ADD',
733  light: '#1D9E75',
734  haiku: '#3E9B5F',
735  sonnet: '#C2569B',
736  explore: '#888780',
737  other: '#888780',
738}
739
740// What each outfit is called, for an image's alt text.
741export const COSTUME_NAME: Record<string, string> = {
742  fable: 'wizard',
743  heavy: 'scientist',
744  careful: 'painter',
745  doll: 'doll',
746  medium: 'cowboy',
747  light: 'ninja',
748  haiku: 'gardener',
749  sonnet: 'musician',
750  explore: 'explorer',
751  other: 'crab',
752}
753
754// --- Motion -------------------------------------------------------------------------------------
755
756// Every crab dances its outfit's routine (moves.ts) while its agent works; its props keep their own
757// motion on top. Loops longer than the once-a-second redraw (the dances, the scientist's flask, the
758// painter's strokes, the explorer's glances, the wizard's star, the doll's flip and twinkle) run from
759// the phase the drawing passes in (`--d`, a negative delay), so they carry on where they were instead
760// of starting over.
761export { DANCE_SECONDS }
762
763// Whether `ms` into the loop falls in the second half of a `period` that starts `delay` in.
764const late = (ms: number, period: number, delay: number): boolean => (((ms - delay) % period) + period) % period >= period / 2
765
766// A prop that dims and brightens over the 4 s loop, read every `stepMs`, held between changes.
767const blinks = (name: string, stepMs: number, dimmed: string, isDim: (ms: number) => boolean): string => {
768  const steps: Array<[number, string]> = []
769  for (let ms = 0; ms < DANCE_SECONDS * 1000; ms += stepMs) {
770    const rule = isDim(ms) ? `opacity:${dimmed}` : 'opacity:1'
771    if (steps[steps.length - 1]?.[1] !== rule) steps.push([ms / 1000, rule])
772  }
773  return keyframes(name, steps)
774}
775
776// The scientist's bubbles: three rise from the flask, then the fourth second is the blast.
777const BUBBLES: Array<[number, string]> = [0, 1, 3].flatMap((k): Array<[number, string]> => [
778  [k, 'transform:translateY(1px);opacity:0'],
779  [k + 0.25, 'opacity:1'],
780  [k + 0.99, 'transform:translateY(-4px);opacity:0'],
781]).concat([[2, 'transform:translateY(1px);opacity:0'], [2.99, 'transform:translateY(1px);opacity:0']])
782
783const flashes = (on: number, off: number): Array<[number, string]> => [[0, 'opacity:0'], [on, 'opacity:1'], [off, 'opacity:0']]
784const stroke = (start: number): Array<[number, string]> => [
785  [0, 'opacity:0'], [start - 0.01, 'opacity:0'], [start, 'opacity:1'], [1.6, 'opacity:1'], [2, 'opacity:0'],
786]
787const loop = (cls: string, name: string, timing: string, outfit: string, extra = ''): string =>
788  `.c-${outfit}.run .${cls}{${extra}animation:${name} ${DANCE_SECONDS}s ${timing} infinite var(--d,0s)}`
789
790// Each outfit's props and how they move, with the routine's timing where they follow the dance.
791const PROP_CSS: Record<string, string[]> = {
792  fable: [
793    '.c-fable.run .it{transform-origin:0 100%;animation:wand 1s ease-in-out infinite}',
794    '@keyframes wand{25%{transform:rotate(15deg)}75%{transform:rotate(-15deg)}}',
795    // The star blinks every half second, and twice as fast while he rises (1 s to 2.5 s).
796    blinks('star', 125, '.2', ms => late(ms, ms >= 1000 && ms < 2500 ? 250 : 500, 125)),
797    loop('star', 'star', 'steps(1,end)', 'fable'),
798  ],
799  heavy: [
800    keyframes('bub', BUBBLES),
801    keyframes('pop', flashes(2, 2.15)),
802    keyframes('boom', flashes(2.15, 2.45)),
803    keyframes('smoke', [[0, 'transform:translateY(0);opacity:0'], [2.4, 'transform:translateY(0);opacity:0'],
804      [2.401, 'transform:translateY(0);opacity:1'], [3.2, 'transform:translateY(-3px);opacity:0']]),
805    '.c-heavy .pop,.c-heavy .boom,.c-heavy .smoke{opacity:0}',
806    loop('bub', 'bub', 'ease-out', 'heavy'),
807    loop('pop', 'pop', 'steps(1,end)', 'heavy'),
808    loop('boom', 'boom', 'steps(1,end)', 'heavy'),
809    loop('smoke', 'smoke', 'linear', 'heavy'),
810  ],
811  careful: [
812    '.c-careful.run .it{transform-origin:50% 100%;animation:brush .5s ease-in-out infinite}',
813    '@keyframes brush{50%{transform:rotate(-25deg)}}',
814    keyframes('pa', stroke(0.2), 2),
815    keyframes('pb', stroke(0.5), 2),
816    keyframes('pc', stroke(0.8), 2),
817    ...['pa', 'pb', 'pc'].map(cls => `.c-careful.run .${cls}{animation:${cls} 2s linear infinite var(--d,0s)}`),
818  ],
819  doll: [
820    // The hair swings out on the flip, the skirt flares on the turn, the sparkle twinkles (faster in
821    // the closing pose) and a heart floats up every second.
822    keyframes('hair', move([[0, 'rotate(0deg)'], [2, 'rotate(10deg)'], [2.15, 'rotate(-12deg)'], [2.3, 'rotate(0deg)']])),
823    loop('hair', 'hair', 'steps(1,end)', 'doll', 'transform-origin:50% 18.75%;'),
824    keyframes('flare', move([[0, 'scaleX(1)'], [2.5, 'scaleX(1.2)'], [2.875, 'scaleX(1)']])),
825    loop('skirt', 'flare', 'steps(1,end)', 'doll', 'transform-origin:50% 0;'),
826    blinks('twinkle', 25, '.15', ms => late(ms, ms >= 3000 ? 250 : 500, 100)),
827    loop('sp', 'twinkle', 'steps(1,end)', 'doll'),
828    '@keyframes heart{0%{transform:translateY(2px);opacity:0}25%{opacity:1}100%{transform:translateY(-5px);opacity:0}}',
829    '.c-doll.run .ht{animation:heart 1s ease-out infinite var(--d,0s)}',
830  ],
831  medium: [
832    '.c-medium.run .loop{transform-origin:50% 50%;animation:lasso 1s ease-in-out infinite}',
833    '@keyframes lasso{50%{transform:scaleX(.2)}}',
834  ],
835  light: [
836    '.c-light.run .flag{transform-origin:0 50%;animation:flutter .25s steps(1) infinite}',
837    '.c-light.run .it{transform-origin:50% 50%;animation:spin .25s steps(1) infinite}',
838    '@keyframes flutter{50%{transform:skewY(-14deg) scaleX(.85)}}@keyframes spin{50%{transform:rotate(45deg)}}',
839  ],
840  haiku: [
841    '.c-haiku.run .can{transform-origin:100% 50%;animation:pour 1s ease-in-out infinite}',
842    '.c-haiku.run .drop{animation:drip 1s linear infinite}',
843    '@keyframes pour{20%,40%{transform:rotate(-15deg)}60%{transform:rotate(0)}}',
844    '@keyframes drip{0%{transform:translateY(0);opacity:1}100%{transform:translateY(4px);opacity:0}}',
845  ],
846  sonnet: [
847    '.c-sonnet.run .nt{animation:rise 1s ease-out infinite var(--d,0s)}.c-sonnet.run .nt2{animation:rise 1s ease-out infinite calc(var(--d,0s) - .5s)}',
848    '@keyframes rise{0%{transform:translateY(3px);opacity:0}25%{opacity:1}100%{transform:translateY(-4px);opacity:0}}',
849  ],
850  explore: [
851    '.c-explore.run .needle{transform-origin:50% 50%;animation:swing 1s ease-in-out infinite}',
852    '@keyframes swing{25%{transform:rotate(30deg)}75%{transform:rotate(-30deg)}}',
853    keyframes('look', [[0, 'transform:translateX(-1px)'], [0.8, 'transform:translateX(0)'], [1, 'transform:translateX(1px)'],
854      [1.8, 'transform:translateX(0)']], 2),
855    '.c-explore.run .eye{animation:look 2s steps(1,end) infinite var(--d,0s)}',
856  ],
857  other: [],
858}
859
860// An outfit nobody drew (a new savvy tier, a typo) is the plain crab, its dance and all.
861const outfitOf = (costume: string): string => (ART.outfits[costume] ? costume : 'other')
862
863// One crab's stylesheet: pure CSS, run by the compositor. It holds only that crab's outfit: the
864// shared rules, its props' motion and its dance. Reduced motion freezes all of it.
865export const crabCss = (costume: string): string => {
866  const outfit = outfitOf(costume)
867  return [
868    '<style>',
869    '.run g{transform-box:fill-box}',
870    ...(PROP_CSS[outfit] ?? []),
871    routineCss(outfit),
872    '@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}',
873    '</style>',
874  ].join('\n')
875}
876
877// --- Drawing ------------------------------------------------------------------------------------
878
879type Run = { x: number; y: number; w: number; colour: string; group: string }
880
881// The outfit's pixels, merged into horizontal runs of one colour in one group, base first.
882const runsOf = (outfit: string, accent: string): Run[] => {
883  const pixels: Array<{ x: number; y: number; colour: string; group: string }> = []
884  ART.base.rows.forEach((row, y) =>
885    [...row].forEach((ch, x) => {
886      const cell = ART.base.legend[ch]
887      if (!cell) return
888      if (cell[2]) pixels.push({ x, y, colour: cell[2], group: 'bd' })
889      pixels.push({ x, y, colour: cell[0], group: cell[1] })
890    }),
891  )
892  for (const part of ART.outfits[outfit]?.parts ?? []) {
893    part.rows.forEach((row, j) =>
894      [...row].forEach((ch, i) => {
895        const colour = part.legend[ch]
896        if (colour) pixels.push({ x: part.x + i, y: part.y + j, colour: colour === 'accent' ? accent : colour, group: part.group })
897      }),
898    )
899  }
900  const runs: Run[] = []
901  for (const p of pixels) {
902    const last = runs[runs.length - 1]
903    if (last && last.y === p.y && last.x + last.w === p.x && last.colour === p.colour && last.group === p.group) last.w += 1
904    else runs.push({ x: p.x, y: p.y, w: 1, colour: p.colour, group: p.group })
905  }
906  return runs
907}
908
909// Props that stay put while the crab dances: drawn outside the dancing group.
910const STILL_PROPS = new Set(['nt', 'nt2'])
911// The claws, drawn after every other prop so they sit in front of the outfit (the doll's hair).
912const CLAWS = ['cl', 'cr']
913
914// One crab's markup in its own 30 x 28 grid, for a caller to place and scale. Body and props nest
915// inside `bd` so a prop rides the squat and adds its own motion, the claws last; legs stay outside it
916// and lift on their own; `dn` holds all of them (and a frame that fixes its box, the dance's pivot),
917// and the still props sit outside it. `phase` is how long the agent has run, in seconds: where the
918// dance and the long loops pick up after a redraw.
919export const crabMarkup = (costume: string, accent: string, isWalking: boolean, phase = 0): string => {
920  const outfit = outfitOf(costume)
921  const groups = new Map<string, string[]>([['bd', []]])
922  for (const r of runsOf(outfit, accent)) {
923    if (!groups.has(r.group)) groups.set(r.group, [])
924    groups.get(r.group)?.push(`<rect x="${r.x}" y="${r.y}" width="${r.w}" height="1" fill="${r.colour}"/>`)
925  }
926  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
927  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
928  const moving = [...props.filter(k => !STILL_PROPS.has(k) && !CLAWS.includes(k)), ...CLAWS.filter(k => groups.has(k))]
929  const still = props.filter(k => STILL_PROPS.has(k))
930  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${moving.map(group).join('')}</g>`
931  const delay = isWalking && phase > 0 ? ` style="--d:-${(phase % DANCE_SECONDS).toFixed(2)}s"` : ''
932  return (
933    `<g class="c-${outfit}${isWalking ? ' run' : ''}"${delay}>` +
934    `<g class="dn"><rect x="0" y="0" width="30" height="28" fill="none"/>${body}${group('la')}${group('lb')}</g>` +
935    `${still.map(group).join('')}</g>`
936  )
937}
938
939
hooks/format.ts 79 lines
1import type { Main, Run } from '../types'
2import { planSummary } from './plan'
3
4// How long a finished agent's row stays in the band before it leaves.
5export const LINGER_MS = 30_000
6
7const MAX_TITLE = 48
8
9// The context window by model: 200K for Haiku 4 and older models, 1M otherwise.
10export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
11
12// claude-haiku-5-5 -> Haiku 5.5; anything else keeps its own name.
13export const modelName = (id: string): string => {
14  const [, family = '', major = '', minor] = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id) ?? []
15  if (!family) {
16    const bare = id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
17    return bare ? bare.charAt(0).toUpperCase() + bare.slice(1) : '—'
18  }
19  const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
20  return minor ? `${name} ${major}.${minor}` : `${name} ${major}`
21}
22
23const pad = (n: number): string => String(n).padStart(2, '0')
24
25// 0:15 / 12:04 / 1:02:05
26export const elapsed = (ms: number): string => {
27  const s = Math.max(0, Math.floor(ms / 1000))
28  const h = Math.floor(s / 3600)
29  const m = Math.floor((s % 3600) / 60)
30  return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`
31}
32
33export const isVisible = (run: Run, at: number): boolean =>
34  run.status === 'running' || (run.endedAt !== undefined && at - run.endedAt < LINGER_MS)
35
36const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
37
38// A subagent's details: Haiku 5.5 · ctx 6% · 0:15
39export const runMeta = (run: Run, at: number): string => {
40  const parts = [modelName(run.model)]
41  if (run.contextTokens > 0) {
42    parts.push(`ctx ${Math.round((run.contextTokens / windowOf(run.model)) * 100)}%`)
43  }
44  parts.push(elapsed((run.endedAt ?? at) - run.startedAt))
45  return parts.join(' · ')
46}
47
48export const runTitle = (run: Run): string => clip(run.description, MAX_TITLE)
49
50// Count hooks · Haiku 5.5 · ctx 6% · 0:15
51export const rowText = (run: Run, at: number): string => `${runTitle(run)} · ${runMeta(run, at)}`
52
53// A 6-cell gauge of steps done: ▓▓▓░░░
54export const stepBar = (done: number, total: number): string => {
55  const filled = total > 0 ? Math.round((done / total) * 6) : 0
56  return '▓'.repeat(filled) + '░'.repeat(6 - filled)
57}
58
59// What the main session is on: Write the parser · 2/5 ▓▓░░░░ (or Working / Waiting for you / Ready)
60export const mainTitle = (main: Main): string => {
61  const { current, done, total } = planSummary(main.plan)
62  if (total === 0) return main.isWorking ? 'Working' : main.turnStartedAt > 0 ? 'Waiting for you' : 'Ready'
63  return `${clip(done === total ? 'All steps done' : (current ?? 'Next step'), MAX_TITLE)} · ${done}/${total} ${stepBar(done, total)}`
64}
65
66// Its details: Opus 5.5 · ctx 24% · 2:13 (the time only while it works)
67export const mainMeta = (main: Main, at: number): string => {
68  const parts: string[] = []
69  if (main.model) parts.push(modelName(main.model))
70  const percent =
71    main.contextPercent ?? (main.contextTokens > 0 && main.model ? (main.contextTokens / windowOf(main.model)) * 100 : undefined)
72  if (percent !== undefined) parts.push(`ctx ${Math.round(percent)}%`)
73  if (main.isWorking && main.turnStartedAt > 0) parts.push(elapsed(at - main.turnStartedAt))
74  return parts.join(' · ')
75}
76
77// The main session's band row: Write the parser · 2/5 ▓▓░░░░ · Opus 5.5 · ctx 24% · 2:13
78export const mainRowText = (main: Main, at: number): string => [mainTitle(main), mainMeta(main, at)].filter(Boolean).join(' · ')
79
hooks/limits-format.ts 43 lines
1import type { SessionRateLimit } from 'claude-code'
2
3// One gauge per window, then the countdown to its reset:
4//   5h ▓▓▓░░░░░░░ 31% ↺2h14m   7d ▓▓▓▓▓░░░░░ 52% ↺4d
5const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
6const MINUTE = 60_000
7
8const gauge = (percent: number): string => {
9  const filled = Math.min(10, Math.max(0, Math.round(percent / 10)))
10  return '▓'.repeat(filled) + '░'.repeat(10 - filled)
11}
12
13// Compact countdown: 4d3h / 2h14m / 47m / <1m / now
14export const eta = (ms: number): string => {
15  if (ms <= 0) return 'now'
16  const minutes = Math.floor(ms / MINUTE)
17  const d = Math.floor(minutes / 1440)
18  const h = Math.floor((minutes % 1440) / 60)
19  const m = minutes % 60
20  if (d > 0) return h > 0 ? `${d}d${h}h` : `${d}d`
21  if (h > 0) return m > 0 ? `${h}h${m}m` : `${h}h`
22  return m > 0 ? `${m}m` : '<1m'
23}
24
25// One segment per window; undefined (clears the line) when there is no reading,
26// as off a subscription or before the session's first API response.
27export const formatLimits = (
28  limits: readonly SessionRateLimit[],
29  now: number,
30): string | undefined => {
31  if (limits.length === 0) return undefined
32
33  return limits
34    .map(limit => {
35      const label = LABELS[limit.kind] ?? limit.kind
36      const percent = Math.round(limit.percentUsed)
37      const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
38      const reset = Number.isNaN(resetsAt) ? '' : ` ↺${eta(resetsAt - now)}`
39      return `${label} ${gauge(percent)} ${percent}%${reset}`
40    })
41    .join('   ')
42}
43
hooks/moves.ts 455 lines
1// The dances: each outfit's 4 s routine, as data. A routine poses the crab's groups over the loop:
2// the whole crab (`dn`: jx, jy, rot about `origin`, mirror), a squat of the body (`bd`: b), each claw
3// (`cl`, `cr`: [dx, dy] on top of the squat) and each leg pair (`la`, `lb`: a lift), in grid units.
4// A key holds until the next. A missing field is at rest: 0, false or [0, 0]. `box` is the view that
5// holds every frame of the routine, props and all: [x, y, width, height] on the 30 x 28 grid.
6//
7// The MOVES block is strict JSON between the MOVES:BEGIN and MOVES:END lines, so docs/make_crab_gif.py
8// reads the very same routines.
9
10// Every routine, and every prop loop longer than the once-a-second redraw, lasts this long.
11export const DANCE_SECONDS = 4
12
13export const pct = (second: number, loop = DANCE_SECONDS): string => `${((second / loop) * 100).toFixed(3)}%`
14export const keyframes = (name: string, steps: Array<[number, string]>, loop = DANCE_SECONDS): string =>
15  `@keyframes ${name}{${steps.map(([second, rule]) => `${pct(second, loop)}{${rule}}`).join('')}}`
16export const move = (steps: Array<[number, string]>): Array<[number, string]> =>
17  steps.map(([s, tf]) => [s, `transform:${tf}`])
18
19export type Pose = {
20  jx?: number
21  jy?: number
22  rot?: number
23  mirror?: boolean
24  b?: number
25  cl?: [number, number]
26  cr?: [number, number]
27  la?: number
28  lb?: number
29}
30export type Routine = { origin: [number, number]; box: [number, number, number, number]; keys: Array<[number, Pose]> }
31
32// Exported once from the approved samples (sampled every 50 ms, held steps); edit it here from now on.
33export const MOVES: Record<string, Routine> & { other: Routine } =
34// MOVES:BEGIN
35{
36  "fable": {
37    "origin": [15, 17],
38    "box": [-4, -8, 38, 38],
39    "keys": [
40      [0, {"rot": 8, "cr": [0, -3]}],
41      [0.2, {"jy": -1, "rot": 8, "cr": [0, -3]}],
42      [0.25, {"jy": -1, "rot": -8, "cl": [0, -3]}],
43      [0.35, {"jy": -2, "rot": -8, "cl": [0, -3]}],
44      [0.5, {"jy": -2, "rot": 8, "cr": [0, -3]}],
45      [0.7, {"jy": -1, "rot": 8, "cr": [0, -3]}],
46      [0.75, {"jy": -1, "rot": -8, "cl": [0, -3]}],
47      [0.85, {"rot": -8, "cl": [0, -3]}],
48      [1, {"jy": -2, "cl": [0, -5], "cr": [0, -5]}],
49      [1.25, {"jy": -3, "cl": [0, -5], "cr": [0, -5]}],
50      [1.45, {"jy": -4, "cl": [0, -5], "cr": [0, -5]}],
51      [1.6, {"jy": -5, "cl": [0, -5], "cr": [0, -5]}],
52      [1.8, {"jy": -6, "cl": [0, -5], "cr": [0, -5]}],
53      [2, {"jy": -6, "rot": 45, "cl": [0, -3], "cr": [0, -3]}],
54      [2.1, {"jy": -6, "rot": 90, "cl": [0, -3], "cr": [0, -3]}],
55      [2.15, {"jy": -6, "rot": 135, "cl": [0, -3], "cr": [0, -3]}],
56      [2.2, {"jy": -6, "rot": 180, "cl": [0, -3], "cr": [0, -3]}],
57      [2.25, {"jy": -6, "rot": 225, "cl": [0, -3], "cr": [0, -3]}],
58      [2.35, {"jy": -6, "rot": 270, "cl": [0, -3], "cr": [0, -3]}],
59      [2.4, {"jy": -6, "rot": 315, "cl": [0, -3], "cr": [0, -3]}],
60      [2.45, {"jy": -6, "cl": [0, -3], "cr": [0, -3]}],
61      [2.5, {"jy": -6, "rot": 8, "cl": [0, -2], "cr": [0, -2]}],
62      [2.75, {"jy": -5, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
63      [2.95, {"jy": -4, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
64      [3, {"jy": -4, "rot": 8, "cl": [0, -2], "cr": [0, -2]}],
65      [3.1, {"jy": -3, "rot": 8, "cl": [0, -2], "cr": [0, -2]}],
66      [3.25, {"jy": -3, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
67      [3.3, {"jy": -2, "rot": -8, "cl": [0, -2], "cr": [0, -2]}],
68      [3.5, {"jy": -2, "rot": 8, "cr": [0, -3]}],
69      [3.7, {"jy": -1, "rot": 8, "cr": [0, -3]}],
70      [3.75, {"jy": -1, "rot": -8, "cl": [0, -3]}],
71      [3.85, {"rot": -8, "cl": [0, -3]}]
72    ]
73  },
74  "heavy": {
75    "origin": [15, 17],
76    "box": [-3, -5, 34, 34],
77    "keys": [
78      [0, {"cl": [0, -3]}],
79      [0.25, {"jx": 1, "rot": 6, "cr": [0, -3]}],
80      [0.5, {"jx": 2, "rot": 6, "cl": [0, -3]}],
81      [0.75, {"jx": 1, "cr": [0, -3]}],
82      [1, {"b": 1, "cl": [0, -3], "cr": [0, -1]}],
83      [1.15, {"cl": [0, -1], "cr": [0, -3]}],
84      [1.25, {"b": 1, "cl": [0, -3], "cr": [0, -1]}],
85      [1.4, {"cl": [0, -1], "cr": [0, -3]}],
86      [1.5, {"jx": -1, "rot": -6, "cl": [0, -3]}],
87      [1.75, {"jx": -2, "rot": -6, "cr": [0, -3]}],
88      [2, {"jx": -1}],
89      [2.15, {"jx": -1, "jy": -3, "cl": [0, -5], "cr": [0, -5], "la": -1, "lb": -1}],
90      [2.25, {"jx": 1, "jy": -3, "cl": [0, -5], "cr": [0, -5], "la": -1, "lb": -1}],
91      [2.45, {"rot": -10, "b": 1}],
92      [2.6, {"rot": 10, "b": 1}],
93      [2.85, {"rot": -10, "b": 1}],
94      [3, {"rot": 10, "b": 1}],
95      [3.2, {"cl": [0, -3]}],
96      [3.45, {"jx": 1, "rot": 6, "cr": [0, -3]}],
97      [3.7, {"jx": 2, "rot": 6, "cl": [0, -3]}],
98      [3.95, {"jx": 1, "cr": [0, -3]}]
99    ]
100  },
101  "careful": {
102    "origin": [15, 26],
103    "box": [-4, -3, 41, 32],
104    "keys": [
105      [0, {"b": 1}],
106      [0.05, {"rot": 1.6, "b": 1}],
107      [0.1, {"rot": 3.1, "b": 1, "cr": [0, -1]}],
108      [0.15, {"jx": 1, "rot": 4.5, "b": 1, "cr": [0, -1]}],
109      [0.2, {"jx": 1, "rot": 5.9, "b": 1, "cr": [0, -2]}],
110      [0.25, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
111      [0.3, {"jx": 1, "rot": 8.1, "cr": [0, -2]}],
112      [0.35, {"jx": 1, "rot": 8.9, "cr": [0, -3]}],
113      [0.4, {"jx": 1, "rot": 9.5, "cr": [0, -3]}],
114      [0.45, {"jx": 1, "rot": 9.9, "cr": [0, -3]}],
115      [0.5, {"jx": 2, "rot": 10, "b": 1, "cr": [0, -3]}],
116      [0.55, {"jx": 1, "rot": 9.9, "b": 1, "cr": [0, -3]}],
117      [0.6, {"jx": 1, "rot": 9.5, "b": 1, "cr": [0, -3]}],
118      [0.65, {"jx": 1, "rot": 8.9, "b": 1, "cr": [0, -3]}],
119      [0.7, {"jx": 1, "rot": 8.1, "b": 1, "cr": [0, -2]}],
120      [0.75, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
121      [0.8, {"jx": 1, "rot": 5.9, "cr": [0, -2]}],
122      [0.85, {"jx": 1, "rot": 4.5, "cr": [0, -1]}],
123      [0.9, {"rot": 3.1, "cr": [0, -1]}],
124      [0.95, {"rot": 1.6}],
125      [1, {"b": 1}],
126      [1.05, {"rot": -1.6, "b": 1}],
127      [1.1, {"rot": -3.1, "b": 1, "cl": [0, -1]}],
128      [1.15, {"jx": -1, "rot": -4.5, "b": 1, "cl": [0, -1]}],
129      [1.2, {"jx": -1, "rot": -5.9, "b": 1, "cl": [0, -2]}],
130      [1.25, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
131      [1.3, {"jx": -1, "rot": -8.1, "cl": [0, -2]}],
132      [1.35, {"jx": -1, "rot": -8.9, "cl": [0, -3]}],
133      [1.4, {"jx": -1, "rot": -9.5, "cl": [0, -3]}],
134      [1.45, {"jx": -1, "rot": -9.9, "cl": [0, -3]}],
135      [1.5, {"jx": -2, "rot": -10, "b": 1, "cl": [0, -3]}],
136      [1.55, {"jx": -1, "rot": -9.9, "b": 1, "cl": [0, -3]}],
137      [1.6, {"jx": -1, "rot": -9.5, "b": 1, "cl": [0, -3]}],
138      [1.65, {"jx": -1, "rot": -8.9, "b": 1, "cl": [0, -3]}],
139      [1.7, {"jx": -1, "rot": -8.1, "b": 1, "cl": [0, -2]}],
140      [1.75, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
141      [1.8, {"jx": -1, "rot": -5.9, "cl": [0, -2]}],
142      [1.85, {"jx": -1, "rot": -4.5, "cl": [0, -1]}],
143      [1.9, {"rot": -3.1, "cl": [0, -1]}],
144      [1.95, {"rot": -1.6}],
145      [2, {"b": 1}],
146      [2.05, {"rot": 1.6, "b": 1}],
147      [2.1, {"rot": 3.1, "b": 1, "cr": [0, -1]}],
148      [2.15, {"jx": 1, "rot": 4.5, "b": 1, "cr": [0, -1]}],
149      [2.2, {"jx": 1, "rot": 5.9, "b": 1, "cr": [0, -2]}],
150      [2.25, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
151      [2.3, {"jx": 1, "rot": 8.1, "cr": [0, -2]}],
152      [2.35, {"jx": 1, "rot": 8.9, "cr": [0, -3]}],
153      [2.4, {"jx": 1, "rot": 9.5, "cr": [0, -3]}],
154      [2.45, {"jx": 1, "rot": 9.9, "cr": [0, -3]}],
155      [2.5, {"jx": 2, "rot": 10, "b": 1, "cr": [0, -3]}],
156      [2.55, {"jx": 1, "rot": 9.9, "b": 1, "cr": [0, -3]}],
157      [2.6, {"jx": 1, "rot": 9.5, "b": 1, "cr": [0, -3]}],
158      [2.65, {"jx": 1, "rot": 8.9, "b": 1, "cr": [0, -3]}],
159      [2.7, {"jx": 1, "rot": 8.1, "b": 1, "cr": [0, -2]}],
160      [2.75, {"jx": 1, "rot": 7.1, "cr": [0, -2]}],
161      [2.8, {"jx": 1, "rot": 5.9, "cr": [0, -2]}],
162      [2.85, {"jx": 1, "rot": 4.5, "cr": [0, -1]}],
163      [2.9, {"rot": 3.1, "cr": [0, -1]}],
164      [2.95, {"rot": 1.6}],
165      [3, {"b": 1}],
166      [3.05, {"rot": -1.6, "b": 1}],
167      [3.1, {"rot": -3.1, "b": 1, "cl": [0, -1]}],
168      [3.15, {"jx": -1, "rot": -4.5, "b": 1, "cl": [0, -1]}],
169      [3.2, {"jx": -1, "rot": -5.9, "b": 1, "cl": [0, -2]}],
170      [3.25, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
171      [3.3, {"jx": -1, "rot": -8.1, "cl": [0, -2]}],
172      [3.35, {"jx": -1, "rot": -8.9, "cl": [0, -3]}],
173      [3.4, {"jx": -1, "rot": -9.5, "cl": [0, -3]}],
174      [3.45, {"jx": -1, "rot": -9.9, "cl": [0, -3]}],
175      [3.5, {"jx": -2, "rot": -10, "b": 1, "cl": [0, -3]}],
176      [3.55, {"jx": -1, "rot": -9.9, "b": 1, "cl": [0, -3]}],
177      [3.6, {"jx": -1, "rot": -9.5, "b": 1, "cl": [0, -3]}],
178      [3.65, {"jx": -1, "rot": -8.9, "b": 1, "cl": [0, -3]}],
179      [3.7, {"jx": -1, "rot": -8.1, "b": 1, "cl": [0, -2]}],
180      [3.75, {"jx": -1, "rot": -7.1, "cl": [0, -2]}],
181      [3.8, {"jx": -1, "rot": -5.9, "cl": [0, -2]}],
182      [3.85, {"jx": -1, "rot": -4.5, "cl": [0, -1]}],
183      [3.9, {"rot": -3.1, "cl": [0, -1]}],
184      [3.95, {"rot": -1.6}]
185    ]
186  },
187  "doll": {
188    "origin": [15, 17],
189    "box": [-4, -4, 37, 33],
190    "keys": [
191      [0, {"jx": 1, "rot": 6, "cr": [1, -6]}],
192      [0.25, {"rot": -4, "b": 1, "cr": [-3, 1]}],
193      [0.5, {"jx": 1, "rot": 6, "cr": [1, -6]}],
194      [0.75, {"rot": -4, "b": 1, "cr": [-3, 1]}],
195      [1, {"jx": -1, "rot": -6, "cl": [-1, -6]}],
196      [1.25, {"rot": 4, "b": 1, "cl": [3, 1]}],
197      [1.5, {"jx": -1, "rot": -6, "cl": [-1, -6]}],
198      [1.75, {"rot": 4, "b": 1, "cl": [3, 1]}],
199      [2, {"rot": -12, "cl": [0, -2], "cr": [0, -2]}],
200      [2.15, {"rot": 10, "cl": [0, -3], "cr": [0, -3]}],
201      [2.3, {"b": 1}],
202      [2.5, {"jy": -1, "mirror": true, "cl": [0, -2], "cr": [0, -2]}],
203      [2.65, {"jy": -2, "cl": [0, -3], "cr": [0, -3]}],
204      [2.75, {"jy": -1, "mirror": true, "cl": [0, -2], "cr": [0, -2]}],
205      [2.9, {"b": 1}],
206      [3, {"cl": [-1, -5], "cr": [1, -5]}],
207      [3.5, {"b": 1, "cl": [-1, -5], "cr": [1, -5]}],
208      [3.65, {"cl": [-1, -5], "cr": [1, -5]}],
209      [3.75, {"b": 1, "cl": [-1, -5], "cr": [1, -5]}],
210      [3.9, {"cl": [-1, -5], "cr": [1, -5]}]
211    ]
212  },
213  "medium": {
214    "origin": [15, 17],
215    "box": [-1, -4, 35, 31],
216    "keys": [
217      [0, {"jx": 1, "jy": -1, "rot": 8, "la": -3}],
218      [0.25, {"jx": 1, "b": 1}],
219      [0.5, {"jx": 2, "jy": -1, "rot": 8, "lb": -3}],
220      [0.75, {"jx": 2, "b": 1}],
221      [1, {"jx": -1, "jy": -1, "rot": -8, "lb": -3}],
222      [1.25, {"jx": -1, "b": 1}],
223      [1.5, {"jx": -2, "jy": -1, "rot": -8, "la": -3}],
224      [1.75, {"jx": -2, "b": 1}],
225      [2, {"jy": -3, "cl": [1, -4], "cr": [-1, -4], "la": -1, "lb": -1}],
226      [2.25, {"b": 1}],
227      [2.5, {"jy": -3, "cl": [1, -4], "cr": [-1, -4], "la": -1, "lb": -1}],
228      [2.75, {"b": 1}],
229      [3, {"jy": -1, "rot": 8, "mirror": true, "la": -3}],
230      [3.25, {"mirror": true, "b": 1}],
231      [3.5, {"jy": -1, "rot": -8, "lb": -3}],
232      [3.75, {"b": 1}]
233    ]
234  },
235  "light": {
236    "origin": [15, 17],
237    "box": [-3, -5, 35, 34],
238    "keys": [
239      [0, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
240      [0.15, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
241      [0.25, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
242      [0.4, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}],
243      [0.5, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
244      [0.65, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
245      [0.75, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
246      [0.9, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}],
247      [1, {"b": 2, "cl": [0, 1], "cr": [0, 1]}],
248      [1.25, {"jy": -4, "rot": 315, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
249      [1.35, {"jy": -4, "rot": 270, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
250      [1.4, {"jy": -6, "rot": 225, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
251      [1.45, {"jy": -6, "rot": 180, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
252      [1.5, {"jy": -6, "rot": 135, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
253      [1.6, {"jy": -6, "rot": 90, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
254      [1.65, {"jy": -4, "rot": 45, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
255      [1.7, {"jy": -4, "cl": [0, -1], "cr": [0, -1], "la": -2, "lb": -2}],
256      [1.75, {"b": 2, "cl": [0, 1], "cr": [0, 1]}],
257      [2, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
258      [2.15, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
259      [2.25, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
260      [2.4, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
261      [2.5, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
262      [2.65, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
263      [2.75, {"jx": -1, "rot": -8, "cl": [0, -2], "cr": [0, -2], "lb": -3}],
264      [2.9, {"jx": 1, "rot": 8, "cl": [0, -2], "cr": [0, -2], "la": -3}],
265      [3, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
266      [3.15, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
267      [3.25, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
268      [3.4, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}],
269      [3.5, {"rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
270      [3.65, {"jy": -1, "rot": -5, "cl": [-2, -1], "cr": [0, -1]}],
271      [3.75, {"rot": 5, "cl": [0, -1], "cr": [2, -1]}],
272      [3.9, {"jy": -1, "rot": 5, "cl": [0, -1], "cr": [2, -1]}]
273    ]
274  },
275  "haiku": {
276    "origin": [15, 26],
277    "box": [-6, 2, 39, 28],
278    "keys": [
279      [0, {"rot": -20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
280      [0.1, {"rot": -15, "cl": [-1, -1], "cr": [0, -5]}],
281      [0.2, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
282      [0.35, {"rot": -5, "cl": [-1, -1], "cr": [0, -5]}],
283      [0.4, {"b": 1, "cl": [-1, -1], "cr": [0, -5]}],
284      [0.5, {"rot": 5, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
285      [0.55, {"rot": 5, "cl": [-1, -1], "cr": [0, -5]}],
286      [0.65, {"rot": 10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
287      [0.75, {"rot": 15, "cl": [-1, -1], "cr": [0, -5]}],
288      [0.8, {"rot": 20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
289      [0.9, {"rot": 10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
290      [0.95, {"rot": 10, "cl": [-1, -1], "cr": [0, -5]}],
291      [1, {"cl": [-1, -1], "cr": [0, -5]}],
292      [1.05, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
293      [1.1, {"rot": -10, "cl": [-1, -1], "cr": [0, -5]}],
294      [1.15, {"rot": -20, "cl": [-1, -1], "cr": [0, -5]}],
295      [1.2, {"rot": -20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
296      [1.3, {"rot": -15, "cl": [-1, -1], "cr": [0, -5]}],
297      [1.45, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
298      [1.5, {"rot": -5, "cl": [-1, -1], "cr": [0, -5]}],
299      [1.6, {"b": 1, "cl": [-1, -1], "cr": [0, -5]}],
300      [1.7, {"rot": 5, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
301      [1.75, {"rot": 5, "cl": [-1, -1], "cr": [0, -5]}],
302      [1.8, {"rot": 10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
303      [1.95, {"rot": 15, "cl": [-1, -1], "cr": [0, -5]}],
304      [2, {"rot": 20, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
305      [2.1, {"rot": 10, "cl": [-1, -1], "cr": [0, -5]}],
306      [2.2, {"b": 1, "cl": [-1, -1], "cr": [0, -5]}],
307      [2.25, {"rot": -10, "b": 1, "cl": [-1, -1], "cr": [0, -5]}],
308      [2.35, {"rot": -20, "cl": [-1, -1], "cr": [0, -5]}],
309      [2.4, {"jy": -2, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
310      [2.6, {"cl": [0, -3], "cr": [0, -3]}],
311      [2.85, {"jy": -2, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
312      [3, {"cl": [0, -3], "cr": [0, -3]}],
313      [3.2, {"b": 1, "cl": [0, -2]}],
314      [3.25, {}],
315      [3.5, {"b": 1, "cl": [0, -2]}],
316      [3.75, {}]
317    ]
318  },
319  "sonnet": {
320    "origin": [15, 16],
321    "box": [-1, -3, 30, 35],
322    "keys": [
323      [0, {"jx": -1, "cl": [0, -3], "la": -1}],
324      [0.25, {"jy": 1, "cr": [0, -3], "lb": -1}],
325      [0.5, {"jx": 1, "cl": [0, -3], "la": -1}],
326      [0.75, {"jy": 1, "cr": [0, -3], "lb": -1}],
327      [1, {"jx": -1, "cl": [0, -3], "la": -1}],
328      [1.25, {"jy": 1, "cr": [0, -3], "lb": -1}],
329      [1.5, {"jy": 1, "rot": 45}],
330      [1.55, {"jy": 1, "rot": 90}],
331      [1.6, {"jy": 1, "rot": 135}],
332      [1.65, {"jy": 1, "rot": 180, "la": -1, "lb": -1}],
333      [1.75625, {"jy": 1, "rot": 225, "la": -1, "lb": -1}],
334      [1.8625, {"jy": 1, "rot": 270, "la": -1, "lb": -1}],
335      [1.96875, {"jy": 1, "rot": 315, "la": -1, "lb": -1}],
336      [2.075, {"jy": 1, "rot": 360, "la": -1, "lb": -1}],
337      [2.18125, {"jy": 1, "rot": 405, "la": -1, "lb": -1}],
338      [2.2875, {"jy": 1, "rot": 450, "la": -1, "lb": -1}],
339      [2.39375, {"jy": 1, "rot": 495, "la": -1, "lb": -1}],
340      [2.5, {"jy": 2, "rot": 180, "cr": [0, -4]}],
341      [2.56, {"jy": 2, "rot": 135, "cr": [0, -4]}],
342      [3.5, {"jy": 1, "rot": 90}],
343      [3.55, {"jy": 1, "rot": 45}],
344      [3.6, {"jy": 1}],
345      [3.65, {"jy": -2}],
346      [3.7, {"jy": -3}],
347      [3.8, {"jy": -2}],
348      [3.85, {"jy": -1}],
349      [3.9, {}]
350    ]
351  },
352  "explore": {
353    "origin": [15, 17],
354    "box": [-4, 1, 38, 27],
355    "keys": [
356      [0, {"jx": 3, "rot": -8, "cr": [0, -2], "lb": -1}],
357      [0.2, {"jx": 2, "rot": -8, "cr": [0, -2], "lb": -1}],
358      [0.25, {"jx": 2, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
359      [0.5, {"jx": 1, "rot": -8, "cr": [0, -2], "lb": -1}],
360      [0.75, {"jx": 1, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
361      [0.8, {"rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
362      [1, {"rot": -8, "cr": [0, -2], "lb": -1}],
363      [1.1, {"jx": -1, "rot": -8, "cr": [0, -2], "lb": -1}],
364      [1.25, {"jx": -1, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
365      [1.4, {"jx": -2, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
366      [1.5, {"jx": -2, "rot": -8, "cr": [0, -2], "lb": -1}],
367      [1.7, {"jx": -3, "rot": -8, "cr": [0, -2], "lb": -1}],
368      [1.75, {"jx": -3, "rot": -8, "b": 1, "cl": [0, -2], "la": -1}],
369      [1.85, {"jx": -3, "jy": -3, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
370      [2, {"jx": -3, "jy": -3, "mirror": true, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
371      [2.15, {"jx": -3, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
372      [2.25, {"jx": -3, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
373      [2.35, {"jx": -2, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
374      [2.5, {"jx": -2, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
375      [2.65, {"jx": -1, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
376      [2.75, {"jx": -1, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
377      [2.95, {"rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
378      [3, {"rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
379      [3.25, {"jx": 1, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
380      [3.5, {"jx": 1, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
381      [3.55, {"jx": 2, "rot": 8, "mirror": true, "cr": [0, -2], "lb": -1}],
382      [3.75, {"jx": 2, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}],
383      [3.85, {"jx": 3, "rot": 8, "mirror": true, "b": 1, "cl": [0, -2], "la": -1}]
384    ]
385  },
386  "other": {
387    "origin": [15, 17],
388    "box": [-1, 4, 32, 25],
389    "keys": [
390      [0, {"jx": -2, "rot": -10, "cl": [0, -3], "la": -1}],
391      [0.25, {"jx": -2, "rot": -10, "b": 1, "cl": [0, -3], "lb": -1}],
392      [0.5, {"cl": [0, -2], "cr": [0, -2], "la": -1}],
393      [0.75, {"b": 1, "lb": -1}],
394      [1, {"jx": 2, "rot": 10, "cr": [0, -3], "la": -1}],
395      [1.25, {"jx": 2, "rot": 10, "b": 1, "cr": [0, -3], "lb": -1}],
396      [1.5, {"cl": [0, -2], "cr": [0, -2], "la": -1}],
397      [1.75, {"b": 1, "lb": -1}],
398      [2, {"cl": [1, -4], "cr": [-1, -4]}],
399      [2.15, {"cl": [0, -2], "cr": [0, -2]}],
400      [2.25, {"cl": [1, -4], "cr": [-1, -4]}],
401      [2.4, {"cl": [0, -2], "cr": [0, -2]}],
402      [2.5, {"b": 2}],
403      [2.75, {"jy": -3, "cl": [0, -5], "cr": [0, -5], "la": -1, "lb": -1}],
404      [3, {"jy": -4, "cl": [0, -5], "cr": [0, -5], "la": -2, "lb": -2}],
405      [3.25, {"jy": -2, "cl": [0, -3], "cr": [0, -3], "la": -1, "lb": -1}],
406      [3.5, {"b": 2}],
407      [3.75, {"b": 1}]
408    ]
409  }
410}
411// MOVES:END
412
413const keyOf = (costume: string): string => (MOVES[costume] ? costume : 'other')
414
415// An outfit with no routine of its own (a new savvy tier, a typo) dances the plain crab's.
416export const routineOf = (costume: string): Routine => MOVES[keyOf(costume)] ?? MOVES.other
417
418const GROUPS = ['dn', 'bd', 'cl', 'cr', 'la', 'lb'] as const
419type Group = (typeof GROUPS)[number]
420
421const transformOf = (group: Group, p: Pose): string => {
422  if (group === 'dn') return `translate(${p.jx ?? 0}px,${p.jy ?? 0}px) rotate(${p.rot ?? 0}deg) scale(${p.mirror ? -1 : 1},1)`
423  if (group === 'bd') return `translate(0,${p.b ?? 0}px)`
424  if (group === 'cl' || group === 'cr') {
425    const [dx, dy] = p[group] ?? [0, 0]
426    return `translate(${dx}px,${dy}px)`
427  }
428  return `translateY(${p[group] ?? 0}px)`
429}
430
431// Where the whole crab turns, as a share of the 30 x 28 frame `dn` fills.
432const pivot = ([x, y]: [number, number]): string => `${+((x / 30) * 100).toFixed(3)}% ${+((y / 28) * 100).toFixed(3)}%`
433
434// One outfit's routine as CSS: a keyframe track for each group that moves, held steps on the 4 s
435// loop from the drawing's phase (`--d`). Every name carries the outfit (`mv-<outfit>-<group>`) and
436// every rule its class, so crabs of different outfits never share a rule, even inlined on one page.
437export const routineCss = (costume: string): string => {
438  const outfit = keyOf(costume)
439  const routine = routineOf(outfit)
440  return GROUPS.flatMap(group => {
441    const steps: Array<[number, string]> = []
442    for (const [second, pose] of routine.keys) {
443      const tf = transformOf(group, pose)
444      if (steps[steps.length - 1]?.[1] !== tf) steps.push([second, tf])
445    }
446    if (steps.every(([, tf]) => tf === transformOf(group, {}))) return []
447    const name = `mv-${outfit}-${group}`
448    const origin = group === 'dn' ? `transform-origin:${pivot(routine.origin)};` : ''
449    return [
450      keyframes(name, move(steps)),
451      `.c-${outfit}.run .${group}{${origin}animation:${name} ${DANCE_SECONDS}s steps(1,end) infinite var(--d,0s)}`,
452    ]
453  }).join('\n')
454}
455
hooks/plan.ts 73 lines
1import type { PlanItem, PlanStatus } from '../types'
2
3// The main session's task list, replayed from the calls the model makes to keep it: the desktop
4// app's update_plan (a whole list, or advance / done / start), its wrap_up (ticks everything off),
5// and the terminal's TodoWrite (a whole list). Read only: the calls go on unchanged.
6export const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
7export const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
8export const TODO_TOOL = 'TodoWrite'
9
10const MAX_ITEMS = 50
11const STATUSES: readonly PlanStatus[] = ['pending', 'in_progress', 'completed']
12const statusOf = (value: unknown): PlanStatus =>
13  STATUSES.includes(value as PlanStatus) ? (value as PlanStatus) : 'pending'
14
15type PlanCall = { items?: unknown; advance?: unknown; done?: unknown; start?: unknown }
16
17export const applyPlanCall = (plan: readonly PlanItem[], call: PlanCall): PlanItem[] => {
18  if (Array.isArray(call.items)) {
19    return call.items
20      .flatMap((raw): PlanItem[] => {
21        const item = raw as { content?: unknown; status?: unknown }
22        return typeof item?.content === 'string' ? [{ title: item.content, status: statusOf(item.status) }] : []
23      })
24      .slice(0, MAX_ITEMS)
25  }
26  const next = plan.map(item => ({ ...item }))
27  if (call.advance === true) {
28    const current = next.findIndex(item => item.status === 'in_progress')
29    const finished = next[current]
30    if (finished) finished.status = 'completed'
31    const following = next.find((item, k) => k > current && item.status === 'pending')
32    if (following) following.status = 'in_progress'
33  }
34  if (Array.isArray(call.done)) {
35    for (const n of call.done) {
36      const item = next[Number(n) - 1]
37      if (item) item.status = 'completed'
38    }
39  }
40  if (typeof call.start === 'number') {
41    const item = next[call.start - 1]
42    if (item) item.status = 'in_progress'
43  }
44  return next
45}
46
47export const applyTodos = (todos: unknown): PlanItem[] =>
48  Array.isArray(todos)
49    ? todos
50        .flatMap((raw): PlanItem[] => {
51          const todo = raw as { content?: unknown; status?: unknown; activeForm?: unknown }
52          if (typeof todo?.content !== 'string') return []
53          const status = statusOf(todo.status)
54          const title = status === 'in_progress' && typeof todo.activeForm === 'string' ? todo.activeForm : todo.content
55          return [{ title, status }]
56        })
57        .slice(0, MAX_ITEMS)
58    : []
59
60export const finishAll = (plan: readonly PlanItem[]): PlanItem[] =>
61  plan.map(item => ({ ...item, status: 'completed' }))
62
63export const isFinished = (plan: readonly PlanItem[]): boolean =>
64  plan.length > 0 && plan.every(item => item.status === 'completed')
65
66// What the row says: the step in progress (or the next one), and how many are done.
67export const planSummary = (plan: readonly PlanItem[]): { current?: string; done: number; total: number } => {
68  const done = plan.filter(item => item.status === 'completed').length
69  const current =
70    plan.find(item => item.status === 'in_progress')?.title ?? plan.find(item => item.status === 'pending')?.title
71  return { current, done, total: plan.length }
72}
73
types/index.d.ts 50 lines
1export type RunStatus = 'running' | 'done' | 'failed'
2
3export type Run = {
4  id: string
5  agentId?: string
6  description: string
7  /** The subagent type (Explore, general-purpose, savvy-light...): picks the crab's costume. */
8  type?: string
9  model: string
10  /** The effort its model requests ran at, once one reported it. */
11  effort?: string
12  status: RunStatus
13  startedAt: number
14  endedAt?: number
15  /** The last model request's prompt plus reply: how full the agent's window is. */
16  contextTokens: number
17}
18
19export type PlanStatus = 'pending' | 'in_progress' | 'completed'
20
21export type PlanItem = { title: string; status: PlanStatus }
22
23/** The main session: the row that always leads the band. */
24export type Main = {
25  /** The main loop's model, '' until known. */
26  model: string
27  effort?: string
28  isWorking: boolean
29  /** When the turn in progress (or the last one) began; 0 before the first. */
30  turnStartedAt: number
31  /** Tokens of its last model request, the fallback when no measurement has come. */
32  contextTokens: number
33  /** The engine's own measurement of the context window's fill, when it has one. */
34  contextPercent?: number
35  /** Its task list, as the model last set it. */
36  plan: PlanItem[]
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'crab-band': {
42      runs: Run[]
43      now: number
44      main: Main
45      /** Whether the agents pane is on screen (open, placed and the shown tab): the band steps aside. */
46      paneShown: boolean
47    }
48  }
49}
50