Spotter: a Claude Code dashboard by Steady Matter. It watches the work and counts the steps. A progress bar above the prompt, a row per helper agent with a…

Your Claude Code helpers, as pixel Pals that hatch, grow and get knocked out.
Spotter is a mod for Claude Code. It watches the work and counts the steps: a progress bar for the job, a Pal for every helper agent, and a headline for every reply. It works in the desktop app and in the terminal.

claude plugin install spotter --marketplace Steady-Matter/spotter-pals
Start a new session and it is on. Nothing to set up.
Needs Claude Code 2.1.287 or later (2.1.286 in the desktop app). Built and tested on 2.1.296.
Real screenshots from a live session, with the chat covered up. The side panel is Spotter; the bar above the prompt is Spotter too.

<img src="art/panel-growing.png" width="46%" alt="The panel early in a job: one Pal finished, one still an egg, four growing"> <img src="art/panel-done.png" width="46%" alt="The same panel at the end: five Pals at four stars and one knocked out">
Left: half a minute in, with one helper still an egg. Right: the same job finished.
Tip: close the app's own session panel (the one with the computer, folder, context and connectors) and let Spotter have the side. Spotter shows the context too, plus your limits, the cache and every helper.
When Claude sends a helper agent off to do something, Spotter gives it a Pal picked by the job:
| Pal | Job | Shows up when a helper is… |
|---|---|---|
| Vixy | scouting | searching, reading, exploring the code |
| Depresso | checking | reviewing, testing, auditing |
| Anubis | building | writing code, fixing, wiring things up |
| Lifmunk | writing | drafting docs, notes and design |
| Hoocrates | planning | planning, scoping, architecture |
| Foxparks | quick job | renames, tidying, anything fast |


