KOZMOS Epilogue: a recap band after every turn. Duration, tool families as colored chips, files written, subagents spawned, tokens and estimated cost, the…

A recap after every main turn, shown as a compact band until you send the next prompt:
aiSummary setting on, claude-haiku-5-5 sums up the answer in at most 15 words./epilogue opens a pane with the last 20 recaps; /epilogue hide / show (or ✕) controls the band. Terminal: colored text with filled chips. Desktop: one SVG card per recap, sliding in with a shine.
Her ana turdan sonra, bir sonraki isteme kadar görünen kısa bir özet bandı: süre, tahmini maliyet ve oturum maliyet farkı, token sayısı, araç ailelerine göre renkli etiketler, yazılan dosyalar, başlatılan alt ajanlar ve başarısız araçlar. aiSummary açıksa claude-haiku-5-5 cevabı en fazla 15 kelimeyle özetler. /epilogue son 20 özeti bir panelde açar.
hooks/register.tsx 347 lines1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface, Register, RenderChildren, TextProps } from 'claude-code'
3
4import type { EpilogueRecap } from '../types'
5import { KZ, fitText, fmtClock, fmtTokens, fmtUsd, pxOf, svg, svgText, textWidth, toolDetail, xml } from './lib/kz.ts'
6import { FAMILIES, addTool, addUsage, cleanSummary, fileList, newTally, summaryPrompt, toRecap } from './recap.ts'
7import type { Family, Tally } from './recap.ts'
8
9const PANE = 'kz-epilogue'
10const TITLE = 'KOZMOS · Epilogue'
11const KEEP = 20
12const SUMMARY_MODEL = 'claude-haiku-5-5'
13
14const recapsAtom = atom({ plugin: 'epilogue', key: 'recaps' } as const, [])
15const shownAtom = atom({ plugin: 'epilogue', key: 'isShown' } as const, false)
16const hiddenAtom = atom({ plugin: 'epilogue', key: 'isHidden' } as const, false)
17
18let tally: Tally | null = null
19let wantsSummary = false
20
21async function costNow($: EngineInterface): Promise<number | undefined> {
22 try {
23 return (await $.session.usage()).cost?.usd
24 } catch {
25 return undefined
26 }
27}
28
29async function setHidden($: EngineInterface, value: boolean): Promise<void> {
30 await update($, hiddenAtom, () => value)
31 try {
32 await $.store.set('hidden', value)
33 } catch {
34 // Session only.
35 }
36}
37
38async function togglePane($: EngineInterface): Promise<boolean> {
39 if ((await $.ui.panes()).some(p => p.id === PANE)) {
40 await $.ui.close({ id: PANE })
41 return false
42 }
43 await $.ui.open({ id: PANE, title: TITLE })
44 return true
45}
46
47async function summarize($: EngineInterface, id: string, answer: string): Promise<void> {
48 let summary: string | undefined
49 try {
50 const r = await $.model.complete({ model: SUMMARY_MODEL, prompt: summaryPrompt(answer), maxTokens: 80, effort: 'low', timeoutMs: 20_000 })
51 summary = r.isAnswered ? cleanSummary(r.text) : undefined
52 } catch {
53 summary = undefined
54 }
55 if (!summary) return
56 const text = summary
57 await update($, recapsAtom, list => list.map(r => (r.id === id ? { ...r, summary: text } : r)))
58}
59
60export const register: Register = (on, options) => {
61 on('session.start', async ($, e, next) => {
62 const started = await next(e)
63 tally = null
64 wantsSummary = options.aiSummary === true
65 try {
66 const isHidden = (await $.store.get('hidden')) === true
67 await update($, hiddenAtom, () => isHidden)
68 } catch {
69 // Shown by default.
70 }
71 await $.command.register({ name: 'epilogue', description: 'KOZMOS: open the last 20 turn recaps (hide / show the band)', argumentHint: '[hide | show]', immediate: true })
72 return started
73 })
74
75 on('command.run', { command: 'epilogue' }, async ($, e) => {
76 const verb = e.args.trim().toLowerCase()
77 if (verb === 'hide' || verb === 'show') {
78 await setHidden($, verb === 'hide')
79 return { text: verb === 'hide' ? 'Epilogue band hidden; /epilogue still lists recaps.' : 'Epilogue band shown after each turn.' }
80 }
81 return { text: (await togglePane($)) ? 'Epilogue open.' : 'Epilogue closed.' }
82 })
83
84 on('prompt.submit', async ($, e, next) => {
85 await update($, shownAtom, () => false)
86 return next(e)
87 }).catch(($, e, next) => next(e))
88
89 on('turn.start', async ($, e, next) => {
90 tally = newTally(e.turnId, await $.clock.now(), e.text, await costNow($))
91 await update($, shownAtom, () => false)
92 return next(e)
93 }).catch(($, e, next) => next(e))
94
95 on('turn.step', async function* ($, e, next) {
96 const r = yield* next(e)
97 if (tally && r.usage) addUsage(tally, r.usage.model || e.model, r.usage)
98 return r
99 })
100
101 on('tool.call', async ($, e, next) => {
102 const ran = await next(e)
103 if (tally) {
104 const failed = ran.isError === true || ran.deny !== undefined
105 addTool(tally, String(e.tool), e as unknown as Record<string, unknown>, failed, toolDetail(String(e.tool), e))
106 }
107 return ran
108 }).catch(($, e, next) => next(e))
109
110 on('agent.spawn', async ($, e, next) => {
111 const r = await next(e)
112 if (tally && (r as { agentId?: string }).agentId) tally.agents++
113 return r
114 }).catch(($, e, next) => next(e))
115
116 on('turn.complete', async ($, e, next) => {
117 const done = await next(e)
118 if (e.agentId !== undefined || !tally) return done
119 const t = tally
120 tally = null
121 const recap = toRecap(t, await $.clock.now(), e.durationMs, e.reason, await costNow($))
122 await update($, recapsAtom, list => [...list, recap].slice(-KEEP))
123 await update($, shownAtom, () => true)
124 if (wantsSummary && e.answer.trim()) void summarize($, recap.id, e.answer).catch(() => undefined)
125 return done
126 }).catch(($, e, next) => next(e))
127
128 // ---- the band -------------------------------------------------------------
129 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130 const drawn = await next(e)
131 if (e.props.hasSurvey || e.props.isWorking) return drawn
132 if (!(await read($, shownAtom)) || (await read($, hiddenAtom))) return drawn
133 // Shown only once turn.complete stored a recap.
134 const recaps = await read($, recapsAtom)
135 const r = recaps[recaps.length - 1]!
136 const { Box, Button, Text } = $.ui.resolve(e)
137 const cols = Math.max(40, e.props.bodyColumns || 80)
138 const hide = <Button key="epilogue-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
139
140 // The terminal draws text rows; every other surface one SVG card.
141 if (e.surface !== 'terminal') {
142 const { Svg } = $.ui.resolve(e)
143 const W = pxOf(cols)
144 const card = recapCard(r, W, { isBand: true })
145 return (
146 <Box flexDirection="column">
147 {drawn}
148 <Box key="epilogue" flexDirection="row">
149 <Svg source={card.source} alt={altOf(r)} width={W} height={card.height} />
150 {hide}
151 </Box>
152 </Box>
153 )
154 }
155
156 const lines = termLines(r, cols - 3, Text)
157 return (
158 <Box flexDirection="column">
159 {drawn}
160 <Box key="epilogue" flexDirection="row">
161 <Box flexDirection="column" flexGrow={1}>
162 {lines}
163 </Box>
164 {hide}
165 </Box>
166 </Box>
167 )
168 })
169
170 // ---- the pane -------------------------------------------------------------
171 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
172 const recaps = [...(await read($, recapsAtom))].reverse()
173 const { Box, Text } = $.ui.resolve(e)
174 const cols = Math.max(32, e.props.bodyColumns || 60)
175 if (recaps.length === 0) {
176 return (
177 <Box flexDirection="column">
178 <Text color={KZ.violet} bold>◆ Epilogue</Text>
179 <Text dimColor>No turns yet: a recap lands here after each one.</Text>
180 </Box>
181 )
182 }
183 if (e.surface !== 'terminal') {
184 const { Svg } = $.ui.resolve(e)
185 const W = pxOf(cols, 60)
186 return (
187 <Box flexDirection="column">
188 {recaps.map((r, i) => {
189 const card = recapCard(r, W, { isBand: false, index: recaps.length - i })
190 return <Svg key={`ep-${r.id}`} source={card.source} alt={altOf(r)} width={W} height={card.height} />
191 })}
192 </Box>
193 )
194 }
195 return (
196 <Box flexDirection="column">
197 {recaps.map((r, i) => (
198 <Box key={`ep-${r.id}`} flexDirection="column" marginBottom={1}>
199 <Text wrap="truncate-end">
200 <Text color={KZ.violet} bold>#{recaps.length - i} </Text>
201 <Text dimColor>{timeOf(r.endedAt)} · </Text>
202 <Text>{r.prompt ? `“${r.prompt}”` : '(continuation)'}</Text>
203 </Text>
204 {termLines(r, cols, Text, true)}
205 </Box>
206 ))}
207 </Box>
208 )
209 })
210}
211
212// ---------------------------------------------------------------------------
213
214type TextTag = ElementConstructor<TextProps>
215
216function timeOf(ms: number): string {
217 const d = new Date(ms)
218 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
219}
220
221function costLine(r: EpilogueRecap): string {
222 const est = r.estUsd > 0 ? `~${fmtUsd(r.estUsd)}` : '$0'
223 return r.sessionDeltaUsd !== undefined ? `${est} (session +${fmtUsd(r.sessionDeltaUsd)})` : est
224}
225
226function reasonMark(r: EpilogueRecap): string {
227 return r.reason === 'aborted' ? '⏹ interrupted' : r.reason === 'error' ? '⚠ ended on an error' : r.reason === 'refusal' ? '⊘ refused' : ''
228}
229
230function altOf(r: EpilogueRecap): string {
231 const tools = r.tools.map(t => `${FAMILIES[t.family as Family].label} ${t.count}`).join(', ')
232 return [
233 `Turn recap: ${fmtClock(r.durationMs)}`,
234 `${fmtTokens(r.tokens)} tokens, ${costLine(r)}`,
235 tools ? `tools ${tools}` : 'no tools',
236 r.files.length ? `files ${r.files.join(', ')}` : '',
237 r.agents ? `${r.agents} subagents` : '',
238 r.failed.length ? `${r.failed.length} failed` : '',
239 r.summary ?? '',
240 ].filter(Boolean).join('; ')
241}
242
243/** One or two rows of colored text: the figures and chips, then files, agents and the one-liner. */
244function termLines(r: EpilogueRecap, cols: number, Text: TextTag, isPane = false): RenderChildren[] {
245 const chips = r.tools.map(t => {
246 const f = FAMILIES[t.family as Family]
247 return [
248 <Text key={`c-${t.family}`} backgroundColor={f.color} color="#111111" bold> {f.glyph} {t.count} </Text>,
249 <Text key={`s-${t.family}`}> </Text>,
250 ]
251 })
252 const mark = reasonMark(r)
253 const row1 = (
254 <Text key="r1" wrap="truncate-end">
255 {isPane ? null : <Text color={KZ.violet} bold>◆ </Text>}
256 <Text bold>{fmtClock(r.durationMs)}</Text>
257 <Text dimColor> · </Text>
258 <Text color={KZ.yellow}>{costLine(r)}</Text>
259 <Text dimColor> · {fmtTokens(r.tokens)} tok · </Text>
260 {chips.length ? chips : <Text dimColor>no tools </Text>}
261 {r.failed.length ? <Text color={KZ.red} bold>✖ {r.failed.length} failed </Text> : null}
262 {mark ? <Text color={KZ.amber}>{mark}</Text> : null}
263 </Text>
264 )
265 const bits: RenderChildren[] = []
266 if (r.files.length) bits.push(<Text key="f" color={KZ.yellow}>✎ <Text dimColor>{fileList(r.files, Math.max(16, Math.floor(cols * 0.45)))}</Text></Text>)
267 if (r.agents) bits.push(<Text key="a" color={KZ.violet}>◈ {r.agents} agent{r.agents === 1 ? '' : 's'}</Text>)
268 if (isPane && r.failed.length) bits.push(<Text key="x" color={KZ.red}>✖ {r.failed.map(f => `${f.tool}${f.detail ? ` ${f.detail}` : ''}`).join(', ')}</Text>)
269 if (r.summary) bits.push(<Text key="s" italic color={KZ.cyan}>“{r.summary}”</Text>)
270 if (!bits.length) return [row1]
271 const joined: RenderChildren[] = []
272 bits.forEach((b, i) => {
273 if (i) joined.push(<Text key={`d${i}`} dimColor> · </Text>)
274 joined.push(b)
275 })
276 return [row1, <Text key="r2" wrap="truncate-end">{isPane ? '' : ' '}{joined}</Text>]
277}
278
279/** The recap as one SVG card: figures, family chips, files, failures and the one-liner. */
280function recapCard(r: EpilogueRecap, W: number, opts: { isBand: boolean; index?: number }): { source: string; height: number } {
281 const pad = 14
282 const hasLine3 = Boolean(r.summary) || (!opts.isBand && Boolean(r.prompt))
283 const H = hasLine3 ? 74 : 54
284 const css = `
285.ep-ep{animation:epIn .45s cubic-bezier(.2,.8,.2,1) both}@keyframes epIn{from{opacity:0;transform:translateY(5px)}}
286.ep-chip{animation:epChip .4s cubic-bezier(.2,.8,.2,1) both}@keyframes epChip{from{opacity:0;transform:scale(.6)}}
287.ep-chip{transform-box:fill-box;transform-origin:center}
288.ep-shine{animation:epShine${W} 1.6s ease-out .35s both}@keyframes epShine${W}{from{transform:translateX(-160px)}to{transform:translateX(${W + 160}px)}}
289.ep-accent{animation:epHue 6s linear infinite}@keyframes epHue{50%{opacity:.55}}
290`
291 const p: string[] = []
292 p.push(`<defs><linearGradient id="epA" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}"/><stop offset="1" stop-color="${KZ.cyan}"/></linearGradient>` +
293 `<linearGradient id="epS" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".22"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
294 `<clipPath id="epC${W}x${H}"><rect x="0" y="0" width="${W}" height="${H}" rx="12"/></clipPath></defs>`)
295 p.push(`<g class="ep-ep">`)
296 p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`)
297 p.push(`<rect class="ep-accent" x="0" y="0" width="4" height="${H}" fill="url(#epA)" clip-path="url(#epC${W}x${H})"/>`)
298 // Line 1: label, duration, cost, tokens; failures and the end reason on the right.
299 let x = pad
300 const label = opts.index !== undefined ? `#${opts.index} · ${timeOf(r.endedAt)}` : 'EPILOGUE'
301 p.push(svgText(x, 21, label, { size: 10, weight: 700, fill: KZ.violet }))
302 x += textWidth(label, 10) + 12
303 p.push(svgText(x, 21, fmtClock(r.durationMs), { size: 13, weight: 700, mono: true }))
304 x += textWidth(fmtClock(r.durationMs), 13) + 14
305 p.push(svgText(x, 21, costLine(r), { size: 12, weight: 600, fill: '#eab308' }))
306 x += textWidth(costLine(r), 12) + 14
307 p.push(svgText(x, 21, `${fmtTokens(r.tokens)} tokens`, { cls: 's', size: 11.5 }))
308 let rx = W - pad - (opts.isBand ? 18 : 0)
309 if (r.failed.length) {
310 const t = `✖ ${r.failed.length} failed`
311 const w = textWidth(t, 10.5) + 16
312 rx -= w
313 p.push(`<rect x="${rx}" y="9" width="${w}" height="17" rx="8.5" fill="${KZ.red}" opacity=".16"/>`)
314 p.push(svgText(rx + w / 2, 21, t, { size: 10.5, weight: 700, anchor: 'middle', fill: KZ.red }))
315 rx -= 8
316 }
317 const mark = reasonMark(r)
318 if (mark) p.push(svgText(rx, 21, mark, { size: 10.5, weight: 600, anchor: 'end', fill: KZ.amber }))
319 // Line 2: chips, files, agents.
320 x = pad
321 r.tools.forEach((t, i) => {
322 const f = FAMILIES[t.family as Family]
323 const txt = `${f.glyph} ${f.label} ${t.count}`
324 const w = textWidth(txt, 10.5) + 16
325 if (x + w > W - pad) return
326 p.push(`<g class="ep-chip" style="animation-delay:${(0.08 + i * 0.06).toFixed(2)}s"><rect x="${x}" y="31" width="${w}" height="18" rx="9" fill="${f.color}" opacity=".2"/>` +
327 svgText(x + w / 2, 44, txt, { size: 10.5, weight: 650, anchor: 'middle', fill: f.color }) + `</g>`)
328 x += w + 6
329 })
330 if (!r.tools.length) {
331 p.push(svgText(x, 44, 'no tools', { cls: 'm', size: 10.5 }))
332 x += 56
333 }
334 const tail: string[] = []
335 if (r.agents) tail.push(`◈ ${r.agents} agent${r.agents === 1 ? '' : 's'}`)
336 if (r.files.length) tail.push(`✎ ${fileList(r.files, Math.max(12, Math.floor((W - x - pad) / 6.6) - 14))}`)
337 if (tail.length && x < W - pad - 60) p.push(svgText(x + 6, 44, fitText(tail.join(' '), 10.5, W - pad - x - 6), { cls: 's', size: 10.5, mono: true }))
338 // Line 3: the one-liner, or (in the pane) the prompt.
339 if (hasLine3) {
340 const body = r.summary ? `“${r.summary}”` : `› ${r.prompt}`
341 p.push(`<text ${r.summary ? `fill="${KZ.cyan}"` : 'class="m"'} x="${pad}" y="64" font-size="11.5" font-style="${r.summary ? 'italic' : 'normal'}" font-family="-apple-system,'Segoe UI',sans-serif">${xml(fitText(body, 11.5, W - pad * 2))}</text>`)
342 }
343 p.push(`<g clip-path="url(#epC${W}x${H})"><rect class="ep-shine" x="0" y="0" width="140" height="${H}" fill="url(#epS)"/></g>`)
344 p.push(`</g>`)
345 return { source: svg(W, H, p.join(''), css), height: H }
346}
347hooks/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}
510hooks/recap.ts 139 lines1// Epilogue's bookkeeping, pure: tool families, the running tally of a turn,
2// and the recap it becomes.
3import type { EpilogueRecap } from '../types'
4import { KZ, baseName, costOf, tokensOf } from './lib/kz.ts'
5import type { Usage } from './lib/kz.ts'
6
7export type Family = 'edit' | 'shell' | 'read' | 'agent' | 'web' | 'tasks' | 'mcp' | 'other'
8
9export const FAMILIES: Record<Family, { label: string; glyph: string; color: string }> = {
10 edit: { label: 'Edit', glyph: '✎', color: KZ.yellow },
11 shell: { label: 'Shell', glyph: '$', color: KZ.green },
12 read: { label: 'Read', glyph: '◉', color: KZ.blue },
13 agent: { label: 'Agent', glyph: '◈', color: KZ.violet },
14 web: { label: 'Web', glyph: '◍', color: KZ.cyan },
15 tasks: { label: 'Tasks', glyph: '☑', color: KZ.teal },
16 mcp: { label: 'MCP', glyph: '⬡', color: KZ.magenta },
17 other: { label: 'Other', glyph: '•', color: KZ.mist },
18}
19
20export const FAMILY_ORDER: readonly Family[] = ['edit', 'shell', 'read', 'agent', 'web', 'tasks', 'mcp', 'other']
21
22export function familyOf(tool: string): Family {
23 const t = String(tool)
24 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return 'edit'
25 if (t === 'Bash' || t === 'PowerShell' || t === 'Monitor') return 'shell'
26 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP' || t === 'ToolSearch') return 'read'
27 if (t === 'Agent' || t === 'Task' || t === 'Workflow' || t === 'SendMessage') return 'agent'
28 if (t.startsWith('Web')) return 'web'
29 if (t.startsWith('Todo') || t.startsWith('Task')) return 'tasks'
30 if (t.startsWith('mcp__')) return 'mcp'
31 return 'other'
32}
33
34/** The file a tool call writes, when it writes one. */
35export function writtenFile(tool: string, input: Record<string, unknown>): string | undefined {
36 if (tool !== 'Edit' && tool !== 'Write' && tool !== 'NotebookEdit' && tool !== 'MultiEdit') return undefined
37 const p = input.file_path ?? input.notebook_path
38 return typeof p === 'string' && p ? p : undefined
39}
40
41export type Tally = {
42 turnId: string
43 startedAt: number
44 prompt: string
45 costAtStart?: number
46 tools: Partial<Record<Family, number>>
47 files: string[]
48 agents: number
49 failed: { tool: string; detail: string }[]
50 usage: Required<Usage>
51 estUsd: number
52 steps: number
53}
54
55export function newTally(turnId: string, startedAt: number, prompt: string, costAtStart?: number): Tally {
56 return {
57 turnId,
58 startedAt,
59 prompt,
60 costAtStart,
61 tools: {},
62 files: [],
63 agents: 0,
64 failed: [],
65 usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
66 estUsd: 0,
67 steps: 0,
68 }
69}
70
71export function addTool(t: Tally, tool: string, input: Record<string, unknown>, failed: boolean, detail: string): void {
72 const fam = familyOf(tool)
73 t.tools[fam] = (t.tools[fam] ?? 0) + 1
74 const f = writtenFile(tool, input)
75 if (f && !t.files.includes(f)) t.files.push(f)
76 if (failed) t.failed.push({ tool, detail })
77}
78
79export function addUsage(t: Tally, model: string, u: Usage | null | undefined): void {
80 if (!u) return
81 t.usage.input_tokens += u.input_tokens ?? 0
82 t.usage.output_tokens += u.output_tokens ?? 0
83 t.usage.cache_read_input_tokens += u.cache_read_input_tokens ?? 0
84 t.usage.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0
85 t.estUsd += costOf(model, u)
86 t.steps++
87}
88
89export function toRecap(t: Tally, endedAt: number, durationMs: number, reason: string, costNow?: number): EpilogueRecap {
90 const tools = FAMILY_ORDER.flatMap(f => {
91 const count = t.tools[f] ?? 0
92 return count > 0 ? [{ family: f, count }] : []
93 })
94 return {
95 id: t.turnId,
96 endedAt,
97 durationMs,
98 reason,
99 prompt: t.prompt.replace(/\s+/g, ' ').trim().slice(0, 120),
100 tools,
101 toolCount: tools.reduce((a, x) => a + x.count, 0),
102 files: t.files.map(f => baseName(f)),
103 agents: t.agents,
104 failed: t.failed.slice(0, 12),
105 tokens: tokensOf(t.usage),
106 outputTokens: t.usage.output_tokens,
107 estUsd: t.estUsd,
108 sessionDeltaUsd: costNow !== undefined && t.costAtStart !== undefined ? Math.max(0, costNow - t.costAtStart) : undefined,
109 }
110}
111
112/** `register.tsx, kz.ts +3` within `max` characters. */
113export function fileList(files: readonly string[], max: number): string {
114 let out = ''
115 let n = 0
116 for (const f of files) {
117 const next = out ? `${out}, ${f}` : f
118 const rest = files.length - n - 1
119 if (next.length + (rest > 0 ? ` +${rest}`.length : 0) > max && n > 0) break
120 out = next
121 n++
122 }
123 return n < files.length ? `${out} +${files.length - n}` : out
124}
125
126/** The prompt sent to the small model for the one-liner. */
127export function summaryPrompt(answer: string): string {
128 const clipped = answer.length > 6000 ? `${answer.slice(0, 3000)}\n…\n${answer.slice(-3000)}` : answer
129 return `Summarize what this assistant reply reports, in at most 15 words, as a plain past-tense sentence fragment with no quotes and no preamble.\n\n<reply>\n${clipped}\n</reply>`
130}
131
132/** Cleans the model's one-liner: one line, no quotes, at most 15 words. */
133export function cleanSummary(text: string): string | undefined {
134 const line = text.replace(/\s+/g, ' ').replace(/^["'“”‘’\s]+|["'“”‘’\s]+$/g, '').trim()
135 if (!line) return undefined
136 const words = line.split(' ')
137 return words.length > 15 ? `${words.slice(0, 15).join(' ')}…` : line
138}
139types/index.d.ts 24 lines1export type EpilogueRecap = {
2 id: string
3 endedAt: number
4 durationMs: number
5 reason: string
6 prompt: string
7 tools: { family: string; count: number }[]
8 toolCount: number
9 files: string[]
10 agents: number
11 failed: { tool: string; detail: string }[]
12 tokens: number
13 outputTokens: number
14 estUsd: number
15 sessionDeltaUsd?: number
16 summary?: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 epilogue: { recaps: EpilogueRecap[]; isShown: boolean; isHidden: boolean }
22 }
23}
24