Live band above the prompt: tool activity, running subagents and todo progress of this session.

Live session activity at a glance: a band above the prompt that shows your tool calls, running subagents, and todo progress in real time.
Know what Claude is doing right now—which tools it's calling, how many have failed, what agents are running, and how far your todos have come. The band stays updated with every tool call and every turn, all session-local. Nothing leaves your machine.
Type /pulse to toggle the band. /pulse details opens the full breakdown.
The band shows at the top of every turn.
▼ to collapse, ▲ when collapsed. Details button opens the pane for the full view. X hides the band.One-line summary:
1/3).Open with the Details button on the band, or run /pulse details at the prompt.
Pane shows:
Tool calls — every tool Claude runs this session: name, count, failures, and execution times.
Subagents — each spawned agent: type, description, when it started and ended, and whether it errored.
Todos — from TodoWrite calls: content, active form, and status.
Recent history — the last ~40 calls and their targets (file paths, command snippets, queries), useful for checking what Claude just tried without scrolling the transcript.
Nothing is saved after the session ends. Each session starts fresh.
Needs Claude Code 2.1.287 or later.
Desktop app: in the Code tab, send this as a message:
Install the pulse plugin from the GitHub marketplace kyojinkim/claude-code-pulse-mod
Then allow claude plugin commands if asked, and quit and reopen the app.
Terminal: run these one at a time:
claude plugin marketplace add kyojinkim/claude-code-pulse-mod
claude plugin install pulse@kyojinkim/claude-code-pulse-mod
/reload-plugins
The band appears once the plugins reload.
Either way installs it for both the desktop app and the terminal.
▲/▼ to collapse/expand, and ▼ to collapse, [-] to hide)Clone and run from the folder:
git clone https://github.com/kyojinkim/claude-code-pulse-mod
cd claude-code-pulse-mod
claude --plugin-dir .
Then run /pulse to verify. Check the details pane with /pulse details.
Run the test suite:
claude plugin test .
Everything runs on your machine with the same access Claude Code has. Nothing is sent anywhere. Read the code before installing—it's open source.
MIT © 2026 Kory Kim
hooks/register.tsx 449 lines1import { atom, read, update } from 'claude-code'
2import type { Register, RenderElement } from 'claude-code'
3import type { AgentRun, CallRow, Pulse, Todo, ToolStat } from '../types'
4
5// pulse: one band above the prompt with what this session is doing right
6// now (which tools ran and failed, which subagents run, how far the todo
7// list has come), and a pane with the detail. Nothing leaves the machine.
8
9const EMPTY: Pulse = { tools: [], lastTool: null, agents: [], todos: [], turnStartedAt: null, recent: [], turns: 0 }
10
11const pulse = atom({ plugin: 'pulse', key: 'pulse' } as const, EMPTY)
12const collapsed = atom({ plugin: 'pulse', key: 'collapsed' } as const, true)
13const hidden = atom({ plugin: 'pulse', key: 'hidden' } as const, false)
14
15const MAX_AGENTS_SHOWN = 4
16const MAX_TOOLS_SHOWN = 6
17const MAX_RECENT = 40
18const RECENT_SHOWN = 12
19const DONE_AGENT_TTL_MS = 60_000
20const PANE = 'pulse'
21
22type Host = Parameters<typeof update>[0]
23
24// State written by an older pulse lacks the newer fields; fill them in.
25const norm = (p: Partial<Pulse> | undefined): Pulse => ({
26 ...EMPTY,
27 ...(p ?? {}),
28 tools: (p?.tools ?? []).map(t => ({ ...t, ms: t.ms ?? 0, lastAt: t.lastAt ?? null })),
29 recent: p?.recent ?? [],
30 turns: p?.turns ?? 0,
31})
32const readPulse = async ($: Host) => norm(await read($, pulse))
33const edit = ($: Host, f: (p: Pulse) => Pulse) => update($, pulse, p => f(norm(p)))
34
35// The band is one site: whichever plugin sits on top answers for it. pulse draws
36// its own row and keeps whatever another plugin beneath it drew, stacked above it.
37const isBlank = (el: unknown): boolean => {
38 if (!el || typeof el !== 'object') return true
39 const t = (el as { type?: unknown }).type
40 if (t === 'engine') return true
41 const kids = (el as { children?: unknown }).children
42 return t === 'Box' && (!Array.isArray(kids) || kids.length === 0)
43}
44
45const short = (name: string) => name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name
46
47// What a call was about, in a few words: a file's last two segments, else
48// the call's own description, command, pattern, query or address.
49const detailOf = (input: Record<string, unknown>): string => {
50 const s = (k: string) => typeof input[k] === 'string' && (input[k] as string).length > 0 ? input[k] as string : undefined
51 const path = s('file_path') ?? s('notebook_path') ?? s('path')
52 if (path) return path.split('/').filter(Boolean).slice(-2).join('/')
53 const text = s('description') ?? s('command') ?? s('pattern') ?? s('query') ?? s('url') ?? s('skill') ?? s('prompt') ?? ''
54 return text.replace(/\s+/g, ' ').trim().slice(0, 160)
55}
56
57const since = (from: number, now: number) => {
58 const s = Math.max(0, Math.round((now - from) / 1000))
59 if (s < 60) return `${s}s`
60 const m = Math.floor(s / 60)
61 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
62}
63
64const ms = (n: number) => n < 1000 ? `${Math.round(n)}ms` : n < 60_000 ? `${(n / 1000).toFixed(1)}s` : since(0, n)
65
66const bar = (done: number, total: number, width: number) => {
67 if (total === 0) return '─'.repeat(width)
68 const filled = Math.round((done / total) * width)
69 return '█'.repeat(filled) + '░'.repeat(width - filled)
70}
71
72const agentGlyph = (a: AgentRun) => a.endedAt === null ? '●' : a.isError ? '✗' : '✓'
73const agentColor = (a: AgentRun) => a.endedAt === null ? 'warning' : a.isError ? 'error' : 'success'
74// Running agents first, then the newest.
75const byLive = (a: AgentRun, b: AgentRun) => (a.endedAt === null ? 0 : 1) - (b.endedAt === null ? 0 : 1) || b.startedAt - a.startedAt
76
77export const register: Register = on => {
78 on('session.start', ($, e, next) => {
79 $.command.register({ name: 'pulse', description: 'Show or hide the pulse band; "/pulse details" opens the pulse pane' })
80 return next(e)
81 })
82
83 on('command.run', { command: 'pulse' }, async ($, e) => {
84 if (/^\s*(details|pane|open)\s*$/i.test(e.args)) {
85 const opened = await $.ui.open({ id: PANE, title: 'Pulse' })
86 return { text: opened.isPlaced ? 'pulse: details pane opened.' : `pulse: pane waits (${opened.reason}).` }
87 }
88 const isHidden = await update($, hidden, v => !v)
89 return { text: isHidden ? 'pulse: band hidden. /pulse shows it again.' : 'pulse: band shown.' }
90 }).catch(() => ({ text: 'pulse: could not toggle' }))
91
92 on('prompt.submit', async ($, e, next) => {
93 const now = await $.clock.now()
94 await edit($, p => ({
95 ...p,
96 turnStartedAt: now,
97 turns: p.turns + 1,
98 tools: p.tools.map(t => ({ ...t, busy: 0 })),
99 agents: p.agents.filter(a => a.endedAt === null || now - a.endedAt < DONE_AGENT_TTL_MS),
100 }))
101 return next(e)
102 }).catch(($, e, next) => next(e))
103
104 on('agent.spawn', async ($, e, next) => {
105 const result = await next(e)
106 if (result.deny !== undefined || !result.agentId) return result
107 const now = await $.clock.now()
108 const run: AgentRun = { id: result.agentId, type: e.subagentType, description: e.description, startedAt: now, endedAt: null, isError: false }
109 await edit($, p => ({ ...p, agents: [...p.agents.filter(a => a.id !== run.id), run] }))
110 return result
111 }).catch(($, e, next) => next(e))
112
113 on('tool.call', async ($, e, next) => {
114 const name = short(e.tool)
115 const isMain = e.agentId === undefined
116 const startedAt = await $.clock.now()
117 const id = e.tool_use_id ?? `${name}@${startedAt}`
118
119 // The main loop's calls are the session's activity; a subagent's are its own.
120 if (isMain) {
121 const row: CallRow = { id, tool: name, detail: detailOf(e as unknown as Record<string, unknown>), startedAt, endedAt: null, isError: false }
122 await edit($, p => {
123 const tools = p.tools.some(t => t.name === name)
124 ? p.tools.map(t => t.name === name ? { ...t, calls: t.calls + 1, busy: t.busy + 1 } : t)
125 : [...p.tools, { name, calls: 1, errors: 0, busy: 1, ms: 0, lastAt: null } satisfies ToolStat]
126 return { ...p, tools, lastTool: name, recent: [...p.recent, row].slice(-MAX_RECENT) }
127 })
128 }
129
130 if (e.tool === 'TodoWrite') {
131 const todos: Todo[] = e.todos.map(t => ({ content: t.content, activeForm: t.activeForm, status: t.status }))
132 await edit($, p => ({ ...p, todos }))
133 }
134
135 const result = await next(e)
136 const isError = result.deny !== undefined || result.isError === true
137 const endedAt = await $.clock.now()
138
139 if (isMain) {
140 await edit($, p => ({
141 ...p,
142 tools: p.tools.map(t => t.name === name
143 ? { ...t, busy: Math.max(0, t.busy - 1), errors: t.errors + (isError ? 1 : 0), ms: t.ms + (endedAt - startedAt), lastAt: endedAt }
144 : t),
145 recent: p.recent.map(r => r.id === id ? { ...r, endedAt, isError } : r),
146 }))
147 }
148
149 if (e.tool === 'Agent' && result.deny === undefined) {
150 const live = await $.agent.list()
151 const alive = new Set(live.map(a => a.id))
152 await edit($, p => ({
153 ...p,
154 agents: p.agents.map(a => a.endedAt === null && !alive.has(a.id) ? { ...a, endedAt, isError } : a),
155 }))
156 }
157
158 return result
159 }).catch(($, e, next) => next(e))
160
161 on('turn.complete', async ($, e, next) => {
162 const now = await $.clock.now()
163 const live = await $.agent.list()
164 const alive = new Set(live.map(a => a.id))
165 await edit($, p => ({
166 ...p,
167 turnStartedAt: null,
168 tools: p.tools.map(t => ({ ...t, busy: 0 })),
169 // A call still open when the turn ends was cut off.
170 recent: p.recent.map(r => r.endedAt === null ? { ...r, endedAt: now } : r),
171 agents: p.agents.map(a => a.endedAt === null && !alive.has(a.id) ? { ...a, endedAt: now } : a),
172 }))
173 return next(e)
174 })
175
176 // The band: one row, a glance at the session. Open, it adds the busiest tools
177 // and this session's agents, each row named. Detail lives in the pane.
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 const p = await readPulse($)
180 const isCollapsed = await read($, collapsed)
181 const isHidden = await read($, hidden)
182 const isEmpty = p.tools.length === 0 && p.agents.length === 0 && p.todos.length === 0
183 if (e.props.hasSurvey || isHidden || isEmpty) return next(e)
184
185 const { Box, Text, Button } = $.ui.resolve(e)
186 const now = await $.clock.now()
187
188 const running = p.agents.filter(a => a.endedAt === null)
189 const done = p.todos.filter(t => t.status === 'completed').length
190 const current = p.todos.find(t => t.status === 'in_progress')
191 const calls = p.tools.reduce((n, t) => n + t.calls, 0)
192 const errors = p.tools.reduce((n, t) => n + t.errors, 0)
193 const busy = p.tools.filter(t => t.busy > 0)
194
195 const tools = (
196 <Box gap={1} flexShrink={1}>
197 <Text color={busy.length > 0 ? 'claude' : 'subtle'}>{busy.length > 0 ? '◆' : '◇'}</Text>
198 <Text bold tabular>{calls}</Text>
199 <Text dimColor>{calls === 1 ? 'tool call' : 'tool calls'}</Text>
200 {errors > 0 ? <Text color="error">{`· ${errors} failed`}</Text> : null}
201 {busy.length > 0
202 ? <Text color="claude" wrap="truncate-end">{`· running ${busy.map(t => t.name).join(', ')}`}</Text>
203 : p.lastTool ? <Text dimColor wrap="truncate-end">{`· last ${p.lastTool}`}</Text> : null}
204 </Box>
205 )
206 const agents = (
207 <Box gap={1} flexShrink={1}>
208 <Text color={running.length > 0 ? 'warning' : 'subtle'}>{running.length > 0 ? '●' : '○'}</Text>
209 {running.length > 0 ? <Text bold tabular>{running.length}</Text> : null}
210 <Text dimColor>{running.length === 0 ? 'No agents running' : running.length === 1 ? 'agent running' : 'agents running'}</Text>
211 </Box>
212 )
213 const todos = p.todos.length > 0 ? (
214 <Box gap={1} flexShrink={1}>
215 <Text color={done === p.todos.length ? 'success' : 'claude'} tabular>{bar(done, p.todos.length, 8)}</Text>
216 <Text bold tabular>{`${done}/${p.todos.length}`}</Text>
217 <Text dimColor wrap="truncate-end">{current ? current.activeForm || current.content : done === p.todos.length ? 'todos done' : 'todos'}</Text>
218 </Box>
219 ) : null
220
221 const controls = (
222 <Box gap={1} alignItems="center" flexShrink={0}>
223 <Button key="details" label="Details" onPress={() => $.ui.open({ id: PANE, title: 'Pulse' })} />
224 <Button key="toggle" label={isCollapsed ? '▲' : '▼'} plain onPress={() => update($, collapsed, v => !v)} />
225 <Button key="hide" label="X" role="dismiss" plain dimColor onPress={() => update($, hidden, () => true)} />
226 </Box>
227 )
228
229 const summary = (
230 <Box gap={2} alignItems="center">
231 <Box gap={2} flexGrow={1} flexShrink={1} flexWrap="wrap">
232 {tools}
233 {agents}
234 {todos}
235 </Box>
236 {controls}
237 </Box>
238 )
239
240 const below = await next(e)
241 const rule = (
242 <Box width="100%" justifyContent="center">
243 <Text dimColor>· · · · ·</Text>
244 </Box>
245 )
246 const stacked = (mine: RenderElement) => isBlank(below)
247 ? mine
248 : <Box flexDirection="column" gap={1}>{below}{rule}{mine}</Box>
249 if (isCollapsed) return stacked(summary)
250
251 // Open: each row says what it lists, in a dim label column.
252 const label = (text: string) => (
253 <Box width={10} flexShrink={0}>
254 <Text dimColor>{text}</Text>
255 </Box>
256 )
257 const top = [...p.tools].sort((a, b) => b.calls - a.calls).slice(0, MAX_TOOLS_SHOWN)
258 const toolRow = (
259 <Box gap={1}>
260 {label('Top tools')}
261 <Box flexWrap="wrap" columnGap={3} flexShrink={1}>
262 {top.map(t => (
263 <Box key={t.name} gap={1}>
264 <Text color={t.busy > 0 ? 'claude' : 'text'}>{t.name}</Text>
265 <Text dimColor tabular>{`×${t.calls}`}</Text>
266 {t.errors > 0 ? <Text color="error" tabular>{`${t.errors} failed`}</Text> : null}
267 </Box>
268 ))}
269 {p.tools.length > top.length ? <Text dimColor>{`+${p.tools.length - top.length} more`}</Text> : null}
270 </Box>
271 </Box>
272 )
273 const shownAgents = [...p.agents].sort(byLive).slice(0, MAX_AGENTS_SHOWN)
274 const agentRow = (
275 <Box gap={1}>
276 {label('Agents')}
277 <Box flexWrap="wrap" columnGap={3} flexShrink={1}>
278 {shownAgents.length === 0 ? <Text dimColor>None this session</Text> : null}
279 {shownAgents.map(a => (
280 <Box key={a.id} gap={1}>
281 <Text color={agentColor(a)}>{agentGlyph(a)}</Text>
282 <Text>{a.type}</Text>
283 <Text dimColor tabular>{since(a.startedAt, a.endedAt ?? now)}</Text>
284 </Box>
285 ))}
286 {p.agents.length > shownAgents.length ? <Text dimColor>{`+${p.agents.length - shownAgents.length} more`}</Text> : null}
287 </Box>
288 </Box>
289 )
290
291 return stacked(
292 <Box flexDirection="column">
293 {summary}
294 <Box flexDirection="column" marginTop={1} marginLeft={2}>
295 {toolRow}
296 {agentRow}
297 </Box>
298 </Box>
299 )
300 })
301
302 // The pane: the session at a glance, then tools as a table, the latest
303 // calls, agents and todos.
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const { Box, Text } = $.ui.resolve(e)
306 const p = await readPulse($)
307 const now = await $.clock.now()
308 const width = Math.max(40, e.props.bodyColumns)
309
310 const calls = p.tools.reduce((n, t) => n + t.calls, 0)
311 const errors = p.tools.reduce((n, t) => n + t.errors, 0)
312 const done = p.todos.filter(t => t.status === 'completed').length
313 const running = p.agents.filter(a => a.endedAt === null).length
314 const sorted = [...p.tools].sort((a, b) => b.calls - a.calls)
315 const maxCalls = Math.max(1, ...sorted.map(t => t.calls))
316 const isWorking = p.turnStartedAt !== null
317
318 // Columns: name, share bar, calls, failed, avg, last; the bar takes what is left.
319 const nameW = Math.min(20, Math.max(6, ...sorted.map(t => t.name.length), ...p.recent.map(r => r.tool.length)))
320 const barW = Math.max(4, Math.min(24, width - (nameW + 6 + 7 + 7 + 9) - 10))
321 const cell = (w: number, child: RenderElement, right = false) => (
322 <Box width={w} flexShrink={0} justifyContent={right ? 'flex-end' : 'flex-start'}>{child}</Box>
323 )
324 const section = (title: string, note: string) => (
325 <Box gap={2}>
326 <Text bold>{title}</Text>
327 <Text dimColor>{note}</Text>
328 </Box>
329 )
330 const stat = (value: string | number, unit: string, color?: 'error' | 'warning' | 'claude') => (
331 <Box gap={1}>
332 <Text bold tabular color={color}>{String(value)}</Text>
333 <Text dimColor>{unit}</Text>
334 </Box>
335 )
336
337 const header = (
338 <Box flexDirection="column">
339 <Box gap={1}>
340 <Text color={isWorking ? 'claude' : 'subtle'}>{isWorking ? '◆' : '◇'}</Text>
341 <Text bold>{isWorking ? 'Working' : 'Idle'}</Text>
342 {isWorking ? <Text dimColor tabular>{`turn ${since(p.turnStartedAt as number, now)}`}</Text> : null}
343 </Box>
344 <Box gap={3} flexWrap="wrap" marginLeft={2}>
345 {stat(p.turns, p.turns === 1 ? 'turn' : 'turns')}
346 {stat(calls, calls === 1 ? 'call' : 'calls')}
347 {errors > 0 ? stat(errors, 'failed', 'error') : null}
348 {stat(running, 'agents running', running > 0 ? 'warning' : undefined)}
349 {p.todos.length > 0 ? stat(`${done}/${p.todos.length}`, 'todos') : null}
350 </Box>
351 </Box>
352 )
353
354 const toolTable = (
355 <Box flexDirection="column">
356 {section('Tools', calls === 0 ? 'None yet' : 'By calls, this session')}
357 {sorted.length > 0 ? (
358 <Box gap={1}>
359 {cell(nameW, <Text dimColor>Tool</Text>)}
360 {cell(barW, <Text dimColor>Share</Text>)}
361 {cell(6, <Text dimColor>Calls</Text>, true)}
362 {cell(7, <Text dimColor>Failed</Text>, true)}
363 {cell(7, <Text dimColor>Avg</Text>, true)}
364 {cell(9, <Text dimColor>Last</Text>, true)}
365 </Box>
366 ) : null}
367 {sorted.map(t => {
368 const finished = Math.max(0, t.calls - t.busy)
369 const filled = Math.max(1, Math.round((t.calls / maxCalls) * barW))
370 return (
371 <Box key={t.name} gap={1}>
372 {cell(nameW, <Text color={t.busy > 0 ? 'claude' : 'text'} wrap="truncate-end">{t.name}</Text>)}
373 {cell(barW, (
374 <Box>
375 <Text color={t.errors > 0 ? 'warning' : 'claude'}>{'█'.repeat(filled)}</Text>
376 <Text dimColor>{'░'.repeat(barW - filled)}</Text>
377 </Box>
378 ))}
379 {cell(6, <Text tabular>{String(t.calls)}</Text>, true)}
380 {cell(7, t.errors > 0 ? <Text color="error" tabular>{String(t.errors)}</Text> : <Text dimColor>–</Text>, true)}
381 {cell(7, <Text dimColor tabular>{finished > 0 ? ms(t.ms / finished) : '–'}</Text>, true)}
382 {cell(9, t.busy > 0 ? <Text color="claude">running</Text> : <Text dimColor tabular>{t.lastAt !== null ? `${since(t.lastAt, now)} ago` : '–'}</Text>, true)}
383 </Box>
384 )
385 })}
386 </Box>
387 )
388
389 const latest = [...p.recent].reverse().slice(0, RECENT_SHOWN)
390 const recentList = (
391 <Box flexDirection="column">
392 {section('Recent', latest.length === 0 ? 'No calls yet' : `Last ${latest.length} of ${calls}, newest first`)}
393 {latest.map(r => (
394 <Box key={r.id} gap={1}>
395 <Text color={r.endedAt === null ? 'claude' : r.isError ? 'error' : 'success'}>{r.endedAt === null ? '◆' : r.isError ? '✗' : '✓'}</Text>
396 {cell(nameW, <Text wrap="truncate-end">{r.tool}</Text>)}
397 <Box flexGrow={1} flexShrink={1}>
398 <Text dimColor wrap="truncate-end">{r.detail || '–'}</Text>
399 </Box>
400 {cell(7, r.endedAt === null ? <Text color="claude">running</Text> : <Text dimColor tabular>{ms(r.endedAt - r.startedAt)}</Text>, true)}
401 {cell(9, <Text dimColor tabular>{`${since(r.startedAt, now)} ago`}</Text>, true)}
402 </Box>
403 ))}
404 </Box>
405 )
406
407 const agentList = [...p.agents].sort(byLive)
408 const typeW = Math.min(20, Math.max(6, ...agentList.map(a => a.type.length)))
409 const agentSection = (
410 <Box flexDirection="column">
411 {section('Agents', agentList.length === 0 ? 'None this session' : `${running} running · ${agentList.length - running} done`)}
412 {agentList.map(a => (
413 <Box key={a.id} gap={1}>
414 <Text color={agentColor(a)}>{agentGlyph(a)}</Text>
415 {cell(typeW, <Text bold wrap="truncate-end">{a.type}</Text>)}
416 <Box flexGrow={1} flexShrink={1}>
417 <Text dimColor wrap="truncate-end">{a.description}</Text>
418 </Box>
419 {cell(9, a.endedAt === null ? <Text color="warning" tabular>{since(a.startedAt, now)}</Text> : <Text dimColor tabular>{since(a.startedAt, a.endedAt)}</Text>, true)}
420 </Box>
421 ))}
422 </Box>
423 )
424
425 const todoSection = (
426 <Box flexDirection="column">
427 {section('Todos', p.todos.length === 0 ? 'No list yet' : `${done} of ${p.todos.length} done`)}
428 {p.todos.length > 0 ? <Text color={done === p.todos.length ? 'success' : 'claude'} tabular>{bar(done, p.todos.length, Math.min(32, width - 4))}</Text> : null}
429 {p.todos.map((t, i) => (
430 <Box key={`${i}-${t.content}`} gap={1}>
431 <Text color={t.status === 'completed' ? 'success' : t.status === 'in_progress' ? 'claude' : 'subtle'}>{t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▶' : '·'}</Text>
432 <Text dimColor={t.status === 'completed'} strikethrough={t.status === 'completed'} wrap="wrap">{t.status === 'in_progress' ? t.activeForm || t.content : t.content}</Text>
433 </Box>
434 ))}
435 </Box>
436 )
437
438 return (
439 <Box flexDirection="column" gap={1}>
440 {header}
441 {toolTable}
442 {recentList}
443 {agentSection}
444 {todoSection}
445 </Box>
446 )
447 })
448}
449types/index.d.ts 20 lines1export type ToolStat = { name: string; calls: number; errors: number; busy: number; ms: number; lastAt: number | null }
2export type CallRow = { id: string; tool: string; detail: string; startedAt: number; endedAt: number | null; isError: boolean }
3export type AgentRun = { id: string; type: string; description: string; startedAt: number; endedAt: number | null; isError: boolean }
4export type Todo = { content: string; activeForm: string; status: 'pending' | 'in_progress' | 'completed' }
5export type Pulse = {
6 tools: ToolStat[]
7 lastTool: string | null
8 agents: AgentRun[]
9 todos: Todo[]
10 turnStartedAt: number | null
11 recent: CallRow[]
12 turns: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 pulse: { pulse: Pulse; collapsed: boolean; hidden: boolean }
18 }
19}
20