SLOPSHOPPER

live-recap

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

newbandspinnerguardmodeltimer
★ 1v0.1.0no licenseupdated 2026-10-01benjaminmodayil/live-recap
A shopper browsing a rack in a slop shop
README

live-recap

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.
╰──────────────────────────────────────────────────────────────────────

Also in this repo

  • message-timestamps: prefixes each Claude reply with the local time it arrived, no model calls. Install with claude plugin install message-timestamps@live-recap.

Requirements

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.

Install

claude plugin marketplace add benjaminmodayil/live-recap
claude plugin install live-recap@live-recap

Restart Claude Code (or run /reload-plugins).

Options

Set with claude plugin configure live-recap.

KeyDefaultMeaning
intervalMinutes2Card first shows after a turn runs this long, then refreshes at the same interval
sourcehaikuhaiku: small model reads recent activity (cheap). fork: session model reads full transcript (costs more)

Side effects

  • On session start (interactive only), turns off the built-in session recap (awaySummaryEnabled: false in user settings) since it draws over this card. This persists if the plugin is removed.
  • Headless runs draw nothing and make no model calls.

Troubleshooting

  • No card at all: check CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set (see Requirements), then claude plugin list shows live-recap loaded.
  • Card missing on short turns: expected. The first card appears only after a turn runs intervalMinutes (default 2).
  • Card hidden while a question dialog is open: the dialog takes over the area above the prompt; the card returns once the dialog closes.

Develop

.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)
Source 2 files
hooks/register.tsx 435 lines
1import { 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}
435
types/index.d.ts 21 lines
1export 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