Band above the prompt: open questions Claude asked you plus bullets of what the last prompt did

A band above the prompt that keeps the important bits from scrolling away: a short list of what the last prompt did, and the questions Claude asked you that still need an answer.
After each prompt finishes, the band shows:
Done last prompt
- Added a status line that shows each subagent's model
- Kept the line visible after agents finish
- Validated the mod and ran a headless check
Open questions
? Should I add the new mod to your default load?
? Want a README for each mod?
[Clear questions]
When a run produces a lot of output, a question or a result in the middle gets buried as the conversation scrolls. This puts both in a fixed spot.
claude --plugin-dir /path/to/claude-code-mods/session-recap
To load it in every session, see Install.
Try it:
List the files in this folder, read the README, and tell me in two sentences what it's for. Then ask me which thing to work on next, and whether you should add tests.
One small Claude Haiku 4.5 call per finished prompt (a few hundred tokens). It reads your prompt, a one-line list of the tool calls and Claude's final message. Interrupted prompts and subagent turns are skipped.
hooks/register.tsx if the bullets are too long or too vague.prompt.submit and tool.call collect what the prompt did, turn.complete makes the Haiku call through $.model.complete and stores the result, and a ui.render hook on the AbovePrompt band draws it.
hooks/register.tsx 120 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Recap } from '../types'
5
6const turns = atom({ plugin: 'session-recap', key: 'turns' } as const, [] as Recap[])
7const questions = atom({ plugin: 'session-recap', key: 'questions' } as const, [] as string[])
8
9// What the current prompt has done so far; reset on each prompt.
10let prompt = ''
11let steps: string[] = []
12
13const clip = (text: string, max: number) =>
14 text.length > max ? `${text.slice(0, max)}...` : text
15
16// One short line per tool call, for the summarizer to read.
17function describe(call: Record<string, unknown>) {
18 const target = call.description ?? call.file_path ?? call.command ?? call.pattern ?? call.url ?? ''
19
20 return clip(`${String(call.tool)} ${String(target)}`.trim(), 120)
21}
22
23function parseRecap(text: string) {
24 try {
25 const json = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
26 const parsed = JSON.parse(json) as { done?: unknown; asked?: unknown }
27 const strings = (v: unknown) =>
28 Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.trim() !== '') : []
29
30 return { done: strings(parsed.done), asked: strings(parsed.asked) }
31 } catch {
32 return null
33 }
34}
35
36export const register: Register = on => {
37 on('prompt.submit', ($, e, next) => {
38 prompt = e.text
39 steps = []
40
41 return next(e)
42 })
43
44 on('tool.call', ($, e, next) => {
45 if (!e.agentId) {
46 steps = [...steps, describe(e as unknown as Record<string, unknown>)].slice(-40)
47 }
48
49 return next(e)
50 })
51
52 on('turn.complete', async ($, e, next) => {
53 const done = await next(e)
54
55 if (e.agentId || e.reason !== 'answer') {
56 return done
57 }
58
59 const result = await $.model.complete({
60 model: 'haiku',
61 maxTokens: 400,
62 system: 'You write terse recaps for a busy developer. Reply with JSON only.',
63 prompt: [
64 `The user asked: ${clip(prompt, 500)}`,
65 `Tool calls made:\n${steps.join('\n') || '(none)'}`,
66 `The assistant's final message:\n${clip(e.answer, 3000)}`,
67 '',
68 'Reply with JSON: {"done": [...], "asked": [...]}',
69 '"done": 2-5 plain-English bullets of what was actually done or found, each under 12 words, no file paths or jargon unless essential.',
70 '"asked": every question the assistant put to the user in its final message that still needs an answer (a choice, a confirmation, an offer like "want me to...?"), each rewritten under 20 words. Empty list if none.',
71 ].join('\n'),
72 })
73
74 if (!result.isAnswered) {
75 return done
76 }
77
78 const recap = parseRecap(result.text)
79
80 if (recap && recap.done.length > 0) {
81 await update($, turns, list =>
82 [...list, { prompt: clip(prompt, 80), bullets: recap.done }].slice(-30),
83 )
84 await update($, questions, list =>
85 [...list, ...recap.asked.filter(q => !list.includes(q))].slice(-30),
86 )
87 }
88
89 return done
90 })
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 const asked = await read($, questions)
94 const past = await read($, turns)
95 const latest = past[past.length - 1]
96
97 if (e.props.hasSurvey || (asked.length === 0 && !latest)) {
98 return next(e)
99 }
100
101 const { Box, Button, Text } = $.ui.resolve(e)
102
103 return (
104 <Box flexDirection="column">
105 {latest && <Text bold>Done last prompt</Text>}
106 {latest?.bullets.map((b, j) => (
107 <Text key={`b${j}`} dimColor>- {b}</Text>
108 ))}
109 {asked.length > 0 && <Text bold>Open questions</Text>}
110 {asked.map((q, i) => (
111 <Text key={`q${i}`}>? {q}</Text>
112 ))}
113 {asked.length > 0 && (
114 <Button key="clear" label="Clear questions" onPress={() => update($, questions, () => [])} />
115 )}
116 </Box>
117 )
118 })
119}
120types/index.d.ts 8 lines1export type Recap = { prompt: string; bullets: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-recap': { turns: Recap[]; questions: string[] }
6 }
7}
8