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

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.
claude -p, where no app shows the pane.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./resume. The store keeps the 100 most recent sessions.git status --short, git diff --stat and git log --oneline -n 5 in the session's working directory before each update.~/.claude/plugins/store/./catch-me-up to open it at any width.r): fold in what is new now, even if no tool was used.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.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-uphooks/register.js 209 lines1// 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