A card above the prompt that says what Claude is working on, refreshed every few minutes while a turn runs

A Claude Code plugin that draws a card above the prompt saying what Claude is working on, refreshed every few minutes while a turn runs. After the run ends, the last recap stays up (muted) until the next prompt.
╭─ tracking running 6m · 2 in background · haiku · ~1m ago ──────────╮
│ Editing hooks/register.tsx to draw the live card. It shows the user
│ what Claude is doing while they look away.
╰──────────────────────────────────────────────────────────────────────
message-timestamps: prefixes each Claude reply with the local time it arrived, no model calls. Install with claude plugin install message-timestamps@live-recap.Function hooks are early access and off by default. Enable them before installing, or the plugin loads but never draws:
// ~/.claude/settings.json
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Or export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in your shell profile. Restart Claude Code after setting it.
claude plugin marketplace add benjaminmodayil/live-recap
claude plugin install live-recap@live-recap
Restart Claude Code (or run /reload-plugins).
Set with claude plugin configure live-recap.
| Key | Default | Meaning |
|---|---|---|
intervalMinutes | 2 | Card first shows after a turn runs this long, then refreshes at the same interval |
source | haiku | haiku: small model reads recent activity (cheap). fork: session model reads full transcript (costs more) |
awaySummaryEnabled: false in user settings) since it draws over this card. This persists if the plugin is removed.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set (see Requirements), then claude plugin list shows live-recap loaded.intervalMinutes (default 2)..claude-plugin/types/ is written by Claude Code when it loads the plugin from a folder you own; it is gitignored. Load the plugin once to generate it.
tsc -p . # typecheck
claude plugin validate --strict . # manifest + hooks check
claude plugin test . # tests/*.test.ts (when available in your CLI build)hooks/register.tsx 435 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, ModelForkResult, Register, Timer } from 'claude-code'
3
4import type { LiveRecapCard, LiveRecapSource } from '../types'
5
6const TICK_MS = 30_000
7const MAX_SENTENCES = 2
8const MAX_TOOLS = 15
9const HAIKU_TIMEOUT_MS = 60_000
10const ACCENT = '#D77757'
11const WAITING_TEXT = 'Claude is working. This card refreshes while the work runs.'
12
13const RULES = [
14 'Use at most two sentences.',
15 'Sentence one: the concrete task the agent is doing right now, named by its file, command, or subject.',
16 "Sentence two: the purpose that task serves in the user's goal.",
17 'Never state that the agent is waiting, that the step completed, or that the work matters as a bare claim.',
18 'Skip history, progress lists, and next actions. Plain text only, no markdown.',
19]
20
21const HAIKU_SYSTEM = [
22 'You write a live status card for a developer who looked away while Claude Code, a coding agent, works.',
23 'The work can run in background subagents and shells while the main loop waits for them.',
24 ...RULES,
25].join('\n')
26
27// The fork reads as a user turn appended to the live transcript, so it must not steer the agent.
28const FORK_PROMPT = [
29 'This is a status check from the live recap card, not a new instruction.',
30 'Do not call tools and do not change your plan. The user looked away while you work.',
31 ...RULES,
32].join('\n')
33
34const card = atom({ plugin: 'live-recap', key: 'card' } as const, null)
35const now = atom({ plugin: 'live-recap', key: 'now' } as const, 0)
36
37type RecentTool = { id: string; tool: string; detail: string; agentId?: string; isDone: boolean }
38
39type Background = { type: string; description: string; command?: string; agentType?: string }
40
41type Summary = { text?: string; source: LiveRecapSource; error?: string }
42
43const DETAIL_KEYS = ['command', 'file_path', 'pattern', 'description', 'url', 'query', 'skill', 'path', 'prompt']
44
45const clip = (text: string, limit: number) => {
46 const compact = text.replace(/\s+/g, ' ').trim()
47 return compact.length <= limit ? compact : `${compact.slice(0, limit - 1)}…`
48}
49
50const limitToSentences = (text: string, max: number) => {
51 const compact = text.replace(/\s+/g, ' ').trim()
52 if (!compact) return ''
53
54 // A dot inside a path (`SKILL.md`) is not a sentence end, so the sentence stays whole.
55 const matches = compact.match(/[\s\S]*?[.!?]+["')\]]*(?=\s|$)|[\s\S]+$/g) ?? [compact]
56 return matches.slice(0, max).join(' ').replace(/\s+/g, ' ').trim()
57}
58
59const formatDuration = (ms: number) => {
60 const minutes = Math.max(0, Math.floor(ms / 60_000))
61 if (minutes < 60) return `${minutes}m`
62 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
63}
64
65// Coarse buckets, so the card does not read as a live timer.
66const formatAge = (ms: number) => {
67 const seconds = Math.floor(Math.max(0, ms) / 1000)
68 if (seconds < 30) return 'just now'
69 if (seconds < 90) return '~1m ago'
70 const minutes = Math.floor(seconds / 60)
71 if (minutes < 60) return `~${minutes}m ago`
72 return `~${Math.floor(minutes / 60)}h ago`
73}
74
75const wrap = (text: string, width: number) => {
76 const lines: string[] = []
77 let line = ''
78 for (const word of text.split(/\s+/).filter(Boolean)) {
79 let rest = word
80 while (rest.length > width) {
81 if (line) lines.push(line)
82 line = ''
83 lines.push(rest.slice(0, width))
84 rest = rest.slice(width)
85 }
86 if (!line) line = rest
87 else if (line.length + 1 + rest.length <= width) line = `${line} ${rest}`
88 else {
89 lines.push(line)
90 line = rest
91 }
92 }
93 if (line) lines.push(line)
94 return lines
95}
96
97const detailOf = (input: Record<string, unknown>) => {
98 const key = DETAIL_KEYS.find(name => typeof input[name] === 'string')
99 return key ? clip(String(input[key]), 160) : ''
100}
101
102const reasonOf = (result: ModelForkResult) => {
103 if (result.isAnswered) return undefined
104 return result.reason === 'api-error'
105 ? `api-error ${result.status ?? 'no-status'} ${result.error}`
106 : result.reason
107}
108
109const metaOf = (shown: LiveRecapCard, at: number) =>
110 [
111 shown.endedAt === undefined
112 ? `running ${formatDuration(at - shown.runStartedAt)}`
113 : `ran ${formatDuration(shown.endedAt - shown.runStartedAt)}`,
114 shown.endedAt === undefined && shown.background > 0 ? `${shown.background} in background` : undefined,
115 shown.source,
116 shown.generatedAt === undefined ? undefined : formatAge(at - shown.generatedAt),
117 shown.error === undefined ? undefined : `refresh failed: ${clip(shown.error, 40)}`,
118 ].filter((part): part is string => part !== undefined)
119
120const terminalCard = (
121 { Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>,
122 shown: LiveRecapCard,
123 at: number,
124 width: number,
125) => {
126 const isLast = shown.endedAt !== undefined
127 const title = isLast ? ' recapped ' : ' tracking '
128 // The last recap draws muted, so it reads as the finished run's and not live.
129 const edge = isLast ? { dimColor: true } : { color: ACCENT }
130 // Meta segments that do not fit whole are dropped, so the line never cuts mid-word.
131 const fitted = metaOf(shown, at).reduce<string[]>((kept, part) => {
132 const candidate = [...kept, part]
133 return 2 + title.length + candidate.join(' · ').length + 3 <= width ? candidate : kept
134 }, [])
135 const metaText = fitted.length ? ` ${fitted.join(' · ')} ` : ''
136 const fill = '─'.repeat(Math.max(0, width - 2 - title.length - metaText.length))
137
138 return (
139 <Box flexDirection="column">
140 <Text wrap="truncate-end">
141 <Text {...edge}>╭─</Text>
142 <Text color={ACCENT} bold>
143 {title}
144 </Text>
145 <Text dimColor>{metaText}</Text>
146 <Text {...edge}>{fill}</Text>
147 </Text>
148 {wrap(shown.text ?? WAITING_TEXT, width - 2).map(line => (
149 <Text>
150 <Text {...edge}>│ </Text>
151 <Text dimColor={isLast} italic={isLast}>
152 {line}
153 </Text>
154 </Text>
155 ))}
156 <Text {...edge} wrap="truncate-end">
157 {`╰${'─'.repeat(width - 1)}`}
158 </Text>
159 </Box>
160 )
161}
162
163export const register: Register = (on, options) => {
164 const minutes = typeof options.intervalMinutes === 'number' && options.intervalMinutes > 0 ? options.intervalMinutes : 2
165 const intervalMs = minutes * 60_000
166 const preferred: LiveRecapSource = options.source === 'fork' ? 'fork' : 'haiku'
167
168 let ticker: Timer | undefined
169 // A run lasts from the prompt until the main loop and every background agent or shell it started are done.
170 let isActive = false
171 let isMainRunning = false
172 let background: Background[] = []
173 // The last summary stays up after the run ends, until the next prompt starts a run.
174 let hasLastRecap = false
175 let isBusy = false
176 let runStartedAt = 0
177 let lastAttemptAt = 0
178 let goal = ''
179 let recentTools: RecentTool[] = []
180 let stop: AbortController | undefined
181 // The band's width, which the card drawn with the spinner matches; the Spinner site carries none.
182 let bandColumns: number | undefined
183
184 const endRun = () => {
185 isActive = false
186 isMainRunning = false
187 background = []
188 stop?.abort()
189 }
190
191 on('session.start', async ($, e, next) => {
192 await update($, card, () => null)
193
194 const digestOf = async (at: number) => {
195 const messages = await $.session.messages()
196 const notes = (Array.isArray(messages) ? messages : [])
197 .filter(row => row.role === 'assistant' && row.text.trim())
198 .slice(-3)
199 .map(row => `- ${clip(row.text, 500)}`)
200 const tools = recentTools.map(
201 one => `- ${one.agentId ? '[subagent] ' : ''}${one.tool}: ${one.detail}${one.isDone ? '' : ' (running)'}`,
202 )
203 const jobs = background.map(
204 job => `- ${job.type}${job.agentType ? ` (${job.agentType})` : ''}: ${clip(job.command ?? job.description, 200)}`,
205 )
206 const waiting = isMainRunning ? '' : ' The main loop is waiting on the background work below.'
207
208 return [
209 `The user's request: ${clip(goal, 1200) || '(not captured)'}`,
210 `Claude has been working for ${formatDuration(at - runStartedAt)}.${waiting}`,
211 '',
212 'Background work still running:',
213 ...(jobs.length ? jobs : ['- (none)']),
214 '',
215 'Latest notes Claude wrote, oldest first:',
216 ...(notes.length ? notes : ['- (none yet)']),
217 '',
218 'Latest tool calls, oldest first:',
219 ...(tools.length ? tools : ['- (none yet)']),
220 ].join('\n')
221 }
222
223 const summarize = async (at: number, signal: AbortSignal): Promise<Summary> => {
224 if (preferred === 'fork') {
225 const result = await $.model.fork({ prompt: FORK_PROMPT })
226 return result.isAnswered
227 ? { text: limitToSentences(result.text, MAX_SENTENCES), source: 'fork' }
228 : { source: 'fork', error: reasonOf(result) }
229 }
230
231 try {
232 const result = await $.model.complete(
233 {
234 model: 'haiku',
235 system: HAIKU_SYSTEM,
236 prompt: await digestOf(at),
237 maxTokens: 200,
238 effort: 'low',
239 timeoutMs: HAIKU_TIMEOUT_MS,
240 },
241 { signal },
242 )
243 return result.isAnswered
244 ? { text: limitToSentences(result.text, MAX_SENTENCES), source: 'haiku' }
245 : { source: 'haiku', error: reasonOf(result) }
246 } catch (error) {
247 // The engine rejects only a request it will not send, such as a model the org allowlist blocks.
248 return { source: 'haiku', error: `refused: ${error instanceof Error ? error.message : String(error)}` }
249 }
250 }
251
252 const refresh = async () => {
253 const at = await $.clock.now()
254 const run = runStartedAt
255 isBusy = true
256 lastAttemptAt = at
257 stop = new AbortController()
258 await update($, card, shown => shown ?? { runStartedAt: run, background: background.length })
259
260 try {
261 const summary = await summarize(at, stop.signal)
262 const doneAt = await $.clock.now()
263 // A run that ended, or restarted, while the summary was in flight makes it stale.
264 if (!isActive || runStartedAt !== run) return
265
266 const { text, error } = summary
267 if (text) {
268 await update($, card, () => ({
269 runStartedAt: run,
270 text,
271 generatedAt: doneAt,
272 source: summary.source,
273 background: background.length,
274 }))
275 } else {
276 // The card keeps its last text; the failure shows in its header until a refresh lands.
277 await update($, card, shown => (shown === null ? null : { ...shown, error: error ?? 'no text' }))
278 }
279 } finally {
280 isBusy = false
281 stop = undefined
282 }
283 }
284
285 const tick = async () => {
286 if (!isActive && !hasLastRecap) return
287
288 const at = await $.clock.now()
289
290 await update($, now, () => at)
291 if (!isActive || isBusy || at - runStartedAt < intervalMs || at - lastAttemptAt < intervalMs) return
292
293 await refresh()
294 }
295
296 // A headless run draws no band, so a timer there would only spend model calls.
297 if (e.isInteractive) {
298 ticker?.cancel()
299 ticker = $.clock.every(TICK_MS, () => void tick())
300
301 // The built-in session recap draws its own row over this card. Turning it off writes
302 // `awaySummaryEnabled: false` to user settings, as /config does, so it stays off if this mod is removed.
303 const recap = (await $.config.list()).find(row => row.key === 'recap')
304 if (recap?.value === true && !recap.isLocked) await $.config.set({ key: 'recap', value: false })
305 }
306
307 return next(e)
308 })
309
310 on('session.end', async ($, e, next) => {
311 endRun()
312 hasLastRecap = false
313 goal = ''
314 recentTools = []
315 await update($, card, () => null)
316 return next(e)
317 })
318
319 on('turn.start', async ($, e, next) => {
320 isMainRunning = true
321 // A finished background task wakes the main loop with a notification, which is not the user's goal.
322 if (e.text.trim() && !e.text.trimStart().startsWith('<task-notification>')) goal = e.text
323 if (!isActive) {
324 isActive = true
325 hasLastRecap = false
326 runStartedAt = await $.clock.now()
327 lastAttemptAt = 0
328 recentTools = []
329 await update($, now, () => runStartedAt)
330 await update($, card, () => null)
331 }
332
333 return next(e)
334 })
335
336 // Stop fires as each main-loop turn ends, listing the background agents and shells still in flight.
337 on('classic.Stop', async ($, e, next) => {
338 background = (e.background_tasks ?? []).map(task => ({
339 type: task.type,
340 description: task.description,
341 command: task.command,
342 agentType: task.agent_type,
343 }))
344 const count = background.length
345 await update($, card, shown => (shown === null ? null : { ...shown, background: count }))
346
347 return next(e)
348 })
349
350 on('turn.complete', async ($, e, next) => {
351 // A subagent's run ends in a turn.complete too; only the main loop's end can close the run.
352 if (e.agentId === undefined) {
353 isMainRunning = false
354 if (e.isAborted || background.length === 0) {
355 endRun()
356 const shown = await read($, card)
357 // A card still on its waiting text has nothing worth keeping.
358 const last = shown?.text === undefined ? null : { ...shown, endedAt: await $.clock.now() }
359 hasLastRecap = last !== null
360 await update($, card, () => last)
361 }
362 }
363
364 return next(e)
365 })
366
367 on('tool.call', async ($, e, next) => {
368 if (!isActive) return next(e)
369
370 const entry: RecentTool = {
371 id: e.tool_use_id,
372 tool: e.tool,
373 detail: detailOf(e as unknown as Record<string, unknown>),
374 agentId: e.agentId,
375 isDone: false,
376 }
377 recentTools = [...recentTools, entry].slice(-MAX_TOOLS)
378 const ran = await next(e)
379 recentTools = recentTools.map(one => (one.id === entry.id ? { ...one, isDone: true } : one))
380
381 return ran
382 })
383
384 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
385 bandColumns = e.props.bodyColumns
386 if (e.props.hasSurvey) return next(e)
387
388 const shown = await read($, card)
389 const width = e.props.bodyColumns
390 if (shown === null || width < 12) return next(e)
391 // While a turn runs on the terminal, the Spinner hook draws the card above the spinner line instead.
392 if (e.surface === 'terminal' && e.props.isWorking && shown.endedAt === undefined) return next(e)
393
394 const at = Math.max(await read($, now), shown.generatedAt ?? 0)
395 const { Box, Text } = $.ui.resolve(e)
396
397 if (e.surface !== 'terminal') {
398 const isLast = shown.endedAt !== undefined
399 return (
400 <Box flexDirection="column">
401 <Text>
402 <Text color={ACCENT} bold>
403 {isLast ? 'recapped' : 'tracking'}
404 </Text>
405 <Text dimColor> · {metaOf(shown, at).join(' · ')}</Text>
406 </Text>
407 <Text dimColor={isLast} italic={isLast}>
408 {shown.text ?? WAITING_TEXT}
409 </Text>
410 </Box>
411 )
412 }
413
414 return terminalCard({ Box, Text }, shown, at, width)
415 })
416
417 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
418 if (e.surface !== 'terminal') return next(e)
419
420 const shown = await read($, card)
421 const width = bandColumns ?? e.viewport?.columns ?? 0
422 if (shown === null || shown.endedAt !== undefined || width < 12) return next(e)
423
424 const at = Math.max(await read($, now), shown.generatedAt ?? 0)
425 const { Box, Text } = $.ui.resolve(e)
426
427 return (
428 <Box flexDirection="column">
429 {terminalCard({ Box, Text }, shown, at, width)}
430 {await next(e)}
431 </Box>
432 )
433 })
434}
435types/index.d.ts 21 lines1export type LiveRecapSource = 'haiku' | 'fork'
2
3export type LiveRecapCard = {
4 runStartedAt: number
5 background: number
6 text?: string
7 generatedAt?: number
8 source?: LiveRecapSource
9 endedAt?: number
10 error?: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'live-recap': {
16 card: LiveRecapCard | null
17 now: number
18 }
19 }
20}
21