SLOPSHOPPER

catch-me-up

A side pane with a live catch-up summary of your Claude Code session: why it started, what was done, where things stand, and what needs you

newpanecommandmodelprocess
v0.1.3MITupdated 2026-10-03oliverow/catch-me-up
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · catch-me-up
│ ┃ Catch me up ✕ › fix the failing auth test and add an audit log call │ ┃ r: Refresh b: Rebuild Update failed: ENOEN │ ┃ /plugins/catch-me-up ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ The summary appears after the first turn ⏺ Update(src/auth.ts) │ ┃ that uses a tool, or press Refresh. ⎿ 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 │ │ › /catch-me-up │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Catch me up
r: Refresh b: Rebuild Update failed: ENOENT: no such file /plugins/catch-me-up/INSTRUCTIONS.md The summary appears after the first turn that uses a tool, or press Refresh.
README

catch-me-up

A Claude Code mod that shows a live catch-up summary of the current session in a side pane, kept up to date as the session goes on: why the session started, what was done, what came out of it, where things stand, and what needs you. Come back to a session after a break and the pane tells you where you left off, without scrolling through the transcript.

Requires Claude Code 2.1.287 or later. Tested with 2.1.287 and 2.1.288.

How it works

  • When it updates. After each turn of the main conversation that used at least one tool, such as a command, an edit, a file read, a search or an MCP call. Turns that are only chat are not summarized on their own; their messages are folded in at the next update. Subagent turns are skipped, and nothing runs in claude -p, where no app shows the pane.
  • How it updates. A rolling update with Haiku. Each call gets the previous summary, the messages added since then (long texts and tool results are clipped), and the output of git status --short, git diff --stat, and git log --oneline -n 5. Haiku writes the full new summary. A long backlog, such as after Rebuild, is folded in over several calls of about 100,000 characters each.
  • What it writes. It follows the writing rules in INSTRUCTIONS.md, plus a few rules for the narrow pane: at most 4 short bullets per section.
  • Cost. One Haiku call per update, on your plan or API key.
  • Storage. Each session's summary is kept in the mod's store, so it is still there after a restart or /resume. The store keeps the 100 most recent sessions.

