SLOPSHOPPER

recap-box

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…

newpaneguardcommandpromptmodel
v?MITupdated 2026-10-26duysqubix/recap-box
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recap-box
│ ┃ Recap ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for activity. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /recap-box │ ⎿ recap-box: Recap box opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Recap
Waiting for activity.
README

recap-box

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.

  • On top: the stage of the work and one or two sentences on what is happening now. The stage is one of researching, planning, coding, testing, debugging, reviewing, writing or shipping, each in its own theme color.
  • The stage comes from the tools the session runs, not from a guess. It is the newest of the last five calls that does more than look. Edits are coding, or writing when they change docs. Test runs are testing, review agents are reviewing, and commits, pushes and CI watches are shipping. A shell command counts by the commands it starts, so grep -rn test is a look and npm test a test run.
  • When the last five calls only read, search or run other commands, the stage is what a helper agent at work does, or researching. While the session waits on a helper agent, the stage is the helper's. When the newest test run among the last five failed, by its exit code or by a failure its output reports, the stage is debugging. A run sent to the background does not count, since its result comes later.
  • Below, pinned: the goal of the whole session, drawn from what you asked for. Haiku keeps it until your requests move on. The box sees only what you ask after it loads, so in a resumed session the goal starts from the transcript.
  • The recap and the goal use everyday words, with no jargon, no abbreviations and no names of files or code.
  • The box updates as the session works, with no periodic polling. Each tool call, and each helper agent that starts or ends, has it read the stage again, two seconds after the last of a burst; a steady stream of events still gets a check about every ten seconds. It asks Haiku for a new recap when a turn of the session ends, and every fifth call of a long turn, at most once every ten seconds. A recap due sooner waits out the ten seconds, then reads what is newest. When the transcript has not changed, it asks nothing.
  • After a /resume, the box recaps the transcript the resume brings once the engine has it in place. After a /clear it waits for new activity.
  • While the box waits undrawn (opened on its own on a terminal too narrow for it, or with no app attached) it reads nothing. A recap that came due meanwhile is written once the box is drawn. A box drawn but behind another tab still reads.

/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.

Install

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.

Updates

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:

  • Each session checks for new commits at a random moment in its first 10 minutes. A session that takes one shows Plugin updated: recap-box, then reloads it or says to run /reload-plugins.
  • The install heals itself. If the marketplace or the plugin record goes missing from ~/.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.

Develop

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.

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