SLOPSHOPPER

taskforge

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…

newpaneguardcommandtimeragents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/taskforge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taskforge
│ ┃ KOZMOS · Taskforge ✕ › fix the failing auth test and add an audit log call │ ┃ ⚒ 0/0 done 0% │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ░░░░░░░░░░ ⎿ Read 6 lines │ ┃ ○ 0 pending ◐ 0 active ● 0 done ⏺ Update(src/auth.ts) │ ┃ No tasks yet: TodoWrite lists and TaskCreate ⎿ Added 2 lines, removed 1 line │ ┃ tasks land here. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /taskforge │ ⎿ taskforge: Taskforge open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Taskforge
⚒ 0/0 done 0% ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ○ 0 pending ◐ 0 active ● 0 done No tasks yet: TodoWrite lists and TaskCreate tasks land here.
README

KOZMOS Taskforge

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.

  • Terminal: three side-by-side columns from 72 columns wide, stacked sections (in progress first) below that.
  • Desktop: an SVG header and an SVG board; in-progress cards wear a marching dashed border and a pulsing dot.
  • /taskforge toggles the sidebar; autoOpen (default off) opens it at start.

Türkçe

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.

  • Terminal: 72 sütundan genişte yan yana üç sütun, daha darda alt alta bölümler (önce sürenler).
  • Masaüstü: SVG başlık ve SVG pano; süren kartların kenarı yürüyen kesikli çizgiyle ve nabız gibi atan bir noktayla çizilir.
  • /taskforge kenar çubuğunu açar/kapatır; autoOpen (varsayılan kapalı) başlangıçta açar.
Source 3 files
hooks/register.tsx 473 lines
1import { 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}
473
hooks/lib/kz.ts 510 lines
1// 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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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}
510
types/index.d.ts 29 lines
1export 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