📺 Catches you up: a recap when you come back to a session, a morning digest of yesterday, and /standup.

Catches you up when you come back.

/plugin install previously-on@claude-desk-neighbours
If you've been idle for 20 minutes in a session that has some finished turns, your first keystroke brings up a recap:
📺 Previously on payments-service…
Waiting on you: keep or drop the old cache?
Done: retry logic for webhook sends, tests green
Pending: idempotency key on refunds
It's only generated once each time you come back, and reused if nothing has changed. It goes away when you send a prompt or press Dismiss (0 in an empty prompt, or click it). Here's the recap from the demo above, sitting under a Your Serve question:

The first session of your day also shows a one-off digest of the previous day's sessions:
📺 Yesterday: payments-service (webhook retries) · web-app (login copy fixes)
And /standup prints the previous day's and today's sessions so you can paste them into your standup channel.

Click 📺 on air in the status row, or run /previously-on, to see today's and the previous day's sessions. There are buttons for Recap now (r) and Copy standup (c).

| Setting | Default |
|---|---|
| Recap after this many idle minutes | 20 |
The recap re-reads the session transcript in one model call, which mostly comes out of the prompt cache. After the first turn, and then every fifth turn, Haiku looks at your prompts and gives the session a topic for the day log. The log has one line per session and is kept for two weeks in the plugin's store.
hooks/register.tsx 408 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Activity, DayEntry } from '../types'
5
6const RECAP = { plugin: 'previously-on', key: 'recap' } as const
7const DIGEST = { plugin: 'previously-on', key: 'digest' } as const
8const ACTIVITY = { plugin: 'previously-on', key: 'activity' } as const
9
10const FRESH: Activity = { lastActiveAt: 0, finishedTurns: 0, cacheTurns: -1, cacheLines: [] }
11
12const recap = atom(RECAP, null)
13const digest = atom(DIGEST, null)
14const activity = atom(ACTIVITY, FRESH)
15const JOURNAL = { plugin: 'previously-on', key: 'journal' } as const
16const journal = atom(JOURNAL, { repo: '', days: [] })
17
18const PANE = 'previously-on'
19const TITLE = '📺 Previously On'
20
21const KEEP_DAYS = 14
22const DAY_MS = 86_400_000
23const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
24const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
25const LABELS = ['Waiting on you', 'Done', 'Pending'] as const
26
27const RECAP_PROMPT = [
28 'The user is coming back to this session after a break. Write a recap of the session so far in at',
29 'most three lines, each under 70 characters, exactly in this form and order:',
30 'Waiting on you: <the question your last message asked the user, only if it asked one>',
31 'Done: <what has been completed>',
32 'Pending: <what remains to do>',
33 'Leave out a line that would be empty. No other text.',
34].join('\n')
35
36const TOPIC_SYSTEM = 'Reply with a topic of 2 to 5 lowercase words and nothing else.'
37
38export function localDay(ms: number): string {
39 const d = new Date(ms)
40 const pad = (n: number) => String(n).padStart(2, '0')
41 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
42}
43
44function dayName(day: string): string {
45 const [y, m, d] = day.split('-').map(Number)
46 const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1)
47 return `${WEEKDAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}`
48}
49
50function basename(path: string): string {
51 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path
52}
53
54/** The recap's lines in the fixed order, "Waiting on you" first; empty when the reply has none. */
55export function recapLines(reply: string): string[] {
56 const lines = reply.split('\n').map(line => line.replace(/^[\s*-]+/, '').trim())
57 const found: string[] = []
58 for (const label of LABELS) {
59 const line = lines.find(l => l.toLowerCase().startsWith(`${label.toLowerCase()}:`))
60 const body = line?.slice(label.length + 1).trim() ?? ''
61 if (body !== '' && !/^(none|n\/a|nothing)\.?$/i.test(body)) found.push(`${label}: ${body}`)
62 }
63 return found
64}
65
66/** `text` cut to at most `max` characters at a word break, never mid-word. */
67export function wholeWords(text: string, max: number): string {
68 if (text.length <= max) return text
69 const at = text.slice(0, max + 1).lastIndexOf(' ')
70 return at > 0 ? text.slice(0, at) : text.slice(0, max)
71}
72
73function isEntry(value: unknown): value is DayEntry {
74 if (typeof value !== 'object' || value === null) return false
75 const v = value as Record<string, unknown>
76 return typeof v.day === 'string' && typeof v.sessionId === 'string' && typeof v.repo === 'string' && typeof v.topic === 'string'
77}
78
79/** Entries of the latest day before `today`, and which day that was. */
80export function lastDayBefore(entries: readonly DayEntry[], today: string): { day: string; entries: DayEntry[] } | null {
81 const days = [...new Set(entries.map(e => e.day).filter(d => d < today))].sort()
82 const day = days.at(-1)
83 return day === undefined ? null : { day, entries: entries.filter(e => e.day === day) }
84}
85
86function dayLabel(day: string, today: string): string {
87 const [y, m, d] = today.split('-').map(Number)
88 const yesterday = localDay(new Date(y ?? 1970, (m ?? 1) - 1, (d ?? 1) - 1).getTime())
89 return day === yesterday ? 'Yesterday' : `Last time (${dayName(day)})`
90}
91
92function bySession(entries: readonly DayEntry[]): string[] {
93 return entries.filter(e => e.topic !== '').map(e => `${e.repo} (${e.topic})`)
94}
95
96// Mirrors of the activity value, so a keystroke reads no state; filled once per load.
97let lastActiveAt = 0
98let finishedTurns = 0
99let isLoaded = false
100let isRecapping = false
101
102async function repoName($: EngineInterface): Promise<string> {
103 return basename((await $.session.repo())?.root ?? (await $.session.root()))
104}
105
106async function loadDays($: EngineInterface): Promise<DayEntry[]> {
107 const stored = await $.store.get('days')
108 return Array.isArray(stored) ? stored.filter(isEntry) : []
109}
110
111async function markActive($: EngineInterface, isTurnDone: boolean): Promise<void> {
112 lastActiveAt = await $.clock.now()
113 if (isTurnDone) finishedTurns += 1
114 isLoaded = true
115 const at = lastActiveAt
116 const turns = finishedTurns
117 await update($, activity, a => ({ ...a, lastActiveAt: at, finishedTurns: turns }))
118}
119
120async function hideAll($: EngineInterface): Promise<void> {
121 if ((await read($, recap)) !== null) await $.state.set(RECAP, null)
122 if ((await read($, digest)) !== null) await $.state.set(DIGEST, null)
123}
124
125async function maybeRecap($: EngineInterface, idleMs: number): Promise<void> {
126 if (!isLoaded) {
127 const held = await read($, activity)
128 lastActiveAt = held.lastActiveAt
129 finishedTurns = held.finishedTurns
130 isLoaded = true
131 }
132 const now = await $.clock.now()
133 const isReturn = finishedTurns > 0 && lastActiveAt > 0 && now - lastActiveAt >= idleMs
134 if (!isReturn) return
135 lastActiveAt = now
136 await update($, activity, a => ({ ...a, lastActiveAt: now }))
137 await makeRecap($)
138}
139
140/** Shows the recap: the one made for the session as it stands, or a new one. */
141async function makeRecap($: EngineInterface): Promise<void> {
142 if (isRecapping) return
143 isRecapping = true
144 try {
145 const held = await read($, activity)
146 let lines = held.cacheTurns === held.finishedTurns ? held.cacheLines : []
147 if (lines.length === 0) {
148 const reply = await $.model.fork({ prompt: RECAP_PROMPT })
149 if (!reply.isAnswered) {
150 if (reply.reason === 'nothing-to-fork') $.ui.toast('📺 Nothing to recap yet')
151 return
152 }
153 lines = recapLines(reply.text)
154 const turns = held.finishedTurns
155 await update($, activity, a => ({ ...a, cacheTurns: turns, cacheLines: lines }))
156 }
157 if (lines.length > 0) await $.state.set(RECAP, { repo: await repoName($), lines })
158 } finally {
159 isRecapping = false
160 }
161}
162
163async function refreshJournal($: EngineInterface): Promise<void> {
164 await $.state.set(JOURNAL, { repo: await repoName($), days: await loadDays($) })
165}
166
167async function recapFromPane($: EngineInterface): Promise<void> {
168 await $.ui.close({ id: PANE })
169 await makeRecap($)
170}
171
172async function copyStandup($: EngineInterface, surface: 'terminal' | 'desktop' | 'mobile' | 'vscode'): Promise<void> {
173 const copied = await $.ui.copy({ text: await standup($), surface })
174 $.ui.toast(copied.isCopied ? '📺 Standup copied' : '📺 Could not copy: /standup prints it')
175}
176
177async function openJournal($: EngineInterface): Promise<void> {
178 await refreshJournal($)
179 await togglePane($, PANE, TITLE)
180}
181
182async function logTopic($: EngineInterface): Promise<void> {
183 const messages = await $.session.messages()
184 if ('deny' in messages) return
185 const asked = messages
186 .filter(m => m.role === 'user' && m.text.trim() !== '')
187 .slice(-10)
188 .map(m => `- ${m.text.replace(/\s+/g, ' ').slice(0, 200)}`)
189 if (asked.length === 0) return
190 const reply = await $.model.complete({
191 model: 'haiku',
192 effort: 'low',
193 maxTokens: 30,
194 timeoutMs: 15000,
195 system: TOPIC_SYSTEM,
196 prompt: `What is this coding session about? The user asked:\n${asked.join('\n')}`,
197 })
198 if (!reply.isAnswered) return
199 const topic = wholeWords(reply.text.replace(/[\s."']+$/g, '').replace(/\s+/g, ' ').trim().toLowerCase(), 40)
200 if (topic === '') return
201 const now = await $.clock.now()
202 const entry: DayEntry = { day: localDay(now), sessionId: await $.session.id(), repo: await repoName($), topic }
203 const oldest = localDay(now - KEEP_DAYS * DAY_MS)
204 const kept = (await loadDays($)).filter(e => e.day >= oldest && !(e.sessionId === entry.sessionId && e.day === entry.day))
205 await $.store.set('days', [...kept, entry])
206 await refreshJournal($)
207}
208
209async function morningDigest($: EngineInterface): Promise<void> {
210 const today = localDay(await $.clock.now())
211 if ((await $.store.get('lastDigestDay')) === today) return
212 await $.store.set('lastDigestDay', today)
213 const last = lastDayBefore(await loadDays($), today)
214 if (last === null) return
215 const sessions = bySession(last.entries)
216 if (sessions.length === 0) return
217 await $.state.set(DIGEST, `📺 ${dayLabel(last.day, today)}: ${sessions.join(' · ')}`)
218}
219
220async function standup($: EngineInterface): Promise<string> {
221 const today = localDay(await $.clock.now())
222 const entries = await loadDays($)
223 const last = lastDayBefore(entries, today)
224 const list = (rows: readonly DayEntry[]) =>
225 rows.filter(e => e.topic !== '').map(e => `- ${e.repo}: ${e.topic}`).join('\n') || '- nothing logged'
226 const before = last === null ? '**Yesterday**\n- nothing logged' : `**${dayLabel(last.day, today).replace(/^Last time \((.*)\)$/, '$1')}**\n${list(last.entries)}`
227 return `${before}\n\n**Today**\n${list(entries.filter(e => e.day === today))}`
228}
229
230async function reset($: EngineInterface): Promise<void> {
231 lastActiveAt = 0
232 finishedTurns = 0
233 await $.state.set(ACTIVITY, FRESH)
234 await $.state.set(RECAP, null)
235}
236
237// The hint line under the prompt is shared. Each Desk Neighbours mod adds one clickable item to
238// a row keyed `desk-hint` after the engine's own hint; items are kept in order by their keys
239// (`desk-1-…` to `desk-5-…`), so the row reads the same alone or together, in any load order.
240const DESK = 'desk-hint'
241
242// The engine draws the band's collapse mark `[-]` over the right end of its first row without
243// narrowing `bodyColumns`, so band rows stop this many cells short of the edge.
244const MARKER = 4
245
246function keyOf(node: RenderNode | undefined): string {
247 if (typeof node !== 'object' || node === null || !('props' in node)) return ''
248 const key = (node.props as Record<string, unknown> | undefined)?.key
249 return typeof key === 'string' ? key : ''
250}
251
252export function joinDesk(below: RenderElement, mine: RenderElement, wrap: (children: RenderNode[]) => RenderElement): RenderElement {
253 if (below.type !== 'Box' || keyOf(below) !== DESK) return wrap([below, mine])
254 const [line, ...items] = below.children ?? []
255 const sorted = [...items.filter(n => keyOf(n) !== keyOf(mine)), mine].sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
256 return { ...below, children: line === undefined ? sorted : [line, ...sorted] }
257}
258
259/** Opens this mod's pane, or closes it when it is the one showing. */
260async function togglePane($: EngineInterface, id: string, title: string): Promise<void> {
261 const pane = (await $.ui.panes()).find(p => p.id === id)
262 if (pane?.isShown === true) {
263 await $.ui.close({ id })
264 return
265 }
266 await $.ui.open({ id, title, focus: true, closeOnEscape: true })
267}
268
269/** "10:42" in local time. */
270function clockTime(ms: number): string {
271 const d = new Date(ms)
272 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
273}
274
275export const register: Register = (on, options) => {
276 const idleMs = Number(options.idleMinutes ?? 20) * 60_000
277
278 on('session.start', async ($, e, next) => {
279 await $.command.register({ name: 'standup', description: "📺 Yesterday's and today's sessions, ready to paste" })
280 await $.command.register({ name: 'previously-on', description: '📺 The day log, a recap on demand, and the standup to copy' })
281 if (e.isInteractive) void morningDigest($).catch(() => undefined)
282 void refreshJournal($).catch(() => undefined)
283 return next(e)
284 })
285
286 // `/clear` and `/resume` go on under a new session with empty state and fire no `session.start`;
287 // their classic SessionStart (source `clear` or `resume`) is the one place to load the day log again.
288 on('classic.SessionStart', ($, e, next) => {
289 if (e.source === 'clear' || e.source === 'resume') void refreshJournal($).catch(() => undefined)
290 return next(e)
291 })
292
293 on('session.end', async ($, e, next) => {
294 if (e.reason === 'clear' || e.reason === 'resume') await reset($).catch(() => undefined)
295 return next(e)
296 })
297
298 on('turn.complete', async ($, e, next) => {
299 const done = await next(e)
300 if (e.agentId === undefined && e.reason !== 'aborted') {
301 void markActive($, true)
302 .then(() => (finishedTurns === 1 || finishedTurns % 5 === 0 ? logTopic($) : undefined))
303 .catch(() => undefined)
304 }
305 return done
306 })
307
308 on('prompt.submit', ($, e, next) => {
309 void markActive($, false).catch(() => undefined)
310 void hideAll($).catch(() => undefined)
311 return next(e)
312 })
313
314 // The keystroke goes through untouched and at once; the return check runs beside it.
315 on('prompt.edit', ($, e, next) => {
316 void maybeRecap($, idleMs).catch(() => undefined)
317 return next(e)
318 })
319
320 on('command.run', { command: 'standup' }, async $ => ({ text: await standup($) }))
321
322 on('command.run', { command: 'previously-on' }, async $ => {
323 await refreshJournal($)
324 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
325 return { text: '📺 Previously On open. Esc closes.' }
326 })
327
328 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
329 if (e.props.hasSurvey) return next(e)
330 const shown = await read($, recap)
331 const morning = await read($, digest)
332 if (shown === null && morning === null) return next(e)
333 const below = await next(e)
334 const { Box, Text, Button } = $.ui.resolve(e)
335 // Digits press from an empty prompt; the Desk Neighbours split them so none clash (Previously On 0).
336 const dismiss = <Button key="previously-dismiss" hotkey="0" plain label="Dismiss" onPress={() => void hideAll($)} />
337 // Lines wrap rather than truncate, so the recap and Dismiss always show whole.
338 const line = (text: string, withDismiss: boolean) => (
339 <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={e.props.bodyColumns - MARKER}>
340 <Box flexShrink={1}>
341 <Text>{text}</Text>
342 </Box>
343 {withDismiss && <Box flexShrink={0}>{dismiss}</Box>}
344 </Box>
345 )
346 return (
347 <Box flexDirection="column">
348 {below}
349 {morning !== null && line(morning, shown === null)}
350 {shown !== null && line(`📺 Previously on ${shown.repo}…`, true)}
351 {shown !== null &&
352 shown.lines.map(text => (
353 <Box paddingLeft={3} width={e.props.bodyColumns - MARKER}>
354 <Text>{text}</Text>
355 </Box>
356 ))}
357 </Box>
358 )
359 })
360
361 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
362 const below = await next(e)
363 const { Box, Button } = $.ui.resolve(e)
364 const mine = <Button key="desk-5-previously-on" plain dimColor label="📺 on air" onPress={() => void openJournal($)} />
365 return joinDesk(below, mine, children => (
366 <Box key={DESK} flexDirection="row" columnGap={2}>
367 {children}
368 </Box>
369 ))
370 })
371
372 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
373 const { Box, Text, Button } = $.ui.resolve(e)
374 const { repo, days } = await read($, journal)
375 const today = localDay(await $.clock.now())
376 const last = lastDayBefore(days, today)
377 const row = (label: string, entries: readonly DayEntry[]) => (
378 <Box flexDirection="row" width={e.props.bodyColumns}>
379 <Box width={12} flexShrink={0}>
380 <Text dimColor>{label}</Text>
381 </Box>
382 <Box flexShrink={1}>
383 <Text dimColor={entries.length === 0}>
384 {entries.length === 0 ? 'nothing logged yet' : bySession(entries).join(' · ')}
385 </Text>
386 </Box>
387 </Box>
388 )
389 return (
390 <Box flexDirection="column">
391 <Box flexDirection="row" marginBottom={1}>
392 <Text bold>📺 Previously On</Text>
393 <Text dimColor> · {repo}</Text>
394 </Box>
395 {row('Today', days.filter(d => d.day === today))}
396 {last !== null && row(dayLabel(last.day, today).replace(/^Last time \((.*)\)$/, '$1'), last.entries)}
397 <Box flexDirection="row" gap={2} marginTop={1}>
398 <Button key="pane-recap" hotkey="r" plain label="Recap now" autoFocus onPress={() => void recapFromPane($)} />
399 <Button key="pane-standup" hotkey="c" plain label="Copy standup" onPress={press => void copyStandup($, press.surface)} />
400 </Box>
401 <Box marginTop={1}>
402 <Text dimColor>A line per session, kept two weeks · esc closes</Text>
403 </Box>
404 </Box>
405 )
406 })
407}
408types/index.d.ts 18 lines1/** The recap the band shows on return. */
2export type Recap = { repo: string; lines: string[] }
3
4/** One line of the day log: a session, its repo and what it was about. */
5export type DayEntry = { day: string; sessionId: string; repo: string; topic: string }
6
7/** When the person was last active, and the recap made for the session as it stood. */
8export type Activity = { lastActiveAt: number; finishedTurns: number; cacheTurns: number; cacheLines: string[] }
9
10/** The day log as the pane draws it, and the repo it is drawn for. */
11export type Journal = { repo: string; days: DayEntry[] }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'previously-on': { recap: Recap | null; digest: string | null; activity: Activity; journal: Journal }
16 }
17}
18