What it runs and sends

  • Runs git status --short, git diff --stat and git log --oneline -n 5 in the session's working directory before each update.
  • Sends the previous summary, the new transcript messages (your prompts, Claude's replies, and tool calls with clipped results) and that git output to Claude Haiku, through Claude Code's own model API with your session's credentials. Nothing is sent anywhere else.
  • Stores each session's summary on your machine, in the mod's store under ~/.claude/plugins/store/.

The pane

  • Opens by itself when a session starts. In the terminal it only appears when the window is at least 144 columns wide (110 once you have opened it yourself); run /catch-me-up to open it at any width.
  • Refresh (r): fold in what is new now, even if no tool was used.
  • Rebuild (b): throw the summary away and summarize the session again from the start. Claude Code shows a mod at most the newest 4,096 messages. Does nothing while an update is running.
  • A failed update shows its reason in place of the time of the last update.

Install

claude plugin marketplace add oliverow/catch-me-up
claude plugin install catch-me-up@catch-me-up

To try it for one session from a clone:

claude --plugin-dir ./catch-me-up
Source 1 files
hooks/register.js 209 lines
1// catch-me-up: a pane that keeps a live catch-up summary of the session.
2// After each turn that used a tool, Haiku folds the messages added since the
3// last update into the previous summary (a rolling update).
4
5const PANE = 'catch-me-up'
6const MODEL = 'haiku'
7// About how many characters of new messages go to the model in one call. A
8// longer backlog, such as after Rebuild, is folded in over several calls.
9const CHUNK_CHARS = 100_000
10// How many sessions' summaries the store keeps
11const KEEP_SESSIONS = 100
12
13// The current session's summary, how many transcript messages it covers, and
14// a fingerprint of the last of them
15let sessionId = ''
16let summary = ''
17let covered = 0
18let marker = ''
19let updatedAt = 0
20let status = ''
21let running = false
22// The update asked for while one was running: null, or its onlyIfWork
23let queued = null
24
25async function load($) {
26  sessionId = await $.session.id()
27  const saved = await $.store.get('session:' + sessionId)
28  summary = saved?.summary ?? ''
29  covered = saved?.covered ?? 0
30  marker = saved?.marker ?? ''
31  updatedAt = saved?.updatedAt ?? 0
32  status = ''
33  $.ui.invalidate('ui.render')
34}
35
36async function save($) {
37  await $.store.set('session:' + sessionId, { summary, covered, marker, updatedAt })
38  // Most recent first; drop the summaries of the oldest sessions
39  const order = [sessionId, ...((await $.store.get('order')) ?? []).filter((id) => id !== sessionId)]
40  for (const id of order.slice(KEEP_SESSIONS)) await $.store.delete('session:' + id)
41  await $.store.set('order', order.slice(0, KEEP_SESSIONS))
42}
43
44const fingerprint = (m) => m.role + '\n' + m.text.slice(0, 200) + '\n' + m.toolUses.map((t) => t.tool_use_id).join(',')
45
46// Claude Code adds system reminders to user messages; they aren't the user's words
47const unremind = (s) => s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
48
49const clip = (s, n) => (s.length > n ? s.slice(0, n) + ' […]' : s)
50
51function formatMessage(m) {
52  const lines = []
53  const text = unremind(m.text)
54  if (text) lines.push((m.role === 'user' ? 'USER: ' : 'ASSISTANT: ') + clip(text, 4000))
55  for (const t of m.toolUses) {
56    lines.push(`  [tool ${t.tool}] ${clip(JSON.stringify(t.input), 300)}` + (t.text ? ` → ${clip(t.text, 300)}` : ''))
57  }
58  return lines.join('\n')
59}
60
61async function gitState($) {
62  const out = []
63  for (const argv of [['git', 'status', '--short'], ['git', 'diff', '--stat'], ['git', 'log', '--oneline', '-n', '5']]) {
64    const r = await $.process.run(argv)
65    out.push('$ ' + argv.join(' ') + '\n' + ((r.exitCode ? r.stderr : r.stdout).trim() || '(empty)'))
66  }
67  return out.join('\n\n')
68}
69
70async function systemPrompt($) {
71  const instructions = await $.fs.read($.plugin.root + '/INSTRUCTIONS.md')
72  return [
73    'You keep a live catch-up summary of a Claude Code session. It is shown in a narrow side pane and updated as the session goes on.',
74    'Follow the instructions below for what to write and how. They were written for a one-off summary by an agent that reads the whole conversation and runs commands. You cannot. You get your previous summary, the messages added since it was written, and the current git state. Write the full updated summary: keep what is still true, rewrite what changed, drop what no longer matters. The previous summary is your only memory of earlier messages, so keep its facts unless the new messages change them.',
75    'Put the weight on the high-level context, the motivation, a summary of the method, and the current progress.',
76    'Format, overriding the instructions below: the pane is narrow, so every section is a list of "- " bullets, never a paragraph. At most 4 bullets per section, each one or two short lines. Output only the summary Markdown, starting with the first heading.',
77    '<instructions>\n' + instructions + '\n</instructions>',
78  ].join('\n\n')
79}
80
81// Fold the messages added since the last update into the summary.
82// With onlyIfWork, skip when none of them used a tool.
83async function update($, onlyIfWork) {
84  if (running) {
85    queued = (queued ?? true) && onlyIfWork
86    return
87  }
88  running = true
89  const id = sessionId
90  try {
91    const messages = await $.session.messages()
92    // The summary normally ends at message `covered`. Past the newest 4096
93    // messages the list slides, and compaction replaces it, so otherwise look
94    // for the last message summarized, and start over when it is gone.
95    let i =
96      covered > 0 && covered <= messages.length && fingerprint(messages[covered - 1]) === marker
97        ? covered
98        : messages.findLastIndex((m) => fingerprint(m) === marker) + 1
99    const fresh = messages.slice(i)
100    if (!fresh.length) return
101    if (onlyIfWork && !fresh.some((m) => m.toolUses.length)) return
102
103    status = 'Updating…'
104    $.ui.invalidate('ui.render')
105    const system = await systemPrompt($)
106    const git = await gitState($)
107
108    while (i < messages.length) {
109      let chunk = ''
110      let end = i
111      while (end < messages.length && (end === i || chunk.length < CHUNK_CHARS)) chunk += formatMessage(messages[end++]) + '\n\n'
112      const r = await $.model.complete({
113        model: MODEL,
114        system,
115        prompt:
116          '<previous_summary>\n' + (summary || '(none yet)') + '\n</previous_summary>\n\n' +
117          '<new_messages>\n' + chunk + '</new_messages>\n\n' +
118          '<git_state>\n' + git + '\n</git_state>',
119        maxTokens: 3000,
120      })
121      if (!r.isAnswered) throw new Error(r.reason + (r.error ? ` (${r.error})` : ''))
122      // /clear, /resume or /branch switched sessions while the model ran
123      if (sessionId !== id) return
124      summary = r.text.trim().slice(0, 10_000)
125      covered = i = end
126      marker = fingerprint(messages[end - 1])
127      updatedAt = await $.clock.now()
128      await save($)
129      $.ui.invalidate('ui.render')
130    }
131    status = ''
132  } catch (err) {
133    status = 'Update failed: ' + err.message
134  } finally {
135    running = false
136    $.ui.invalidate('ui.render')
137    if (queued !== null) {
138      const onlyIfWorkNext = queued
139      queued = null
140      update($, onlyIfWorkNext)
141    }
142  }
143}
144
145export function register(on) {
146  on('session.start', async ($, e, next) => {
147    await load($)
148    await $.command.register({ name: 'catch-me-up', description: 'Open the catch-me-up summary pane', immediate: true })
149    // Opened without being asked: the terminal places it only when wide enough
150    await $.ui.open({ id: PANE, title: 'Catch me up' })
151    return next(e)
152  })
153
154  // /clear, /resume and /branch switch to another session's summary
155  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
156    await load($)
157    return next(e)
158  })
159
160  on('command.run', { command: 'catch-me-up' }, async ($) => {
161    await $.ui.open({ id: PANE, title: 'Catch me up' })
162    return {}
163  })
164
165  on('turn.complete', async ($, e, next) => {
166    // No app shows the pane in a claude -p run. Not awaited, so the turn ends
167    // without waiting for the summary.
168    if (!e.agentId && (await $.session.surfaces()).length) update($, true)
169    return next(e)
170  })
171
172  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
173    if (e.requestId !== PANE) return next(e)
174    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
175    const d = new Date(updatedAt)
176    const when = updatedAt ? `Updated ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` : 'No summary yet'
177    return Box({
178      flexDirection: 'column',
179      children: [
180        Box({
181          flexDirection: 'row',
182          columnGap: 2,
183          children: [
184            Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, onPress: () => update($, false) }),
185            Button({
186              key: 'rebuild',
187              label: 'Rebuild',
188              hotkey: 'b',
189              plain: true,
190              onPress: () => {
191                if (running) return
192                summary = ''
193                covered = 0
194                marker = ''
195                update($, false)
196              },
197            }),
198            Text({ dimColor: true, children: [status || when] }),
199          ],
200        }),
201        Text({ children: [' '] }),
202        summary
203          ? Markdown({ key: 'summary', text: summary })
204          : Text({ dimColor: true, children: ['The summary appears after the first turn that uses a tool, or press Refresh.'] }),
205      ],
206    })
207  })
208}
209