A one-line band above the prompt: the stage and progress of a superpowers flow, another skill or Claude's own to-do list; the last test run; the request's…

프롬프트 입력창 위에 한 줄짜리 진행 막대를 띄웁니다. 지금 작업의 단계와 진행 상황, 그리고 실행 중인 서브에이전트 수를 보여 줍니다. 이미 일어나는 스킬 호출, 할 일 목록, 파일 쓰기, 서브에이전트 이벤트를 읽기만 하므로 토큰을 더 쓰지 않습니다.
● Auth login ▓▓▓▓▓▓▓▓▓▓[ Build 3/7 ]░░│░░░│░░ 57% ✓ tests 2:13 🦀 ×2 ✕
/plugin install work-band --marketplace StarHawkMK1/cc-mods
이미 cc-mods 마켓플레이스를 추가했다면 claude plugin install work-band@cc-mods --scope user로도 됩니다. 테스트한 버전은 Claude Code 2.1.299~2.1.301(Windows 11)입니다.
brainstorming → spec 문서 → writing-plans → 구현(subagent-driven-development, executing-plans, test-driven-development) → requesting-code-review → finishing-a-development-branch를 6칸 눈금으로 보여 줍니다. 구현 칸은 계획의 작업 수 중 끝난 수만큼 채워집니다. 제목은 plan, spec 문서 제목, brainstorming 주제 순입니다.Tasks 3/5), 목록이 없으면 이번 요청의 서브에이전트(Agents 1/2)를 보여 줍니다. 간단한 요청에는 막대가 뜨지 않습니다.impeccable, taste-skill:…, docs-system:wrapup)을 모델이 부르거나 슬래시로 입력하면 막대 제목에 스킬 이름이 뜹니다. 진행은 할 일 목록, 없으면 이번 요청의 서브에이전트, 둘 다 없으면 Running → Done입니다. 설정 skills로 띄울 스킬을 고릅니다.✗ tests, 통과면 초록 ✓ tests입니다. npm test, pytest, go test, cargo test, claude plugin test 같은 명령을 알아보고, | tail처럼 종료 코드가 테스트의 것이 아니면 출력의 요약 줄로 판정합니다. superpowers 흐름 안에서는 요청이 바뀌어도 남습니다.2:13)입니다. 메인 턴이 끝나면 멈추고, 다음 요청에서 다시 잽니다. 슬래시로 부른 스킬도 새 요청입니다.claude --resume·--continue, /resume, /branch로 세션을 다시 열면 대화 기록을 다시 읽어 superpowers 흐름과 끝나지 않은 할 일 목록을 되살립니다./work-band를 입력하면 에이전트 패널(모델·effort, 컨텍스트, 예상 비용, 시간, 서브에이전트의 할 일 진행)을 열고 닫습니다.비용은 토큰 수와 내장 가격표로 계산한 추정치입니다.
language: auto(기본, Claude Code language 설정 → 시스템 로캘), en, ko. /config에서 바꾸거나, 폴더에서 바로 불러온 mod라면 ~/.claude/settings.json에 씁니다.
skills: superpowers 외에 띄울 스킬을 쉼표로 적습니다. 이름 전체(impeccable)나 플러그인 접두어(taste-skill:)를 쓰고, 비우면(기본) 모두 띄웁니다.
testCommands: 테스트 실행으로 볼 명령 접두어를 쉼표로 더합니다(예: just test, ./run-tests.sh).
{ "pluginConfigs": { "work-band": { "options": { "language": "ko", "skills": "taste-skill:, impeccable, docs-system:", "testCommands": "just test" } } } }
testCommands에 더하세요.skill.prompt 훅이 돌지 않는 곳에서는 슬래시로 입력한 스킬이 흐름을 움직이지도, 새 요청을 시작하지도 않습니다. /superpowers:*도 마찬가지입니다. 조직이 관리 설정(managed settings)으로 Claude Code를 관리하는 환경(사용자가 설치한 플러그인의 이 훅은 돌지 않습니다)이나 이 훅이 없는 Claude Code 빌드가 그렇습니다. 모델이 부른 스킬은 이런 곳에서도 반영됩니다.막대·크랩·패널 그리기와 비용 계산 코드 일부는 johnnyvizz/claude-kit의 savvy-progress(MIT)에서 차용해 고쳤습니다. 자세한 내용은 THIRD_PARTY_NOTICES.md를 보세요.
hooks/work-band.tsx 566 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Panel, SlashCandidate } from '../types'
5import { costOf, ctxOf, elapsed, fmtCost, fmtTime, fmtTokens, modelName, runModel, totals, usageTokens, windowOf, type Usage } from './agents'
6import {
7 ACCENT,
8 CLAY,
9 DONE,
10 H,
11 HEADER_H,
12 LINE_H,
13 ROW_H,
14 STATUS_GLYPH,
15 agentSvg,
16 barText,
17 compactSvg,
18 ctxBar,
19 gridSvg,
20 gridTiles,
21 headerSvg,
22 rowSvg,
23} from './draw'
24import { MAX_TEXT, blankWork, clip, clockMs, isClockLive, listOf, mdPathOf, pickBar, skillStage, stepsOf, withPlan, type TaskUpdateInput } from './flow'
25import { confirmSlash, onAgentEnd, onAgentStart, onDismiss, onFile, onMainTurnEnd, onRequest, onSkill, onSlash, onTaskCreate, onTaskUpdate, onTest, onTodoWrite } from './events'
26import { STRINGS, langOf, type Lang } from './i18n'
27import { replay } from './replay'
28import { parseList, testVerdict, type BashAnswer, type BashCall } from './tests'
29
30const PANE = 'work-band-agents'
31// Where a prompt the user sent comes from: the prompt box, Remote Control, an SDK host such as the desktop app.
32const USER_ORIGINS: ReadonlySet<unknown> = new Set(['composer', 'bridge', 'sdk'])
33
34// The engine lists a module's state from these literals: plugin and key stay spelled out.
35const work = atom({ plugin: 'work-band', key: 'work' } as const, blankWork())
36const agents = atom({ plugin: 'work-band', key: 'agents' } as const, [])
37const panel = atom({ plugin: 'work-band', key: 'panel' } as const, { isCompact: false, isDoneCollapsed: false })
38const now = atom({ plugin: 'work-band', key: 'now' } as const, 0)
39
40// Module scope is fine: session.start sets it on every (re)load.
41let lang: Lang = 'en'
42const tr = () => STRINGS[lang]
43
44async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
45 if (option === 'en' || option === 'ko') return option
46 try {
47 const settings = (await $.settings.read()) as Record<string, unknown>
48 if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
49 } catch {
50 // No settings: the locale decides.
51 }
52 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
53 return langOf(locale)
54}
55
56// True when the tool ran and answered: not refused by a hook, not an error, not a failure it reported.
57const isAnswered = (r: { deny?: string; isError?: boolean; result?: unknown }): boolean =>
58 r.deny === undefined && !r.isError && (r.result as { success?: unknown } | undefined)?.success !== false
59
60// Opens the agents pane, or closes it when it is up; true when it ends up open.
61async function togglePane($: EngineInterface): Promise<boolean> {
62 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
63 if (isOpen) {
64 await $.ui.close({ id: PANE })
65 return false
66 }
67 const at = await $.clock.now()
68 await update($, now, () => at)
69 await $.ui.open({ id: PANE, title: tr().pane })
70 return true
71}
72
73// A plan's text; undefined when there is none to read.
74async function planText($: EngineInterface, path: string | undefined): Promise<string | undefined> {
75 if (!path) return undefined
76 try {
77 const md = await $.fs.read(path)
78 return typeof md === 'string' ? md : undefined
79 } catch {
80 // An unreadable plan: the to-do list counts the tasks.
81 return undefined
82 }
83}
84
85// Reads the plan an executing skill was handed, for its title and task count.
86async function readPlan($: EngineInterface, path: string | undefined): Promise<void> {
87 const md = await planText($, path)
88 if (md !== undefined) await update($, work, prev => (prev.flow ? { ...prev, flow: withPlan(prev.flow, md) } : prev))
89}
90
91// A skill, called by the model or typed as a slash command: superpowers' move
92// the flow, another becomes the request's skill; an executing skill handed a
93// plan path reads the plan's task count.
94async function applySkill($: EngineInterface, name: unknown, args: unknown, skills: readonly string[]): Promise<void> {
95 const w = await update($, work, prev => onSkill(prev, name, args, skills))
96 if (skillStage(name) === 'build' && w.flow && !w.flow.planTasks) await readPlan($, mdPathOf(args))
97}
98
99// A file the main session wrote or edited; one outside the project root never starts the build.
100async function applyFile($: EngineInterface, path: unknown, content?: unknown): Promise<void> {
101 const root = await $.session.root().catch(() => undefined)
102 await update($, work, w => onFile(w, path, content, root))
103}
104
105// A resumed or branched session starts with empty state: rebuild what outlives
106// a request from the transcript, once. Live work already there stands, and so
107// does anything written while the transcript was read. The plan is read before
108// the one write of the rebuilt work: a `get` after a write in the same dispatch
109// would not see it.
110async function restore($: EngineInterface, skills: readonly string[], tests: readonly string[]): Promise<void> {
111 const held = await $.state.get({ plugin: 'work-band', key: 'work' })
112 const w0 = held.value ?? blankWork()
113 if (w0.isReplayed) return
114 const marked = await $.state.set({ plugin: 'work-band', key: 'work' }, { ...w0, isReplayed: true }, { ifVersion: held.version })
115 if (!marked.isSet || w0.flow || w0.todos.length) return
116 try {
117 const messages = await $.session.messages()
118 const root = await $.session.root().catch(() => undefined)
119 const at = await $.clock.now()
120 const { work: restored, planPath } = replay(messages, { root, skills, tests, at })
121 if (!restored.flow && !restored.todos.length) return
122 const md = await planText($, planPath)
123 const flow = restored.flow && md !== undefined ? withPlan(restored.flow, md) : restored.flow
124 await $.state.set({ plugin: 'work-band', key: 'work' }, { ...restored, flow }, { ifVersion: marked.version })
125 } catch {
126 // No transcript to read: the band waits for live events.
127 }
128}
129
130export const register: Register = (on, options) => {
131 // More command prefixes that count as a test run, from the plugin's settings.
132 const testCommands = parseList(options.testCommands)
133 // Which other skills the band shows, from the plugin's settings; empty shows all.
134 const skills = parseList(options.skills)
135
136 on('session.start', async ($, e, next) => {
137 const started = await next(e)
138 lang = await detectLang($, options.language)
139 await $.command.register({
140 name: 'work-band',
141 description: 'Show or hide the agents panel: running and finished subagents with model, context, cost and time',
142 })
143 // Ticks the running agents' clocks and the request's; quiet when nothing runs.
144 $.clock.every(1000, () => {
145 void (async () => {
146 const list = await read($, agents)
147 const w = await read($, work)
148 if (!list.some(a => a.status === 'running') && !isClockLive(w)) return
149 const at = await $.clock.now()
150 await update($, now, () => at)
151 })()
152 })
153 // `claude --resume` or `--continue`: the transcript is already there.
154 try {
155 await restore($, skills, testCommands)
156 } catch {
157 // Nothing restored: the band waits for live events.
158 }
159 return started
160 })
161
162 // /resume and /branch inside a session clear the state and do not run session.start again.
163 on('classic.SessionStart', async ($, e, next) => {
164 const result = await next(e)
165 try {
166 if (e.source === 'resume' || e.source === 'fork') await restore($, skills, testCommands)
167 } catch {
168 // Nothing restored: the band waits for live events.
169 }
170 return result
171 })
172
173 // To-do lists, the main session's and each subagent's, read as they are written.
174 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
175 const result = await next(e)
176 if (!isAnswered(result)) return result
177 await update($, work, w => onTodoWrite(w, e.agentId, (e as unknown as { todos?: unknown }).todos))
178 return result
179 })
180
181 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
182 const result = await next(e)
183 if (!isAnswered(result)) return result
184 const input = e as unknown as { subject?: unknown; activeForm?: unknown }
185 const id = (result.result as { task?: { id?: unknown } } | undefined)?.task?.id
186 await update($, work, w => onTaskCreate(w, e.agentId, id, input.subject, input.activeForm))
187 return result
188 })
189
190 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
191 const result = await next(e)
192 if (!isAnswered(result)) return result
193 await update($, work, w => onTaskUpdate(w, e.agentId, e as unknown as TaskUpdateInput))
194 return result
195 })
196
197 // The main session's skills and files move the superpowers flow; a subagent's never do.
198 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
199 const result = await next(e)
200 if (e.agentId || !isAnswered(result)) return result
201 const input = e as unknown as { skill?: unknown; args?: unknown }
202 await applySkill($, input.skill, input.args, skills)
203 return result
204 })
205
206 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
207 const result = await next(e)
208 if (e.agentId || !isAnswered(result)) return result
209 const input = e as unknown as { file_path?: unknown; content?: unknown }
210 await applyFile($, input.file_path, input.content)
211 return result
212 })
213
214 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
215 const result = await next(e)
216 if (e.agentId || !isAnswered(result)) return result
217 await applyFile($, (e as unknown as { file_path?: unknown }).file_path)
218 return result
219 })
220
221 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
222 const result = await next(e)
223 if (e.agentId || !isAnswered(result)) return result
224 await applyFile($, (e as unknown as { notebook_path?: unknown }).notebook_path)
225 return result
226 })
227
228 // A test run, the main session's or a subagent's: the chip shows how the last one went.
229 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
230 const result = await next(e)
231 try {
232 const verdict = testVerdict(e as unknown as BashCall, result as BashAnswer, testCommands)
233 if (verdict) {
234 const at = await $.clock.now()
235 await update($, work, w => onTest(w, verdict === 'pass', at))
236 }
237 } catch {
238 // The chip stays as it was; the tool call's answer goes back untouched.
239 }
240 return result
241 })
242
243 // A slash command the user sent waits for skill.prompt to say it is a skill.
244 // Builds without prompt.submit's `command` report it only here; with it, the second is a no-op.
245 on('command.run', async ($, e, next) => {
246 const kind = e.origin?.kind
247 if (kind === undefined || USER_ORIGINS.has(kind)) await update($, work, w => onSlash(w, e.command, e.args, true))
248 return next(e)
249 })
250
251 // The engine expands a skill: the waiting slash command was one. Typed while
252 // idle it is a new request; a superpowers skill moves the flow.
253 on('skill.prompt', async ($, e, next) => {
254 const at = await $.clock.now()
255 // An object, not a `let`: TypeScript does not see a write made inside the update callback.
256 const box: { slash?: SlashCandidate } = {}
257 const w = await update($, work, prev => {
258 const r = confirmSlash(prev, e.skill, e.pluginName, at, skills)
259 box.slash = r.slash
260 return r.work
261 })
262 const slash = box.slash
263 if (slash && skillStage(slash.name) === 'build' && w.flow && !w.flow.planTasks) await readPlan($, mdPathOf(slash.args))
264 return next(e)
265 })
266
267 // A prompt the user sent while idle starts a new request; a slash command
268 // waits for skill.prompt; a prompt typed into a running turn, or one the user
269 // did not send (a finished background task, a peer), does neither.
270 on('prompt.submit', async ($, e, next) => {
271 const isUser = USER_ORIGINS.has(e.origin?.kind)
272 if (e.command) {
273 if (isUser) await update($, work, w => onSlash(w, e.command?.name, e.command?.args, e.turnId === undefined))
274 } else if (e.turnId === undefined && isUser) {
275 const at = await $.clock.now()
276 await update($, work, w => onRequest(w, at))
277 }
278 return next(e)
279 })
280
281 on('agent.spawn', async ($, e, next) => {
282 const started = await next(e)
283 if (started.deny !== undefined) return started
284 const at = await $.clock.now()
285 await update($, agents, list => {
286 const run: AgentRun = {
287 id: started.agentId ?? e.tool_use_id,
288 agentId: started.agentId,
289 type: e.subagentType,
290 description: clip(e.description ?? '', MAX_TEXT),
291 model: started.model,
292 status: 'running',
293 startedAt: at,
294 contextTokens: 0,
295 contextMax: windowOf(started.model),
296 tokens: 0,
297 costUsd: 0,
298 steps: 0,
299 }
300 return [...list.filter(a => a.id !== run.id), run].slice(-200)
301 })
302 await update($, now, () => at)
303 await update($, work, onAgentStart)
304 return started
305 })
306
307 // Each model request of a subagent: live context, tokens and cost.
308 on('turn.step', async function* ($, e, next) {
309 const result = yield* next(e)
310 const agentId = e.agentId
311 const usage = result.usage
312 if (!agentId || !usage) return result
313 const model = usage.model || e.model
314 await update($, agents, list =>
315 list.map((a): AgentRun =>
316 a.agentId !== agentId
317 ? a
318 : {
319 ...a,
320 model,
321 effort: typeof e.effort === 'string' || typeof e.effort === 'number' ? String(e.effort) : a.effort,
322 status: 'running',
323 endedAt: undefined,
324 contextTokens: usageTokens(usage),
325 contextMax: windowOf(model),
326 tokens: a.tokens + usageTokens(usage),
327 costUsd: a.costUsd + costOf(model, usage, true),
328 steps: a.steps + 1,
329 },
330 ),
331 )
332 return result
333 })
334
335 on('turn.complete', async ($, e, next) => {
336 const agentId = e.agentId
337 const at = await $.clock.now()
338 if (agentId) {
339 await update($, agents, list =>
340 list.map((a): AgentRun => {
341 if (a.agentId !== agentId) return a
342 // A run whose steps went unseen still gets the turn's own sum.
343 const usage = a.steps === 0 ? (e.usage as (Usage & { model?: string }) | null | undefined) : undefined
344 return {
345 ...a,
346 status: e.reason === 'answer' ? 'done' : 'failed',
347 endedAt: at,
348 ...(usage ? { model: usage.model || a.model, tokens: usageTokens(usage), costUsd: costOf(usage.model || a.model, usage, false) } : {}),
349 }
350 }),
351 )
352 await update($, now, () => at)
353 await update($, work, w => onAgentEnd(w, agentId))
354 } else {
355 // The request's clock stops; in Finish, the main turn that carries out the
356 // user's reply closes the flow, if it answered.
357 await update($, work, w => onMainTurnEnd(w, at, e.reason === 'answer'))
358 }
359 return next(e)
360 })
361
362 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
363 if (e.props.hasSurvey) return next(e)
364 const s = tr()
365 const w = await read($, work)
366 const list = await read($, agents)
367 const bar = pickBar(w, list, s)
368 if (!bar) return next(e)
369 // Reading `now` redraws the band on each tick; the clock reads the time itself.
370 await read($, now)
371 const ms = clockMs(w, await $.clock.now())
372 const isLive = isClockLive(w)
373 const chip = w.test ? { isPass: w.test.isPass, text: `${w.test.isPass ? '✓' : '✗'} ${s.tests}` } : null
374 // Draw this row with the other mods' bands beneath it. Claude Code's own
375 // placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
376 const drawn = await Promise.resolve(next(e)).catch(() => null)
377 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
378
379 const ui = $.ui.resolve(e)
380 const { Box, Text, Button } = ui
381 const running = list.filter(a => a.status === 'running').length
382 const percent = `${Math.round(bar.ratio * 100)}%`
383 const crew = running ? <Button key="work-band-agents" label={`×${running}`} plain onPress={() => void togglePane($)} /> : null
384 const dismiss = <Button key="work-band-dismiss" label="✕" plain role="dismiss" onPress={() => update($, work, prev => onDismiss(prev, bar.isFlow))} />
385
386 // The terminal's element table is completed with an `Svg` that draws nothing: test the surface too.
387 if (e.surface !== 'terminal' && 'Svg' in ui) {
388 const { Svg } = ui
389 // About 8 CSS px per reported column; the rest is the count, the dismiss and their gaps.
390 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
391 // Too narrow for everything: the time goes first.
392 const time = ms !== null && width >= 360 ? fmtTime(ms) : undefined
393 const alt = [bar.title, bar.pill, percent, chip?.text, time, bar.note, bar.doing].filter(Boolean).join(' · ')
394 return (
395 <Box flexDirection="column">
396 <Box flexDirection="row" alignItems="center" gap={1}>
397 <Svg source={rowSvg(bar, width, running > 0, { time, isLive, ...(chip ? { test: { isPass: chip.isPass, word: s.tests } } : {}) })} alt={alt} width={width} height={H} />
398 {crew}
399 {dismiss}
400 </Box>
401 {below}
402 </Box>
403 )
404 }
405
406 const cols = e.props.bodyColumns
407 // Too narrow for everything: the time goes first.
408 const time = ms !== null && cols >= 60 ? fmtTime(ms) : null
409 const titleW = Math.max(8, Math.min(30, bar.title.length + 2, Math.floor(cols / 3)))
410 const width = Math.max(6, Math.min(40, cols - titleW - 32 - (time ? time.length + 2 : 0) - (chip ? chip.text.length + 2 : 0)))
411 const color = bar.isFinished ? DONE : ACCENT
412 return (
413 <Box flexDirection="column">
414 <Box flexDirection="row" gap={2}>
415 <Box width={titleW} flexShrink={0}>
416 <Text color={color}>● </Text>
417 <Text wrap="truncate-end">{bar.title}</Text>
418 </Box>
419 <Text color={color}>{barText(bar.ratio, width)}</Text>
420 <Text bold>{bar.pill}</Text>
421 <Text dimColor>{percent}</Text>
422 {chip && <Text color={chip.isPass ? 'green' : 'red'}>{chip.text}</Text>}
423 {time && <Text dimColor={isLive}>{time}</Text>}
424 <Text color={CLAY}>▣</Text>
425 {crew}
426 {dismiss}
427 </Box>
428 {below}
429 </Box>
430 )
431 })
432
433 on('command.run', { command: 'work-band' }, async $ => {
434 const isOpen = await togglePane($)
435 return { text: isOpen ? tr().opened : tr().closed }
436 })
437
438 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
439 const s = tr()
440 const ui = $.ui.resolve(e)
441 const { Box, Text, Button } = ui
442 const list = await read($, agents)
443 const w = await read($, work)
444 const p: Panel = await read($, panel)
445 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
446 const running = list.filter(a => a.status === 'running').reverse()
447 const finished = list.filter(a => a.status !== 'running').reverse()
448 const t = totals(list, at)
449 // A running subagent's own to-do list; never the main session's.
450 const stepsFor = (a: AgentRun) => (a.status === 'running' && a.agentId ? stepsOf(listOf(w, a.agentId)) : null)
451 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
452
453 const toggleCompact = (
454 <Button key="compact" label={p.isCompact ? s.expand : s.collapse} plain onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))} />
455 )
456 const toggleDone = (
457 <Button
458 key="done"
459 label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
460 plain
461 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
462 />
463 )
464
465 if (e.surface === 'desktop' && 'Svg' in ui) {
466 const { Svg } = ui
467 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
468 if (p.isCompact) {
469 return (
470 <Box flexDirection="column" gap={1}>
471 <Svg source={compactSvg(W, list, t)} alt={`${list.length} ${s.pane}, ${summary}`} width={W} height={32} />
472 {toggleCompact}
473 </Box>
474 )
475 }
476 const shownDone = p.isDoneCollapsed ? 0 : finished.length
477 const lines = 1 + (list.length === 0 ? 1 : 0) + (running.length > 0 ? 1 : 0) + (finished.length > 0 ? 1 : 0)
478 const gridH = HEADER_H + lines * LINE_H + (running.length + shownDone) * ROW_H
479 return (
480 <Box flexDirection="column">
481 {/* The grid sizes the pane; the content is placed over it. */}
482 {gridTiles(gridH).map((tileH, i) => (
483 <Svg key={`grid-${i}`} source={gridSvg(W, tileH)} alt="dotted grid" width={W} height={tileH} />
484 ))}
485 <Box flexDirection="column" position="absolute" top={0} left={0} right={0}>
486 <Svg source={headerSvg(W, t, s)} alt={summary} width={W} height={HEADER_H} />
487 {toggleCompact}
488 {list.length === 0 && <Text dimColor>{s.empty}</Text>}
489 {running.length > 0 && <Text dimColor>{`${s.running} · ${running.length}`}</Text>}
490 {running.map(a => (
491 <Svg key={a.id} source={agentSvg(W, a, at, stepsFor(a), s)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={ROW_H} />
492 ))}
493 {finished.length > 0 && toggleDone}
494 {!p.isDoneCollapsed &&
495 finished.map(a => (
496 <Svg key={a.id} source={agentSvg(W, a, at, null, s)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={ROW_H} />
497 ))}
498 </Box>
499 </Box>
500 )
501 }
502
503 // Terminal: the same content in text rows.
504 const cols = Math.max(24, e.props.bodyColumns || 40)
505 const barW = Math.max(6, Math.min(20, cols - 34))
506 const row = (a: AgentRun) => {
507 const ctx = ctxOf(a)
508 const steps = stepsFor(a)
509 const progress = a.status === 'done' ? 1 : steps ? steps.done / steps.total : null
510 const stepText = steps ? `${steps.done}/${steps.total}${steps.note ? ' ' + steps.note : ''} · ` : ''
511 return (
512 <Box key={a.id} flexDirection="column" marginBottom={1}>
513 <Box flexDirection="row" gap={1}>
514 <Text color={CLAY}>▣</Text>
515 <Text bold wrap="truncate-end">
516 {a.description || a.type}
517 </Text>
518 <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : ACCENT}>{STATUS_GLYPH[a.status]}</Text>
519 </Box>
520 <Text dimColor wrap="truncate-end">
521 {' '}
522 {a.type} · {runModel(a)}
523 </Text>
524 <Text wrap="truncate-end">
525 {' '}
526 {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={ACCENT}>{ctxBar(progress * 100, barW)}</Text>}
527 <Text dimColor>
528 {' '}
529 {stepText}
530 {s.ctx} {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
531 </Text>
532 </Text>
533 </Box>
534 )
535 }
536
537 return (
538 <Box flexDirection="column">
539 <Box flexDirection="row" justifyContent="space-between">
540 <Text dimColor>
541 ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
542 </Text>
543 {toggleCompact}
544 </Box>
545 {p.isCompact ? (
546 <Text wrap="truncate-end">
547 {[...running, ...finished].map(a => (
548 <Text key={a.id} color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : ACCENT}>
549 {STATUS_GLYPH[a.status]}{' '}
550 </Text>
551 ))}
552 </Text>
553 ) : (
554 <Box flexDirection="column" marginTop={1}>
555 {list.length === 0 && <Text dimColor>{s.empty}</Text>}
556 {running.length > 0 && <Text dimColor>{`${s.running} · ${running.length}`}</Text>}
557 {running.map(row)}
558 {finished.length > 0 && toggleDone}
559 {!p.isDoneCollapsed && finished.map(row)}
560 </Box>
561 )}
562 </Box>
563 )
564 })
565}
566hooks/agents.ts 88 lines1// Prices, model names and formats borrowed from savvy-progress
2// (johnnyvizz/claude-kit, MIT; see THIRD_PARTY_NOTICES.md).
3import type { AgentRun } from '../types'
4
5// USD per million tokens: input, output, cache read, cache write. The first row
6// that matches a model id wins, so a release sits above its family. The engine
7// reports tokens, not money, so cost is an estimate; cache writes are priced at
8// the 1-hour rate (2x input), which Claude Code writes on a subscription.
9type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
10
11const PRICES: [RegExp, Price][] = [
12 [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
13 [/fable|mythos/, [10, 50, 1, 20]],
14 [/opus-5-5/, [4, 20, 0.2, 8]],
15 [/opus/, [5, 25, 0.5, 10]],
16 [/sonnet-5-5/, [2, 10, 0.1, 4]],
17 [/sonnet-5/, [2, 10, 0.2, 4]],
18 [/sonnet/, [3, 15, 0.3, 6]],
19 [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
20 [/haiku/, [1, 5, 0.1, 2]],
21]
22
23// Haiku 5.5 is priced by prompt length: a request whose prompt (input, cache
24// reads and cache writes together) is over 100,000 tokens pays these instead.
25const HAIKU_LONG_PROMPT = 100_000
26const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1]
27
28export type Usage = {
29 input_tokens: number
30 output_tokens: number
31 cache_read_input_tokens: number
32 cache_creation_input_tokens: number
33}
34
35const priceOf = (model: string, prompt: number): Price => {
36 const id = model.toLowerCase()
37 if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG
38 return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8]
39}
40
41export const usageTokens = (u: Usage): number =>
42 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
43
44// `isRequest`: the usage is one model request, which a prompt-length price is set by.
45export const costOf = (model: string, u: Usage, isRequest: boolean): number => {
46 const prompt = isRequest ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
47 const [i, o, r, w] = priceOf(model, prompt)
48 return ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) / 1e6
49}
50
51// Haiku 4.5 and the Claude 3 models have a 200K window; the rest 1M.
52export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
53
54export const modelName = (id: string): string => {
55 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
56 const [, family = '', major = '', minor] = m ?? []
57 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
58 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
59}
60
61export const runModel = (a: AgentRun): string => (a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model))
62
63export const fmtTokens = (n: number): string =>
64 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
65
66export const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
67
68export const fmtTime = (ms: number): string => {
69 const s = Math.max(0, Math.round(ms / 1000))
70 const h = Math.floor(s / 3600)
71 const m = Math.floor((s % 3600) / 60)
72 const ss = String(s % 60).padStart(2, '0')
73 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
74}
75
76export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
77
78export const totals = (list: AgentRun[], at: number) => {
79 const cost = list.reduce((sum, a) => sum + a.costUsd, 0)
80 const tokens = list.reduce((sum, a) => sum + a.tokens, 0)
81 if (!list.length) return { cost, tokens, time: 0 }
82 const start = Math.min(...list.map(a => a.startedAt))
83 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
84 return { cost, tokens, time: end - start }
85}
86
87export const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
88hooks/draw.ts 264 lines1// Drawings borrowed from savvy-progress (johnnyvizz/claude-kit, MIT; see
2// THIRD_PARTY_NOTICES.md): the dithered band, the pixel crab, the panel rows.
3import type { AgentRun } from '../types'
4import { ctxOf, elapsed, fmtCost, fmtTime, fmtTokens, runModel } from './agents'
5import type { Bar, Steps } from './flow'
6import type { Strings } from './i18n'
7
8export const H = 22
9const BAR_H = 16
10const CRAB_W = 26
11const CELL = 3
12const CRAB_SCALE = 1.1
13const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI','Malgun Gothic',sans-serif"
14export const ACCENT = '#8f8cf4'
15export const DONE = '#5fbf8f'
16export const CLAY = '#D97757'
17export const PASS = '#3B9C5F'
18export const FAIL = '#D0453F'
19const INK = '#1F1E1D'
20
21export const xml = (s: string): string =>
22 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
23
24// CJK, Hangul, fullwidth forms and emoji advance about 1em.
25const WIDE =
26 /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꥠ-가-힣豈-︰-﹏!-⦆¢-₩\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
27
28// Rough advance of system UI text, in em; good enough to size a slot.
29const charEm = (ch: string): number =>
30 WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56
31
32export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
33
34// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
35export const fitText = (s: string, size: number, maxW: number): string => {
36 if (textWidth(s, size) <= maxW) return s
37 let out = ''
38 for (const ch of s) {
39 if (textWidth(out + ch + '…', size) > maxW) break
40 out += ch
41 }
42 return out + '…'
43}
44
45const clamp01 = (n: number): number => Math.max(0, Math.min(1, Number.isFinite(n) ? n : 0))
46
47// Deterministic noise so the dither does not shimmer between redraws.
48const noise = (x: number, y: number): number => {
49 const v = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
50 return v - Math.floor(v)
51}
52
53// Pure CSS, run by the compositor. Legs step and the body bobs while walking.
54const CRAB_CSS = `<style>
55.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
56.run .bd{animation:bob .5s steps(1) infinite -.125s}
57.run g{transform-box:fill-box}
58@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
59@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
60</style>`
61
62// Pixel Clawd: a 24×18 crab on a 30×28 grid.
63export const crab = (x: number, y: number, isWalking: boolean, scale = CRAB_SCALE, dim = false): string => {
64 const groups: Record<'bd' | 'la' | 'lb', string[]> = { bd: [], la: [], lb: [] }
65 const f = (cx: number, cy: number, w: number, h: number, c: string, cls: 'bd' | 'la' | 'lb' = 'bd') =>
66 groups[cls].push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
67 f(7, 10, 16, 12, CLAY)
68 f(3, 14, 4, 4, CLAY)
69 f(23, 14, 4, 4, CLAY)
70 f(9, 12, 2, 2, INK)
71 f(19, 12, 2, 2, INK)
72 f(7, 22, 2, 4, CLAY, 'la')
73 f(17, 22, 2, 4, CLAY, 'la')
74 f(11, 22, 2, 4, CLAY, 'lb')
75 f(21, 22, 2, 4, CLAY, 'lb')
76 const g = (cls: 'bd' | 'la' | 'lb') => `<g class="${cls}">${groups[cls].join('')}</g>`
77 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="crab${isWalking ? ' run' : ''}">${g('bd')}${g('la')}${g('lb')}</g></g>`
78}
79
80// What sits between the percent and the crab, laid out right to left.
81export type Tail = { time?: string; isLive?: boolean; test?: { isPass: boolean; word: string } }
82const TAIL_FONT = 12
83
84// The tail's SVG, ending at `right`, and the width it takes with its gap.
85export const tailSvg = (tail: Tail, right: number): { svg: string; width: number } => {
86 const parts: string[] = []
87 let x = right
88 const put = (text: string, attrs: string) => {
89 parts.push(`<text x="${x}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="${TAIL_FONT}" font-variant-numeric="tabular-nums" ${attrs}>${xml(text)}</text>`)
90 x -= Math.round(textWidth(text, TAIL_FONT)) + 10
91 }
92 if (tail.time) put(tail.time, `class="m"${tail.isLive ? ' opacity="0.6"' : ''}`)
93 if (tail.test) put(`${tail.test.isPass ? '✓' : '✗'} ${tail.test.word}`, `fill="${tail.test.isPass ? PASS : FAIL}" font-weight="600"`)
94 return { svg: parts.join(''), width: right - x }
95}
96
97// The whole row is one SVG (the desktop wraps sibling elements): title, bar,
98// pill, percent, the tail and the crab; the count and the dismiss are Buttons beside it.
99export const rowSvg = (b: Bar, W: number, isWorking: boolean, tail: Tail = {}): string => {
100 const right = W - CRAB_W - 6
101 const extra = tailSvg(tail, right)
102 const title = fitText(b.title, 13, Math.max(60, W * 0.4 - extra.width))
103 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
104 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W - extra.width)
105 const color = b.isFinished ? DONE : ACCENT
106 const y0 = (H - BAR_H) / 2
107 const fillW = Math.round(BAR_W * clamp01(b.ratio))
108 const runW = Math.round(BAR_W * clamp01(Math.max(b.ratio, b.runRatio)))
109 // At most 200 dot columns, spaced wider on a wide bar: the engine refuses an
110 // Svg over 131072 characters, and a refused band draws nothing.
111 const cellX = Math.max(CELL, Math.ceil(BAR_W / 200))
112 const cols = Math.floor(fillW / cellX)
113 const rows = Math.floor(BAR_H / CELL)
114
115 // Dithered fill: sparse at the start, dense toward the head.
116 const dots: string[] = []
117 for (let c = 0; c < cols; c++) {
118 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
119 for (let r = 0; r < rows; r++) {
120 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * cellX + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
121 }
122 }
123 // Under way, not yet done: a faint second layer.
124 const faint: string[] = []
125 for (let c = cols; c < Math.floor(runW / cellX); c++) {
126 for (let r = 0; r < rows; r++) {
127 if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * cellX + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
128 }
129 }
130 const ticks = b.ticks
131 .map(t => Math.round(BAR_W * t))
132 .filter(x => x > fillW + 4)
133 .map(x => `<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
134
135 const pillW = Math.round(18 + textWidth(b.pill, 11))
136 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
137 const percent = `${Math.round(clamp01(b.ratio) * 100)}%`
138
139 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
140<style>
141.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
142@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
143.t0,.t1,.t2,.t3{animation:tw ${b.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
144.t1{animation-duration:${b.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${b.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${b.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
145@keyframes tw{0%,100%{opacity:1}50%{opacity:${b.isFinished ? 0.8 : 0.3}}}
146@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
147</style>
148<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
149<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
150<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
151<g transform="translate(${BAR_X},${y0})">
152<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
153<g clip-path="url(#c)">
154<g fill="${color}">${dots.join('')}</g>
155<g fill="${color}" opacity="0.45">${faint.join('')}</g>
156<g class="tk">${ticks.join('')}</g>
157</g>
158<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
159<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(b.pill)}</text>
160</g>
161<text class="m" x="${right - extra.width}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
162${extra.svg}${CRAB_CSS}${crab(W - CRAB_W + 1, 0, isWorking, 0.8)}
163</svg>`
164}
165
166export const barText = (ratio: number, width: number): string => {
167 const filled = Math.round(width * clamp01(ratio))
168 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
169}
170
171export const ctxBar = (pct: number, width: number): string => barText(pct / 100, width)
172
173export const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗' }
174
175// ---------------------------------------------------------------------------
176// Panel: each row is one SVG over a dotted grid.
177
178const GRID_STEP = 16
179// A Text or Button row on the desktop is about 20px; the grid errs taller.
180export const LINE_H = 24
181export const HEADER_H = 44
182export const ROW_H = 66
183// The engine refuses an Svg taller than 4096px: a long grid is stacked from tiles.
184const GRID_TILE = 4096
185
186export const gridTiles = (h: number): number[] =>
187 Array.from({ length: Math.ceil(h / GRID_TILE) }, (_, i) => Math.min(GRID_TILE, h - i * GRID_TILE))
188
189export const gridSvg = (W: number, h: number): string => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}">
190<style>.d{fill:#d9d8d4}@media (prefers-color-scheme: dark){.d{fill:#3a3a38}}</style>
191<defs><pattern id="g" width="${GRID_STEP}" height="${GRID_STEP}" patternUnits="userSpaceOnUse"><circle class="d" cx="${GRID_STEP / 2}" cy="${GRID_STEP / 2}" r="1"/></pattern></defs>
192<rect width="${W}" height="${h}" fill="url(#g)"/>
193</svg>`
194
195const PANE_CSS = `<style>
196.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
197@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
198.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
199@media (prefers-reduced-motion: reduce){.live{animation:none}}
200</style>`
201
202const svg = (W: number, h: number, body: string): string =>
203 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
204
205const statusMark = (x: number, y: number, status: string): string => {
206 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${ACCENT}"/>`
207 if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="${PASS}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
208 return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="${FAIL}" stroke-width="1.8" stroke-linecap="round"/>`
209}
210
211type Totals = { cost: number; tokens: number; time: number }
212
213export const headerSvg = (W: number, t: Totals, s: Strings): string => {
214 const gap = 6
215 const tw = (W - gap * 2) / 3
216 const tile = (i: number, k: string, v: string) =>
217 `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
218<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${xml(k)}</text>
219<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
220 return svg(W, HEADER_H, `${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`)
221}
222
223// A run's row: its own steps when its to-do list says, else its context, grey.
224export const agentSvg = (W: number, a: AgentRun, at: number, steps: Steps | null, s: Strings): string => {
225 const ctx = ctxOf(a)
226 const textW = W - 42 - 22
227 const meta = [a.type, runModel(a)]
228 if (a.status === 'failed') meta.push(s.failed)
229 const progress = a.status === 'done' ? 1 : steps ? Math.min(1, steps.done / steps.total) : null
230 const stats = `${s.ctx} ${ctx}% · ${fmtTokens(a.contextTokens)} ≈${fmtCost(a.costUsd)} ${fmtTime(elapsed(a, at))}`
231 const stepText = steps ? `${steps.done}/${steps.total}${steps.note ? ' · ' + steps.note : ''}` : ''
232 const stepsW = Math.max(0, textW - textWidth(stats, 11) - 12)
233 const fillW = Math.round(textW * (progress ?? ctx / 100))
234 return svg(
235 W,
236 ROW_H,
237 `${crab(0, 14, a.status === 'running')}
238<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
239<text class="s" x="42" y="34" font-family="${FONT}" font-size="11">${xml(fitText(meta.join(' · '), 11, textW))}</text>
240${stepText && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(stepText, 11, stepsW))}</text>` : ''}
241<text class="s" x="${42 + textW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>
242<rect class="k" x="42" y="55" width="${textW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${ACCENT}"`}/>
243${statusMark(W - 8, 16, a.status)}
244<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
245 )
246}
247
248export const compactSvg = (W: number, list: AgentRun[], t: Totals): string => {
249 const ordered = [...list.filter(a => a.status === 'running'), ...list.filter(a => a.status !== 'running')]
250 const fit = Math.max(1, Math.floor((W - 150) / 36))
251 const shown = ordered.slice(0, fit)
252 const more = ordered.length - shown.length
253 const body = shown
254 .map((a, i) => crab(i * 36, 0, a.status === 'running') + (a.status === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ACCENT}"/>` : ''))
255 .join('')
256 const x = shown.length * 36 + (more ? 4 : 0)
257 return svg(
258 W,
259 32,
260 `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
261<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
262 )
263}
264hooks/flow.ts 313 lines1import type { AgentRun, Flow, Stage, Todo, TodoStatus, Work } from '../types'
2import type { Strings } from './i18n'
3
4export const MAX_TODOS = 200
5export const MAX_TEXT = 120
6
7export const blankWork = (): Work => ({ flow: null, todos: [], agentTodos: {}, since: 0, isHidden: false })
8
9export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
10
11export const cleanText = (v: unknown): string | undefined =>
12 typeof v === 'string' && v.trim() ? clip(v.trim(), MAX_TEXT) : undefined
13
14const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed']
15
16export const statusOr = (v: unknown, fallback: TodoStatus): TodoStatus => STATUSES.find(s => s === v) ?? fallback
17
18// ---------------------------------------------------------------------------
19// To-do lists: TodoWrite replaces a list, TaskCreate/TaskUpdate keep it row by row.
20
21type TodoInput = { content?: unknown; status?: unknown; activeForm?: unknown }
22
23export const cleanTodos = (todos: unknown): Todo[] =>
24 (Array.isArray(todos) ? (todos as TodoInput[]) : [])
25 .filter(t => cleanText(t?.content))
26 .slice(0, MAX_TODOS)
27 .map((t, i) => ({
28 // Never the engine's TaskCreate ids ('1', '2', …): a TaskUpdate must not hit a TodoWrite row.
29 id: `todo:${i + 1}`,
30 title: cleanText(t.content) ?? '',
31 activeForm: cleanText(t.activeForm),
32 status: statusOr(t.status, 'pending'),
33 }))
34
35// A new pending row; null when the call carried no id or no subject.
36export const createTodo = (todos: Todo[], id: unknown, subject: unknown, activeForm: unknown): Todo[] | null => {
37 const title = cleanText(subject)
38 if (typeof id !== 'string' || !id || !title) return null
39 const row: Todo = { id, title, activeForm: cleanText(activeForm), status: 'pending' }
40 return [...todos.filter(t => t.id !== id), row].slice(-MAX_TODOS)
41}
42
43export type TaskUpdateInput = { taskId?: unknown; status?: unknown; subject?: unknown; activeForm?: unknown }
44
45// The list with that row changed or removed; null for a row it never saw.
46export const updateTodo = (todos: Todo[], input: TaskUpdateInput): Todo[] | null => {
47 if (!todos.some(t => t.id === input.taskId)) return null
48 if (input.status === 'deleted') return todos.filter(t => t.id !== input.taskId)
49 return todos.map(t =>
50 t.id !== input.taskId
51 ? t
52 : {
53 ...t,
54 status: statusOr(input.status, t.status),
55 title: cleanText(input.subject) ?? t.title,
56 activeForm: cleanText(input.activeForm) ?? t.activeForm,
57 },
58 )
59}
60
61// The list a to-do call changes: the main session's, or that subagent's.
62export const listOf = (w: Work, agentId?: string): Todo[] =>
63 agentId ? (Object.hasOwn(w.agentTodos, agentId) ? (w.agentTodos[agentId] ?? []) : []) : w.todos
64
65// A change to the main list brings a dismissed band back.
66export const withList = (w: Work, agentId: string | undefined, todos: Todo[]): Work =>
67 agentId ? { ...w, agentTodos: { ...w.agentTodos, [agentId]: todos } } : { ...w, todos, isHidden: false }
68
69export const doingOf = (todos: Todo[]): string | undefined => {
70 const active = todos.find(t => t.status === 'in_progress')
71 return active?.activeForm || active?.title
72}
73
74export type Steps = { done: number; total: number; note?: string }
75
76export const stepsOf = (todos: Todo[]): Steps | null =>
77 todos.length ? { done: todos.filter(t => t.status === 'completed').length, total: todos.length, note: doingOf(todos) } : null
78
79// ---------------------------------------------------------------------------
80// Requests: a prompt typed while idle.
81
82// A finished flow and a finished list go; the subagents shown are counted from now.
83// In Finish, the request is the reply to the finishing skill's question.
84export const startRequest = (w: Work, at: number): Work => ({
85 ...w,
86 flow: w.flow?.isFinished ? null : w.flow?.stage === 'finish' ? { ...w.flow, hasReply: true } : w.flow,
87 todos: w.todos.every(t => t.status === 'completed') ? [] : w.todos,
88 since: at,
89})
90
91// This request's subagents: still running, or started since it began.
92export const requestAgents = (list: AgentRun[], since: number): AgentRun[] =>
93 list.filter(a => a.status === 'running' || a.startedAt >= since)
94
95// ---------------------------------------------------------------------------
96// The superpowers flow: skills set the stage, files written fill in the rest.
97
98export const STAGES: readonly Stage[] = ['brainstorm', 'spec', 'plan', 'build', 'review', 'finish']
99
100const SKILL_STAGE: Record<string, Stage> = {
101 brainstorming: 'brainstorm',
102 'writing-plans': 'plan',
103 'subagent-driven-development': 'build',
104 'executing-plans': 'build',
105 'test-driven-development': 'build',
106 'requesting-code-review': 'review',
107 'finishing-a-development-branch': 'finish',
108}
109
110// The stage a superpowers skill starts, or null for any other skill.
111export const skillStage = (name: unknown): Stage | null => {
112 if (typeof name !== 'string') return null
113 const key = /^\/?superpowers:([\w-]+)$/.exec(name.trim())?.[1]
114 return key && Object.hasOwn(SKILL_STAGE, key) ? (SKILL_STAGE[key] ?? null) : null
115}
116
117export const firstLine = (s: unknown): string | undefined =>
118 typeof s === 'string' ? cleanText(s.split(/\r?\n/).find(l => l.trim())) : undefined
119
120// Brainstorming always starts a new flow; any other skill starts one when there
121// is none or it finished, and otherwise only moves it forward.
122export const advance = (flow: Flow | null, stage: Stage, args?: unknown): Flow => {
123 if (stage === 'brainstorm') {
124 const topic = firstLine(args)
125 return topic ? { stage, isFinished: false, topic } : { stage, isFinished: false }
126 }
127 if (!flow || flow.isFinished) return { stage, isFinished: false }
128 return STAGES.indexOf(stage) > STAGES.indexOf(flow.stage) ? { ...flow, stage } : flow
129}
130
131const slash = (p: string): string => p.replace(/\\/g, '/')
132
133const isUnder = (path: string, dir: string): boolean => {
134 const p = slash(path)
135 return p.startsWith(dir) || p.includes(`/${dir}`)
136}
137
138const SPECS = 'docs/superpowers/specs/'
139const PLANS = 'docs/superpowers/plans/'
140// Writing here does not mean the build began: the flow's own docs, the visual
141// companion's folder, Claude's own settings and memory.
142const NOT_BUILD = ['docs/superpowers/', '.superpowers/', '.claude/']
143
144export const heading = (md: string): string | undefined => firstLine(/^#[ \t]+(.+)$/m.exec(md)?.[1])
145
146export const countTasks = (md: string): number => (md.match(/^###[ \t]+Task\b/gm) ?? []).length
147
148const planTitleOf = (md: string): string | undefined => heading(md)?.replace(/\s+Implementation Plan$/i, '') || undefined
149const specTitleOf = (md: string): string | undefined => heading(md)?.replace(/\s+(설계|Design)$/i, '') || undefined
150
151// The plan's title and task count, where the text has them.
152export const withPlan = (flow: Flow, md: string): Flow => {
153 const title = planTitleOf(md)
154 const tasks = countTasks(md)
155 return { ...flow, ...(title ? { planTitle: title } : {}), ...(tasks ? { planTasks: tasks } : {}) }
156}
157
158// An absolute path outside the project root (scratchpad, /tmp, another repo).
159// Relative paths, and any path when the root is unknown, count as inside.
160export const isOutside = (path: string, root?: string): boolean => {
161 if (!root) return false
162 const p = slash(path).toLowerCase()
163 if (!/^([a-z]:)?\//.test(p)) return false
164 const r = slash(root).toLowerCase().replace(/\/+$/, '')
165 return p !== r && !p.startsWith(`${r}/`)
166}
167
168// A file the main session wrote or edited (`content` is Write's): the spec
169// moves Brainstorm to Spec, the plan gives title and task count, and any other
170// file of the project in Brainstorm or Spec means the build began without a plan.
171export const onWrite = (flow: Flow | null, path: unknown, content?: unknown, root?: string): Flow | null => {
172 if (!flow || flow.isFinished || typeof path !== 'string') return flow
173 const text = typeof content === 'string' ? content : undefined
174 const isMd = slash(path).toLowerCase().endsWith('.md')
175 if (isMd && isUnder(path, SPECS)) {
176 const next: Flow = flow.stage === 'brainstorm' ? { ...flow, stage: 'spec' } : flow
177 const title = text ? specTitleOf(text) : undefined
178 return title ? { ...next, specTitle: title } : next
179 }
180 if (isMd && isUnder(path, PLANS)) return text ? withPlan(flow, text) : flow
181 if (NOT_BUILD.some(dir => isUnder(path, dir)) || isOutside(path, root)) return flow
182 return flow.stage === 'brainstorm' || flow.stage === 'spec' ? { ...flow, stage: 'build' } : flow
183}
184
185// A `.md` path in a skill's arguments: the plan an executing skill was handed.
186export const mdPathOf = (args: unknown): string | undefined =>
187 typeof args === 'string' ? /[^\s"'`]+\.md\b/i.exec(args)?.[0] : undefined
188
189// ---------------------------------------------------------------------------
190// What the band draws.
191
192// The request's clock runs from its start until its main turn ends.
193export const isClockLive = (w: Work): boolean => !!w.clock && w.clock.to === undefined
194
195// Milliseconds on the request's clock, or null when no request is timed.
196export const clockMs = (w: Work, now: number): number | null =>
197 w.clock ? (w.clock.to ?? Math.max(now, w.clock.from)) - w.clock.from : null
198
199export type Bar = {
200 title: string
201 /** The label riding the head of the fill. */
202 pill: string
203 /** Filled share, 0..1. */
204 ratio: number
205 /** Filled plus in-progress share, 0..1: the faint second layer. */
206 runRatio: number
207 /** Where ticks go, as shares of the bar. */
208 ticks: number[]
209 isFinished: boolean
210 /** The superpowers flow's band (✕ ends it) or the harness's (✕ hides it). */
211 isFlow: boolean
212 /** What is in progress now, for the alt text. */
213 doing: string
214 /** The skill's argument, for the alt text. */
215 note?: string
216}
217
218const doneCount = (todos: Todo[]): number => todos.filter(t => t.status === 'completed').length
219const busyCount = (todos: Todo[]): number => todos.filter(t => t.status === 'in_progress').length
220
221const evenTicks = (n: number): number[] => (n > 1 && n <= 50 ? Array.from({ length: n - 1 }, (_, i) => (i + 1) / n) : [])
222
223// Six equal stages; inside Build the share of tasks done fills its own stage.
224export const flowBar = (flow: Flow, todos: Todo[], s: Strings): Bar => {
225 const n = STAGES.length
226 const at = STAGES.indexOf(flow.stage)
227 const isBuild = flow.stage === 'build'
228 const total = flow.planTasks || todos.length
229 const done = Math.min(total, doneCount(todos))
230 const inner = isBuild && total ? done / total : 0
231 // The stage being worked shows faintly: in Build the tasks in progress, elsewhere the whole stage.
232 const ahead = isBuild && total ? Math.min(total - done, busyCount(todos)) / total : 1
233 return {
234 title: flow.planTitle ?? flow.specTitle ?? flow.topic ?? doingOf(todos) ?? s.superpowers,
235 pill: flow.isFinished ? s.done : isBuild && total ? `${s.stage.build} ${done}/${total}` : s.stage[flow.stage],
236 ratio: flow.isFinished ? 1 : (at + inner) / n,
237 runRatio: flow.isFinished ? 1 : Math.min(1, (at + inner + ahead) / n),
238 ticks: evenTicks(n),
239 isFinished: flow.isFinished,
240 isFlow: true,
241 doing: doingOf(todos) ?? '',
242 }
243}
244
245export const todoBar = (todos: Todo[], s: Strings): Bar | null => {
246 if (!todos.length) return null
247 const done = doneCount(todos)
248 const isFinished = done === todos.length
249 const pending = todos.find(t => t.status !== 'completed')
250 return {
251 title: doingOf(todos) || pending?.title || todos.at(-1)?.title || s.tasks,
252 pill: isFinished ? s.done : `${s.tasks} ${done}/${todos.length}`,
253 ratio: done / todos.length,
254 runRatio: (done + busyCount(todos)) / todos.length,
255 ticks: evenTicks(todos.length),
256 isFinished,
257 isFlow: false,
258 doing: doingOf(todos) ?? '',
259 }
260}
261
262// Ended counts failures too: a failed run is no longer working.
263export const agentsBar = (runs: AgentRun[], s: Strings): Bar | null => {
264 if (!runs.length) return null
265 const running = runs.filter(a => a.status === 'running').length
266 const ended = runs.length - running
267 return {
268 title: s.subagents,
269 pill: running ? `${s.agents} ${ended}/${runs.length}` : s.done,
270 ratio: ended / runs.length,
271 runRatio: 1,
272 ticks: evenTicks(runs.length),
273 isFinished: running === 0,
274 isFlow: false,
275 doing: '',
276 }
277}
278
279// `x:x` (a plugin named after its one skill) reads `x`.
280export const skillLabel = (name: string): string => {
281 const parts = name.split(':')
282 return parts.length === 2 && parts[0] === parts[1] ? (parts[0] ?? name) : name
283}
284
285// The skills setting: empty shows all; `plugin:` matches a prefix; anything else the whole name. Case is ignored.
286export const isShownSkill = (name: string, skills: readonly string[]): boolean => {
287 const n = name.toLowerCase()
288 return !skills.length || skills.some(raw => {
289 const k = raw.toLowerCase()
290 return k.endsWith(':') ? n.startsWith(k) : n === k
291 })
292}
293
294// A skill's band: its name over the to-do list's progress, else the subagents',
295// else Running until the main turn ends.
296export const skillBar = (w: Work, runs: AgentRun[], s: Strings): Bar | null => {
297 if (!w.skill) return null
298 const title = skillLabel(w.skill.name)
299 const note = w.skill.args ? { note: w.skill.args } : {}
300 const base = todoBar(w.todos, s) ?? agentsBar(requestAgents(runs, w.since), s)
301 if (base) return { ...base, title, ...note }
302 const isFinished = w.clock?.to !== undefined
303 const ratio = isFinished ? 1 : 0
304 return { title, pill: isFinished ? s.done : s.running, ratio, runRatio: ratio, ticks: [], isFinished, isFlow: false, doing: '', ...note }
305}
306
307// The flow when there is one; else, unless dismissed, this request's skill, the main list, this request's subagents.
308export const pickBar = (w: Work, runs: AgentRun[], s: Strings): Bar | null => {
309 if (w.flow) return flowBar(w.flow, w.todos, s)
310 if (w.isHidden) return null
311 return skillBar(w, runs, s) ?? todoBar(w.todos, s) ?? agentsBar(requestAgents(runs, w.since), s)
312}
313hooks/events.ts 132 lines1import type { SlashCandidate, Work } from '../types'
2import { advance, cleanTodos, createTodo, firstLine, isShownSkill, listOf, onWrite, skillStage, startRequest, updateTodo, withList, type TaskUpdateInput } from './flow'
3
4// How the work changes with each signal. The live hooks and the replay of a
5// resumed session's transcript fold the same functions, so each rule lives once.
6
7// A superpowers stage skill moves the flow; a new flow starts without the last
8// one's test result. Another skill (not superpowers', and one the `skills`
9// setting shows) becomes the request's skill and brings a dismissed band back.
10export const onSkill = (w: Work, name: unknown, args: unknown, skills: readonly string[]): Work => {
11 const stage = skillStage(name)
12 if (stage) {
13 const flow = advance(w.flow, stage, args)
14 if (stage !== 'brainstorm' && w.flow && !w.flow.isFinished) return { ...w, flow }
15 const { test: _old, ...rest } = w
16 return { ...rest, flow }
17 }
18 const key = typeof name === 'string' ? name.trim().replace(/^\//, '') : ''
19 if (!key || /^superpowers:/i.test(key) || !isShownSkill(key, skills)) return w
20 const note = firstLine(args)
21 return { ...w, skill: note ? { name: key, args: note } : { name: key }, isHidden: false }
22}
23
24// A file the main session wrote or edited (`content` is Write's).
25export const onFile = (w: Work, path: unknown, content: unknown, root?: string): Work => {
26 const flow = onWrite(w.flow, path, content, root)
27 return flow === w.flow ? w : { ...w, flow }
28}
29
30export const onTodoWrite = (w: Work, agentId: string | undefined, todos: unknown): Work => withList(w, agentId, cleanTodos(todos))
31
32export const onTaskCreate = (w: Work, agentId: string | undefined, id: unknown, subject: unknown, activeForm: unknown): Work => {
33 const todos = createTodo(listOf(w, agentId), id, subject, activeForm)
34 return todos ? withList(w, agentId, todos) : w
35}
36
37export const onTaskUpdate = (w: Work, agentId: string | undefined, input: TaskUpdateInput): Work => {
38 const todos = updateTodo(listOf(w, agentId), input)
39 return todos ? withList(w, agentId, todos) : w
40}
41
42// A prompt the user sent while idle: 0.1's rules, the request's clock starts,
43// and the last request's skill and a slash command still waiting go. The last
44// test result stays with a flow still under way, across its requests.
45export const onRequest = (w: Work, at: number): Work => {
46 const { pendingSlash: _stale, skill: _skill, test, ...rest } = startRequest(w, at)
47 const keepsTest = !!rest.flow && !rest.flow.isFinished
48 return { ...rest, clock: { from: at }, ...(keepsTest && test ? { test } : {}) }
49}
50
51// The main turn ended at `at`: the request's clock stops there if it still ran.
52// A slash command sent while idle that was never expanded is stale; one typed
53// into the turn waits in the queue and expands after it, so it stays. The
54// finishing skill ends its turn asking merge / PR / keep; the turn that
55// answered the user's reply ends the flow.
56export const onMainTurnEnd = (w: Work, at: number, isAnswer: boolean): Work => {
57 const { pendingSlash, ...rest } = w
58 const clock = rest.clock && rest.clock.to === undefined ? { ...rest.clock, to: at } : rest.clock
59 const flow = isAnswer && rest.flow?.stage === 'finish' && rest.flow.hasReply && !rest.flow.isFinished ? { ...rest.flow, isFinished: true } : rest.flow
60 return { ...rest, flow, ...(clock ? { clock } : {}), ...(pendingSlash && !pendingSlash.isIdle ? { pendingSlash } : {}) }
61}
62
63// ✕: the flow's band ends the flow; the harness band hides until new data. The test result goes either way.
64export const onDismiss = (w: Work, isFlow: boolean): Work => {
65 const { test: _gone, ...rest } = w
66 return isFlow ? { ...rest, flow: null, isHidden: true } : { ...rest, isHidden: true }
67}
68
69// A test run finished: the chip shows it.
70export const onTest = (w: Work, isPass: boolean, at: number): Work => ({ ...w, test: { isPass, at } })
71
72// A new subagent brings a dismissed band back.
73export const onAgentStart = (w: Work): Work => (w.isHidden ? { ...w, isHidden: false } : w)
74
75// A finished run's own list is done with: the panel shows it full.
76export const onAgentEnd = (w: Work, agentId: string): Work => {
77 if (!Object.hasOwn(w.agentTodos, agentId)) return w
78 const { [agentId]: _gone, ...rest } = w.agentTodos
79 return { ...w, agentTodos: rest }
80}
81
82// ---------------------------------------------------------------------------
83// Slash commands: a skill only once `skill.prompt` expands it.
84
85const slashName = (v: unknown): string => (typeof v === 'string' ? v.trim().replace(/^\//, '') : '')
86const keyOf = (v: unknown): string => slashName(v).toLowerCase()
87
88// A slash command the user sent; `isIdle` when no turn was running. The same
89// command again (command.run after prompt.submit) keeps the first.
90export const onSlash = (w: Work, name: unknown, args: unknown, isIdle: boolean): Work => {
91 const key = keyOf(name)
92 if (!key || keyOf(w.pendingSlash?.name) === key) return w
93 return { ...w, pendingSlash: { name: slashName(name), args: typeof args === 'string' ? args : '', isIdle } }
94}
95
96// The names a `skill.prompt` may go by: as given, with its plugin in front, and
97// a qualified one without its plugin (a slash command may be typed short).
98const promptedKeys = (skill: unknown, pluginName: unknown): string[] => {
99 const s = keyOf(skill)
100 const p = keyOf(pluginName)
101 if (!s.includes(':')) return p && s ? [s, `${p}:${s}`] : [s]
102 return [s, s.slice(s.indexOf(':') + 1)]
103}
104
105// The skill a confirmed slash command is: a name typed short gets its plugin in
106// front, from `pluginName` or from the qualified name `skill.prompt` gave.
107const confirmedName = (typed: string, skill: unknown, pluginName: unknown): string => {
108 if (typed.includes(':')) return typed
109 const plugin = slashName(pluginName)
110 if (plugin) return `${plugin}:${typed}`
111 const prompted = slashName(skill)
112 return prompted.includes(':') ? prompted : typed
113}
114
115// `skill.prompt` for the waiting slash command: it was a skill. An idle one is a
116// new request first, then the skill applies. Any other `skill.prompt` (the
117// model's Skill call, a subagent's) changes nothing here.
118export const confirmSlash = (
119 w: Work,
120 skill: unknown,
121 pluginName: unknown,
122 at: number,
123 skills: readonly string[],
124): { work: Work; slash?: SlashCandidate } => {
125 const c = w.pendingSlash
126 if (!c || !promptedKeys(skill, pluginName).includes(keyOf(c.name))) return { work: w }
127 const { pendingSlash: _done, ...rest } = w
128 const base = c.isIdle ? onRequest(rest, at) : rest
129 const slash = { ...c, name: confirmedName(c.name, skill, pluginName) }
130 return { work: onSkill(base, slash.name, slash.args, skills), slash }
131}
132hooks/i18n.ts 62 lines1import type { Stage } from '../types'
2
3export type Lang = 'en' | 'ko'
4
5const en = {
6 stage: { brainstorm: 'Brainstorm', spec: 'Spec', plan: 'Plan', build: 'Build', review: 'Review', finish: 'Finish' } as Record<Stage, string>,
7 done: 'Done',
8 tasks: 'Tasks',
9 agents: 'Agents',
10 tests: 'tests',
11 subagents: 'Subagents',
12 superpowers: 'superpowers',
13 pane: 'Agents',
14 cost: 'Cost',
15 tokens: 'Tokens',
16 time: 'Time',
17 collapse: 'Collapse',
18 expand: 'Expand',
19 running: 'Running',
20 finished: 'Finished',
21 empty: 'No subagents yet.',
22 failed: 'error',
23 tokensWord: 'tokens',
24 ctx: 'ctx',
25 isRunning: 'running',
26 isFinished: 'finished',
27 opened: 'Agents panel opened.',
28 closed: 'Agents panel closed.',
29}
30
31export type Strings = typeof en
32
33const ko: Strings = {
34 stage: { brainstorm: '브레인스토밍', spec: '스펙', plan: '계획', build: '구현', review: '리뷰', finish: '마무리' },
35 done: '완료',
36 tasks: '작업',
37 agents: '에이전트',
38 tests: '테스트',
39 subagents: '서브에이전트',
40 superpowers: 'superpowers',
41 pane: '에이전트',
42 cost: '비용',
43 tokens: '토큰',
44 time: '시간',
45 collapse: '접기',
46 expand: '펼치기',
47 running: '실행 중',
48 finished: '끝남',
49 empty: '아직 서브에이전트가 없습니다.',
50 failed: '오류',
51 tokensWord: '토큰',
52 ctx: 'ctx',
53 isRunning: '실행 중',
54 isFinished: '끝남',
55 opened: '에이전트 패널을 열었습니다.',
56 closed: '에이전트 패널을 닫았습니다.',
57}
58
59export const STRINGS: Record<Lang, Strings> = { en, ko }
60
61export const langOf = (v: unknown): Lang => (typeof v === 'string' && /^(ko|korean|한국)/i.test(v.trim()) ? 'ko' : 'en')
62hooks/replay.ts 99 lines1import type { Work } from '../types'
2import { onFile, onMainTurnEnd, onRequest, onSkill, onTaskCreate, onTaskUpdate, onTest, onTodoWrite } from './events'
3import { blankWork, mdPathOf, skillStage, type TaskUpdateInput } from './flow'
4import { testVerdict } from './tests'
5
6// A resumed or branched session starts with empty state: its transcript, read
7// back through the same rules the live hooks use, rebuilds what outlives a request.
8
9type Use = { tool?: unknown; input?: unknown; result?: unknown; text?: unknown; isError?: unknown }
10type Message = { role?: unknown; text?: unknown; toolUses?: unknown; toolResults?: unknown }
11
12export type Signal =
13 | { kind: 'request' }
14 | { kind: 'slash'; name: string; args: string }
15 | { kind: 'tool'; tool: string; input: Record<string, unknown>; result?: unknown; text?: string; isError: boolean }
16 | { kind: 'answer' }
17
18// User rows that are not a prompt the user wrote.
19const NOT_PROMPT = ['<task-notification', '<system-reminder', '<local-command', 'Caveat:', 'Base directory for this skill:', '[Request interrupted']
20const COMMAND = /<command-name>\/?([^<\s]+)\s*<\/command-name>/
21const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/
22
23export const signalsOf = (messages: unknown): Signal[] => {
24 const out: Signal[] = []
25 for (const m of Array.isArray(messages) ? (messages as unknown[]) : []) {
26 if (!m || typeof m !== 'object') continue
27 const { role, text: rawText, toolUses, toolResults } = m as Message
28 const text = typeof rawText === 'string' ? rawText.trim() : ''
29 if (role === 'user') {
30 if (Array.isArray(toolResults) && toolResults.length) continue
31 const command = COMMAND.exec(text)?.[1]
32 if (command) {
33 // Only a superpowers stage skill is known to be a skill here: any other
34 // slash command may be a built-in (/model), and other skills are not restored.
35 if (skillStage(command)) out.push({ kind: 'request' }, { kind: 'slash', name: command, args: COMMAND_ARGS.exec(text)?.[1]?.trim() ?? '' })
36 continue
37 }
38 if (text && !NOT_PROMPT.some(p => text.startsWith(p))) out.push({ kind: 'request' })
39 } else if (role === 'assistant') {
40 const uses = Array.isArray(toolUses) ? (toolUses as Use[]) : []
41 for (const u of uses) {
42 if (!u || typeof u.tool !== 'string') continue
43 out.push({
44 kind: 'tool',
45 tool: u.tool,
46 input: u.input && typeof u.input === 'object' ? (u.input as Record<string, unknown>) : {},
47 result: u.result,
48 ...(typeof u.text === 'string' ? { text: u.text } : {}),
49 isError: u.isError === true,
50 })
51 }
52 // A reply with no tool calls ends the turn with an answer.
53 if (!uses.length && text) out.push({ kind: 'answer' })
54 }
55 }
56 return out
57}
58
59export type ReplayOptions = { root?: string; skills: readonly string[]; tests: readonly string[]; at: number }
60
61export const replay = (messages: unknown, o: ReplayOptions): { work: Work; planPath?: string } => {
62 let w = blankWork()
63 let planPath: string | undefined
64 const skillSignal = (name: unknown, args: unknown) => {
65 w = onSkill(w, name, args, o.skills)
66 if (skillStage(name) === 'build') planPath = mdPathOf(args) ?? planPath
67 }
68 for (const s of signalsOf(messages)) {
69 // A new prompt means the main turn before it ended, answer or not: in Finish
70 // that closes the flow once the user has replied (spec §9.2).
71 if (s.kind === 'request') w = onRequest(onMainTurnEnd(w, 0, true), 0)
72 else if (s.kind === 'slash') skillSignal(s.name, s.args)
73 else if (s.kind === 'answer') w = onMainTurnEnd(w, 0, true)
74 else if (s.tool === 'Bash') {
75 const verdict = testVerdict(s.input, { isError: s.isError, result: s.result, ...(s.text !== undefined ? { text: s.text } : {}) }, o.tests)
76 if (verdict) w = onTest(w, verdict === 'pass', 0)
77 } else if (s.isError || (s.result as { success?: unknown } | undefined)?.success === false) continue
78 else if (s.tool === 'Skill') skillSignal(s.input.skill, s.input.args)
79 else if (s.tool === 'Write') w = onFile(w, s.input.file_path, s.input.content, o.root)
80 else if (s.tool === 'Edit') w = onFile(w, s.input.file_path, undefined, o.root)
81 else if (s.tool === 'NotebookEdit') w = onFile(w, s.input.notebook_path, undefined, o.root)
82 else if (s.tool === 'TodoWrite') w = onTodoWrite(w, undefined, s.input.todos)
83 else if (s.tool === 'TaskCreate') w = onTaskCreate(w, undefined, (s.result as { task?: { id?: unknown } } | undefined)?.task?.id, s.input.subject, s.input.activeForm)
84 else if (s.tool === 'TaskUpdate') w = onTaskUpdate(w, undefined, s.input as TaskUpdateInput)
85 }
86 // Only what outlives a request: the flow, an unfinished list, and the test result while the flow is under way.
87 const flow = w.flow
88 const isLive = !!flow && !flow.isFinished
89 const work: Work = {
90 ...blankWork(),
91 flow,
92 todos: w.todos.every(t => t.status === 'completed') ? [] : w.todos,
93 since: o.at,
94 isReplayed: true,
95 ...(isLive && w.test ? { test: w.test } : {}),
96 }
97 return isLive && flow?.stage === 'build' && !flow.planTasks && planPath ? { work, planPath } : { work }
98}
99hooks/tests.ts 98 lines1// Is a Bash command a test run, and did it pass? From the exit code where it is
2// the test's own, else from the summary the runner printed.
3
4export type Verdict = 'pass' | 'fail'
5
6export const TEST_COMMANDS: readonly string[] = [
7 'npm test', 'npm run test', 'npm t', 'pnpm test', 'pnpm run test', 'yarn test', 'bun test', 'bun run test',
8 'vitest', 'jest', 'mocha', 'playwright test', 'deno test',
9 'pytest', 'python -m pytest', 'python3 -m pytest', 'py.test', 'tox',
10 'cargo test', 'cargo nextest', 'go test', 'dotnet test', 'swift test', 'ctest', 'make test',
11 'mvn test', 'gradle test', './gradlew test', 'rspec', 'rake test', 'phpunit', 'mix test',
12 'claude plugin test',
13]
14
15// A comma-separated setting: its trimmed, non-empty entries.
16export const parseList = (v: unknown): string[] =>
17 typeof v === 'string' ? v.split(',').map(s => s.trim()).filter(Boolean) : []
18
19export type Piece = { text: string; next: string }
20
21// A lone `&`: not `&&`, nor part of a redirection such as `2>&1`, `&>`, `<&3` or `|&`.
22const BACKGROUND = '(?<![&>|<])&(?![&>])'
23const OPERATOR = new RegExp(`(&&|\\|\\||\\|&|;|\\||\\n|${BACKGROUND})`)
24
25// The command cut at &&, ||, ;, |, |&, a newline and a background &; `next` is
26// the operator after each piece ('' for the last). A line ending in a backslash
27// or in &&, ||, | or |& goes on, and a trailing newline ends nothing.
28export const piecesOf = (command: string): Piece[] => {
29 const parts = command
30 .replace(/\r\n?/g, '\n')
31 .replace(/\\\n/g, ' ')
32 .replace(/(&&|\|\||\|&|\|)[ \t]*\n\s*/g, '$1 ')
33 .trim()
34 .split(OPERATOR)
35 const out: Piece[] = []
36 for (let i = 0; i < parts.length; i += 2) out.push({ text: parts[i] ?? '', next: parts[i + 1] ?? '' })
37 return out
38}
39
40const ENV = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/
41const RUNNER = /^(?:npx|bunx|pnpm\s+(?:exec|dlx)|uv\s+run|poetry\s+run|bundle\s+exec)\s+/
42
43// A piece without what runs before the command itself: `cd dir`, `VAR=value`, a runner.
44export const bareOf = (piece: string): string => {
45 let s = piece.trim().replace(/\s+/g, ' ')
46 if (/^cd(\s|$)/.test(s)) return ''
47 for (let prev = ''; prev !== s; ) {
48 prev = s
49 s = s.replace(ENV, '').replace(RUNNER, '')
50 }
51 return s
52}
53
54// The piece starts with a test command, word for word.
55export const isTestRun = (piece: string, extra: readonly string[] = []): boolean => {
56 const s = bareOf(piece).toLowerCase()
57 if (!s) return false
58 return [...TEST_COMMANDS, ...extra].some(p => {
59 const q = p.trim().toLowerCase()
60 return !!q && (s === q || s.startsWith(`${q} `))
61 })
62}
63
64// Failure first: a failed count, a FAIL at a line's head (go, jest), "Tests failed".
65const FAILED = [/\b[1-9]\d*\s+(?:failed|failing|failures?|fail)\b/i, /^\s*(?:FAIL|FAILED)\b/m, /^--- FAIL:/m, /\btests failed\b/i]
66const PASSED = [/\b\d+\s+(?:passed|pass)\b/i, /^ok\s/m, /\btests passed\b/i, /\b0\s+failed\b/i]
67
68export const verdictOf = (output: string): Verdict | null =>
69 FAILED.some(r => r.test(output)) ? 'fail' : PASSED.some(r => r.test(output)) ? 'pass' : null
70
71export type BashCall = { command?: unknown; run_in_background?: unknown }
72export type BashAnswer = { deny?: string; isError?: boolean; result?: unknown; text?: string }
73
74// What the command printed: an errored call's text, or the record's stdout and stderr, and what the model read.
75const outputOf = (r: BashAnswer): string => {
76 const res = r.result
77 const parts: unknown[] =
78 typeof res === 'string' ? [res] : res && typeof res === 'object' ? [(res as { stdout?: unknown }).stdout, (res as { stderr?: unknown }).stderr] : []
79 return [...parts, r.text].filter((p): p is string => typeof p === 'string').join('\n')
80}
81
82// 'pass' or 'fail' for a test run that finished in the foreground; null for anything else.
83export const testVerdict = (call: BashCall, r: BashAnswer, extra: readonly string[] = []): Verdict | null => {
84 if (r.deny !== undefined || call.run_in_background === true || typeof call.command !== 'string') return null
85 const res = (r.result && typeof r.result === 'object' ? r.result : {}) as { interrupted?: unknown; backgroundTaskId?: unknown; timedOutAfterMs?: unknown }
86 if (res.interrupted === true || res.backgroundTaskId || res.timedOutAfterMs) return null
87 const pieces = piecesOf(call.command)
88 let at = -1
89 pieces.forEach((p, i) => {
90 if (isTestRun(p.text, extra)) at = i
91 })
92 if (at < 0) return null
93 // `a && b` stops at a failure, so the exit code is still the test's; `|`, `;`,
94 // `||`, a newline and a background `&` hide it.
95 const isOwnExit = pieces.slice(at).every(p => p.next === '' || p.next === '&&')
96 return isOwnExit ? (r.isError ? 'fail' : 'pass') : verdictOf(outputOf(r))
97}
98types/index.d.ts 94 lines1export type Stage = 'brainstorm' | 'spec' | 'plan' | 'build' | 'review' | 'finish'
2
3export type TodoStatus = 'pending' | 'in_progress' | 'completed'
4
5/** One row of a to-do list: TodoWrite's, or TaskCreate/TaskUpdate's. */
6export type Todo = {
7 id: string
8 title: string
9 activeForm?: string
10 status: TodoStatus
11}
12
13/** The main session's superpowers flow. */
14export type Flow = {
15 stage: Stage
16 isFinished: boolean
17 /** The first line of brainstorming's argument. */
18 topic?: string
19 /** The first `# ` heading of the spec written in this flow. */
20 specTitle?: string
21 /** The first `# ` heading of the plan written in this flow. */
22 planTitle?: string
23 /** The plan's `### Task` headings. */
24 planTasks?: number
25 /** In Finish, the user has replied to the finishing skill's question: the next main turn ends the flow. */
26 hasReply?: boolean
27}
28
29/** The current request's clock: from its start until its main turn first ends. */
30export type Clock = { from: number; to?: number }
31
32/** A slash command the user sent, waiting for `skill.prompt` to say it is a skill. */
33export type SlashCandidate = { name: string; args: string; isIdle: boolean }
34
35/** The last test run that finished in the foreground, the main session's or a subagent's. */
36export type TestResult = { isPass: boolean; at: number }
37
38/** A skill other than superpowers' that this request ran. */
39export type SkillRun = { name: string; args?: string }
40
41export type Work = {
42 flow: Flow | null
43 /** The main session's to-do list. */
44 todos: Todo[]
45 /** Each subagent's own to-do list, by agentId: the panel's step bars. */
46 agentTodos: Record<string, Todo[]>
47 /** When the current request began; subagents started since are its. */
48 since: number
49 /** The harness band was dismissed with ✕ and waits for new data. */
50 isHidden: boolean
51 /** Set by a new request; absent after a replay until the next one. */
52 clock?: Clock
53 pendingSlash?: SlashCandidate
54 test?: TestResult
55 skill?: SkillRun
56 /** This state was rebuilt from the transcript, or tried to be: never twice. */
57 isReplayed?: boolean
58}
59
60export type AgentStatus = 'running' | 'done' | 'failed'
61
62export type AgentRun = {
63 id: string
64 agentId?: string
65 type: string
66 description: string
67 model: string
68 effort?: string
69 status: AgentStatus
70 startedAt: number
71 endedAt?: number
72 contextTokens: number
73 contextMax: number
74 tokens: number
75 costUsd: number
76 steps: number
77}
78
79export type Panel = {
80 isCompact: boolean
81 isDoneCollapsed: boolean
82}
83
84declare module 'claude-code' {
85 interface PluginState {
86 'work-band': {
87 work: Work
88 agents: AgentRun[]
89 panel: Panel
90 now: number
91 }
92 }
93}
94