SLOPSHOPPER

previously-on

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

newpanebandspinnercommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · previously-on
│ ┃ 📺 Previously On ✕ › fix the failing auth test and add an audit log call │ ┃ 📺 Previously On · app │ ┃ ⏺ Read(src/auth.ts) │ ┃ Today app (ok) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ r: Recap now c: Copy standup ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ A line per session, kept two weeks · esc ⎿ 3 pass, 1 fail │ ┃ closes │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /standup │ ⎿ previously-on: **Yesterday** │ ⎿ previously-on: - nothing logged │ ⎿ previously-on: │ ⎿ previously-on: **Today** │ ⎿ previously-on: - nothing logged │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 📺 on air

Draws

Pane · 📺 Previously On
📺 Previously On · app Today app (ok) r: Recap now c: Copy standup A line per session, kept two weeks · esc closes
Prompt hint
⟨Claude Code's own drawing⟩ 📺 on air
README

📺 Previously On

Catches you up when you come back.

After 20 minutes away, the first keystroke brings up a recap of the session; 0 dismisses it

/plugin install previously-on@claude-desk-neighbours

How it works

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:

A recap under a Your Serve question: waiting on you, update the webhook tests to a 250ms base; done; pending

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.

/standup output: yesterday, nothing logged; today, payments-service and its topic

The pane

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

The Previously On pane: today's sessions, wrapped to fit, with Recap now and Copy standup buttons

Settings

SettingDefault
Recap after this many idle minutes20

In the background

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.

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