SLOPSHOPPER

chronicle

KOZMOS Chronicle: the session as a timeline. Prompts, turn ends with duration, tool count and spend, subagents spawned and finished, commits and pushes, failed…

newpaneguardcommandpromptagents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/chronicle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chronicle
│ ┃ KOZMOS · Chronicle ✕ › fix the failing auth test and add an audit log call │ ┃ ❯ 1 prompts ◈ 0 agents ◉ 0 commits ✖ 1 e… │ ┃ █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⏺ Read(src/auth.ts) │ ┃ [ All 5 ] [ Prompts 2 ] [ Agents 0 ] [ Git 1 ⎿ Read 6 lines │ ┃ 08:53:20 ◆ turn · 0:42 ⏺ Update(src/auth.ts) │ ┃ │ 9 tools ⎿ Added 2 lines, removed 1 line │ ┃ 08:53:20 ⇡ push ⏺ Bash(bun test) │ ┃ │ rm -rf build && git push --force… ⎿ 3 pass, 1 fail │ ┃ 08:53:20 ✖ Bash failed │ ┃ │ Run the test suite ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 08:53:20 ❯ fix the failing auth test and ad… │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ 08:53:20 ★ session started │ ┃ /work/app › /chronicle │ ⎿ chronicle: Chronicle open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Chronicle
❯ 1 prompts ◈ 0 agents ◉ 0 commits ✖ 1 errors █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ [ All 5 ] [ Prompts 2 ] [ Agents 0 ] [ Git 1 ] [ Errors 1 ] 08:53:20 ◆ turn · 0:42 │ 9 tools 08:53:20 ⇡ push │ rm -rf build && git push --force origin main 08:53:20 ✖ Bash failed │ Run the test suite 08:53:20 ❯ fix the failing auth test and add an audit log c… │ 08:53:20 ★ session started /work/app
README

KOZMOS Chronicle

The session as a timeline in a sidebar, newest first, each event stamped with its local time (HH:MM:SS) and drawn with its own icon and color:

EventWhat is recorded
❯ promptthe first 80 characters of each prompt you submit
◆ turnits duration, the tools it called and the spend it added
◈ / ◇ agenta subagent spawned, and finished or failed with its run time
◉ commit, ⇡ pusha successful git commit / git push through Bash, with sha, branch and message
✖ failed toolany tool call that errored or was denied
⇊ compactionmanual or automatic, with tokens before → after
▲ limit, ◔ contextcrossing 50, 80 and 95 % of a rate-limit window or of the context

Up to 300 events are kept. Filter buttons (hotkeys 1–5) narrow the view to All, Prompts, Agents, Git or Errors. A ribbon above squeezes the whole session into one row, each slice colored by its loudest event.

On the desktop the header carries counters and the ribbon, and the timeline is one SVG with a gradient rail, colored dots and a ripple on the newest event.

  • /chronicle toggles the sidebar; autoOpen (default off) opens it at start.

Türkçe

Oturumu kenar çubuğunda bir zaman çizelgesi olarak gösterir; en yeni en üstte, her olay yerel saatiyle (SS:DD:ss) ve kendi simgesi ve rengiyle:

OlayKaydedilen
❯ istemgönderdiğin her istemin ilk 80 karakteri
◆ tursüresi, çağırdığı araç sayısı ve eklediği maliyet
◈ / ◇ ajanbaşlatılan alt ajan; bitişi ya da hatası ve çalışma süresi
◉ commit, ⇡ pushBash ile başarılı git commit / git push; sha, dal ve mesaj
✖ başarısız araçhata veren ya da reddedilen her araç çağrısı
⇊ sıkıştırmaelle ya da otomatik; önce → sonra token
▲ limit, ◔ bağlambir kullanım penceresinin ya da bağlamın yüzde 50, 80 ve 95 eşiğini geçmek

En fazla 300 olay tutulur. Süzgeç düğmeleri (kısayol 1–5) görünümü Tümü, İstemler, Ajanlar, Git ya da Hatalar ile daraltır. Üstteki şerit tüm oturumu tek satıra sıkıştırır; her dilim en önemli olayının rengini alır.

Masaüstünde başlıkta sayaçlar ve şerit, zaman çizelgesi ise renk geçişli bir ray, renkli noktalar ve en yeni olayda bir dalga ile tek bir SVG'dir.

  • /chronicle kenar çubuğunu açar/kapatır; autoOpen (varsayılan kapalı) başlangıçta açar.
Source 3 files
hooks/register.tsx 433 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { ChronEvent, ChronFilter, ChronKind, ChronSnap } from '../types'
5import { Canvas, KZ, clip, fitText, fmtClock, fmtTokens, fmtUsd, limitLabel, pxOf, svg, svgText, toolDetail, toolName } from './lib/kz.ts'
6
7const PANE = 'kz-chronicle'
8const TITLE = 'KOZMOS · Chronicle'
9const MAX_EVENTS = 300
10const MILESTONES = [50, 80, 95]
11
12const snapAtom = atom({ plugin: 'chronicle', key: 'snap' } as const, null)
13const filterAtom = atom({ plugin: 'chronicle', key: 'filter' } as const, 'all')
14
15type KindStyle = { icon: string; color: string; rank: number }
16
17const KIND: Record<ChronKind, KindStyle> = {
18  session: { icon: '★', color: KZ.mist, rank: 0 },
19  prompt: { icon: '❯', color: KZ.cyan, rank: 2 },
20  turn: { icon: '◆', color: KZ.violet, rank: 1 },
21  'agent-start': { icon: '◈', color: KZ.magenta, rank: 3 },
22  'agent-end': { icon: '◇', color: KZ.magenta, rank: 3 },
23  commit: { icon: '◉', color: KZ.green, rank: 5 },
24  push: { icon: '⇡', color: KZ.teal, rank: 5 },
25  'tool-fail': { icon: '✖', color: KZ.red, rank: 6 },
26  compact: { icon: '⇊', color: KZ.blue, rank: 4 },
27  limit: { icon: '▲', color: KZ.amber, rank: 4 },
28  context: { icon: '◔', color: KZ.yellow, rank: 4 },
29}
30
31const FILTERS: { id: ChronFilter; label: string; hotkey: string }[] = [
32  { id: 'all', label: 'All', hotkey: '1' },
33  { id: 'prompts', label: 'Prompts', hotkey: '2' },
34  { id: 'agents', label: 'Agents', hotkey: '3' },
35  { id: 'git', label: 'Git', hotkey: '4' },
36  { id: 'errors', label: 'Errors', hotkey: '5' },
37]
38
39function passes(f: ChronFilter, ev: ChronEvent): boolean {
40  if (f === 'all') return true
41  if (f === 'prompts') return ev.kind === 'prompt' || ev.kind === 'turn'
42  if (f === 'agents') return ev.kind === 'agent-start' || ev.kind === 'agent-end'
43  if (f === 'git') return ev.kind === 'commit' || ev.kind === 'push'
44  return ev.isError === true || ev.kind === 'tool-fail'
45}
46
47const colorOf = (ev: ChronEvent) => (ev.isError ? KZ.red : KIND[ev.kind].color)
48
49// ---------------------------------------------------------------------------
50// The record. Module state starts over on a reload.
51
52let events: ChronEvent[] = []
53let startedAt = 0
54let nextId = 1
55let lastPublished = ''
56let pubSeq = 0
57// Turn bookkeeping for the main loop.
58let turnTools = 0
59let turnCost: number | undefined
60let lastCost: number | undefined
61// Subagents by id: what they are and when they began.
62let agents = new Map<string, { type: string; description: string; at: number }>()
63// Milestones already announced, per rate-limit kind and for the context.
64let armed = new Map<string, number>()
65let isSeeded = false
66
67function hhmmss(at: number): string {
68  const d = new Date(at)
69  const p = (n: number) => String(n).padStart(2, '0')
70  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
71}
72
73function record(at: number, kind: ChronKind, title: string, detail?: string, isError?: boolean): void {
74  events = [...events, { id: nextId++, at, kind, title, ...(detail ? { detail } : {}), ...(isError ? { isError } : {}) }].slice(-MAX_EVENTS)
75}
76
77async function publish($: EngineInterface): Promise<void> {
78  const seq = ++pubSeq
79  const snap: ChronSnap = { events, startedAt }
80  const key = JSON.stringify(snap)
81  if (key === lastPublished) return
82  await Promise.resolve()
83  if (seq !== pubSeq) return
84  lastPublished = key
85  await update($, snapAtom, () => snap)
86}
87
88/** Crossing 50, 80 and 95 %; a fall below re-arms the levels it fell under. */
89function milestone(key: string, percent: number | undefined, at: number, announce: (level: number) => void): void {
90  if (percent === undefined || !Number.isFinite(percent)) return
91  const level = MILESTONES.filter(m => percent >= m).pop() ?? 0
92  const had = armed.get(key) ?? 0
93  if (level > had && isSeeded) announce(level)
94  if (level !== had) armed.set(key, level)
95}
96
97function commitMessage(cmd: string): string {
98  const m = /-m\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/.exec(cmd)
99  const raw = m?.[1] ?? m?.[2] ?? m?.[3] ?? ''
100  return clip(raw.replace(/\\n/g, ' '), 72)
101}
102
103async function toggle($: EngineInterface): Promise<boolean> {
104  if ((await $.ui.panes()).some(p => p.id === PANE)) {
105    await $.ui.close({ id: PANE })
106    return false
107  }
108  await $.ui.open({ id: PANE, title: TITLE })
109  return true
110}
111
112async function costNow($: EngineInterface): Promise<number | undefined> {
113  try {
114    return (await $.session.usage()).cost?.usd
115  } catch {
116    return lastCost
117  }
118}
119
120export const register: Register = (on, options) => {
121  on('session.start', async ($, e, next) => {
122    const started = await next(e)
123    startedAt = await $.clock.now()
124    events = []
125    nextId = 1
126    lastPublished = ''
127    turnTools = 0
128    turnCost = undefined
129    lastCost = undefined
130    agents = new Map()
131    armed = new Map()
132    isSeeded = false
133    record(startedAt, 'session', 'session started', e.cwd ? clip(e.cwd, 60) : undefined)
134    await $.command.register({ name: 'chronicle', description: 'KOZMOS: toggle the Chronicle timeline sidebar', immediate: true })
135    await publish($)
136    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
137    return started
138  })
139
140  on('command.run', { command: 'chronicle' }, async $ => ({
141    text: (await toggle($)) ? 'Chronicle open.' : 'Chronicle closed.',
142  }))
143
144  on('prompt.submit', async ($, e, next) => {
145    const r = await next(e)
146    // The person's own prompts: not task notifications, peers or triggers.
147    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
148    if (r.drop === undefined && isPerson && (r.text || e.text).trim()) {
149      record(await $.clock.now(), 'prompt', clip(r.text || e.text, 80))
150      await publish($)
151    }
152    return r
153  }).catch(($, e, next) => next(e))
154
155  on('turn.start', async ($, e, next) => {
156    turnTools = 0
157    turnCost = lastCost ?? (await costNow($))
158    return next(e)
159  })
160
161  on('turn.complete', async ($, e, next) => {
162    const at = await $.clock.now()
163    if (e.agentId === undefined) {
164      const cost = await costNow($)
165      if (cost !== undefined) lastCost = cost
166      const delta = cost !== undefined && turnCost !== undefined ? cost - turnCost : undefined
167      const how = e.reason === 'answer' ? 'turn' : e.reason === 'aborted' ? 'turn interrupted' : e.reason === 'refusal' ? 'turn refused' : 'turn failed'
168      const bits = [`${turnTools} tool${turnTools === 1 ? '' : 's'}`, delta !== undefined && delta > 0 ? `+${fmtUsd(delta)}` : '']
169      record(at, 'turn', `${how} · ${fmtClock(e.durationMs)}`, bits.filter(Boolean).join(' · '), e.reason === 'error' || e.reason === 'refusal')
170      turnCost = cost
171    } else {
172      const a = agents.get(e.agentId)
173      if (a) {
174        const ok = e.reason === 'answer'
175        record(at, 'agent-end', `${a.type} ${ok ? 'finished' : e.reason === 'aborted' ? 'stopped' : 'failed'} · ${fmtClock(at - a.at)}`, a.description, !ok)
176      }
177    }
178    await publish($)
179    return next(e)
180  })
181
182  on('agent.spawn', async ($, e, next) => {
183    const r = await next(e)
184    if (r.deny === undefined && r.agentId) {
185      const at = await $.clock.now()
186      agents.set(r.agentId, { type: e.subagentType || 'agent', description: e.description, at })
187      record(at, 'agent-start', `${e.subagentType || 'agent'} spawned${e.background ? ' (background)' : ''}`, e.description)
188      await publish($)
189    }
190    return r
191  }).catch(($, e, next) => next(e))
192
193  on('tool.call', async ($, e, next) => {
194    if (e.agentId === undefined) turnTools++
195    const ran = await next(e)
196    const at = await $.clock.now()
197    const failed = ran.isError === true || ran.deny !== undefined
198    const name = String(e.tool)
199    if (failed) {
200      const who = e.agentId === undefined ? '' : ` in ${agents.get(e.agentId)?.type ?? 'agent'}`
201      record(at, 'tool-fail', `${toolName(name)} ${ran.deny !== undefined ? 'denied' : 'failed'}${who}`, toolDetail(name, e) || undefined, true)
202      await publish($)
203    } else if (e.tool === 'Bash') {
204      const cmd = e.command
205      const res = ran.result as { interrupted?: boolean; gitOperation?: { commit?: { sha?: string; kind?: string; branch?: string }; push?: { branch?: string } } } | undefined
206      const op = res?.gitOperation
207      if (res?.interrupted !== true && (op?.commit || /\bgit\b[^|;&]*\bcommit\b/.test(cmd))) {
208        const sha = op?.commit?.sha ? op.commit.sha.slice(0, 7) : ''
209        const verb = op?.commit?.kind === 'amended' ? 'amend' : 'commit'
210        record(at, 'commit', `${verb}${sha ? ` ${sha}` : ''}${op?.commit?.branch ? ` on ${op.commit.branch}` : ''}`, commitMessage(cmd) || undefined)
211        await publish($)
212      }
213      if (res?.interrupted !== true && (op?.push || /\bgit\b[^|;&]*\bpush\b/.test(cmd))) {
214        record(at, 'push', `push${op?.push?.branch ? ` → ${op.push.branch}` : ''}`, clip(cmd, 72))
215        await publish($)
216      }
217    }
218    return ran
219  }).catch(($, e, next) => next(e))
220
221  on('session.compact', async ($, e, next) => {
222    const r = await next(e)
223    if (e.trigger !== 'precompute' && r.skip === undefined) {
224      const at = await $.clock.now()
225      const span = r.tokensBefore !== undefined && r.tokensAfter !== undefined ? `${fmtTokens(r.tokensBefore)} → ${fmtTokens(r.tokensAfter)}` : undefined
226      const who = e.agentId === undefined ? '' : ` (${agents.get(e.agentId)?.type ?? 'agent'})`
227      record(at, 'compact', `compacted · ${e.trigger}${who}`, span)
228      // The context fell: its milestones can fire again.
229      if (e.agentId === undefined) armed.delete('context')
230      await publish($)
231    }
232    return r
233  }).catch(($, e, next) => next(e))
234
235  on('session.measure', async ($, e, next) => {
236    const at = await $.clock.now()
237    if (e.cost) lastCost = e.cost.usd
238    for (const l of e.rateLimits) {
239      milestone(`limit:${l.kind}`, l.percentUsed, at, level =>
240        record(at, 'limit', `${limitLabel(l.kind)} limit past ${level}%`, `${Math.round(l.percentUsed)}% used`, level >= 95))
241    }
242    milestone('context', e.context.percent, at, level =>
243      record(at, 'context', `context past ${level}%`, e.context.tokens !== undefined ? `${fmtTokens(e.context.tokens)} of ${fmtTokens(e.context.window)}` : undefined, level >= 95))
244    isSeeded = true
245    await publish($)
246    return next(e)
247  }).catch(($, e, next) => next(e))
248
249  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250    const snap = (await read($, snapAtom)) ?? { events, startedAt }
251    const filter = await read($, filterAtom)
252    const ui = $.ui.resolve(e)
253    const { Box, Text, Button } = ui
254    const shown = snap.events.filter(ev => passes(filter, ev)).reverse()
255    const buttons = (
256      <Box key="filters" flexDirection="row" gap={1} flexWrap="wrap">
257        {FILTERS.map(f => (
258          <Button
259            key={`f-${f.id}`}
260            label={`${f.label} ${snap.events.filter(ev => passes(f.id, ev)).length}`}
261            hotkey={f.hotkey}
262            variant={f.id === filter ? 'primary' : undefined}
263            dimColor={f.id !== filter}
264            onPress={() => void update($, filterAtom, () => f.id)}
265          />
266        ))}
267      </Box>
268    )
269
270    if ('Svg' in ui && e.surface !== 'terminal') {
271      const { Svg } = ui
272      const W = pxOf(e.props.bodyColumns, 48)
273      const head = headerSvg(snap, W)
274      const line = timelineSvg(shown.slice(0, 60), W)
275      return (
276        <Box flexDirection="column">
277          <Svg source={head.source} alt={altHeader(snap)} width={W} height={head.height} />
278          {buttons}
279          <Svg source={line.source} alt={shown.slice(0, 12).map(ev => `${hhmmss(ev.at)} ${ev.title}`).join('; ') || 'no events'} width={W} height={line.height} />
280        </Box>
281      )
282    }
283
284    // Only the terminal is left, and it always has Raster.
285    const { Raster } = ui as Elements['terminal']
286    const cols = Math.max(30, e.props.bodyColumns || 44)
287    const t = tally(snap)
288    const ribbonW = Math.max(8, cols - 2)
289    return (
290      <Box flexDirection="column">
291        <Text wrap="truncate-end">
292          <Text color={KZ.cyan}>❯ {t.prompts}</Text><Text dimColor> prompts  </Text>
293          <Text color={KZ.magenta}>◈ {t.agents}</Text><Text dimColor> agents  </Text>
294          <Text color={KZ.green}>◉ {t.commits}</Text><Text dimColor> commits  </Text>
295          <Text color={t.errors ? KZ.red : KZ.mist}>✖ {t.errors}</Text><Text dimColor> errors</Text>
296        </Text>
297        <Raster key="ribbon" columns={ribbonW} rows={1} cells={ribbon(snap, ribbonW)} />
298        {buttons}
299        {shown.length === 0 ? <Text dimColor>  nothing here yet</Text> : null}
300        {shown.slice(0, 80).map((ev, i) => {
301          const c = colorOf(ev)
302          const isLast = i === Math.min(shown.length, 80) - 1
303          return (
304            <Box key={`ev-${ev.id}`} flexDirection="column">
305              <Text wrap="truncate-end">
306                <Text dimColor>{hhmmss(ev.at)} </Text>
307                <Text color={c} bold>{KIND[ev.kind].icon} </Text>
308                <Text bold={ev.kind === 'prompt' || ev.kind === 'commit'} color={ev.isError ? KZ.red : undefined}>{ev.title}</Text>
309              </Text>
310              {ev.detail || !isLast
311                ? (
312                    <Text wrap="truncate-end">
313                      <Text dimColor>{'         '}</Text>
314                      <Text color={c} dimColor>{isLast ? ' ' : '│'} </Text>
315                      {ev.detail ? <Text dimColor>{ev.detail}</Text> : null}
316                    </Text>
317                  )
318                : null}
319            </Box>
320          )
321        })}
322      </Box>
323    )
324  })
325}
326
327// ---------------------------------------------------------------------------
328
329function tally(s: ChronSnap): { prompts: number; agents: number; commits: number; errors: number } {
330  const t = { prompts: 0, agents: 0, commits: 0, errors: 0 }
331  for (const ev of s.events) {
332    if (ev.kind === 'prompt') t.prompts++
333    else if (ev.kind === 'agent-start') t.agents++
334    else if (ev.kind === 'commit') t.commits++
335    if (ev.isError) t.errors++
336  }
337  return t
338}
339
340/** The session squeezed into `w` slices, each painted by its loudest event. */
341function slices(s: ChronSnap, w: number): (ChronEvent | undefined)[] {
342  const out: (ChronEvent | undefined)[] = Array.from({ length: w }, () => undefined)
343  const last = s.events[s.events.length - 1]?.at ?? s.startedAt
344  const span = Math.max(60_000, last - s.startedAt)
345  for (const ev of s.events) {
346    const i = Math.min(w - 1, Math.max(0, Math.floor(((ev.at - s.startedAt) / span) * w)))
347    const cur = out[i]
348    const rank = (x: ChronEvent) => (x.isError ? 9 : KIND[x.kind].rank)
349    if (!cur || rank(ev) >= rank(cur)) out[i] = ev
350  }
351  return out
352}
353
354function ribbon(s: ChronSnap, w: number): string {
355  const cv = new Canvas(w, 1)
356  slices(s, w).forEach((ev, x) => {
357    if (ev) cv.set(x, 0, ev.kind === 'session' ? '▏' : '█', colorOf(ev))
358    else cv.set(x, 0, '▁', '#3f3f46')
359  })
360  return cv.encode()
361}
362
363// ---------------------------------------------------------------------------
364// Desktop.
365
366const CSS = `
367.ring{animation:chr 2s ease-out infinite;transform-box:fill-box;transform-origin:center}
368@keyframes chr{0%{opacity:.6;transform:scale(.7)}100%{opacity:0;transform:scale(2.2)}}
369`
370
371function altHeader(s: ChronSnap): string {
372  const t = tally(s)
373  return `${s.events.length} events: ${t.prompts} prompts, ${t.agents} agents, ${t.commits} commits, ${t.errors} errors`
374}
375
376function headerSvg(s: ChronSnap, W: number): { source: string; height: number } {
377  const t = tally(s)
378  const p: string[] = []
379  const H = 70
380  p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`)
381  const chips: [string, number, string][] = [['prompts', t.prompts, KZ.cyan], ['agents', t.agents, KZ.magenta], ['commits', t.commits, KZ.green], ['errors', t.errors, t.errors ? KZ.red : KZ.mist]]
382  const cw = (W - 24) / chips.length
383  chips.forEach(([label, n, c], i) => {
384    const x = 12 + i * cw
385    p.push(svgText(x, 26, String(n), { size: 17, weight: 750, fill: c }))
386    p.push(svgText(x + String(n).length * 10.5 + 5, 26, label, { cls: 's', size: 10.5 }))
387  })
388  // The ribbon: the whole session left to right.
389  const rw = W - 24
390  const n = Math.max(10, Math.min(120, Math.floor(rw / 4)))
391  const sw = rw / n
392  p.push(`<rect class="k" x="12" y="40" width="${rw}" height="14" rx="4"/>`)
393  slices(s, n).forEach((ev, i) => {
394    if (ev && ev.kind !== 'session') p.push(`<rect x="${(12 + i * sw + 0.5).toFixed(1)}" y="41" width="${Math.max(1, sw - 1).toFixed(1)}" height="12" rx="1.5" fill="${colorOf(ev)}"/>`)
395  })
396  const last = s.events[s.events.length - 1]?.at ?? s.startedAt
397  p.push(svgText(12, 65, hhmmss(s.startedAt), { cls: 'm', size: 9, mono: true }))
398  p.push(svgText(W - 12, 65, hhmmss(last), { cls: 'm', size: 9, mono: true, anchor: 'end' }))
399  return { source: svg(W, H + 4, p.join(''), CSS), height: H + 4 }
400}
401
402function timelineSvg(list: ChronEvent[], W: number): { source: string; height: number } {
403  const p: string[] = []
404  const lineX = 74
405  let y = 16
406  const ys: number[] = []
407  list.forEach((ev, i) => {
408    const c = colorOf(ev)
409    const h = ev.detail ? 38 : 26
410    ys.push(y)
411    p.push(svgText(12, y + 4, hhmmss(ev.at), { cls: 'm', size: 10, mono: true }))
412    if (i === 0) p.push(`<circle cx="${lineX}" cy="${y}" r="6" fill="${c}" class="ring"/>`)
413    p.push(`<circle cx="${lineX}" cy="${y}" r="${ev.kind === 'prompt' || ev.kind === 'commit' || ev.isError ? 5 : 4}" fill="${c}"/>`)
414    if (ev.kind === 'turn' || ev.kind === 'agent-end') p.push(`<circle cx="${lineX}" cy="${y}" r="1.8" class="p"/>`)
415    const tx = lineX + 14
416    p.push(svgText(tx, y + 4, fitText(ev.title, 12, W - tx - 12), { size: 12, weight: ev.kind === 'prompt' || ev.kind === 'commit' ? 650 : 500, fill: ev.isError ? KZ.red : undefined }))
417    if (ev.detail) p.push(svgText(tx, y + 19, fitText(ev.detail, 10.5, W - tx - 12), { cls: 's', size: 10.5, mono: ev.kind === 'commit' || ev.kind === 'push' || ev.kind === 'tool-fail' }))
418    y += h
419  })
420  if (list.length === 0) {
421    p.push(svgText(W / 2, 22, 'Nothing here yet.', { cls: 'm', size: 11, anchor: 'middle' }))
422    y = 36
423  }
424  const height = Math.max(36, y - 6)
425  const top = ys[0] ?? 0
426  const bottom = ys[ys.length - 1] ?? 0
427  const rail = list.length > 1
428    ? `<defs><linearGradient id="chG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}"/><stop offset=".6" stop-color="${KZ.cyan}" stop-opacity=".7"/><stop offset="1" stop-color="${KZ.cyan}" stop-opacity=".1"/></linearGradient></defs>` +
429      `<rect x="${lineX - 1}" y="${top}" width="2" height="${bottom - top}" rx="1" fill="url(#chG)"/>`
430    : ''
431  return { source: svg(W, height, `<rect class="p" x="0" y="0" width="${W}" height="${height}" rx="12"/>${rail}${p.join('')}`, CSS), height }
432}
433
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 37 lines
1export type ChronKind =
2  | 'session'
3  | 'prompt'
4  | 'turn'
5  | 'agent-start'
6  | 'agent-end'
7  | 'commit'
8  | 'push'
9  | 'tool-fail'
10  | 'compact'
11  | 'limit'
12  | 'context'
13
14export type ChronEvent = {
15  id: number
16  at: number
17  kind: ChronKind
18  title: string
19  detail?: string
20  /** Drawn red, and kept by the Errors filter. */
21  isError?: boolean
22}
23
24export type ChronFilter = 'all' | 'prompts' | 'agents' | 'git' | 'errors'
25
26export type ChronSnap = {
27  /** Oldest first, at most 300. */
28  events: ChronEvent[]
29  startedAt: number
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    chronicle: { snap: ChronSnap | null; filter: ChronFilter }
35  }
36}
37