On any job of three or more steps Claude reports its plan, and Spotter draws it above the prompt: the job, the step under way, how many are left, and how long it took at the end. Helpers can report their own steps too, and each one gets its own count.
No side panel needed. Helpers show up as lines above the prompt while they run, then clear:
● Ship the release Running the tests ████████░░░░░░░░ Steps 3/6 50% 🐺 🐾×3 ✕ ◐ 🦊 ★★ Map the call sites ▰▰▰▰▱▱ 3/4 · Reading hooks Sonnet 5.5 0:42 ◐ 🐨 ★ Review the parser ▰▰▱▱▱▱ 1/4 · Running tests Sonnet 5.5 0:31 ◐ 🥚 Draft the release note ▱▱▱▱▱▱ 0/3 · Starting Haiku 5.5 0:04
Each Pal has an emoji there: 🥚 for the egg, ✨ for Lucky, 💀 for knocked out. /spotter opens the full panel when you want it.
/spotter open the panel /spotter close close it /spotter reset clear its helpers and counters
Two settings, in /config: openOnStart (open the panel by itself in the desktop app) and motion (set it to off to keep the Pals still).
progress, to report steps. That is how the bar gets its numbers, and it costs a few tokens per update.Spotter began as a fork of Flightdeck by Stephen Casella (MIT, see LICENSE). The progress bar's look is adapted from savvy-progress by johnnyvizz (MIT).
The Pals are unofficial fan art: pixel drawings made for this mod of creatures from Palworld. Palworld and its Pals belong to Pocketpair, Inc. Spotter is not affiliated with or endorsed by Pocketpair.
Made by Steady Matter. MIT licence.
hooks/register.tsx 760 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { AgentCard, Main, Plan, Receipt, Turn, View } from '../types'
5import { ACCENT, BAR_HEIGHT, DONE, barSvg } from './bar'
6import {
7 C,
8 PALS,
9 DEFAULT_MAIN,
10 DEFAULT_TURN,
11 DEFAULT_VIEW,
12 EDIT_TOOLS,
13 afterCall,
14 agentRatio,
15 agentSteps,
16 applyStep,
17 cacheLabel,
18 cacheTtlOf,
19 cardTitle,
20 palEmoji,
21 palLook,
22 palOf,
23 palWords,
24 describeInput,
25 fmtDuration,
26 fmtTimer,
27 fmtUsd,
28 kTokens,
29 lanes,
30 limitLabel,
31 listOf,
32 mergeAgentSteps,
33 mergePlan,
34 normalize,
35 normalizeCard,
36 normalizePlan,
37 normalizeReceipt,
38 noteTool,
39 parseConfig,
40 planBar,
41 planHype,
42 planLabel,
43 planLook,
44 planRatio,
45 plural,
46 prettyModel,
47 receiptHeadline,
48 receiptOf,
49 shorten,
50 titleLines,
51} from './core'
52import type { Config } from './core'
53import { PAL_GRID, palSvg } from './pals'
54
55const PANE = 'spotter'
56const TITLE = 'Spotter'
57const PANE_COLUMNS = 48
58const PROGRESS_TOOL = 'mcp__spotter__progress'
59
60// ---------------------------------------------------------------- state
61
62const main = atom({ plugin: 'spotter', key: 'main' } as const, DEFAULT_MAIN)
63const agents = atom({ plugin: 'spotter', key: 'agents' } as const, [])
64const turn = atom({ plugin: 'spotter', key: 'turn' } as const, DEFAULT_TURN)
65const receipt = atom({ plugin: 'spotter', key: 'receipt' } as const, null)
66const view = atom({ plugin: 'spotter', key: 'view' } as const, DEFAULT_VIEW)
67const plan = atom({ plugin: 'spotter', key: 'plan' } as const, null)
68
69// Every read goes through these, so a value with a field missing still reads.
70async function getMain($: EngineInterface): Promise<Main> {
71 return normalize(DEFAULT_MAIN, await read($, main))
72}
73async function getCards($: EngineInterface): Promise<AgentCard[]> {
74 return listOf<unknown>(await read($, agents)).map(normalizeCard)
75}
76async function getTurn($: EngineInterface): Promise<Turn> {
77 return normalize(DEFAULT_TURN, await read($, turn))
78}
79async function getView($: EngineInterface): Promise<View> {
80 return normalize(DEFAULT_VIEW, await read($, view))
81}
82async function getPlan($: EngineInterface): Promise<Plan | null> {
83 return normalizePlan(await read($, plan))
84}
85async function getReceipt($: EngineInterface): Promise<Receipt | null> {
86 return normalizeReceipt(await read($, receipt))
87}
88
89async function openPane($: EngineInterface) {
90 // columns apply when docked beside the transcript, rows when seated inline above the prompt.
91 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
92}
93
94async function resetAll($: EngineInterface) {
95 await update($, main, () => DEFAULT_MAIN)
96 await update($, agents, () => [])
97 await update($, turn, () => DEFAULT_TURN)
98 await update($, receipt, () => null)
99 await update($, view, () => DEFAULT_VIEW)
100 await update($, plan, () => null)
101}
102
103/** One helper's card changed in place; a no-op when there is no card with that id. */
104async function updateCard($: EngineInterface, id: string, change: (c: AgentCard) => AgentCard) {
105 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? change(c) : c)))
106}
107
108/** The session's cost read fresh: the receipt subtracts two of these. */
109async function costNow($: EngineInterface): Promise<number | null> {
110 const u = await $.session.usage().catch(() => null)
111 return u?.cost?.usd ?? null
112}
113
114const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.good : C.agent)
115/**
116 * A clock for one surface: it ticks by itself where the surface runs client modules, and is a
117 * fixed reading where it does not. A start time of 0 is unknown: no clock, not decades.
118 */
119const clockOf = (els: ReturnType<EngineInterface['ui']['resolve']>, now: number) => (key: string, since: number, endAt: number | null, color: string) =>
120 since <= 0 ? (
121 <els.Text color={color}>—</els.Text>
122 ) : 'Client' in els ? (
123 <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
124 ) : (
125 <els.Text color={color}>{fmtTimer((endAt ?? now) - since)}</els.Text>
126 )
127
128const toneColor = (tone: 'good' | 'bad' | 'plain') => (tone === 'good' ? C.good : tone === 'bad' ? C.warn : C.text)
129/** The last reply's headline with its tick or cross in front. */
130const headlineText = (r: Receipt) => `${r.reason === 'answer' ? '✓' : '✗'} ${receiptHeadline(r).text}`
131const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
132
133// ---------------------------------------------------------------- hooks
134
135export const register: Register = (on, options) => {
136 const cfg = parseConfig(options)
137
138 on('session.start', async ($, e, next) => {
139 await $.command.register({
140 name: 'spotter',
141 description: 'Spotter, the live agent dashboard: open, close or reset',
142 argumentHint: '[open|close|reset]',
143 })
144 // The bar above the prompt draws what Claude reports here. Listed from the start, not behind
145 // tool search: a tool Claude has to look up first is one it never reaches for.
146 await $.tool
147 .register({
148 name: 'progress',
149 description:
150 'Shows the person a progress bar above the prompt. Use it when a job has 3 or more distinct steps: ' +
151 'once at the start with `title` (2 to 4 words) and `total` (the number of steps), again with `done` each time a step is finished, ' +
152 'and once with `finished: true` when the job is over. `step` names the step now under way. Fields left out keep their value. ' +
153 'Skip it for short jobs. A helper agent with 3 or more steps of its own may call it the same way: its count shows on its own card. ' +
154 'It costs a round trip when sent alone, so send it beside other tool calls: the start beside the first step\'s first call, ' +
155 'a finished step beside the next step\'s first call, and `finished: true` beside the last step\'s last call.',
156 inputSchema: {
157 type: 'object',
158 properties: {
159 title: { type: 'string', description: 'Short name of the job, 2 to 4 words.' },
160 total: { type: 'integer', minimum: 0, description: 'How many steps the job has.' },
161 done: { type: 'integer', minimum: 0, description: 'How many steps are finished.' },
162 step: { type: 'string', description: 'The step now under way, 2 to 5 words.' },
163 finished: { type: 'boolean', description: 'True once the whole job is over.' },
164 },
165 },
166 isDeferred: false,
167 })
168 .catch(() => undefined)
169 // The terminal gets the strip above the prompt; its side panel opens on /spotter only.
170 // A new chat in the app often has nothing attached yet: opening then printed a no-surface
171 // line in the chat, so the panel waits for the app to attach (session.attach below).
172 if (cfg.openOnStart && e.surface !== 'terminal' && (await $.session.surfaces()).length > 0) void openPane($).catch(() => undefined)
173 return next(e)
174 })
175
176 on('session.attach', async ($, e, next) => {
177 if (cfg.openOnStart && e.surface !== 'terminal') void openPane($).catch(() => undefined)
178 return next(e)
179 })
180
181 on('session.end', async ($, e, next) => {
182 if (e.reason === 'clear') {
183 await resetAll($)
184 }
185 return next(e)
186 })
187
188 on('command.run', { command: 'spotter' }, async ($, e) => {
189 const verb = e.args.trim().split(/\s+/)[0] || 'open'
190 if (verb === 'close') {
191 await $.ui.close({ id: PANE })
192 return { text: 'Spotter closed.' }
193 }
194 if (verb === 'reset') {
195 await resetAll($)
196 return { text: 'Spotter reset.' }
197 }
198 const opened = await openPane($)
199 if (!opened.isPlaced) return { text: `Spotter is not shown yet: ${opened.reason}` }
200 return { text: 'Spotter opened. Click an agent for its details, or focus the pane with ctrl+x tab: 1-6 open agents.' }
201 })
202
203 on('turn.start', async ($, e, next) => {
204 const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
205 await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
206 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
207 // A finished bar stays up until the next turn starts.
208 if ((await getPlan($))?.isFinished) await update($, plan, () => null)
209 return next(e)
210 })
211
212 on('turn.step', async function* ($, e, next) {
213 if (!e.agentId) {
214 const result = yield* next(e)
215 // Each request renews the prompt cache: the session box counts down from here.
216 const now = await $.clock.now()
217 await update($, main, m => {
218 const x = normalize(DEFAULT_MAIN, m)
219 return { ...x, lastRequestAt: now, cacheTtlMs: cacheTtlOf(result.usage, x.cacheTtlMs) }
220 })
221 return result
222 }
223 const result = yield* next(e)
224 await updateCard($, e.agentId, c => applyStep(c, { model: e.model, usage: result.usage, stopReason: result.stopReason }))
225 return result
226 })
227
228 // The bar's own tool only draws: allowed without asking.
229 on('tool.check', async (_$, e, next) => (String(e.tool) === PROGRESS_TOOL ? { decision: 'allow' } : next(e)))
230
231 on('tool.call', async ($, e, next) => {
232 // The bar's own tool: answered here, and kept off the cards.
233 if (String(e.tool) === PROGRESS_TOOL) {
234 // A helper agent's call counts on its own card; the bar is the main session's.
235 if (e.agentId) {
236 await updateCard($, e.agentId, c => mergeAgentSteps(c, e))
237 return { result: 'ok' }
238 }
239 const p = mergePlan(await getPlan($), e, await $.clock.now())
240 await update($, plan, () => p)
241 return { result: `ok: ${planLabel(p)}` }
242 }
243 const ran = await next(e)
244 // Starting an agent and its hand-back are not work to count.
245 if (String(e.tool) === 'SubagentHandback' || e.tool === 'Agent') return ran
246 const didRun = ran.deny === undefined
247 const hasFailed = didRun && ran.isError === true
248 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
249 if (isEdit || (hasFailed && !e.agentId)) {
250 await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
251 }
252 if (e.agentId) {
253 const text = shorten(describeInput(e.tool, e), 80)
254 await updateCard($, e.agentId, c => noteTool(c, { text, isError: hasFailed || ran.deny !== undefined }))
255 }
256 return ran
257 })
258
259 on('agent.spawn', async ($, e, next) => {
260 const started = await next(e)
261 if (started.deny !== undefined || !started.agentId) return started
262 const card: AgentCard = {
263 ...normalizeCard({}),
264 id: started.agentId,
265 type: e.name ?? e.subagentType,
266 model: started.model,
267 description: e.description,
268 spawnedAt: await $.clock.now(),
269 }
270 await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
271 return started
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 const done = await next(e)
276 const id = e.agentId
277 if (!id) {
278 const [t, cards, cost, last] = await Promise.all([getTurn($), getCards($), costNow($), getReceipt($)])
279 const r = receiptOf(t, {
280 durationMs: e.durationMs,
281 agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
282 costNow: cost,
283 reason: e.reason,
284 streakBefore: last?.streak ?? 0,
285 })
286 await update($, receipt, () => r)
287 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
288 return done
289 }
290 const now = await $.clock.now()
291 const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
292 // An agent that finished has finished its own plan, whatever count it last reported.
293 await updateCard($, id, c => ({ ...c, status, endedAt: now, answer: e.answer.trim().slice(0, 3000), stepDone: status === 'done' && c.stepTotal ? c.stepTotal : c.stepDone }))
294 return done
295 })
296
297 // ---------------------------------------------------------------- drawing
298
299 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300 const els = $.ui.resolve(e)
301 const { Box, Text, Button } = els
302 const hasClient = 'Client' in els
303 const [m, cards, t, r, v, now, u] = await Promise.all([getMain($), getCards($), getTurn($), getReceipt($), getView($), $.clock.now(), $.session.usage().catch(() => null)])
304 const W = Math.max(32, e.props.bodyColumns)
305 // Off the terminal the font is not fixed-pitch: ━ ▰ draw about two cells wide, so rows
306 // built from them hold half as many and clip at their box.
307 const wide = (cells: number) => (e.surface === 'terminal' ? cells : Math.floor(cells / 2))
308 const viewed = e.props.view?.agentId ?? null
309 const running = cards.filter(c => c.status === 'running')
310 const hasReceipt = m.isRunning || r !== null
311 // The session box: how full the context is, the plan's limits, and how long the cache has left.
312 const pct = u?.context.percent
313 const limits = u?.rateLimits ?? []
314 const hasSession = pct !== undefined || limits.length > 0 || m.lastRequestAt > 0 || hasReceipt
315
316 const clock = clockOf(els, now)
317
318 // ---- agents: full rows in the desktop app, one line each in the terminal and when seated inline
319 const statusWords = (c: AgentCard) => (c.status === 'done' ? 'finished' : c.status)
320 const expandOnPress = (id: string) => () =>
321 update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
322
323 // Each agent's Pal, picked by its job: a small drawing where the surface draws them (it hops
324 // while the agent works), an emoji in the terminal. Only a drawing big enough to show it grows.
325 const spriteOf = (c: AgentCard, scale: number, grows = false) => {
326 const k = palOf(c)
327 return e.surface !== 'terminal' && 'Svg' in els ? (
328 <els.Svg
329 source={palSvg(k, palLook(c), scale, c.status === 'running', cfg.motion, !grows)}
330 alt={`${palWords(k, palLook(c)).name}${palLook(c).isDown ? '' : `, ${palLook(c).stars} of 4 stars`}`}
331 width={Math.ceil(PAL_GRID.w * scale)}
332 height={Math.ceil(PAL_GRID.h * scale)}
333 />
334 ) : (
335 <Text>{palEmoji(k, palLook(c))}</Text>
336 )
337 }
338 const palLine = (c: AgentCard, withJob: boolean) => {
339 const look = palLook(c)
340 const words = palWords(palOf(c), look)
341 return (
342 <Text wrap="truncate">
343 <Text color={look.isDown ? C.warn : PALS[palOf(c)].color} bold>
344 {words.name}
345 </Text>
346 {withJob && words.job ? <Text dimColor>{` · ${words.job}`}</Text> : null}
347 {!look.isDown && look.stars > 0 ? <Text color={C.amber}>{` ${'★'.repeat(look.stars)}`}</Text> : null}
348 </Text>
349 )
350 }
351 // The agent's own plan as a small gauge when it reports one; how far it has gone otherwise.
352 const stepsRow = (c: AgentCard, w: number) => {
353 const ratio = agentRatio(c)
354 if (ratio === null) {
355 return (
356 <Text dimColor wrap="truncate">
357 {c.steps > 0 ? `${plural(c.steps, 'step')} so far · memory ${kTokens(c.ctx)}` : 'starting…'}
358 </Text>
359 )
360 }
361 const cells = wide(Math.max(4, Math.min(10, w - 12)))
362 const full = Math.round(cells * ratio)
363 return (
364 <Text wrap="truncate">
365 <Text color={PALS[palOf(c)].color}>{'▰'.repeat(full)}</Text>
366 <Text color={C.faint}>{'▱'.repeat(cells - full)}</Text>
367 <Text bold>{` step ${agentSteps(c)}`}</Text>
368 {c.stepNote ? <Text dimColor>{` · ${c.stepNote}`}</Text> : null}
369 </Text>
370 )
371 }
372
373 const isInline = e.props.placement === 'inline'
374 const agentsPanel = (w: number) => {
375 const header = (
376 <Box justifyContent="space-between" width={w}>
377 <Text bold>{`agents · ${running.length} running · ${cards.length} total`}</Text>
378 {w >= 62 ? (
379 <Text color={C.faint}>{e.surface === 'terminal' ? `1-${Math.min(cards.length, 6)} for details` : 'click a name for details'}</Text>
380 ) : null}
381 </Box>
382 )
383 // Where the Pals are drawn (not the terminal) each agent gets a full-width row of its own:
384 // a Pal big enough to see it grow, the whole name, its job and its steps.
385 if (!isInline && e.surface !== 'terminal' && 'Svg' in els) {
386 const shown = cards.slice(-6)
387 return (
388 <Box flexDirection="column" width={w}>
389 {header}
390 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
391 {shown.map((c, i) => {
392 const isViewed = viewed === c.id
393 // The whole name, on two lines when one cannot hold it. The app's font is narrower
394 // than a terminal cell, so a line holds about a third more letters than cells.
395 const room = Math.floor((w - 14) * 1.3)
396 const [first, rest] = titleLines(cardTitle(c), room - 4, room)
397 return (
398 <Box
399 borderStyle={isViewed ? 'double' : 'bold'}
400 borderColor={c.lastStop === 'max_tokens' || palLook(c).isDown ? C.warn : PALS[palOf(c)].color}
401 width={w}
402 paddingX={1}
403 columnGap={2}
404 alignItems="center"
405 >
406 <Box flexShrink={0}>{spriteOf(c, w >= 40 ? 1.6 : 1, true)}</Box>
407 {/* minWidth 0 keeps every line inside the card: the left words shorten so the clock and model stay in. */}
408 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
409 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={first} onPress={expandOnPress(c.id)} />
410 {rest ? <Text wrap="truncate">{rest}</Text> : null}
411 <Box justifyContent="space-between">
412 <Box flexShrink={1} minWidth={0}>{palLine(c, true)}</Box>
413 <Box flexShrink={0}>
414 <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>{`${glyph(c)} `}</Text>
415 {clock(`stack-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
416 </Box>
417 </Box>
418 {/* Its steps on the left, the model it runs on at the right. */}
419 <Box justifyContent="space-between">
420 <Box flexShrink={1} minWidth={0}>{stepsRow({ ...c, stepNote: '' }, w - 14)}</Box>
421 {c.model ? (
422 <Box flexShrink={0} marginLeft={1}>
423 <Text color={C.dim}>{prettyModel(c.model)}</Text>
424 </Box>
425 ) : null}
426 </Box>
427 {c.stepNote && agentRatio(c) !== null ? (
428 <Text dimColor wrap="truncate">
429 {c.stepNote}
430 </Text>
431 ) : null}
432 </Box>
433 </Box>
434 )
435 })}
436 </Box>
437 )
438 }
439 const shown = cards.slice(-6)
440 const nameW = Math.max(12, Math.min(30, w - 40))
441 // Beside the name: the Pal, and from 50 columns the agent's own step count.
442 const stepsW = w >= 50 ? 6 : 0
443 const barW = Math.max(4, w - nameW - 13 - stepsW)
444 const geo = lanes(shown, now, wide(barW))
445 return (
446 <Box flexDirection="column" width={w}>
447 {header}
448 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
449 {shown.map((c, i) => {
450 const gm = geo[i]
451 const isViewed = viewed === c.id
452 return (
453 <Box width={w}>
454 <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
455 <Box flexShrink={0} marginRight={1}>
456 {spriteOf(c, 0.5)}
457 </Box>
458 <Box width={nameW}>
459 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), nameW - 3)} onPress={expandOnPress(c.id)} />
460 </Box>
461 <Box flexGrow={1} overflow="hidden">
462 <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
463 <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
464 <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
465 </Box>
466 {stepsW > 0 ? (
467 <Box width={stepsW} flexShrink={0}>
468 <Text color={PALS[palOf(c)].color} wrap="truncate">
469 {agentSteps(c) ?? ''}
470 </Text>
471 </Box>
472 ) : null}
473 <Box flexShrink={0}>{clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
474 </Box>
475 )
476 })}
477 </Box>
478 )
479 }
480
481 const expandedCard = cards.find(c => c.id === v.expanded)
482 const expandedPanel = (w: number) =>
483 expandedCard ? (
484 <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
485 <Box columnGap={1} alignItems="center">
486 <Box flexShrink={0}>{spriteOf(expandedCard, 0.75)}</Box>
487 {palLine(expandedCard, true)}
488 </Box>
489 <Text bold wrap="wrap">
490 {expandedCard.description || expandedCard.type}
491 </Text>
492 <Text dimColor wrap="truncate">
493 {`${expandedCard.type} agent · ${prettyModel(expandedCard.model)} · ${statusWords(expandedCard)}${expandedCard.spawnedAt > 0 ? ` ${expandedCard.endedAt ? 'in' : 'for'} ${fmtDuration((expandedCard.endedAt ?? now) - expandedCard.spawnedAt)}` : ''} · ${plural(expandedCard.steps, 'step')} so far`}
494 </Text>
495 {agentRatio(expandedCard) !== null ? stepsRow(expandedCard, w - 4) : null}
496 <Text bold>{expandedCard.tools.length >= 12 ? 'What it did (latest 12):' : 'What it did:'}</Text>
497 {expandedCard.tools.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
498 {expandedCard.tools.map(n => (
499 <Text color={n.isError ? C.warn : C.text} wrap="wrap">
500 {`${n.isError ? '✗ failed ·' : '·'} ${n.text}`}
501 </Text>
502 ))}
503 <Text bold>What it reported:</Text>
504 {expandedCard.answer ? null : <Text color={C.faint}>{expandedCard.status === 'running' ? 'still working' : 'nothing'}</Text>}
505 {expandedCard.answer
506 .split('\n')
507 .filter(l => l.trim())
508 .slice(0, 60)
509 .map(l => (
510 <Text dimColor wrap="wrap">
511 {l.trimEnd()}
512 </Text>
513 ))}
514 {expandedCard.answer.length >= 3000 ? <Text color={C.faint}>(cut at 3,000 characters)</Text> : null}
515 <Text color={C.faint}>{e.surface === 'terminal' ? 'press its number again to close' : 'click its name again to close'}</Text>
516 </Box>
517 ) : null
518
519 const row = (label: string, value: RenderChildren) => (
520 <Box>
521 <Box width={e.surface === 'terminal' ? 14 : 10} flexShrink={0}>
522 <Text dimColor>{label}</Text>
523 </Box>
524 {/* minWidth 0 lets the value narrow to the box, so a long one wraps instead of spilling past the border. */}
525 <Box flexGrow={1} flexShrink={1} minWidth={0}>
526 {value}
527 </Box>
528 </Box>
529 )
530 // The reply under way, or the last one: only the counts that are not zero.
531 const helpersNow = t.startedAt > 0 ? cards.filter(c => c.spawnedAt >= t.startedAt).length : 0
532 const counts = (helpers: number, edits: number, errors: number) => {
533 const quiet = [helpers > 0 ? plural(helpers, 'helper') : '', edits > 0 ? plural(edits, 'file change') : ''].filter(Boolean).join(' · ')
534 if (!quiet && errors === 0) return null
535 return row(
536 '',
537 <Text wrap="truncate">
538 <Text dimColor>{quiet}</Text>
539 {errors > 0 ? <Text color={C.warn}>{`${quiet ? ' · ' : ''}${plural(errors, 'error')}`}</Text> : null}
540 </Text>,
541 )
542 }
543 // One box at the top: how full the context is, the plan's limits, how long the cache has
544 // left, and the reply under way or the last one.
545 const sessionPanel = (w: number) => {
546 const cells = wide(10)
547 const full = Math.round((cells * Math.min(100, pct ?? 0)) / 100)
548 const left = u?.context.tokens !== undefined ? Math.max(0, u.context.window - u.context.tokens) : null
549 const cache = cacheLabel(m.lastRequestAt + m.cacheTtlMs - now)
550 const cacheColor = { plain: C.dim, soon: C.amber, cold: C.warn }
551 return (
552 <Box flexDirection="column" borderStyle="round" borderColor={C.dim} paddingX={1} width={w}>
553 {pct !== undefined
554 ? row(
555 'Context',
556 <Text wrap="truncate">
557 <Text color={pct >= 70 ? C.warn : pct >= 40 ? C.amber : C.good}>{'▰'.repeat(full)}</Text>
558 <Text color={C.faint}>{'▱'.repeat(cells - full)}</Text>
559 <Text bold>{` ${Math.round(pct)}% used`}</Text>
560 </Text>,
561 )
562 : null}
563 {pct !== undefined && left !== null && u ? row('', <Text dimColor wrap="truncate">{`${kTokens(left)} left of ${kTokens(u.context.window)}`}</Text>) : null}
564 {limits.map(l =>
565 row(
566 limitLabel(l.kind),
567 <Text color={l.percentUsed >= 80 ? C.warn : l.percentUsed >= 50 ? C.amber : C.text} wrap="truncate">{`${Math.round(l.percentUsed)}% used`}</Text>,
568 ),
569 )}
570 {m.lastRequestAt > 0
571 ? row(
572 'Cache',
573 hasClient ? (
574 <els.Client key="cache-clock" module="./cache.tsx" props={{ expiresAt: m.lastRequestAt + m.cacheTtlMs, now, ...cacheColor }} />
575 ) : (
576 <Text color={cacheColor[cache.tone]} wrap="wrap">
577 {cache.text}
578 </Text>
579 ),
580 )
581 : null}
582 {m.isRunning
583 ? row(
584 'This reply',
585 <Box>
586 <Text color={C.main}>{'◐ working · '}</Text>
587 <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
588 </Box>,
589 )
590 : r
591 ? row('Last reply', <Text wrap="truncate">{`${fmtDuration(r.durationMs)}${r.costDelta !== null ? ` · ${fmtUsd(r.costDelta)}` : ''}`}</Text>)
592 : null}
593 {m.isRunning ? counts(helpersNow, t.edits, t.errors) : r ? counts(r.agents, r.edits, r.errors) : null}
594 {!m.isRunning && r ? (
595 <Text color={toneColor(receiptHeadline(r).tone)} bold wrap="wrap">
596 {headlineText(r)}
597 </Text>
598 ) : null}
599 {!m.isRunning && r && r.streak >= 2 ? <Text color={C.amber}>{`🔥 ${r.streak} clean replies in a row`}</Text> : null}
600 </Box>
601 )
602 }
603
604 // Seated inline above the prompt the pane has 8 rows: the agents' lines alone fill them.
605 if (isInline) {
606 return (
607 <Box flexDirection="column" width={W}>
608 {cards.length > 0 ? agentsPanel(W) : <Text color={C.faint}>No helpers yet.</Text>}
609 </Box>
610 )
611 }
612
613 return (
614 <Box flexDirection="column" width={W}>
615 {hasSession ? sessionPanel(W) : null}
616 {cards.length > 0 ? (
617 agentsPanel(W)
618 ) : (
619 <Text color={C.faint} wrap="wrap">
620 No helpers yet. A Pal hatches here when Claude sends one out.
621 </Text>
622 )}
623 {expandedPanel(W)}
624 </Box>
625 )
626 })
627
628 // Above the prompt: the progress bar from the plan Claude reported, and in the terminal the
629 // strip that stands in for the side panel: a line per helper while any runs, else the last reply.
630 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
631 if (e.props.hasSurvey) return next(e)
632 const isTerminal = e.surface === 'terminal'
633 const [p, cards, m, r, now] = await Promise.all([getPlan($), getCards($), getMain($), getReceipt($), $.clock.now()])
634 const running = cards.filter(c => c.status === 'running')
635 // While any helper runs, every helper of that batch keeps its line, the finished ones ticked.
636 const since = Math.min(...running.map(c => c.spawnedAt))
637 const batch = isTerminal && running.length > 0 ? cards.filter(c => c.status === 'running' || c.spawnedAt >= since) : []
638 const head = isTerminal && batch.length === 0 && !m.isRunning && r ? receiptHeadline(r) : null
639 if (!p && batch.length === 0 && !head) return next(e)
640 // Another mod's band (the radio, say) keeps its row under this one.
641 const beneath = await next(e)
642 const els = $.ui.resolve(e)
643 const { Box, Text, Button } = els
644 const cols = e.props.bodyColumns || 80
645
646 const helperLine = (c: AgentCard) => {
647 const k = palOf(c)
648 const look = palLook(c)
649 const ratio = agentRatio(c)
650 const full = Math.round(6 * (ratio ?? 0))
651 return (
652 <Box width={cols}>
653 <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
654 {/* The Pal and its stars sit in fixed slots, so the names line up as the Pals grow. */}
655 <Box width={5} flexShrink={0}>
656 <Text>{palEmoji(k, look)}</Text>
657 </Box>
658 <Box width={5} flexShrink={0}>
659 <Text color={C.amber}>{look.isDown ? '' : '★'.repeat(look.stars)}</Text>
660 </Box>
661 <Box width={Math.max(12, Math.min(44, cols - 48))} flexShrink={0}>
662 <Text wrap="truncate">{cardTitle(c)}</Text>
663 </Box>
664 <Box flexGrow={1} overflow="hidden">
665 {ratio === null ? (
666 <Text dimColor wrap="truncate">{c.steps > 0 ? ` ${plural(c.steps, 'step')} so far` : ' starting…'}</Text>
667 ) : (
668 <Text wrap="truncate">
669 <Text color={PALS[k].color}>{` ${'▰'.repeat(full)}`}</Text>
670 <Text color={C.faint}>{'▱'.repeat(6 - full)}</Text>
671 <Text bold>{` ${agentSteps(c)}`}</Text>
672 {c.stepNote && cols >= 80 ? <Text dimColor>{` · ${c.stepNote}`}</Text> : null}
673 </Text>
674 )}
675 </Box>
676 {c.model && cols >= 90 ? (
677 <Box width={12} flexShrink={0}>
678 <Text color={C.dim} wrap="truncate">
679 {prettyModel(c.model)}
680 </Text>
681 </Box>
682 ) : null}
683 <Box flexShrink={0}>{clockOf(els, now)(`strip-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
684 </Box>
685 )
686 }
687 const shown = batch.slice(-5)
688 const strip = (
689 <Box flexDirection="column">
690 {batch.length > shown.length ? <Text color={C.faint}>{`+${batch.length - shown.length} more helpers · /spotter for all`}</Text> : null}
691 {shown.map(helperLine)}
692 {head && r ? (
693 <Text wrap="truncate">
694 <Text color={toneColor(head.tone)}>{headlineText(r)}</Text>
695 <Text dimColor>
696 {`${head.text.includes(fmtDuration(r.durationMs)) ? '' : ` · ${fmtDuration(r.durationMs)}`}${r.costDelta !== null ? ` · ${fmtUsd(r.costDelta)}` : ''}`}
697 </Text>
698 {r.streak >= 2 ? <Text color={C.amber}>{` · 🔥 ${r.streak} in a row`}</Text> : null}
699 </Text>
700 ) : null}
701 </Box>
702 )
703 if (!p) {
704 return (
705 <Box flexDirection="column">
706 {strip}
707 {beneath}
708 </Box>
709 )
710 }
711
712 const color = p.isFinished ? DONE : ACCENT
713 const percent = `${Math.round(planRatio(p) * 100)}%`
714 const crew = cards.length > 0 ? <Button key="bar-agents" plain label={`🐾×${cards.length}`} onPress={() => void openPane($).catch(() => undefined)} /> : null
715 const barPal = palEmoji(palOf({ type: '', description: p.title, model: '' }), planLook(p))
716 const dismiss = <Button key="bar-dismiss" plain label="✕" onPress={() => update($, plan, () => null)} />
717
718 // The terminal's table carries an Svg that draws nothing, so the surface decides, not the table.
719 if (!isTerminal && 'Svg' in els) {
720 // About 8 px to the column; the rest of the row is the two buttons and their gaps.
721 const width = Math.max(180, Math.min(1600, cols * 8 - 96))
722 return (
723 <Box flexDirection="column">
724 <Box flexDirection="row" alignItems="center" gap={1}>
725 <els.Svg source={barSvg(p, width, e.props.isWorking)} alt={`${p.title}: ${planLabel(p)}, ${percent}`} width={width} height={BAR_HEIGHT} />
726 {crew}
727 {dismiss}
728 </Box>
729 {beneath}
730 </Box>
731 )
732 }
733
734 const titleW = Math.max(8, Math.min(30, p.title.length + 2, Math.floor(cols / 3)))
735 return (
736 <Box flexDirection="column">
737 <Box flexDirection="row" gap={2}>
738 <Box width={titleW} flexShrink={0}>
739 <Text color={color}>● </Text>
740 <Text wrap="truncate">{p.title}</Text>
741 </Box>
742 {cols >= 90 ? (
743 <Text color={p.isFinished ? DONE : undefined} dimColor={!p.isFinished} wrap="truncate">
744 {shorten(planHype(p), 24)}
745 </Text>
746 ) : null}
747 <Text color={color}>{planBar(p, Math.max(6, Math.min(40, cols - titleW - (cols >= 90 ? 64 : 38))))}</Text>
748 <Text bold>{planLabel(p)}</Text>
749 <Text dimColor>{percent}</Text>
750 <Text>{barPal}</Text>
751 {crew}
752 {dismiss}
753 </Box>
754 {strip}
755 {beneath}
756 </Box>
757 )
758 })
759}
760hooks/bar.ts 103 lines1// The progress bar's desktop drawing: one SVG row above the prompt. Its look (the colours, the
2// dithered fill and the label pill) is adapted from savvy-progress,
3// https://github.com/JohnnyVizz/claude-kit, MIT License, Copyright (c) 2026 johnnyvizz.
4import type { Plan } from '../types'
5import { palOf, planHype, planLabel, planLook, planRatio } from './core'
6import { PAL_CSS, PAL_GRID, pal } from './pals'
7
8export const ACCENT = '#8f8cf4'
9export const DONE = '#5fbf8f'
10
11export const BAR_HEIGHT = 22
12const FILL_H = 16
13const PAL_W = 26
14const CELL = 3
15const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
16
17const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
18
19// Rough advance of system UI text, in em: good enough to size the title's slot.
20const charEm = (ch: string) => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
21const textWidth = (s: string, size: number) => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
22
23const fitText = (s: string, size: number, maxW: number) => {
24 if (textWidth(s, size) <= maxW) return s
25 let out = ''
26 for (const ch of s) {
27 if (textWidth(`${out}${ch}…`, size) > maxW) break
28 out += ch
29 }
30 return `${out}…`
31}
32
33// Deterministic noise, so the dither does not shimmer between redraws.
34const 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
39const CSS = (isFinished: boolean) => `<style>
40.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
41@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
42.t0,.t1,.t2,.t3{animation:tw ${isFinished ? 3.2 : 2.2}s ease-in-out infinite}
43.t1{animation-duration:${isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
44@keyframes tw{0%,100%{opacity:1}50%{opacity:${isFinished ? 0.8 : 0.3}}}
45@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none!important}}
46${PAL_CSS}</style>`
47
48/**
49 * The whole row as one drawing `width` pixels wide: title, the step under way, bar, label pill,
50 * percent and a Pal, picked by the job the title names: an egg at first, Lucky when it is done.
51 */
52export const barSvg = (p: Plan, width: number, isWorking: boolean) => {
53 // The title and the words beside it take what they need, up to 45% of the row; the bar takes the rest.
54 const slot = Math.max(60, width * 0.45)
55 const title = fitText(p.title, 13, Math.min(slot, Math.max(60, width * 0.28)))
56 const subRoom = slot - textWidth(title, 13) - 10
57 const sub = subRoom >= 44 ? fitText(planHype(p), 12, subRoom) : ''
58 const subX = Math.round(16 + textWidth(title, 13) + 8)
59 const barX = Math.round(sub ? subX + textWidth(sub, 12) + 12 : subX + 4)
60 const barW = Math.max(60, width - barX - 46 - PAL_W)
61 const color = p.isFinished ? DONE : ACCENT
62 const fillW = Math.round(barW * planRatio(p))
63
64 // Dithered fill: sparse at the start, dense toward the head.
65 const cols = Math.floor(fillW / CELL)
66 const rows = Math.floor(FILL_H / CELL)
67 const dots: string[] = []
68 for (let c = 0; c < cols; c += 1) {
69 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
70 for (let r = 0; r < rows; r += 1) {
71 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
72 }
73 }
74
75 // One tick per step still ahead.
76 const ticks: string[] = []
77 for (let i = 1; i < p.total && p.total <= 40; i += 1) {
78 const x = Math.round((barW * i) / p.total)
79 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${FILL_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
80 }
81
82 const label = planLabel(p)
83 const pillW = Math.round(18 + label.length * 6.6)
84 const pillX = Math.max(0, Math.min(barW - pillW, fillW - pillW))
85 const mid = BAR_HEIGHT / 2
86
87 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${BAR_HEIGHT}" viewBox="0 0 ${width} ${BAR_HEIGHT}">
88${CSS(p.isFinished)}
89<defs><clipPath id="c"><rect x="0" y="0" width="${barW}" height="${FILL_H}" rx="${FILL_H / 2}"/></clipPath></defs>
90<circle cx="5" cy="${mid}" r="4" fill="${color}"/>
91<text class="t" x="16" y="${mid + 4.5}" font-family="${FONT}" font-size="13" font-weight="600">${xml(title)}</text>
92${sub ? `<text ${p.isFinished ? `fill="${DONE}"` : 'class="m"'} x="${subX}" y="${mid + 4.5}" font-family="${FONT}" font-size="12">${xml(sub)}</text>` : ''}
93<g transform="translate(${barX},${(BAR_HEIGHT - FILL_H) / 2})">
94<rect class="k" width="${barW}" height="${FILL_H}" rx="${FILL_H / 2}"/>
95<g clip-path="url(#c)"><g fill="${color}">${dots.join('')}</g><g class="tk">${ticks.join('')}</g></g>
96<rect x="${pillX}" width="${pillW}" height="${FILL_H}" rx="${FILL_H / 2}" fill="${color}"/>
97<text x="${pillX + pillW / 2}" y="${FILL_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(label)}</text>
98</g>
99<text class="m" x="${width - PAL_W - 6}" y="${mid + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${Math.round(planRatio(p) * 100)}%</text>
100${pal(width - PAL_W + 2, 0, palOf({ type: '', description: p.title, model: '' }), planLook(p), BAR_HEIGHT / PAL_GRID.h, isWorking && !p.isFinished, true, true)}
101</svg>`
102}
103hooks/core.ts 417 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call inside a
3// call; these functions are what the hooks apply).
4import type { AgentCard, Main, Pal, PalLook, Plan, Receipt, ToolNote, Turn, View } from '../types'
5
6// ---------------------------------------------------------------- defaults
7
8export const DEFAULT_MAIN: Main = { isRunning: false, lastRequestAt: 0, cacheTtlMs: 3_600_000 }
9export const DEFAULT_TURN: Turn = { edits: 0, errors: 0, startedAt: 0, costAtStart: null }
10export const DEFAULT_VIEW: View = { expanded: null }
11
12const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
13
14/** A stored object merged over its defaults, so a value with a field missing still reads. */
15export const normalize = <T extends object>(def: T, stored: unknown): T =>
16 isObject(stored) ? ({ ...def, ...stored } as T) : def
17
18/** A stored list, or empty when what is stored is not a list. */
19export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
20
21export const normalizeCard = (stored: unknown): AgentCard =>
22 normalize<AgentCard>(
23 {
24 id: '',
25 type: 'agent',
26 model: '',
27 description: '',
28 status: 'running',
29 spawnedAt: 0,
30 endedAt: null,
31 ctx: 0,
32 steps: 0,
33 lastStop: null,
34 tools: [],
35 answer: '',
36 stepDone: null,
37 stepTotal: null,
38 stepNote: '',
39 },
40 stored,
41 )
42
43// ---------------------------------------------------------------- config
44
45export type Config = { motion: boolean; openOnStart: boolean }
46
47/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
48export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => ({
49 motion: o.motion !== 'off',
50 openOnStart: o.openOnStart !== false,
51})
52
53/** The person's own theme colours, by key: they follow light, dark and colour-blind themes. */
54export const C = {
55 main: 'claude',
56 agent: 'suggestion',
57 good: 'success',
58 amber: 'warning',
59 warn: 'error',
60 dim: 'inactive',
61 faint: 'subtle',
62 text: 'text',
63} as const
64
65// ---------------------------------------------------------------- formatting
66
67/**
68 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
69 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
70 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
71 */
72export const prettyModel = (id: string) => {
73 if (!id) return '—'
74 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
75 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
76 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
77 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
78 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
79 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
80 return shorten(id, 22)
81}
82
83export const shorten = (s: string, n: number) => {
84 const one = s.replace(/\s+/g, ' ').trim()
85 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
86}
87
88export const kTokens = (n: number) =>
89 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
90
91export const fmtDuration = (ms: number) => {
92 const s = Math.max(0, Math.round(ms / 1000))
93 if (s < 60) return `${s}s`
94 const m = Math.floor(s / 60)
95 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
96}
97
98/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
99export const fmtTimer = (ms: number) => {
100 const s = Math.max(0, Math.floor(ms / 1000))
101 const m = Math.floor(s / 60)
102 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
103}
104
105/** `1 error`, `2 errors`. */
106export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
107
108export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
109
110// ---------------------------------------------------------------- redaction
111
112const SECRETS: [RegExp, string][] = [
113 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
114 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
115 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
116 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
117 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
118 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
119 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
120]
121
122/** What an agent's details may store: credentials masked before anything is written to state. */
123export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
124
125// ---------------------------------------------------------------- tools
126
127export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
128
129const TOOL_WORDS: Record<string, string> = {
130 Bash: 'Command',
131 PowerShell: 'Command',
132 Read: 'Read file',
133 Edit: 'Edit file',
134 Write: 'Write file',
135 NotebookEdit: 'Edit notebook',
136 Glob: 'Find files',
137 Grep: 'Search text',
138 WebFetch: 'Open web page',
139 WebSearch: 'Web search',
140 Agent: 'Start agent',
141 Skill: 'Use skill',
142 ToolSearch: 'Look up a tool',
143}
144
145/** A tool's name in plain words: `Bash` → `Command`, `mcp__chrome__open_url` → `chrome: open url`. */
146const toolWords = (tool: string) => {
147 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
148 return TOOL_WORDS[tool] ?? (mcp ? `${mcp[1] ?? ''}: ${(mcp[2] ?? '').replace(/_/g, ' ')}` : tool)
149}
150
151/** One plain line saying what a call was about: its command, path, pattern or search; redacted. */
152export const describeInput = (tool: string, input: unknown) => {
153 const i = isObject(input) ? input : {}
154 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
155 const text = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '')
156 const what = text('command') || path || text('pattern') || text('url') || text('query') || text('skill') || text('description')
157 return what ? `${toolWords(tool)}: ${redact(what)}` : toolWords(tool)
158}
159
160// ---------------------------------------------------------------- turn and receipt
161
162/**
163 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
164 * own); edits count from every loop.
165 */
166export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
167 ...t,
168 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
169 edits: t.edits + (c.isEdit ? 1 : 0),
170})
171
172export const receiptOf = (
173 t: Turn,
174 o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string; streakBefore?: number },
175): Receipt => ({
176 durationMs: o.durationMs,
177 agents: o.agentsSince,
178 edits: t.edits,
179 errors: t.errors,
180 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
181 reason: o.reason,
182 streak: o.reason === 'answer' && t.errors === 0 ? (o.streakBefore ?? 0) + 1 : 0,
183})
184
185export const normalizeReceipt = (stored: unknown): Receipt | null =>
186 isObject(stored) ? normalize<Receipt>({ durationMs: 0, agents: 0, edits: 0, errors: 0, costDelta: null, reason: 'answer', streak: 0 }, stored) : null
187
188/**
189 * The last reply as one headline: the single thing worth knowing about it, said with a wink.
190 * Every number in it is the receipt's own; `tone` picks the colour.
191 */
192export const receiptHeadline = (r: Receipt): { text: string; tone: 'good' | 'bad' | 'plain' } => {
193 const took = fmtDuration(r.durationMs)
194 if (r.reason !== 'answer') return { text: r.reason === 'aborted' ? `Stopped early after ${took}` : `This one did not finish (${took})`, tone: 'bad' }
195 if (r.errors >= 3) return { text: `${r.errors} errors hit. It finished anyway.`, tone: 'bad' }
196 if (r.errors > 0) return { text: `${plural(r.errors, 'stumble')}, still landed it`, tone: 'plain' }
197 if (r.agents >= 2) return { text: `${r.agents} Pals went in. Zero errors came out.`, tone: 'good' }
198 if (r.durationMs >= 600_000) return { text: `A ${Math.round(r.durationMs / 60_000)}-minute marathon, zero errors`, tone: 'good' }
199 if (r.edits === 0 && r.agents === 0) {
200 return { text: r.durationMs < 10_000 ? `Answered in ${took}. Blink and you missed it.` : 'Just talk: no files were changed', tone: 'plain' }
201 }
202 // One helper and no edits: there are no file changes to count.
203 if (r.edits === 0) return { text: '1 Pal went in. Zero errors came out.', tone: 'good' }
204 if (r.edits >= 5) return { text: `${r.edits} file changes, zero errors. Big one.`, tone: 'good' }
205 return { text: `Clean run: ${plural(r.edits, 'file change')}, zero errors`, tone: 'good' }
206}
207
208// ---------------------------------------------------------------- agents
209
210type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | null
211
212/** A card after one of its model requests: its context is the latest step's whole input. */
213export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
214 const u = s.usage ?? {}
215 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
216 return {
217 ...c,
218 model: c.model || s.model,
219 steps: c.steps + 1,
220 ctx: ctx > 0 ? ctx : c.ctx,
221 lastStop: s.stopReason,
222 }
223}
224
225export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-12) })
226
227/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
228export const lanes = (cards: AgentCard[], now: number, width: number) => {
229 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
230 const span = Math.max(1, now - start)
231 return cards.map(c => {
232 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
233 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
234 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
235 })
236}
237
238/** A card's title row: the task in the agent's own words, the type only when there is none. */
239export const cardTitle = (c: AgentCard) => c.description || c.type
240
241/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
242export const titleLines = (title: string, first: number, rest: number): [string, string] => {
243 const t = title.replace(/\s+/g, ' ').trim()
244 if (t.length <= first) return [t, '']
245 // Break at a space, or else just after a - : / or _, so a name like read:projects-mods is not cut mid-word.
246 const space = t.lastIndexOf(' ', first)
247 const mark = Math.max(...['-', ':', '/', '_'].map(m => t.lastIndexOf(m, first - 1) + 1))
248 const at = space > 0 ? space : mark > 1 ? mark : first
249 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
250}
251
252// ---------------------------------------------------------------- plan (the bar above the prompt)
253
254const DEFAULT_PLAN: Plan = { title: 'Working', total: 0, done: 0, isFinished: false, step: '', startedAt: 0, endedAt: null }
255
256export const normalizePlan = (stored: unknown): Plan | null => (isObject(stored) ? normalize(DEFAULT_PLAN, stored) : null)
257
258const count = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : fallback)
259
260/**
261 * The plan after one `progress` call at `now`. Fields left out keep their value; a new title, or
262 * a call after the last plan finished, starts a fresh plan so counts never carry over.
263 */
264export const mergePlan = (prev: Plan | null, input: unknown, now = 0): Plan => {
265 const i = isObject(input) ? input : {}
266 const title = typeof i.title === 'string' ? shorten(i.title, 60) : ''
267 const isFresh = !prev || prev.isFinished || (title !== '' && title !== prev.title)
268 const base = isFresh ? { ...DEFAULT_PLAN, startedAt: now } : prev
269 const total = count(i.total, base.total)
270 const done = count(i.done, base.done)
271 const isFinished = i.finished === true
272 return {
273 title: title || base.title,
274 total,
275 done: total > 0 ? Math.min(total, done) : done,
276 isFinished,
277 step: isFinished ? '' : typeof i.step === 'string' ? shorten(i.step, 40) : base.step,
278 startedAt: base.startedAt,
279 endedAt: isFinished ? now : null,
280 }
281}
282
283export const planRatio = (p: Plan) => (p.isFinished ? 1 : p.total > 0 ? p.done / p.total : 0)
284
285const planTook = (p: Plan) => (p.endedAt !== null && p.startedAt > 0 && p.endedAt >= p.startedAt ? fmtDuration(p.endedAt - p.startedAt) : '')
286
287export const planLabel = (p: Plan) =>
288 p.isFinished ? (planTook(p) ? `Done in ${planTook(p)}` : 'Done') : p.total > 0 ? `Steps ${p.done}/${p.total}` : 'Working'
289
290/** The few words beside the title: the step under way when Claude named it, a nudge otherwise. */
291export const planHype = (p: Plan) => {
292 if (p.isFinished) return 'Nailed it'
293 if (p.step) return p.step
294 if (p.total <= 0) return 'On it'
295 if (p.done === 0) return 'Warming up'
296 if (p.done >= p.total) return 'Wrapping up'
297 if (p.total - p.done === 1) return 'One step left'
298 return planRatio(p) >= 0.75 ? 'Home stretch' : planRatio(p) >= 0.5 ? 'Past halfway' : 'Rolling'
299}
300
301/** The bar as the terminal draws it: █ filled, ░ empty. */
302export const planBar = (p: Plan, width: number) => {
303 const full = Math.max(0, Math.min(width, Math.round(width * planRatio(p))))
304 return '█'.repeat(full) + '░'.repeat(width - full)
305}
306
307// ---------------------------------------------------------------- Pals (one per agent, picked by its job)
308
309/** A helper agent's own plan after one of its `progress` calls: the count on its card. */
310export const mergeAgentSteps = (c: AgentCard, input: unknown): AgentCard => {
311 const i = isObject(input) ? input : {}
312 const total = count(i.total, c.stepTotal ?? 0)
313 const done = i.finished === true && total > 0 ? total : count(i.done, c.stepDone ?? 0)
314 const note = typeof i.step === 'string' ? shorten(i.step, 40) : typeof i.title === 'string' && c.stepTotal === null ? shorten(i.title, 40) : c.stepNote
315 return { ...c, stepTotal: total, stepDone: total > 0 ? Math.min(total, done) : done, stepNote: i.finished === true ? '' : note }
316}
317
318/** `2/5` when the agent reports a plan of its own, null when it does not. */
319export const agentSteps = (c: AgentCard) => (c.stepTotal !== null && c.stepTotal > 0 ? `${c.stepDone ?? 0}/${c.stepTotal}` : null)
320
321export const agentRatio = (c: AgentCard) => (c.stepTotal !== null && c.stepTotal > 0 ? Math.min(1, (c.stepDone ?? 0) / c.stepTotal) : null)
322
323// First match wins, so the order is the tie-break: "review the build" is checking, Depresso's job.
324const PAL_RULES: [Pal, RegExp][] = [
325 ['vixy', /explor|search|find|scout|archaeolog|research|look ?up|locat|guide|map\b|\bread|pull|fetch|scan|probe|trace|inventor|sweep|gather|collect|crawl|hunt|brows|survey|\bsourc|extract|\blist\b|recon/i],
326 ['depresso', /review|verif|audit|check|inspect|test|debug|investigat|diagnos|analy[sz]|assess|measur|compar|classif|benchmark|evaluat|validat|\bqa\b|council|baseline|critiq|judg|grad(e|ing)|\bseat\b|\bcount|bench|\brank|evidence|proof|feasib/i],
327 ['hoocrates', /plan|architect|strateg|design the|spec\b|outlin|roadmap|propos|scope|brainstorm/i],
328 ['lifmunk', /design|asset|document|write|draft|\bdocs?\b|copy|styl|summar|digest|compil|consolidat|\brecord\b|handoff|runbook|caption|script|\bnotes?\b|polish|revis/i],
329 ['foxparks', /quick|fast|status ?line|rename|format|tidy|remov|delet|clean|\bdrop\b|retir|trim|\bmove\b|prune/i],
330 ['anubis', /build|implement|fix|engineer|refactor|code|migrat|edit|appl|\badd\b|chang|creat|set ?up|wire|\b(re)?run\b|split|finish|install|updat|\bport\b|integrat|patch|backtest|convert|generat/i],
331]
332
333/** Types that say nothing about the job: the task description decides. */
334const GENERIC_TYPES = /^(general-purpose|claude|agent|fork)$/i
335
336/** A type names its job in a word, so the rules apply in their order. */
337const palFor = (text: string) => PAL_RULES.find(([, re]) => re.test(text))?.[0] ?? null
338
339/** A task is a sentence: the job is its first telling word ("Add tests" builds, "Test the add" checks). */
340const palForTask = (text: string): Pal | null => {
341 let best: { k: Pal; at: number } | null = null
342 for (const [k, re] of PAL_RULES) {
343 const at = text.search(re)
344 if (at >= 0 && (!best || at < best.at)) best = { k, at }
345 }
346 return best?.k ?? null
347}
348
349/**
350 * The Pal for an agent: its type decides when the type names a job (Explore, a reviewer), its
351 * task description otherwise; a small fast model with nothing else to go on is Foxparks, and
352 * anything left is building, the commonest job, so every agent has a Pal.
353 */
354export const palOf = (c: Pick<AgentCard, 'type' | 'description' | 'model'>): Pal =>
355 (GENERIC_TYPES.test(c.type) ? null : palFor(c.type)) ?? palForTask(c.description) ?? (/haiku/i.test(c.model) ? 'foxparks' : 'anubis')
356
357/** Each Pal's name, the job it stands for, a terminal stand-in for its drawing, and its accent colour. */
358export const PALS: Record<Pal, { name: string; job: string; emoji: string; color: string }> = {
359 vixy: { name: 'Vixy', job: 'scouting', emoji: '🦊', color: '#E0A526' },
360 depresso: { name: 'Depresso', job: 'checking', emoji: '🐨', color: '#5B9BD5' },
361 anubis: { name: 'Anubis', job: 'building', emoji: '🐺', color: '#E08A3A' },
362 lifmunk: { name: 'Lifmunk', job: 'writing', emoji: '🌿', color: '#5FBF8F' },
363 hoocrates: { name: 'Hoocrates', job: 'planning', emoji: '🦉', color: '#8F8CF4' },
364 foxparks: { name: 'Foxparks', job: 'quick job', emoji: '🔥', color: '#E05A5A' },
365}
366
367/** About one agent in twenty has a Lucky Pal; the same agent always gets the same answer. It shows once the agent is done. */
368export const isLucky = (id: string) => [...id].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7) % 20 === 0
369
370/**
371 * How an agent's Pal looks now. It is an egg until the agent has done something, then grows with
372 * the agent's own plan when it reports one and with its steps when it does not, and is fully
373 * grown once the agent is done, which is also when a Lucky one shows it: every Pal grows the
374 * same way first. An agent that failed or was stopped has a knocked-out Pal.
375 */
376export const palLook = (c: AgentCard): PalLook => {
377 const ratio = agentRatio(c)
378 const growing = ratio !== null ? (ratio <= 0 ? 0 : ratio < 0.5 ? 1 : 2) : c.steps === 0 ? 0 : c.steps < 5 ? 1 : 2
379 return { stars: c.status === 'done' ? 4 : growing, isLucky: c.status === 'done' && isLucky(c.id), isDown: c.status === 'failed' || c.status === 'stopped' }
380}
381
382/** The Pal in words: `Vixy · scouting`, `Egg · incubating`, `Lucky Lifmunk · writing`, or that it is knocked out. */
383export const palWords = (k: Pal, look: PalLook) =>
384 look.isDown
385 ? { name: `✗ ${PALS[k].name} knocked out`, job: '' }
386 : look.stars === 0
387 ? { name: 'Egg', job: 'incubating' }
388 : { name: `${look.isLucky ? 'Lucky ' : ''}${PALS[k].name}`, job: PALS[k].job }
389
390/** The bar's own Pal: an egg until the first step is done, then grown, and Lucky once the job is finished. */
391export const planLook = (p: Plan): PalLook => ({ stars: p.isFinished ? 4 : p.done > 0 ? 2 : 0, isLucky: p.isFinished, isDown: false })
392
393/** The terminal's stand-in for the drawing: an egg, the Pal's emoji (sparkling when Lucky), or a skull. */
394export const palEmoji = (k: Pal, look: PalLook) => (look.isDown ? '💀' : look.stars === 0 ? '🥚' : `${PALS[k].emoji}${look.isLucky ? '✨' : ''}`)
395
396// ---------------------------------------------------------------- the session box
397
398/**
399 * How long the prompt cache lasts, read from what a request wrote to it: five minutes when it
400 * wrote only five-minute entries, an hour when it wrote hour ones, the last known value otherwise.
401 */
402export const cacheTtlOf = (usage: unknown, previous: number): number => {
403 const w = (usage as { cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } } | null)?.cache_creation
404 if ((w?.ephemeral_1h_input_tokens ?? 0) > 0) return 3_600_000
405 if ((w?.ephemeral_5m_input_tokens ?? 0) > 0) return 300_000
406 return previous
407}
408
409/** The cache line for the time left on it. `hooks/cache.tsx` draws the same three cases as it counts down. */
410export const cacheLabel = (msLeft: number): { text: string; tone: 'plain' | 'soon' | 'cold' } => {
411 const min = Math.ceil(msLeft / 60_000)
412 if (min <= 0) return { text: 'cold · next message re-reads it all', tone: 'cold' }
413 return { text: `warm · ~${min} min left`, tone: min <= 10 ? 'soon' : 'plain' }
414}
415
416export const limitLabel = (kind: string) => (kind === 'five_hour' ? '5-hour limit' : kind === 'seven_day' ? 'Weekly limit' : kind.replace(/_/g, ' '))
417hooks/pals.ts 90 lines1// The pixel Pals: one for the bar and one per helper agent, picked by the job it runs. A Pal starts
2// as an egg, hatches small and grows as its agent works; a Lucky one is bigger and sparkles; one
3// whose agent failed is knocked out. Which Pal goes with which job is in core.ts.
4import type { Pal, PalLook } from '../types'
5import { EGG_SPRITE, PAL_SPRITES } from './pal-sprites'
6
7/** The Pals' grid: 40×38 cells; a drawing of it at `scale` is this many pixels. */
8export const PAL_GRID = { w: 40, h: 38 }
9
10// How much of the drawing each stage fills, by its stars (0 is the egg). A Lucky Pal fills all of it.
11const SIZE: Record<PalLook['stars'], number> = { 0: 0.65, 1: 0.57, 2: 0.72, 4: 0.87 }
12
13const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
14
15const CROSS = ['xx...xx', 'xxx.xxx', '.xxxxx.', '..xxx..', '.xxxxx.', 'xxx.xxx', 'xx...xx']
16const GHOST = ['..www..', '.wwwww.', 'wwkwkww', 'wwwwwww', 'wwwwwww', '.wwwww.', '.ww.ww.', '..w..w.', '...w...']
17const SPARKLE = ['.g.', 'gwg', '.g.']
18const INKS: Record<string, string> = { x: '#F04A4A', w: '#E9EEF5', k: '#3A3A45', g: '#F5C542' }
19
20// Pure CSS, run by the compositor: no redraws. A working Pal hops and a working egg rocks; a Lucky
21// Pal's sparkles blink and a knocked-out Pal's ghost drifts whenever motion is on.
22export const PAL_CSS = `
23.pal g{transform-box:fill-box}
24.run .bd{animation:hop .5s steps(1) infinite}
25.run .eg{transform-origin:50% 100%;animation:rock .7s ease-in-out infinite}
26.mv .sp{animation:blink .8s steps(1) infinite}.mv .sp2{animation:blink .8s steps(1) infinite -.4s}
27.mv .gh{animation:drift 1.6s ease-in-out infinite}
28@keyframes hop{50%{transform:translateY(-1px)}}@keyframes rock{25%{transform:rotate(-7deg)}75%{transform:rotate(7deg)}}
29@keyframes blink{50%{opacity:.15}}@keyframes drift{50%{transform:translateY(-2px)}}
30@media (prefers-reduced-motion: reduce){.pal g{animation:none!important}}
31`
32
33// One path per colour, each run of cells along a row a single box: a Pal is a dozen paths, not a
34// thousand rectangles.
35const paths = (rows: string[], colorOf: (ch: string) => string, ox = 0, oy = 0) => {
36 const d = new Map<string, string>()
37 rows.forEach((row, y) => {
38 for (let x = 0; x < row.length; ) {
39 const ch = row[x] ?? '.'
40 let n = 1
41 while (row[x + n] === ch) n += 1
42 if (ch !== '.') {
43 const c = colorOf(ch)
44 d.set(c, `${d.get(c) ?? ''}M${ox + x} ${oy + y}h${n}v1h-${n}z`)
45 }
46 x += n
47 }
48 })
49 return [...d].map(([c, p]) => `<path fill="${c}" d="${p}"/>`).join('')
50}
51
52const ink = (rows: string[], x: number, y: number) => paths(rows, ch => INKS[ch] ?? '', x, y)
53
54// A knocked-out Pal's colours: its own, drained to grey and a little darker.
55const drained = (c: string) => {
56 const [r = 0, g = 0, b = 0] = [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
57 const v = Math.round((0.3 * r + 0.59 * g + 0.11 * b) * 0.8 + 5)
58 return `rgb(${v},${v},${v})`
59}
60
61/**
62 * One Pal as SVG markup at (x, y), standing on the bottom of a `PAL_GRID` box at `scale`. It is
63 * drawn at its stage's size inside that box, or filling it when `fills` (a drawing too small to
64 * show growth). Needs `PAL_CSS` in the drawing.
65 */
66export const pal = (x: number, y: number, k: Pal, look: PalLook, scale: number, isWorking: boolean, hasMotion = true, fills = false) => {
67 const s = PAL_SPRITES[k]
68 const isEgg = look.stars === 0 && !look.isDown
69 const isLucky = look.isLucky && !isEgg && !look.isDown
70 const size = fills || isLucky ? 1 : SIZE[look.isDown ? 4 : look.stars]
71 const colorOf = (ch: string) => s.colors[LETTERS.indexOf(ch)] ?? ''
72 const body = isEgg
73 ? `<g class="eg">${paths(EGG_SPRITE.rows, ch => (ch === '*' ? s.egg : (EGG_SPRITE.colors[LETTERS.indexOf(ch)] ?? '')))}</g>`
74 : `<g class="bd">${paths(s.rows, look.isDown ? ch => drained(colorOf(ch)) : colorOf)}${look.isDown ? s.eyes.map(([ex, ey]) => ink(CROSS, ex - 3, ey - 3)).join('') : ''}</g>`
75 const ghost = look.isDown ? `<g class="gh">${ink(GHOST, 33, 0)}</g>` : ''
76 const sparkles = isLucky
77 ? `<g class="sp">${ink(SPARKLE, 1, 3)}${ink(SPARKLE, 36, 29)}${ink(SPARKLE, 33, 0)}</g><g class="sp2">${ink(SPARKLE, 36, 5)}${ink(SPARKLE, 2, 30)}</g>`
78 : ''
79 const dx = x + ((PAL_GRID.w * (1 - size)) / 2) * scale
80 const dy = y + PAL_GRID.h * (1 - size) * scale
81 return `<g transform="translate(${dx.toFixed(2)},${dy.toFixed(2)}) scale(${(scale * size).toFixed(3)})" shape-rendering="crispEdges"><g class="pal${hasMotion ? ' mv' : ''}${isWorking && hasMotion ? ' run' : ''}">${body}${ghost}${sparkles}</g></g>`
82}
83
84/** A Pal on its own, for an agent's row: it hops while its agent works. */
85export const palSvg = (k: Pal, look: PalLook, scale: number, isWorking: boolean, hasMotion = true, fills = false) => {
86 const w = Math.ceil(PAL_GRID.w * scale)
87 const h = Math.ceil(PAL_GRID.h * scale)
88 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><style>${PAL_CSS}</style>${pal(0, 0, k, look, scale, isWorking, hasMotion, fills)}</svg>`
89}
90hooks/elapsed.tsx 38 lines1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 const m = Math.floor(s / 60)
12 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16 const { Text } = surface.elements
17 const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18 if (props.now !== ref.lastNow) {
19 // A fresh reading from the hooks module: restart the local count from it.
20 ref.lastNow = props.now
21 ref.base = (props.endAt ?? props.now) - props.since
22 ref.ticks = 0
23 }
24 ref.isRunning = props.endAt === null
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(1000, () => {
28 if (ref.isRunning) {
29 ref.ticks += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34 return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38hooks/cache.tsx 34 lines1// The prompt cache's countdown, ticking on the surface's frame clock so it stays right while idle.
2// `expiresAt` and `now` come from the hooks module's $.clock; the wording matches cacheLabel in core.ts.
3import type { ClientModule } from 'claude-code'
4
5type Props = { expiresAt: number; now: number; plain: string; soon: string; cold: string }
6type Ref = { left: number; lastNow: number }
7type State = { ref: Ref }
8
9const Cache: ClientModule<Props, State> = (props, surface) => {
10 const { Text } = surface.elements
11 const ref = surface.state?.ref ?? { left: 0, lastNow: -1 }
12 if (props.now !== ref.lastNow) {
13 // A fresh reading from the hooks module: restart the local count from it.
14 ref.lastNow = props.now
15 ref.left = props.expiresAt - props.now
16 }
17 if (surface.state === undefined) {
18 surface.setState({ ref })
19 surface.every(15_000, () => {
20 ref.left -= 15_000
21 surface.setState({ ref })
22 })
23 }
24 const min = Math.ceil(ref.left / 60_000)
25 if (min <= 0) return <Text color={props.cold} wrap="wrap">cold · next message re-reads it all</Text>
26 return (
27 <Text color={min <= 10 ? props.soon : props.plain} wrap="truncate">
28 {`warm · ~${min} min left`}
29 </Text>
30 )
31}
32
33export default Cache
34hooks/pal-sprites.ts 325 lines1// The Pals' pixels, one character a cell on a 40×38 grid: a letter picks from `colors`, a dot is
2// empty, and `*` in the egg is the spot colour of the Pal inside. `eyes` is where a knocked-out
3// Pal's crosses go. Drawn for this mod from the game's own icons; unofficial fan art of Palworld
4// (Pocketpair), not affiliated with or endorsed by it.
5import type { Pal } from '../types'
6
7type Sprite = { colors: string[]; rows: string[] }
8
9export const PAL_SPRITES: Record<Pal, Sprite & { eyes: [number, number][]; egg: string }> = {
10 vixy: {
11 colors: ['#D9A45F', '#F2A6A0', '#F2D24B', '#1F1E1D', '#FFFFFF', '#2E9E3A', '#6FD86B', '#BF8A47', '#F6E3B4'],
12 rows: [
13 '........................................',
14 '.......a........................a.......',
15 '.......aa......................aa.......',
16 '.......aaa....................aaa.......',
17 '.......abba..................abba.......',
18 '......abbba..................abbba......',
19 '......abbbba................abbbba......',
20 '......abbbbba..............abbbbba......',
21 '......abbbbbba..cccccccc..abbbbbba......',
22 '......abbbbbbaaaaccccccaaaabbbbbba......',
23 '......abbbbbaaaaaccacccaaaaabbbbba......',
24 '......abbbbaaaaaaacaacaaaaaaabbba.......',
25 '..........aaaaaaaaaaaaaaaaaaaa..........',
26 '..........aaddddddaaaaadddddda..........',
27 '.........aaedffffeeeeeeeffffdaa.........',
28 '.........aaeefegfeeeeeeefegfeaa.........',
29 '.........aeeefddfeeeeeeefddfeea.........',
30 '.........aaeefddfeeeeeeefddfeaa.........',
31 '.........aaeeffffeeeeeeeffffeaa.........',
32 '.......cc.aeeeeeeeeeeeeeeeeeea.cc.......',
33 '.......cccaaeeeeeeeddeeeeeeeaaccc.......',
34 '........cc.aeeeeeeeeeeeeeeeea.cc........',
35 '............aeeeeeehheeeeeea.....ii.....',
36 '..............eeeeeeeeeeee.....aiiii....',
37 '...............ieeeeeeeei....aaiiiiii...',
38 '..............iiiiiiiiiiii..aaaiiiiii...',
39 '.............iiiiiiiiiiiiii.aaaaiiiia...',
40 '..............iiiiiiiiiiii..aaaaaiiaa...',
41 '.............aaiiiiiiiiiiaa.aaaaaaaaa...',
42 '.............aaaaaaaaaaaaaa.aaaaaaaaa...',
43 '.............aaaaaaaaaaaaaa.aaaaaaaaa...',
44 '.............aaaaaaaaaaaaaa.aaaaaaaaa...',
45 '..............aaaaaaaaaaaa..aaaaaaaaa...',
46 '...............aaaaaaaaaa....aaaaaaa....',
47 '..............ccccaaaacccc.....aaa......',
48 '..............cccc....cccc..............',
49 '..............cccc....cccc..............',
50 '........................................',
51 ],
52 eyes: [[14, 16], [25, 16]],
53 egg: '#C9C2B0',
54 },
55 depresso: {
56 colors: ['#6E3D7A', '#5AA5E0', '#86C4F2', '#8A4E8F', '#1F1E1D', '#FFFFFF', '#E9CDEF', '#2B2350', '#8E8AA8', '#4686C4'],
57 rows: [
58 '........................................',
59 '........................................',
60 '........................................',
61 '....................a...................',
62 '..................aaa...................',
63 '..................aaaa..................',
64 '..................aaaaa.................',
65 '.................aaaaaa.................',
66 '................bbbbbbbb................',
67 '.............bbbbbbbbbbbbbb.............',
68 '...........bbbbbbbbbbbbbbbbbb...........',
69 '.......b..bbbccccccccbbbbbbbbb..b.......',
70 '.....bbbbbbbccccccccccbbbbbbbbbbbbb.....',
71 '....bdddbbbbccccccccccbbbbbbbbbbdddb....',
72 '....bdddbbbbbccccccccbbbbbbbbbbbdddb....',
73 '...bddddbbbbbbbbbbbbbbbbbbbbbbbbddddb...',
74 '....bdddbbeeeeebbbbbbbbbbeeeeeebdddb....',
75 '....bdddbbeeeeeeeeebbbeeeeeeeeebdddb....',
76 '.....bbbbbffghheeeebbbeeehhgffbbbbb.....',
77 '.......bbbfgghhggfbbbbfgghhggfbbb.......',
78 '........bbbfggggfbbbbbbfggggfbbb........',
79 '.........bbbbffbbbbbbbbbbffbbbb.........',
80 '..........bbbbbbbbbbbbbbbbbbbb..........',
81 '...........bbbbbbbbbbbbbbbbbb...........',
82 '.............bbbbeeeeeebbbb.............',
83 '.............iiibbbbbbbbiii.............',
84 '.............iiiiiiiiiiiiii.............',
85 '.............iiiiiiiiiiiiii.............',
86 '...........bbbccccccccccccbbb...........',
87 '...........bbbccccccccccccbbb...........',
88 '...........bbbccccccccccccbbb...........',
89 '...........bbbccccccccccccbbb...........',
90 '...........bbbccccccccccccbbb...........',
91 '...............cccccccccc...............',
92 '..............jjjjccccjjjj..............',
93 '..............jjjj....jjjj..............',
94 '..............jjjj....jjjj..............',
95 '........................................',
96 ],
97 eyes: [[14, 19], [26, 19]],
98 egg: '#7A63B8',
99 },
100 anubis: {
101 colors: ['#50505E', '#39B8E8', '#E8B62E', '#C99A1E', '#1F1E1D', '#3A3A47', '#FFFFFF', '#C9986B', '#8A6240'],
102 rows: [
103 '...........aa..............aa...........',
104 '...........aa..............aa...........',
105 '..........aba.............aba...........',
106 '..........abba............abba..........',
107 '..........abba............abba..........',
108 '..........abbba..........abbba..........',
109 '..........abbba..........abbba..........',
110 '..........abbba..........abbba..........',
111 '..........abbbba........abbbba..........',
112 '..........abbbba.aaaaaa.abbbba..........',
113 '.........abbbbbaaaaccaaaabbbbba.........',
114 '.........abbbbaaaaaccaaaaabbbba.........',
115 '..........abbaaaaaaddaaaaaabbba.........',
116 '............aaaaaaaaaaaaaaaa............',
117 '............eaaaaaaaaaaaaaae............',
118 '............eeeeaaaaaaaaeeee............',
119 '.........ffaagebaaaaaaaabegaaff.........',
120 '.........ffbagbbaaaaaaaabbgabff.........',
121 '.........fffbbbbaaaaaaaabbbbfff.........',
122 '.........fffaaaaaaahhaaaaaaafff.........',
123 '.........fff.aaaahheehhaaaa.fff.........',
124 '.........fff..aaahheehhaaa..fff.........',
125 '.........ccc...ahhhhhhhha...ccc.........',
126 '.........fff...aahhhhhhaa...fff.........',
127 '.........fff.....ahiiha.....fff.........',
128 '.........cff................ffc.........',
129 '.........ccccccccccbbcccccccccc..c......',
130 '.........ccccccccccbbcccccccccc.aa......',
131 '.........fff.ddddddbbdddddd.fff.a.......',
132 '.........cccaaaaaaaaaaaaaaaaccc.a.......',
133 '.........cccaaaaccccccccaaaaccc.a.......',
134 '............cccaafbbbbfaaccc....a.......',
135 '............aaaaafbbbbfaaaaa....a.......',
136 '............aaaaafbbbbfaaaaaaaaaa.......',
137 '................afbbbbfa................',
138 '...............ccccffcccc...............',
139 '...............aaaa..aaaa...............',
140 '........................................',
141 ],
142 eyes: [[14, 17], [25, 17]],
143 egg: '#A9865C',
144 },
145 lifmunk: {
146 colors: ['#4E9A3A', '#7BC25A', '#DDE59A', '#D8362B', '#F59A8C', '#E5492B', '#FFFFFF', '#B8281A', '#1F1E1D', '#C2CD7A', '#F0F4CC'],
147 rows: [
148 '.........aa..................aa.........',
149 '........aab..................aba........',
150 '........aaba................aaba........',
151 '........aaba................aaba........',
152 '........aabaa..............aaaba........',
153 '........aaaba..............aabaa........',
154 '........aaaba..............aabaa........',
155 '.........aabaa............aaaba.........',
156 '.........aaba..............aaba.........',
157 '..........aab..ccccddcccc..aba..........',
158 '...........aacccccdeddcccccaa...........',
159 '...........cccccccddddccccccc...........',
160 '..........ccccccccddddcccccccc..........',
161 '..........cccccccccddccccccccc..........',
162 '.........cccccccccccccccccccccc.........',
163 '.........cccccccccccccccccccccc.........',
164 '.........cccccccccccccccccccccc.........',
165 '........ccccffffccccccccffffcccc........',
166 '.........cccggffccccccccggffccc.........',
167 '.........ccffhhhfccccccffhhhfcc.........',
168 '.....aa..cccfhhfccccccccfhhfccc..aa.....',
169 '....aaa...ccffffccccccccffffcc...aaa....',
170 '.....a....ccccccccciiccccccccc....a.....',
171 '...........cccccccccccccccccc...ggggg...',
172 '.............cccccjccjccccc.....ggggg...',
173 '...............ccccjjcccc......ggggggg..',
174 '............a.aacaaccaacaa.a..ccggggg...',
175 '............aaaaaaaaaaaaaaaa.cccgggggc..',
176 '.............aaccaakkaaccaa..ccccccccc..',
177 '.............ccckkkkkkkkccc..ccccccccc..',
178 '.............ccckkkkkkkkccc..ccccccccc..',
179 '.............cckkkkkkkkkkcc..ccccccccc..',
180 '.............ccckkkkkkkkccc..ccccccccc..',
181 '..............cckkkkkkkkcc....ccccccc...',
182 '...............ccckkkkccc......ccccc....',
183 '..............jjjjccccjjjj......ccc.....',
184 '..............jjjj....jjjj..............',
185 '........................................',
186 ],
187 eyes: [[14, 19], [26, 19]],
188 egg: '#5FAE4E',
189 },
190 hoocrates: {
191 colors: ['#2A3450', '#3A4764', '#A9D6E0', '#C6E6EE', '#F2D21F', '#1F1E1D', '#E8962E'],
192 rows: [
193 '........................................',
194 '........................................',
195 '........................................',
196 '......................aa................',
197 '.....................aa.................',
198 '...................aaaa.................',
199 '.................aaaaaa.................',
200 '................aaaaaaaa................',
201 '................aaaaaaaa................',
202 '...............aaaaaaaaaa...............',
203 '..............aaaaaaaaaaaa..............',
204 '.............aaaaaaaaaaaaaa.............',
205 '............aaaaaaaaaaaaaaaa............',
206 '...........bbbbbbbbbbbbbbbbbb...........',
207 '..........bbbbbbbbbbbbbbbbbbbb..........',
208 '....bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb....',
209 '...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb...',
210 '.....cccccccccccccccccccccccccccccc.....',
211 '..........dddddddbbbbbbddddddd..........',
212 '........ddddddddddbbbbdddddddddd........',
213 '.........dd.dddddddbbddddddd.dd.........',
214 '.........a.bbeedddbbbbdddeebb.a.........',
215 '........aaabeeffeebbbbeeffeebaaa........',
216 '.......aaaaabeffebggggbeffebaaaaa.......',
217 '.......aaaaabbeebbggggbbeebbaaaaa.......',
218 '.......aaaaabbbbcccggcccbbbbaaaaa.......',
219 '.......aaaaabbbccccccccccbbbaaaaa.......',
220 '.......aaaaabbccccccaaacccbbaaaaa.......',
221 '.......aaaaabccccccaaccccccbaaaaa.......',
222 '.......aaaaabcccccaaaaaccccbaaaaa.......',
223 '.......aaaaabcccccccaacccccbaaaaa.......',
224 '........aaabbccccccaaccccccbbaaa........',
225 '.........a.bbbccccaaccccccbbb.a.........',
226 '............bbbccccccccccbbb............',
227 '.............bbbccccccccbbb.............',
228 '...............bbbbbbbbbb...............',
229 '...............gggg..gggg...............',
230 '...............gggg..gggg...............',
231 ],
232 eyes: [[15, 22], [25, 22]],
233 egg: '#7A63B8',
234 },
235 foxparks: {
236 colors: ['#5A2E1A', '#F07A2A', '#F7E0BE', '#FFD23F', '#FF8A2A', '#E23A2E', '#FFFFFF', '#1F1E1D', '#D9482B', '#D55E1A'],
237 rows: [
238 '.........a....................a.........',
239 '.........aa..................aa.........',
240 '.........aa..................aa.........',
241 '.........bcb................bcb.........',
242 '........bccb................bccb........',
243 '........bcccb..............bcccb........',
244 '........bcccb..............bcccb........',
245 '........bccccb............bccccb........',
246 '........bccccb............bccccb........',
247 '........bcccccb..........bcccccb........',
248 '........bcccccb..........bcccccbd.......',
249 '........bccccccbbbbbbbbbbccccccbd..d....',
250 '........bccccbbbbbbbbbbbbbbccccbed.d....',
251 '............bbbbbbbbbbbbbbbb..deeedd....',
252 '...........bbbbbbbbbbbbbbbbbb.defeed....',
253 '...........bbgggbbbbbbbbgggbbdeeffeed...',
254 '..........bbbbbbbbbbbbbbbbbbbbeffffed...',
255 '..........bbbhghbbbbbbbbhghbbbeffffe....',
256 '........ffbbbhiibbbbbbbbhiibbbff........',
257 '.......fffbbbhiibbbbbbbbhiibbbfff.......',
258 '........ffbbbbbbbccccccbbbbbbbffaaaa....',
259 '.......fff.bbbbbccchhcccbbbbbafffaaa....',
260 '........f..bbbbccccccccccbbbbbbfbbb.....',
261 '............bbbbccccccccbbbbbbbbbbbb....',
262 '.............bbbbccccccbbbbbbbbbbbbbb...',
263 '...............bbbbbbbbbb..bbbbbbbbbb...',
264 '................cccccccc...bbbbbbbbbb...',
265 '...............bccccccccb..bbbbbbbbbb...',
266 '..............bccccccccccb.bbbbbbbbbb...',
267 '..............bbccccccccbb.bbbbbbbbbb...',
268 '.............bbbccccccccbbb.bbbbbbbb....',
269 '..............bbbbccccbbbb...bbbbbb.....',
270 '..............bbbbbbbbbbbb....bbbb......',
271 '............d..bbbbbbbbbb..d............',
272 '.............d..bbbbbbbb..d.............',
273 '..............jjjjbbbbjjjj..............',
274 '..............fjjj....jjjf..............',
275 '.............f............f.............',
276 ],
277 eyes: [[14, 18], [25, 18]],
278 egg: '#E0503A',
279 },
280}
281
282export const EGG_SPRITE: Sprite = {
283 colors: ['#F4EEDD', '#B7B2A6', '#FFFFFF'],
284 rows: [
285 '........................................',
286 '........................................',
287 '........................................',
288 '........................................',
289 '........................................',
290 '........................................',
291 '........................................',
292 '........................................',
293 '........................................',
294 '........................................',
295 '........................................',
296 '........................................',
297 '........................................',
298 '........................................',
299 '........................................',
300 '........................................',
301 '................aaaaaaaa................',
302 '..............baaabbaaaaa...............',
303 '..............abbbcabbaaaa..............',
304 '.............acccbccaabaaaa.............',
305 '.............accccccaaa**aa.............',
306 '............aaccccccaa****aa............',
307 '............aaccccccaa****aa............',
308 '............aaaccccaaaa**aaa............',
309 '............aaaaaaaaaaaaaaaa............',
310 '............aa**aaaaaaaaaaaa............',
311 '............a****aaaaaaaa**a............',
312 '............a****aaaaaaaa**a............',
313 '............aa**aaaaaaaaaaaa............',
314 '............aaaaaaaaa**aaaaa............',
315 '............aaaaaaaa****aaaa............',
316 '.............aaaaaaa****aaa.............',
317 '.............aaaa**aa**aaaa.............',
318 '..............aaa**aaaaaaa..............',
319 '...............aaaaaaaaaa...............',
320 '................aaaaaaaa................',
321 '........................................',
322 '........................................',
323 ],
324}
325types/index.d.ts 82 lines1export type Main = {
2 isRunning: boolean
3 /** When the main conversation's last request finished; 0 before the first. The cache countdown starts here. */
4 lastRequestAt: number
5 /** How long the prompt cache lasts after a request: an hour, or five minutes when the requests say so. */
6 cacheTtlMs: number
7}
8
9export type ToolNote = { text: string; isError: boolean }
10
11export type AgentCard = {
12 id: string
13 type: string
14 model: string
15 description: string
16 status: string
17 spawnedAt: number
18 endedAt: number | null
19 /** The agent's context now: input + cache read + cache write of its latest step. */
20 ctx: number
21 steps: number
22 lastStop: string | null
23 tools: ToolNote[]
24 answer: string
25 /** Its own plan, when it reports one through the `progress` tool; null until it does. */
26 stepDone: number | null
27 stepTotal: number | null
28 stepNote: string
29}
30
31export type Turn = {
32 edits: number
33 errors: number
34 startedAt: number
35 costAtStart: number | null
36}
37
38export type Receipt = {
39 durationMs: number
40 agents: number
41 edits: number
42 errors: number
43 costDelta: number | null
44 reason: string
45 /** Replies in a row, this one included, that finished with no errors. */
46 streak: number
47}
48
49export type View = { expanded: string | null }
50
51/** The plan Claude reported through the `progress` tool: what the bar above the prompt draws. */
52export type Plan = {
53 title: string
54 total: number
55 done: number
56 isFinished: boolean
57 /** What the step now under way is, in Claude's words; empty when it did not say. */
58 step: string
59 /** When the plan began and ended; 0 and null when unknown. */
60 startedAt: number
61 endedAt: number | null
62}
63
64/** The Pal that stands for an agent, picked from the kind of job it runs. */
65export type Pal = 'vixy' | 'depresso' | 'anubis' | 'lifmunk' | 'hoocrates' | 'foxparks'
66
67/** How a Pal is drawn: `stars` 0 is the egg and 4 fully grown; Lucky is the rare bigger one; down is knocked out. */
68export type PalLook = { stars: 0 | 1 | 2 | 4; isLucky: boolean; isDown: boolean }
69
70declare module 'claude-code' {
71 interface PluginState {
72 'spotter': {
73 main: Main
74 agents: AgentCard[]
75 turn: Turn
76 receipt: Receipt | null
77 view: View
78 plan: Plan | null
79 }
80 }
81}
82