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

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:
| Event | What is recorded |
|---|---|
❯ prompt | the first 80 characters of each prompt you submit |
◆ turn | its duration, the tools it called and the spend it added |
◈ / ◇ agent | a subagent spawned, and finished or failed with its run time |
◉ commit, ⇡ push | a successful git commit / git push through Bash, with sha, branch and message |
✖ failed tool | any tool call that errored or was denied |
⇊ compaction | manual or automatic, with tokens before → after |
▲ limit, ◔ context | crossing 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.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:
| Olay | Kaydedilen |
|---|---|
❯ istem | gönderdiğin her istemin ilk 80 karakteri |
◆ tur | süresi, çağırdığı araç sayısı ve eklediği maliyet |
◈ / ◇ ajan | başlatılan alt ajan; bitişi ya da hatası ve çalışma süresi |
◉ commit, ⇡ push | Bash 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ırma | elle ya da otomatik; önce → sonra token |
▲ limit, ◔ bağlam | bir 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.hooks/register.tsx 433 lines1import { 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}
433hooks/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 37 lines1export 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