KOZMOS Taskforge: a live kanban of the session's tasks. TodoWrite lists and TaskCreate/TaskUpdate tasks fall into Pending, In progress and Done columns with…

A live kanban of the session's tasks in a sidebar. Every TodoWrite list and every TaskCreate / TaskUpdate task lands in one of three columns: Pending, In progress and Done.
Cards show the subject, the active form while in progress (Fixing the tokenizer), how long the card has sat in its column, the task id and the owner: the owner a TaskUpdate set, or the subagent whose call wrote it. A subagent's todo list stays its own; a new list from the same loop replaces the old one, and a deleted task leaves the board.
The header has the percentage done, one bar split into done / in progress / pending, the counts and the time since the first task.
/taskforge toggles the sidebar; autoOpen (default off) opens it at start.Oturumun görevlerini kenar çubuğunda canlı bir kanban olarak gösterir. Her TodoWrite listesi ve her TaskCreate / TaskUpdate görevi üç sütundan birine düşer: Pending, In progress ve Done.
Kartlarda konu, sürerken etkin hali (Fixing the tokenizer), kartın bu sütunda ne kadar beklediği, görev numarası ve sahibi görünür: TaskUpdate'in verdiği owner ya da çağrıyı yapan alt ajan. Bir alt ajanın yapılacaklar listesi kendine aittir; aynı döngüden gelen yeni liste eskisinin yerini alır, silinen görev panodan çıkar.
Başlıkta tamamlanma yüzdesi, bitti / sürüyor / bekliyor diye bölünmüş tek bir çubuk, sayılar ve ilk görevden bu yana geçen süre var.
/taskforge kenar çubuğunu açar/kapatır; autoOpen (varsayılan kapalı) başlangıçta açar.hooks/register.tsx 473 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ForgeCard, ForgeSnap, ForgeStatus } from '../types'
5import { KZ, clip, fitText, fmtSpan, mix, pxOf, svg, svgText, textWidth } from './lib/kz.ts'
6
7const PANE = 'kz-taskforge'
8const TITLE = 'KOZMOS · Taskforge'
9const snapAtom = atom({ plugin: 'taskforge', key: 'snap' } as const, null)
10
11type Column = { status: ForgeStatus; label: string; glyph: string; color: string }
12
13const PENDING: Column = { status: 'pending', label: 'PENDING', glyph: '○', color: KZ.mist }
14const ACTIVE: Column = { status: 'in_progress', label: 'IN PROGRESS', glyph: '◐', color: KZ.violet }
15const DONE: Column = { status: 'completed', label: 'DONE', glyph: '●', color: KZ.green }
16/** Board order, left to right. */
17const COLS: Column[] = [PENDING, ACTIVE, DONE]
18/** Stacked order on a narrow pane: what runs first. */
19const STACKED: Column[] = [ACTIVE, PENDING, DONE]
20
21// ---------------------------------------------------------------------------
22// The live board. Module state starts over on a reload.
23
24let cards = new Map<string, ForgeCard>()
25let firstAt: number | undefined
26let agentNames = new Map<string, string>()
27let lastPublished = ''
28let ticker: Timer | undefined
29
30const ownerOf = (agentId: string | undefined): string | undefined =>
31 agentId === undefined ? undefined : agentNames.get(agentId) ?? `agent ${agentId.slice(0, 6)}`
32
33function snapshot(now: number): ForgeSnap {
34 return { cards: [...cards.values()].sort((a, b) => a.createdAt - b.createdAt), firstAt, now }
35}
36
37let pubSeq = 0
38
39async function publish($: EngineInterface): Promise<void> {
40 const seq = ++pubSeq
41 const now = await $.clock.now()
42 // A later publish has the newer picture: this one stands down.
43 if (seq !== pubSeq) return
44 const snap = snapshot(now)
45 const active = snap.cards.some(c => c.status === 'in_progress')
46 const key = JSON.stringify({ ...snap, now: active ? now : 0 })
47 if (key !== lastPublished) {
48 lastPublished = key
49 await update($, snapAtom, () => snap)
50 }
51 // Time-in-column only moves visibly while a card is in progress.
52 if (active && !ticker) ticker = $.clock.every(1000, () => void publish($).catch(() => undefined))
53 else if (!active && ticker) {
54 ticker.cancel()
55 ticker = undefined
56 }
57}
58
59function place(key: string, at: number, fill: Omit<ForgeCard, 'key' | 'since' | 'createdAt'>): void {
60 const old = cards.get(key)
61 firstAt ??= at
62 cards.set(key, {
63 ...old,
64 ...fill,
65 key,
66 createdAt: old?.createdAt ?? at,
67 since: old && old.status === fill.status ? old.since : at,
68 })
69}
70
71type Todo = { content: string; status: ForgeStatus; activeForm?: string }
72
73function applyTodos(todos: readonly Todo[], agentId: string | undefined, at: number): void {
74 const scope = agentId ?? 'main'
75 const keep = new Set<string>()
76 todos.forEach((t, i) => {
77 const key = `todo:${scope}:${t.content}`
78 keep.add(key)
79 const old = cards.get(key)
80 // Keep the list's own order for cards met in the same write.
81 place(key, old ? at : at + i / 1000, {
82 subject: t.content,
83 activeForm: t.activeForm || undefined,
84 status: t.status,
85 owner: ownerOf(agentId),
86 source: 'todo',
87 })
88 })
89 for (const [k, c] of cards) if (c.source === 'todo' && k.startsWith(`todo:${scope}:`) && !keep.has(k)) cards.delete(k)
90}
91
92async function toggle($: EngineInterface): Promise<boolean> {
93 if ((await $.ui.panes()).some(p => p.id === PANE)) {
94 await $.ui.close({ id: PANE })
95 return false
96 }
97 await $.ui.open({ id: PANE, title: TITLE })
98 return true
99}
100
101export const register: Register = (on, options) => {
102 on('session.start', async ($, e, next) => {
103 const started = await next(e)
104 cards = new Map()
105 agentNames = new Map()
106 firstAt = undefined
107 lastPublished = ''
108 ticker?.cancel()
109 ticker = undefined
110 await $.command.register({ name: 'taskforge', description: 'KOZMOS: toggle the Taskforge kanban sidebar', immediate: true })
111 await publish($)
112 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
113 return started
114 })
115
116 on('command.run', { command: 'taskforge' }, async $ => ({
117 text: (await toggle($)) ? 'Taskforge open.' : 'Taskforge closed.',
118 }))
119
120 on('agent.spawn', async ($, e, next) => {
121 const r = await next(e)
122 if (r.deny === undefined && r.agentId) agentNames.set(r.agentId, e.subagentType || e.description)
123 return r
124 }).catch(($, e, next) => next(e))
125
126 on('tool.call', async ($, e, next) => {
127 const ran = await next(e)
128 if (ran.isError === true || ran.deny !== undefined) return ran
129 const at = await $.clock.now()
130 if (e.tool === 'TodoWrite') {
131 applyTodos(e.todos, e.agentId, at)
132 await publish($)
133 } else if (e.tool === 'TaskCreate') {
134 const res = ran.result as { task?: { id?: string; subject?: string } } | undefined
135 const id = res?.task?.id
136 if (id !== undefined) {
137 place(`task:${id}`, at, {
138 subject: e.subject || res?.task?.subject || `#${id}`,
139 description: e.description || undefined,
140 activeForm: e.activeForm || undefined,
141 status: 'pending',
142 owner: ownerOf(e.agentId),
143 source: 'task',
144 taskId: id,
145 })
146 await publish($)
147 }
148 } else if (e.tool === 'TaskUpdate') {
149 const key = `task:${e.taskId}`
150 if (e.status === 'deleted') cards.delete(key)
151 else {
152 const old = cards.get(key)
153 place(key, at, {
154 subject: e.subject || old?.subject || `#${e.taskId}`,
155 description: e.description || old?.description,
156 activeForm: e.activeForm || old?.activeForm,
157 status: e.status ?? old?.status ?? 'pending',
158 owner: e.owner || old?.owner || ownerOf(e.agentId),
159 source: 'task',
160 taskId: e.taskId,
161 })
162 }
163 await publish($)
164 }
165 return ran
166 }).catch(($, e, next) => next(e))
167
168 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
169 const snap = (await read($, snapAtom)) ?? snapshot(await $.clock.now())
170 const ui = $.ui.resolve(e)
171 const { Box, Text } = ui
172 const tally = countsOf(snap)
173
174 if ('Svg' in ui && e.surface !== 'terminal') {
175 const { Svg } = ui
176 const W = pxOf(e.props.bodyColumns, 48)
177 const head = headerSvg(snap, W)
178 const board = boardSvg(snap, W)
179 return (
180 <Box flexDirection="column">
181 <Svg source={head.source} alt={`${tally.done} of ${tally.total} tasks done (${tally.pct}%)`} width={W} height={head.height} />
182 <Svg source={board.source} alt={altBoard(snap)} width={W} height={board.height} />
183 </Box>
184 )
185 }
186
187 const cols = Math.max(30, e.props.bodyColumns || 44)
188 const ratio = tally.total ? tally.done / tally.total : 0
189 const barW = Math.max(6, cols - 2)
190 const segDone = tally.total ? Math.round((tally.done / tally.total) * barW) : 0
191 const segActive = tally.total ? Math.min(barW - segDone, Math.max(tally.active ? 1 : 0, Math.round((tally.active / tally.total) * barW))) : 0
192 const header = (
193 <Box key="head" flexDirection="column">
194 <Box flexDirection="row" justifyContent="space-between">
195 <Text bold color={KZ.teal}>⚒ {tally.done}/{tally.total} done</Text>
196 <Text bold color={mix(KZ.violet, KZ.green, ratio)}>{tally.pct}%</Text>
197 </Box>
198 <Text>
199 <Text color={KZ.green}>{'█'.repeat(segDone)}</Text>
200 <Text color={KZ.violet}>{'▓'.repeat(segActive)}</Text>
201 <Text dimColor>{'░'.repeat(Math.max(0, barW - segDone - segActive))}</Text>
202 </Text>
203 <Text wrap="truncate-end">
204 <Text color={KZ.mist}>○ {tally.pending}</Text><Text dimColor> pending </Text>
205 <Text color={KZ.violet}>◐ {tally.active}</Text><Text dimColor> active </Text>
206 <Text color={KZ.green}>● {tally.done}</Text><Text dimColor> done</Text>
207 {snap.firstAt !== undefined ? <Text dimColor> · since {fmtSpan(snap.now - snap.firstAt)}</Text> : null}
208 </Text>
209 </Box>
210 )
211 if (tally.total === 0) {
212 return (
213 <Box flexDirection="column">
214 {header}
215 <Text dimColor>No tasks yet: TodoWrite lists and TaskCreate tasks land here.</Text>
216 </Box>
217 )
218 }
219
220 if (cols >= 72) {
221 const colW = Math.floor((cols - 2) / 3)
222 return (
223 <Box flexDirection="column">
224 {header}
225 <Box flexDirection="row" gap={1} marginTop={1}>
226 {COLS.map(col => {
227 const list = byColumn(snap, col.status)
228 const shown = col.status === 'completed' ? list.slice(-8) : list
229 return (
230 <Box key={`col-${col.status}`} flexDirection="column" width={colW} flexShrink={0}>
231 <Box flexDirection="row" justifyContent="space-between">
232 <Text bold color={col.color}>{col.glyph} {col.label}</Text>
233 <Text dimColor>{list.length}</Text>
234 </Box>
235 <Text color={col.color}>{'━'.repeat(Math.max(2, colW - 1))}</Text>
236 {shown.map(c => wideCard(ui, c, col.color, snap.now, colW))}
237 {list.length > shown.length ? <Text dimColor> +{list.length - shown.length} earlier</Text> : null}
238 {list.length === 0 ? <Text dimColor> —</Text> : null}
239 </Box>
240 )
241 })}
242 </Box>
243 </Box>
244 )
245 }
246
247 return (
248 <Box flexDirection="column">
249 {header}
250 {STACKED.map(col => {
251 const list = byColumn(snap, col.status)
252 if (list.length === 0) return null
253 const shown = col.status === 'completed' ? list.slice(-6) : list
254 return (
255 <Box key={`sec-${col.status}`} flexDirection="column" marginTop={1}>
256 <Text bold color={col.color}>{col.glyph} {col.label} <Text dimColor>{list.length}</Text></Text>
257 {shown.map(c => narrowCard(ui, c, col.color, snap.now, cols))}
258 {list.length > shown.length ? <Text dimColor> +{list.length - shown.length} earlier</Text> : null}
259 </Box>
260 )
261 })}
262 </Box>
263 )
264 })
265}
266
267// ---------------------------------------------------------------------------
268
269function countsOf(s: ForgeSnap): { total: number; pending: number; active: number; done: number; pct: number } {
270 let pending = 0
271 let active = 0
272 let done = 0
273 for (const c of s.cards) {
274 if (c.status === 'pending') pending++
275 else if (c.status === 'in_progress') active++
276 else done++
277 }
278 const total = s.cards.length
279 return { total, pending, active, done, pct: total ? Math.round((100 * done) / total) : 0 }
280}
281
282function byColumn(s: ForgeSnap, st: ForgeStatus): ForgeCard[] {
283 const list = s.cards.filter(c => c.status === st)
284 // Done reads in the order things finished; the others in board order.
285 return st === 'completed' ? list.sort((a, b) => a.since - b.since) : list
286}
287
288function cardMeta(c: ForgeCard, now: number): string {
289 return [c.owner ?? '', fmtSpan(now - c.since), c.taskId !== undefined ? `#${c.taskId}` : ''].filter(Boolean).join(' · ')
290}
291
292type Ui = ReturnType<EngineInterface['ui']['resolve']>
293
294function wideCard(ui: Ui, c: ForgeCard, color: string, now: number, w: number) {
295 const { Box, Text } = ui
296 const isActive = c.status === 'in_progress'
297 const isDone = c.status === 'completed'
298 return (
299 <Box key={`c-${c.key}`} flexDirection="column">
300 <Text wrap="truncate-end">
301 <Text color={color}>▍</Text>
302 <Text bold={isActive} dimColor={isDone} strikethrough={false}>{clip(c.subject, w * 2)}</Text>
303 </Text>
304 <Text wrap="truncate-end">
305 <Text color={color}>▍</Text>
306 {isActive && c.activeForm ? <Text color={KZ.violet} italic>{c.activeForm} </Text> : null}
307 <Text dimColor>{cardMeta(c, now)}</Text>
308 </Text>
309 </Box>
310 )
311}
312
313function narrowCard(ui: Ui, c: ForgeCard, color: string, now: number, cols: number) {
314 const { Box, Text } = ui
315 const isActive = c.status === 'in_progress'
316 const meta = cardMeta(c, now)
317 return (
318 <Box key={`c-${c.key}`} flexDirection="column">
319 <Box flexDirection="row" justifyContent="space-between">
320 <Text wrap="truncate-end">
321 <Text color={color}>▍</Text>
322 <Text bold={isActive} dimColor={c.status === 'completed'}>{clip(c.subject, cols)}</Text>
323 </Text>
324 <Text dimColor> {meta}</Text>
325 </Box>
326 {isActive && c.activeForm
327 ? <Text wrap="truncate-end"><Text color={color}>▍</Text><Text color={KZ.violet} italic>↳ {c.activeForm}</Text></Text>
328 : null}
329 </Box>
330 )
331}
332
333// ---------------------------------------------------------------------------
334// Desktop.
335
336const CSS = `
337.cd{fill:#ffffff}.cds{stroke:#e4e2dc}
338@media (prefers-color-scheme: dark){.cd{fill:#2a2a28}.cds{stroke:#3a3a37}}
339.ants{stroke-dasharray:6 4;animation:tfa .9s linear infinite}@keyframes tfa{to{stroke-dashoffset:-20}}
340.glow{animation:tfg 2.2s ease-in-out infinite}@keyframes tfg{50%{opacity:.25}}
341`
342
343function altBoard(s: ForgeSnap): string {
344 return COLS.map(col => `${col.label.toLowerCase()}: ${byColumn(s, col.status).map(c => c.subject).join(', ') || 'none'}`).join('; ')
345}
346
347function headerSvg(s: ForgeSnap, W: number): { source: string; height: number } {
348 const t = countsOf(s)
349 const p: string[] = []
350 const H = 74
351 p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`)
352 p.push(svgText(14, 34, `${t.pct}%`, { size: 26, weight: 750, fill: mix(KZ.violet, KZ.green, t.total ? t.done / t.total : 0) }))
353 const x0 = 96
354 p.push(svgText(x0, 22, `${t.done} of ${t.total} done`, { size: 12.5, weight: 650 }))
355 p.push(svgText(W - 14, 22, s.firstAt !== undefined ? `since ${fmtSpan(s.now - s.firstAt)}` : 'no tasks yet', { cls: 'm', size: 10.5, anchor: 'end' }))
356 // One track, three segments: done, in progress, pending.
357 const bw = W - x0 - 14
358 const by = 32
359 p.push(`<rect class="k" x="${x0}" y="${by}" width="${bw}" height="9" rx="4.5"/>`)
360 p.push(`<clipPath id="tfc"><rect x="${x0}" y="${by}" width="${bw}" height="9" rx="4.5"/></clipPath>`)
361 const dw = t.total ? (bw * t.done) / t.total : 0
362 const aw = t.total ? (bw * t.active) / t.total : 0
363 p.push(`<g clip-path="url(#tfc)"><rect x="${x0}" y="${by}" width="${dw}" height="9" fill="${KZ.green}"/><rect x="${x0 + dw}" y="${by}" width="${aw}" height="9" fill="${KZ.violet}" class="glow"/></g>`)
364 const chips: [string, number, string][] = [['pending', t.pending, KZ.mist], ['in progress', t.active, KZ.violet], ['done', t.done, KZ.green]]
365 let cx = x0
366 for (const [label, n, c] of chips) {
367 p.push(`<circle cx="${cx + 4}" cy="${58}" r="3.5" fill="${c}"/>`)
368 const txt = `${n} ${label}`
369 p.push(svgText(cx + 12, 62, txt, { cls: 's', size: 10.5 }))
370 cx += textWidth(txt, 10.5) + 26
371 }
372 return { source: svg(W, H, p.join(''), CSS), height: H }
373}
374
375function wrap(s: string, size: number, maxW: number, maxLines: number): string[] {
376 const words = s.replace(/\s+/g, ' ').trim().split(' ')
377 const lines: string[] = []
378 let cur = ''
379 for (const w of words) {
380 const next = cur ? `${cur} ${w}` : w
381 if (textWidth(next, size) <= maxW || !cur) cur = next
382 else {
383 lines.push(cur)
384 cur = w
385 }
386 }
387 if (cur) lines.push(cur)
388 if (lines.length > maxLines) {
389 const head = lines.slice(0, maxLines)
390 head[maxLines - 1] = fitText(`${head[maxLines - 1]} ${lines.slice(maxLines).join(' ')}`, size, maxW)
391 return head
392 }
393 return lines.map(l => fitText(l, size, maxW))
394}
395
396type Drawn = { body: string; height: number }
397
398function cardSvg(c: ForgeCard, x: number, y: number, w: number, color: string, now: number): Drawn {
399 const isActive = c.status === 'in_progress'
400 const isDone = c.status === 'completed'
401 const lines = wrap(c.subject, 11.5, w - 22, 3)
402 const form = isActive ? c.activeForm : undefined
403 const h = 14 + lines.length * 15 + (form ? 15 : 0) + 18
404 const p: string[] = []
405 p.push(`<rect class="cd cds" x="${x}" y="${y}" width="${w}" height="${h}" rx="8" stroke-width="1"/>`)
406 if (isActive) p.push(`<rect class="ants" x="${x + 0.75}" y="${y + 0.75}" width="${w - 1.5}" height="${h - 1.5}" rx="7.5" fill="none" stroke="${color}" stroke-width="1.5"/>`)
407 p.push(`<rect x="${x + 6}" y="${y + 8}" width="3" height="${h - 16}" rx="1.5" fill="${color}"${isDone ? ' opacity=".55"' : ''}/>`)
408 lines.forEach((l, i) => {
409 p.push(svgText(x + 16, y + 20 + i * 15, l, { size: 11.5, weight: isActive ? 650 : 500, cls: isDone ? 's' : 't' }))
410 })
411 let ly = y + 20 + lines.length * 15
412 if (form) {
413 p.push(svgText(x + 16, ly, fitText(`↳ ${form}`, 10.5, w - 22), { size: 10.5, fill: KZ.violet }))
414 ly += 15
415 }
416 p.push(svgText(x + 16, ly + 1, fitText(cardMeta(c, now), 9.5, w - 22), { cls: 'm', size: 9.5 }))
417 if (isDone) p.push(`<path d="M${x + w - 18} ${y + 14}l3 3 6-6" stroke="${KZ.green}" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`)
418 if (isActive) p.push(`<circle cx="${x + w - 12}" cy="${y + 12}" r="3.5" fill="${color}" class="pulse"/>`)
419 return { body: p.join(''), height: h }
420}
421
422function columnSvg(s: ForgeSnap, col: Column, x: number, y: number, w: number): Drawn {
423 const all = byColumn(s, col.status)
424 const list = col.status === 'completed' ? all.slice(-10) : all
425 const p: string[] = []
426 let cy = y + 36
427 for (const c of list) {
428 const d = cardSvg(c, x + 8, cy, w - 16, col.color, s.now)
429 p.push(d.body)
430 cy += d.height + 6
431 }
432 if (all.length > list.length) {
433 p.push(svgText(x + w / 2, cy + 8, `+${all.length - list.length} earlier`, { cls: 'm', size: 10, anchor: 'middle' }))
434 cy += 16
435 }
436 if (all.length === 0) {
437 p.push(`<rect x="${x + 8}" y="${cy}" width="${w - 16}" height="34" rx="8" fill="none" class="ln" stroke-dasharray="4 4"/>`)
438 p.push(svgText(x + w / 2, cy + 21, 'empty', { cls: 'm', size: 10, anchor: 'middle' }))
439 cy += 40
440 }
441 const h = cy - y + 4
442 const head = [
443 `<rect class="p" x="${x}" y="${y}" width="${w}" height="${h}" rx="12"/>`,
444 `<circle cx="${x + 16}" cy="${y + 18}" r="4.5" fill="${col.color}"${col.status === 'in_progress' && all.length ? ' class="pulse"' : ''}/>`,
445 svgText(x + 27, y + 22, col.label, { size: 10.5, weight: 700, cls: 's' }),
446 `<rect x="${x + w - 36}" y="${y + 10}" width="26" height="16" rx="8" fill="${col.color}" opacity=".18"/>`,
447 svgText(x + w - 23, y + 22, String(all.length), { size: 10.5, weight: 700, anchor: 'middle', fill: col.color }),
448 ]
449 return { body: head.join('') + p.join(''), height: h }
450}
451
452function boardSvg(s: ForgeSnap, W: number): { source: string; height: number } {
453 const gap = 8
454 const parts: string[] = []
455 let height = 0
456 if (W >= 520) {
457 const cw = (W - gap * 2) / 3
458 const drawn = COLS.map((col, i) => columnSvg(s, col, i * (cw + gap), 0, cw))
459 height = Math.max(...drawn.map(d => d.height))
460 // Even the columns' panels out to the tallest.
461 drawn.forEach(d => parts.push(d.body.replace(/^<rect class="p" x="([\d.]+)" y="0" width="([\d.]+)" height="[\d.]+"/, `<rect class="p" x="$1" y="0" width="$2" height="${height}"`)))
462 } else {
463 let y = 0
464 for (const col of STACKED) {
465 const d = columnSvg(s, col, 0, y, W)
466 parts.push(d.body)
467 y += d.height + gap
468 }
469 height = y - gap
470 }
471 return { source: svg(W, Math.max(1, height), parts.join(''), CSS), height: Math.max(1, height) }
472}
473hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 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
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510types/index.d.ts 29 lines1export type ForgeStatus = 'pending' | 'in_progress' | 'completed'
2
3export type ForgeCard = {
4 key: string
5 subject: string
6 activeForm?: string
7 description?: string
8 status: ForgeStatus
9 /** When the card entered its current column. */
10 since: number
11 createdAt: number
12 /** The agent or owner it belongs to; absent for the main loop. */
13 owner?: string
14 source: 'todo' | 'task'
15 taskId?: string
16}
17
18export type ForgeSnap = {
19 cards: ForgeCard[]
20 firstAt?: number
21 now: number
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 taskforge: { snap: ForgeSnap | null }
27 }
28}
29