A small box beside the transcript: the stage of the work, what is happening now, and the session's goal pinned below, in plain words, updated as the session…

A Claude Code mod: a small box beside the transcript that says, in plain words, what the session is doing now and what it is working toward.
grep -rn test is a look and npm test a test run./resume, the box recaps the transcript the resume brings once the engine has it in place. After a /clear it waits for new activity./recap-box hides the box while it shows, and shows it again while it is hidden or behind another pane.
/recap-box history turns the history on or off. With it on, the recap and the goal stay at the top of the box, and the session's earlier recaps run below them under a dotted line, the newest first, each dimmed under the stage it was written in. Scroll down in the box for older ones with the mouse wheel in the fullscreen layout, one recap a step. From the keyboard, ctrl+x tab moves the focus to the box: the down arrow or Page Down moves one recap older, Page Up one newer, and Home and End go to the newest and the oldest. Esc moves the focus back to the prompt. The box keeps your place as new recaps come in. Where it sits above the prompt (outside the fullscreen layout, or in a terminal under 110 columns), the box is too short to hold the recap and goal still, so it scrolls as a whole. It keeps the last 200, and a /clear or /resume starts the list over.
Later sessions keep the setting; sessions already open keep theirs until they restart.
/recap-box upgrade installs the newest version and reloads the plugins. While one waits, the box says so at its top: see Updates.
At the prompt of a terminal session:
/plugin install recap-box --marketplace duysqubix/recap-box
Answer y to add the marketplace, then press Enter for the user scope.
From a shell:
claude plugin install recap-box --marketplace duysqubix/recap-box
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with duysqubix/recap-box, then Add plugin.
Every commit on main is a release. plugin.json has no version, so Claude Code versions the plugin by commit.
Claude Code leaves auto-update off for marketplaces it does not ship. To turn it on, set autoUpdate on the marketplace's entry in ~/.claude/settings.json. Claude Code ignores this flag in project and local settings.
"extraKnownMarketplaces": {
"recap-box": {
"source": { "source": "github", "repo": "duysqubix/recap-box" },
"autoUpdate": true
}
}
That entry, with "recap-box@recap-box": true under enabledPlugins, has two effects:
Plugin updated: recap-box, then reloads it or says to run /reload-plugins.~/.claude/plugins, it comes back within two session starts. Missing plugin files come back at the next check.Claude Code skips the check when its own auto-updater is off, as under DISABLE_AUTOUPDATER or DISABLE_UPDATES. Set FORCE_AUTOUPDATE_PLUGINS=1 to keep the check on for plugins.
The box checks on its own, whatever those variables say. While main holds a commit the copy running lacks, it shows Update available: /recap-box upgrade at its top. It asks GitHub when a session starts at the prompt or a surface attaches, then every hour. A copy run from a clone (--plugin-dir) never shows it.
To update right away, run /recap-box upgrade. It runs these two commands with the claude on your PATH, then reloads the plugins of that session:
claude plugin marketplace update recap-box && claude plugin update recap-box@recap-box
Where claude is not on the PATH, as in the desktop app, run them from a shell. Then run /recap-box upgrade or /reload-plugins in any other session that is already open.
From your clone:
git config core.hooksPath .githooks # once: turns on the pre-push checks
claude --plugin-dir . # this folder overrides the installed copy and reloads after each edit
A push to main is a release, so the pre-push hook blocks a push that fails these checks:
claude plugin validate . && claude plugin test . && tsc -p .
tsc needs the types the engine writes into .claude-plugin/types/ the first time a --plugin-dir session loads the folder. Until then the hook skips it.
hooks/register.tsx 604 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, PromptOrigin, Register, SessionMessage, ThemeKey, Timer, ToolUseSummary } from 'claude-code'
3import type { Stage } from '../types'
4
5const PANE = 'recap'
6// A burst of events settles into one check this long after the last.
7const SETTLE_MS = 2_000
8// A long turn gets a recap every this many of its calls, not only at its end.
9const CALLS = 5
10// At most one Haiku call this often; one due sooner waits, so the last activity still gets one.
11const GAP_MS = 10_000
12// A steady stream of events puts the check off this many times at most, so it still runs.
13const DELAYS = 5
14// A check that still reads the transcript a /resume left tries again this many times.
15const TRIES = 5
16// GitHub is asked this often whether main moved past the copy running, so a session left open hears of an update.
17const CHECK_MS = 3_600_000
18// Whether the box lists the recaps before the one on show, read from the store at each start.
19// Not in `$.state`: a /clear goes on under another session, whose values start over.
20let isHistoryShown = false
21// Whether main holds commits the copy running lacks, shown at the box's top; a module variable for the same reason.
22let isBehind = false
23const recap = atom({ plugin: 'recap-box', key: 'recap' } as const, null)
24// What the whole session works toward, pinned under the recap.
25const goal = atom({ plugin: 'recap-box', key: 'goal' } as const, null)
26// What the person asked for, the goal's source: their first prompt and newest eight.
27// ponytail: prompts sent before the mod loaded (a resumed session) are missing; the goal then reads the tail.
28const asks = atom({ plugin: 'recap-box', key: 'asks' } as const, [])
29const stage = atom({ plugin: 'recap-box', key: 'stage' } as const, null)
30// Every recap so far, oldest first, under the stage it was written in.
31const history = atom({ plugin: 'recap-box', key: 'history' } as const, [])
32// Recaps the history keeps, oldest dropped first.
33const HISTORY = 200
34
35// The stages a recap can name, each drawn in its theme color.
36const STAGES: Record<Stage, ThemeKey> = {
37 researching: 'ide',
38 planning: 'planMode',
39 coding: 'claude',
40 testing: 'autoAccept',
41 debugging: 'error',
42 reviewing: 'permission',
43 writing: 'warning',
44 shipping: 'success',
45}
46
47// What a call says the work is, first match wins; a call no rule names casts no vote.
48// A word counts from its start: "latest" is no test, "critical" no review.
49const RULES: [RegExp, Stage][] = [
50 [/^(Agent|Skill) .*\b(review|verif|critic\b|audit)|^ReportFindings/i, 'reviewing'],
51 [/^(Agent|Skill) .*\b(plan|ralplan|architect|analyst)|^(Enter|Exit)PlanMode/i, 'planning'],
52 [/^(Agent|Skill) .*\b(debug|trac(e|ing))/i, 'debugging'],
53 [/^(Agent|Skill) .*\b(explore|research|search|document-specialist)/i, 'researching'],
54 [/^(Agent|Skill) .*\bwriter/i, 'writing'],
55 [/^(Agent|Skill) .*\b(test|qa)/i, 'testing'],
56 [/^(Agent|Skill) .*\b(execut|build|implement)/i, 'coding'],
57 [/^(Agent|Skill) .*\bgit-master/i, 'shipping'],
58 [/^(Edit|Write|NotebookEdit) .*\.(md|mdx|txt|rst)$/i, 'writing'],
59 [/^(Edit|Write|NotebookEdit) /, 'coding'],
60 [/^(Read|Grep|Glob|WebSearch|WebFetch|LSP) /, 'researching'],
61]
62
63// A rule's stage for what a call names, then with its task's words: an agent or a skill
64// goes by its kind, and by its task only when the kind names no stage.
65const named = (head: string, task?: unknown) =>
66 (RULES.find(([re]) => re.test(head)) ?? (task ? RULES.find(([re]) => re.test(`${head} ${String(task)}`)) : undefined))?.[1]
67
68// A shell command goes by the commands it starts, never by an argument, a quote or a
69// heredoc's text: "grep -rn test" looks, "npm test" tests. Every other command looks.
70const HEREDOC = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g
71const QUOTED = /'[^']*'|"(\\.|[^"\\])*"/g
72// What leads a command without being one: a variable, time, timeout 60, then.
73const LEAD = /^([A-Za-z_]\w*=\S*|time|env|sudo|nohup|exec|if|then|else|while|until|do|!|timeout|\d+[smh]?)$/
74// Only what sends work out: reading a pull request or a CI run is a look.
75const SHIP =
76 /^(git( -[Cc] \S+)* (push|commit|tag)|gh (pr (create|merge|ready)|release create|run (watch|rerun))|ci-watch|deploy|(npm|pnpm|yarn|bun|cargo|uv|poetry) publish)\b/
77const TEST =
78 /^((pytest|jest|vitest|tsc|ruff|mypy|eslint)|(npm|npx|pnpm|yarn|bun|bunx|uv|cargo|go|make|deno|poetry|python3?|claude)( -\S+| run| exec| x| plugin)* (test|check|validate|lint|clippy|typecheck|pytest|unittest|jest|vitest|tsc|ruff|mypy|eslint))\b/
79// A run piped to tail exits 0 however it went: its output says.
80const FAILED = /test result: FAILED|\b[1-9]\d* (fail|failed|failing)\b|^error(\[E\d+\])?:|\berror TS\d+:|Validation failed/m
81
82function shellStage(command: string): Stage {
83 const commands = command
84 .replace(HEREDOC, '')
85 .replace(QUOTED, '""')
86 .split(/&&|\|\||[;|&\n(`{]|\$\(/)
87 .map(part => {
88 const words = part.split(/\s+/).filter(Boolean)
89 while (words[0] && LEAD.test(words[0])) words.shift()
90 // A program by its name, wherever it lives: ~/.claude/scripts/ci-watch, node_modules/.bin/vitest.
91 return [words[0]?.split('/').pop(), ...words.slice(1)].join(' ')
92 })
93 if (commands.some(c => SHIP.test(c))) return 'shipping'
94
95 return commands.some(c => TEST.test(c)) ? 'testing' : 'researching'
96}
97
98// A call by what it ran, the file it touched, or the kind of agent or skill it started.
99const callStage = ({ tool, input: i }: ToolUseSummary) =>
100 tool === 'Bash'
101 ? shellStage(String(i.command ?? ''))
102 : named(`${tool} ${String(i.file_path ?? i.notebook_path ?? i.subagent_type ?? i.skill ?? '')}`, i.description)
103
104// The newest work: of the last five calls, the newest that does more than look, and debugging
105// while the newest test run among them failed. A helper agent at work shows while the session
106// waits on it or only looks.
107function stageOf(messages: readonly SessionMessage[], agents: readonly AgentInfo[]): Stage | undefined {
108 const votes = messages
109 .flatMap(m => m.toolUses)
110 .flatMap(t => {
111 const s = callStage(t)
112 return s ? [{ s, t }] : []
113 })
114 .slice(-5)
115 // A run sent to the background answers when it starts, not how it ends.
116 const run = votes.filter(v => v.s === 'testing' && !v.t.input.run_in_background).pop()?.t
117 // An agent's answer is its report: "fixed the 2 failing tests" is no failed run.
118 if (run && (run.isError || (run.tool === 'Bash' && FAILED.test(run.text ?? '')))) return 'debugging'
119 // The session's own helpers at work, or waiting on their own runs: not a plugin's, nor one inside
120 // another agent, nor a teammate in a pane of its own, whose status outlives a closed pane.
121 // ponytail: with two at work, the one listed last shows; pick by start time if that misleads.
122 const helper = agents
123 .filter(a => ['running', 'waiting'].includes(a.status) && !a.parentId && !a.spawnedBy && a.id !== a.teammateId)
124 .map(a => named(`Agent ${a.type}`, a.description))
125 .filter(s => s !== undefined)
126 .pop()
127 // The model spoke last and called nothing: its turn is over.
128 const last = messages.at(-1)
129 const isWaiting = last?.role === 'assistant' && !last.toolUses.length
130
131 return (isWaiting && helper) || votes.findLast(v => v.s !== 'researching')?.s || helper || votes.at(-1)?.s
132}
133
134const SYSTEM =
135 'You keep a live recap of a Claude Code session for anyone watching it, whether they write software or not. ' +
136 'Use everyday words and short sentences: no jargon, no abbreviations, and no names of files, code, commands, ' +
137 'tools or anything else coined during the work. Say what the work is for, not how it is done: ' +
138 '"making sure the sign-in page works again", not "patching the auth middleware". ' +
139 'Answer in two lines. First "Now:" and one or two sentences, 30 words at most, on what is happening at the end of the transcript. ' +
140 'Then "Goal:" and one sentence, 25 words at most, on what the whole session is working toward, ' +
141 'from what the person asked for. While the goal so far still fits, write "Goal: same". ' +
142 'No preamble, no markdown.'
143
144// The prompts in the person's own words: typed, sent from a phone or the web, from an SDK host, the owner's
145// Slack ping, or a plugin that relays them as the person's (a voice or phone relay).
146const isPerson = (o: PromptOrigin) =>
147 ['composer', 'bridge', 'sdk', 'slack-ping'].includes(o.kind) || (o.kind === 'plugin' && o.asUser === true)
148
149// The transcript's tail as plain lines, system reminders dropped.
150const transcript = (messages: readonly SessionMessage[]) =>
151 messages
152 .flatMap(m => {
153 const text = m.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
154 const tools = m.toolUses.map(
155 t => ` ${t.tool} ${String(t.input.file_path ?? t.input.command ?? t.input.description ?? '').slice(0, 80)}`,
156 )
157 return text ? [`${m.role}: ${text.slice(0, 400)}`, ...tools] : tools
158 })
159 .join('\n')
160 .slice(-8000)
161
162// One line of plain text: a Text refuses control characters.
163const clean = (text: string) => text.replace(/[\x00-\x1f\x7f-\x9f\s]+/g, ' ').trim()
164
165// The reply's labeled lines, in either order; a goal that only says it still fits keeps the pinned one.
166const GOAL = /^\W*goal\W*:[\s*]*(.*)$/im
167const NOW = /^\W*now\W*:[\s*]*/im
168const SAME = /^\W*(still )?(the )?(same|unchanged|no changes?|none)( one| goal)?( as before| so far| yet| from before)?\W*$/i
169
170// A cut can split an emoji's pair, and the API refuses a lone half: keep whole pairs, drop halves.
171const whole = (text: string) => text.replace(/([\ud800-\udbff][\udc00-\udfff])|[\ud800-\udfff]/g, '$1')
172
173let seen = ''
174let epoch = 0
175let isBusy = false
176// An event came while a check ran: one more check once it is done, so the last event is not lost.
177let isPending = false
178// Recaps asked for (at the end of the session's turn, or once a long turn made enough calls) and
179// answered: counted, so a check answers only what was asked before it read the transcript.
180let wanted = 0
181let met = 0
182// The session's own calls since the last recap.
183let calls = 0
184// When Haiku was last asked, by the session's clock.
185let asked = -Infinity
186let timer: Timer | undefined
187// How often the pending check was put off.
188let delays = 0
189// The tail a /resume left, and how often a check has read it since: until the engine swaps in
190// the transcript the resume brings, messages() still reads the old one.
191let outgoing = ''
192let tries = 0
193// The earlier recap the person scrolled down to, or null at the newest. The box moves them under the recap and goal
194// itself, so those two stay at its top. Counted from the oldest, the place holds as new recaps come in.
195let place: number | null = null
196// Whether the box sits inline above the prompt in the terminal, too short to hold the recap and goal still.
197let isInline = false
198
199// The recaps before the one on show, oldest first: all of them while a notice is on show.
200async function earlier($: EngineInterface, text: string | null) {
201 const list = await read($, history)
202
203 return list.at(-1)?.text === text ? list.slice(0, -1) : list
204}
205
206// Moves the person's place that many recaps older, or newer when negative, never past the oldest. Back at the newest,
207// it follows new recaps again.
208async function scroll($: EngineInterface, by: number) {
209 const was = epoch
210 const last = (await earlier($, await read($, recap))).length - 1
211 // A /clear meanwhile started another conversation, at its newest.
212 if (was !== epoch) return
213 const at = Math.max(0, (place ?? last) - by)
214 place = at < last ? at : null
215 $.ui.invalidate('ui.render')
216}
217
218// Shows the newest earlier recaps, with the box's window back at its top.
219function top($: EngineInterface) {
220 place = null
221 $.ui.invalidate('ui.render')
222 // Best effort: where nothing can scroll the box (the test kit has no window), it stays put.
223 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
224}
225
226// An installed copy runs from a folder named for its commit, so GitHub can say whether main moved past it. A folder of
227// any other name (a clone given to --plugin-dir) is no commit there and, like a request that fails, shows nothing.
228async function check($: EngineInterface) {
229 const base = $.plugin.root.split(/[\\/]/).pop()
230 const is = await $.http
231 .fetch(`https://api.github.com/repos/duysqubix/recap-box/compare/${base}...main`, { headers: { 'User-Agent': 'recap-box' } })
232 .then(r => r.ok && JSON.parse(r.text).ahead_by > 0)
233 .catch(() => false)
234 if (is === isBehind) return
235 isBehind = is
236 $.ui.invalidate('ui.render')
237}
238
239// Puts a recap on show and adds it to the history, under the stage it was written in.
240async function say($: EngineInterface, at: number, text: string) {
241 const name = await read($, stage)
242 await update($, recap, old => (at === epoch ? text : old))
243 let isDropped = false
244 await update($, history, list => {
245 const isAdded = at === epoch && list.at(-1)?.text !== text
246 isDropped = isAdded && list.length >= HISTORY
247
248 return isAdded ? [...list, { stage: name, text }].slice(-HISTORY) : list
249 })
250 // Past the cap the oldest drops, so the place moves down one to stay on the same recap.
251 if (isDropped && place) {
252 place -= 1
253 $.ui.invalidate('ui.render')
254 }
255}
256
257// Each event puts the check off again, so a burst of them makes one, but only so often: a call
258// every second still gets its checks. A timer outlives the hook that set it, so no hook waits on Haiku.
259function soon($: EngineInterface, ms = SETTLE_MS) {
260 if (timer && delays >= DELAYS) return
261 if (timer) delays += 1
262 timer?.cancel()
263 timer = $.clock.after(ms, () => {
264 timer = undefined
265 delays = 0
266 void refresh($)
267 })
268}
269
270const poke = ($: EngineInterface, isRecap: boolean) => {
271 if (isRecap) wanted += 1
272 soon($)
273}
274
275// While the box is drawn: read the stage, and re-summarize when a recap is due and the transcript's tail changed.
276async function refresh($: EngineInterface) {
277 if (isBusy) {
278 isPending = true
279 return
280 }
281 isBusy = true
282 const at = epoch
283 // Taken before the transcript is read: a recap asked for after that stays due, and calls made after count on.
284 const want = wanted
285 const made = calls
286 try {
287 const panes = await $.ui.panes()
288 if (!panes.some(p => p.id === PANE && p.isPlaced)) return
289 const recent = (await $.session.messages()).slice(-60)
290 // Compared by content: messages() holds only the newest 4096 rows, so a count stalls.
291 const tail = transcript(recent)
292 if (outgoing && tail === outgoing && at === epoch && tries < TRIES) {
293 // Still the conversation a /resume left: neither its stage nor its recap is the new one's.
294 tries += 1
295 soon($)
296 return
297 }
298 outgoing = ''
299 // Read at every check: a call's result can land, or a helper start or end, while the transcript reads the same.
300 // A /clear while this check ran makes it the old conversation's. Checked inside
301 // update: a write that races session.end's re-runs and keeps its null.
302 const name = stageOf(recent, await $.agent.list())
303 if (name) await update($, stage, old => (at === epoch ? name : old))
304 if (want === met || at !== epoch) return
305 if (!tail || tail === seen) {
306 met = want
307 calls = Math.max(0, calls - made)
308 return
309 }
310 // Read before the clock, so the gap runs from the call itself.
311 const prompt = whole(
312 `Goal so far: ${(await read($, goal)) ?? 'none'}\n` +
313 `Previous recap: ${(await read($, recap)) ?? 'none'}\n\n` +
314 `What the person asked for, oldest first:\n${(await read($, asks)).map(t => `- ${t}`).join('\n') || 'none'}\n\n` +
315 `Transcript tail, oldest first:\n${tail}`,
316 )
317 const now = await $.clock.now()
318 // A /clear while this check waited: what is due now is the new conversation's.
319 if (at !== epoch) return
320 // Too soon after the last call: ask once the gap is over, with what is newest then. A check
321 // already pending comes sooner, and sets the wait again.
322 if (now < asked + GAP_MS) {
323 if (!timer) soon($, asked + GAP_MS - now)
324 return
325 }
326
327 // Seen even when the call fails, so a retry waits for new activity.
328 seen = tail
329 met = want
330 calls = Math.max(0, calls - made)
331 asked = now
332 const reply = await $.model.complete({
333 model: 'haiku',
334 system: SYSTEM,
335 prompt,
336 maxTokens: 200,
337 effort: 'low',
338 timeoutMs: 30_000,
339 })
340 if (reply.isAnswered) {
341 // Every line but the goal's is the recap.
342 const aim = clean(reply.text.match(GOAL)?.[1] ?? '')
343 const text = clean(reply.text.replace(GOAL, '').replace(NOW, ''))
344 if (text) await say($, at, text)
345 if (aim && !SAME.test(aim)) await update($, goal, old => (at === epoch ? aim : old))
346 }
347 } catch (err) {
348 // Only a request the engine refuses to send rejects (a blocked model): say so in the box, not in its history.
349 await update($, recap, old => (at === epoch ? clean(`No recap: ${String(err)}`) : old))
350 } finally {
351 isBusy = false
352 if (isPending) {
353 isPending = false
354 void refresh($)
355 }
356 }
357}
358
359export const register: Register = on => {
360 // Opened on every start: the desktop app, VS Code and mobile attach after a start without a surface.
361 on('session.start', async ($, e, next) => {
362 await $.command.register({
363 name: 'recap-box',
364 description: 'Show or hide the session recap box or its history, or upgrade it',
365 argumentHint: '[history|upgrade]',
366 })
367 // The store is a file anyone can edit: anything but true, or a value that cannot be read, leaves the history off.
368 isHistoryShown = (await $.store.get('history').catch(() => undefined)) === true
369 // A reload can draw the box before this read.
370 $.ui.invalidate('ui.render')
371 void $.ui.open({ id: PANE, title: 'Recap', columns: 40 })
372 // An earlier version may have left the window following the box's end, where the oldest recaps now are.
373 if (isHistoryShown) top($)
374 // A resumed session has a transcript already: recap it once the box is drawn.
375 poke($, true)
376 // Main can move on while the session runs: asked every hour, past a /clear too, and at once where someone
377 // watches: a person at the prompt here, or a surface once it attaches. A -p run asks only after an hour.
378 // ponytail: each session asks on its own, and GitHub allows 60 unsigned asks an hour per address; share one
379 // answer through $.store if that runs short.
380 $.clock.every(CHECK_MS, () => void check($))
381 if (e.isInteractive) void check($)
382
383 return next(e)
384 })
385
386 // A surface that attaches draws the box: it shows what is due, and whether an update waits.
387 on('session.attach', async ($, e, next) => {
388 const r = await next(e)
389 poke($, false)
390 void check($)
391
392 return r
393 })
394
395 on('command.run', { command: 'recap-box' }, async ($, e) => {
396 const args = e.args.trim()
397 if (/^history$/i.test(args)) {
398 const isShown = !isHistoryShown
399 // Kept for later sessions, so saved first: a failed save changes nothing.
400 await $.store.set('history', isShown)
401 isHistoryShown = isShown
402 $.ui.invalidate('ui.render')
403 if (!isShown) return { text: 'The box shows the latest recap only.' }
404 await $.ui.open({ id: PANE, title: 'Recap', columns: 40 })
405 top($)
406 poke($, false)
407
408 return {
409 text: 'The box lists its earlier recaps under the goal, the newest first. Scroll down in it for older ones, or press ctrl+x tab, then the down arrow, and Page Up to go back.',
410 }
411 }
412 if (/^upgrade$/i.test(args)) {
413 // The steps a shell takes, by the claude on the PATH (an app may have none): one that fails says how by hand.
414 const steps = [
415 ['claude', 'plugin', 'marketplace', 'update', 'recap-box'],
416 ['claude', 'plugin', 'update', 'recap-box@recap-box'],
417 ]
418 for (const argv of steps) {
419 const r = await $.process
420 .run(argv, { timeoutMs: 120_000 })
421 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
422 if (r.exitCode !== 0) {
423 return {
424 text: `Recap box was not updated: ${clean(r.stderr || r.stdout || `exit code ${r.exitCode}`).slice(0, 300)}. From a shell: ${steps.map(s => s.join(' ')).join(' && ')}, then /reload-plugins.`,
425 }
426 }
427 }
428 // Loads the new copy. A command's hook cannot run another (it would wait on the turn it holds), so a timer
429 // runs it once this one is done.
430 $.clock.after(0, () => void $.command.run({ command: 'reload-plugins' }).catch(() => undefined))
431
432 return { text: 'Recap box is at its newest version; reloading the plugins.' }
433 }
434 if (args) {
435 return {
436 text: '/recap-box shows or hides the box; /recap-box history shows or hides its earlier recaps; /recap-box upgrade installs its newest version. The box updates as the session works.',
437 }
438 }
439 // Shown, it hides; a tab behind another pane, or one waiting for room, comes to the front.
440 const pane = (await $.ui.panes()).find(p => p.id === PANE)
441 if (pane?.isShown && pane.isPlaced) {
442 await $.ui.close({ id: PANE })
443
444 return { text: 'Recap box closed.' }
445 }
446 await $.ui.open({ id: PANE, title: 'Recap', columns: 40 })
447 if (isHistoryShown) top($)
448 poke($, false)
449
450 return { text: 'Recap box opened.' }
451 })
452
453 // Every call can change the stage; a long turn's calls also ask for a recap now and then.
454 on('tool.call', async ($, e, next) => {
455 const r = await next(e)
456 const isMain = e.agentId === undefined
457 if (isMain) calls += 1
458 poke($, isMain && calls >= CALLS)
459
460 return r
461 })
462
463 // A helper starting changes the stage, as the session's own start does not.
464 on('turn.start', async ($, e, next) => {
465 const r = await next(e)
466 if (e.agentId !== undefined) poke($, false)
467
468 return r
469 })
470
471 // The engine's own SessionStart, raised once a /resume has swapped in the transcript it brings.
472 on('classic.SessionStart', async ($, e, next) => {
473 const r = await next(e)
474 if (e.source === 'resume') poke($, true)
475
476 return r
477 })
478
479 // The session's turn ending asks for a recap; a helper's ending only changes the stage.
480 on('turn.complete', async ($, e, next) => {
481 const r = await next(e)
482 poke($, e.agentId === undefined)
483
484 return r
485 })
486
487 // Read here, where the engine says who sent a prompt: the transcript mixes in notices
488 // and other sessions' messages, and carries a prompt typed mid-turn inside a reminder.
489 // A command with a word or none after it asks for nothing (/clear, /effort max), nor does the box's own.
490 on('prompt.submit', async ($, e, next) => {
491 // What entered, as the hooks beneath left it: a dropped prompt has no text, a masked one its mask.
492 const r = await next(e)
493 const text = clean(r.text ?? '').slice(0, 1200)
494 const isAsk = !e.command || (e.command.name !== 'recap-box' && /\s/.test(e.command.args.trim()))
495 if (text && !e.isMeta && isPerson(e.origin) && isAsk) {
496 await update($, asks, list => (list.length < 9 ? [...list, text] : [...list.slice(0, 1), ...list.slice(2), text]))
497 }
498
499 return r
500 })
501
502 // A /clear or /resume starts another conversation: its recap, goal and history start over.
503 on('session.end', async ($, e, next) => {
504 seen = ''
505 epoch += 1
506 place = null
507 // Nothing of the old conversation is due.
508 timer?.cancel()
509 timer = undefined
510 delays = 0
511 met = wanted
512 calls = 0
513 tries = 0
514 // First, so a prompt that follows the /clear at once is not wiped with the old asks.
515 await update($, asks, () => [])
516 await update($, recap, () => null)
517 await update($, goal, () => null)
518 await update($, stage, () => null)
519 await update($, history, () => [])
520 // Read before the end: after it, a check may still read this tail for a while.
521 outgoing = e.reason === 'resume' ? transcript((await $.session.messages()).slice(-60)) : ''
522 // Again once the end is done: a check that started meanwhile read the old conversation.
523 const r = await next(e)
524 seen = ''
525 epoch += 1
526 await update($, recap, () => null)
527 await update($, goal, () => null)
528 await update($, stage, () => null)
529 await update($, history, () => [])
530 // A /resume brings a transcript of its own: recap it. A /clear brings none, and an exit or logout ends here.
531 if (e.reason === 'resume') poke($, true)
532
533 return r
534 })
535
536 // With the history on, the wheel and the keys move the earlier recaps, down only while the box cuts some off, and
537 // the window stays at the top: the recap and goal never scroll away. The engine sends no up arrow while its window
538 // is at the top, so the wheel and Page Up go back. Inline, the box is too short for that, and it scrolls whole.
539 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
540 if (!isHistoryShown || isInline) return next(e)
541 // The engine counts rows: the wheel moves a recap a tick, a key one recap, and Home and End, which ask for the
542 // whole tree, go to the newest and the oldest. Where the tree just fills the box a page key asks for as many, so
543 // that counts as a page key.
544 const isEnd = Math.abs(e.by) === e.contentRows && e.contentRows !== e.bodyRows
545 const by = e.pointer ? e.by : Math.sign(e.by) * (isEnd ? HISTORY : 1)
546 if (by < 0 || e.contentRows > e.bodyRows) await scroll($, by)
547
548 return next({ ...e, offset: 0 })
549 })
550
551 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
552 const { Box, Text } = $.ui.resolve(e)
553 const isHere = e.props.placement === 'inline'
554 // ponytail: ui.scroll names no surface, so the terminal's seat decides how the box scrolls; others draw their own.
555 // A session with no terminal holds the window at the top even inline: keep each surface's seat if that comes up.
556 if (e.surface === 'terminal') isInline = isHere
557 const text = await read($, recap)
558 const aim = await read($, goal)
559 const name = await read($, stage)
560 // Drawn at last (the person opened it, or widened the terminal): a recap that came due while it
561 // waited undrawn is checked now. Only with nothing pending, so the check's own writes, which
562 // draw the box again, schedule nothing.
563 if (wanted > met && !timer && !isBusy) soon($)
564 // The newest first, from the person's place, or all of them where the window scrolls; those past the box's foot
565 // go unseen.
566 const list = isHistoryShown ? await earlier($, text) : []
567 const past = (isHere || place === null ? list : list.slice(0, place + 1)).toReversed()
568
569 // Every color a theme key, so the box follows the person's theme: the goal's label in Claude's own.
570 return (
571 <Box flexDirection="column" gap={1}>
572 {isBehind && <Text color="suggestion">Update available: /recap-box upgrade</Text>}
573 <Box flexDirection="column">
574 {name && <Text bold color={STAGES[name]}>{name.toUpperCase()}</Text>}
575 {text === null ? (
576 !name && !aim && <Text color="inactive">Waiting for activity.</Text>
577 ) : (
578 <Text color="text">{text}</Text>
579 )}
580 </Box>
581 {aim && (
582 <Box flexDirection="column">
583 <Text bold color="claude">GOAL</Text>
584 <Text color="text">{aim}</Text>
585 </Box>
586 )}
587 {/* A faint dotted line across the box parts the recap and goal from the earlier recaps: in cells, so in the
588 monospaced face, and cut at the edge rather than wrapped where a face's glyph runs wider. */}
589 {past.length > 0 && (
590 <Text color="subtle" mono wrap="truncate">
591 {'┈'.repeat(e.props.bodyColumns)}
592 </Text>
593 )}
594 {past.map(p => (
595 <Box flexDirection="column">
596 {p.stage && <Text color={STAGES[p.stage]}>{p.stage.toUpperCase()}</Text>}
597 <Text color="inactive">{p.text}</Text>
598 </Box>
599 ))}
600 </Box>
601 )
602 })
603}
604types/index.d.ts 16 lines1export type Recap = string
2export type Stage = 'researching' | 'planning' | 'coding' | 'testing' | 'debugging' | 'reviewing' | 'writing' | 'shipping'
3export type Entry = { stage: Stage | null; text: Recap }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'recap-box': {
8 recap: Recap | null
9 goal: string | null
10 asks: readonly string[]
11 stage: Stage | null
12 history: readonly Entry[]
13 }
14 }
15}
16