SLOPSHOPPER

day-recap

Writes a short recap of what you did when you step away, and shows it above the prompt when you come back

newbandguardcommandpromptmodel
v0.1.0MITupdated 2026-10-03sivori/claude-mods/plugins/day-recap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · day-recap
› fix the failing auth test and add an audit log call ⏺ 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 ⎿ day-recap: Recap for app (today 1:53am) ⎿ day-recap: Done: edited auth.ts, audit.ts, cache.ts ⎿ day-recap: Next: pick up from "fix the failing auth test and add an audit log call" ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Small mods for Claude Code: status lines, a band above the prompt, a docked pane, and a radio. Each one is a plugin of function hooks.

radio, backlog-band, spend-meter and commit-drift in a Claude Code terminal (illustration)

Install

claude plugin marketplace add sivori/claude-mods
claude plugin install radio@claude-mods        # or any mod below

Update later with claude plugin marketplace update claude-mods.

To try one without installing, clone the repo and run claude --plugin-dir ./plugins/<mod>.

The mods

radio

/tune streams internet radio inside Claude Code: SomaFM, lo-fi, classical and choral. (The command is /tune because Claude Code has a built-in /radio.) All your sessions share one player, so music started in two sessions never plays twice. Switching stations in any session replaces the current stream, and the status line shows what's playing everywhere. While something plays, a row of buttons sits above the prompt: ◀ ■ ▶ to change or stop, − + for volume. Stopping keeps the row, with a play button that resumes the last station.

/tune              toggle (resumes the last station); /tune listen just plays
/tune groove       pick by name, genre ("jazz", "choral") or number
/tune next | prev | stop | list
/tune vol 30       also vol +10 / vol -10
/tune band         hide or show the buttons

Plays through mpv (brew install mpv), which changes volume without a gap; otherwise ffplay from ffmpeg, which restarts the stream on each volume change.

By default the music keeps playing after you close Claude Code, until /tune stop. Turn on stopWithLastSession in /config to have it stop once no session is open; a watchdog catches a crashed session too, within about a minute.

spend-meter

Session cost, context fill and 5-hour rate-limit use in the status line ($1.23 · ctx 42% · 5h 24%). It toasts once as spend passes $5, $10, $25, $50 and $100.

commit-drift

Uncommitted file count and time since the last commit in the status line, for whichever repo you're editing. After an editing turn on a tree that hasn't been committed for 30+ minutes, it nudges you (at most every 10 minutes).

backlog-band

Shows the open ## Now items of the active repo's BACKLOG.md above the prompt. /later <item> appends to ## Next, and /backlog-band hides or shows the band. It expects this format:

## Now
- [ ] Blocking or imminent work

## Next
- [ ] The normal queue

## Someday
- [ ] Ideas

## Done
- [x] 2026-08-01 Completed

day-recap

So you never start cold. While you work it records your prompts, the files edited and the commits made. Once the session has been quiet for 10 minutes, it asks the model for a two-line recap (what got done, the next step) and saves it per repo. When you next open Claude Code in that repo, the recap sits above the prompt until your first message:

↩ Last time in dash · yesterday 5:12pm
  Done: Added the spend chart and matched its colors.
  Next: Write a test for the empty-data case.

/recap writes it now, and /recap last brings back the one from last time. A session that did nothing leaves the earlier recap in place. If you quit before the recap is written, the next session writes it from the recorded activity with Haiku.

quest-board

Three daily quests in a pane, drawn from the active repo: a TODO/FIXME comment to resolve, an open GitHub issue to close (needs gh), and a test command that failed to get passing. It picks one of each kind when it can, keeps the same board all day, and draws a new one tomorrow. A quest clears by itself when the TODO line is gone, the issue is closed or the test command passes. Each cleared quest gets a toast, and clearing all three gets a bigger one. The status line shows ⚔ quests 1/3.

/quests             open the board
/quests reroll      swap the unfinished quests for new ones
/quests check       re-check the issues now (otherwise every 10 minutes)

commonplace-pane

A docked pane of public-domain paintings from the Art Institute of Chicago. Its "weather" follows your repo: calm landscapes while the tree is clean, overcast as changes pile up or commands fail, shipwrecks in a storm. The painting rotates every 20 minutes, and /commonplace next hangs a new one.

The images need a terminal with the kitty graphics protocol (kitty, Ghostty); elsewhere the pane shows the title and artist. It uses macOS sips to convert images. Paintings are cached in ~/.cache/commonplace-pane/, which keeps the newest 30 and deletes the rest after each new painting. Two optional settings live in /config:

  • contact: an email or URL sent in the museum's AIC-User-Agent header, as the Art Institute asks of API clients.
  • seedDir: a folder of seed-*.jpg paintings to fall back on when the museum is unreachable.

Developing

Each mod is plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and a hooks/register.ts(x) module. To check one:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

License

MIT

Source 2 files
hooks/register.tsx 229 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Recap } from '../types'
5
6const previous = atom({ plugin: 'day-recap', key: 'previous' } as const, null)
7const isShown = atom({ plugin: 'day-recap', key: 'isShown' } as const, false)
8
9// Once the session has been quiet this long, the model writes the recap.
10const IDLE_MS = 10 * 60_000
11const MAX_PROMPTS = 30
12const MAX_FILES = 50
13
14const ASK = `You are writing a note to the person for when they come back to this project.
15Reply with exactly two lines and nothing else:
16DONE: <one sentence on what got done this session, concrete, no fluff>
17NEXT: <the single most useful next step, specific enough to start on cold>`
18
19export function parseSummary(text: string): { done?: string; next?: string } {
20  const line = (label: string) =>
21    new RegExp(`^[\\s*]*${label}[\\s*]*:[\\s*]*(.+)$`, 'im').exec(text)?.[1]?.trim()
22  return { done: line('DONE'), next: line('NEXT') }
23}
24
25/** What the recap says without the model: counts and the last thing asked. */
26export function fallback(recap: Recap): { done: string; next: string } {
27  const parts: string[] = []
28  if (recap.commits.length > 0) {
29    parts.push(`${recap.commits.length} commit${recap.commits.length === 1 ? '' : 's'}`)
30  }
31  if (recap.files.length > 0) {
32    const shown = recap.files.slice(-3).map(file => file.split('/').pop())
33    const more = recap.files.length - shown.length
34    parts.push(`edited ${shown.join(', ')}${more > 0 ? ` (+${more})` : ''}`)
35  }
36  if (parts.length === 0) parts.push(`${recap.prompts.length} prompts, no edits`)
37  const last = recap.prompts.at(-1)
38  return { done: parts.join('; '), next: last ? `pick up from "${last}"` : 'start fresh' }
39}
40
41/** "today 5:12pm", "yesterday 9:03am", "Mon 4:40pm" or "Sep 28". */
42export function when(thenMs: number, nowMs: number): string {
43  const then = new Date(thenMs)
44  const dayOf = (d: Date) => Math.floor((d.getTime() - d.getTimezoneOffset() * 60_000) / 86_400_000)
45  const days = dayOf(new Date(nowMs)) - dayOf(then)
46  const h = then.getHours()
47  const time = `${h % 12 || 12}:${String(then.getMinutes()).padStart(2, '0')}${h < 12 ? 'am' : 'pm'}`
48  if (days <= 0) return `today ${time}`
49  if (days === 1) return `yesterday ${time}`
50  if (days < 7) return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][then.getDay()]} ${time}`
51  const months = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ')
52  return `${months[then.getMonth()]} ${then.getDate()}`
53}
54
55export function describe(recap: Recap, nowMs: number): string {
56  const { done, next } = recap.done ? { done: recap.done, next: recap.next ?? '' } : fallback(recap)
57  return [`Last time in ${recap.project} (${when(recap.updatedAt, nowMs)})`, `Done: ${done}`, `Next: ${next}`].join('\n')
58}
59
60// This session's recap. A module variable: a reload re-reads it from the store.
61let current: Recap | undefined
62let idle: { cancel: () => void } | undefined
63
64const keyOf = (root: string) => `recap:${root}`
65const hasActivity = (recap: Recap) => recap.prompts.length > 0 || recap.files.length > 0
66
67async function commitsSince($: EngineInterface, recap: Recap) {
68  const ran = await $.process
69    .run(['git', '-C', recap.root, 'log', `--since=@${Math.floor(recap.startedAt / 1000)}`, '--format=%s'])
70    .catch(() => undefined)
71  if (ran?.exitCode !== 0) return recap.commits
72  return ran.stdout.split('\n').filter(line => line.trim() !== '').reverse()
73}
74
75async function save($: EngineInterface) {
76  // A session that did nothing keeps the last real recap in place.
77  if (current === undefined || !hasActivity(current)) return
78  current = { ...current, commits: await commitsSince($, current), updatedAt: await $.clock.now() }
79  await $.store.set(keyOf(current.root), current)
80}
81
82async function summarize($: EngineInterface): Promise<boolean> {
83  if (current === undefined || !hasActivity(current)) return false
84  const reply = await $.model.fork({ prompt: ASK })
85  if (!reply.isAnswered) return false
86  const { done, next } = parseSummary(reply.text)
87  if (done === undefined) return false
88  current = { ...current, done, next }
89  await save($)
90  return true
91}
92
93/** A recap from an earlier session that never got the model's summary gets one now. */
94async function backfill($: EngineInterface, recap: Recap) {
95  const ask = `${ASK}
96
97Here is what the session recorded.
98Prompts, oldest first:
99${recap.prompts.map(p => `- ${p}`).join('\n') || '- (none)'}
100Files edited: ${recap.files.join(', ') || '(none)'}
101Commits: ${recap.commits.join('; ') || '(none)'}`
102  const reply = await $.model.complete({ model: 'haiku', prompt: ask, maxTokens: 200, timeoutMs: 20_000 })
103  if (!reply.isAnswered) return
104  const { done, next } = parseSummary(reply.text)
105  if (done === undefined) return
106  const filled = { ...recap, done, next }
107  await $.store.set(keyOf(recap.root), filled)
108  await update($, previous, () => filled)
109}
110
111export const register: Register = on => {
112  on('session.start', async ($, e, next) => {
113    await $.command.register({
114      name: 'recap',
115      description: 'Write the recap of this session now; /recap last shows the one from last time',
116      argumentHint: '[last]',
117    })
118    const started = await next(e)
119    const root = (await $.session.repo())?.root ?? (await $.session.root())
120    const sessionId = await $.session.id()
121    const kept = (await $.store.get(keyOf(root))) as Recap | undefined
122
123    if (kept?.sessionId === sessionId) {
124      // A reload or a resume of the same session: carry on with it.
125      current = kept
126    } else {
127      current = {
128        sessionId,
129        root,
130        project: root.split('/').pop() ?? root,
131        startedAt: await $.clock.now(),
132        updatedAt: await $.clock.now(),
133        prompts: [],
134        files: [],
135        commits: [],
136      }
137      if (kept !== undefined && hasActivity(kept)) {
138        await update($, previous, () => kept)
139        await update($, isShown, () => true)
140        if (kept.done === undefined) $.clock.after(0, () => void backfill($, kept))
141      }
142    }
143    return started
144  })
145
146  on('prompt.submit', async ($, e, next) => {
147    idle?.cancel()
148    await update($, isShown, () => false)
149    const first = e.text.trim().split('\n')[0]?.slice(0, 120) ?? ''
150    if (current !== undefined && first !== '' && !first.startsWith('/')) {
151      current = { ...current, prompts: [...current.prompts, first].slice(-MAX_PROMPTS) }
152    }
153    return next(e)
154  })
155
156  on('tool.call', async ($, e, next) => {
157    const ran = await next(e)
158    if (current === undefined || ran.isError || ran.deny !== undefined) return ran
159    const path =
160      e.tool === 'Edit' || e.tool === 'Write' ? e.file_path
161      : e.tool === 'NotebookEdit' ? e.notebook_path
162      : undefined
163    if (path !== undefined) {
164      const file = path.startsWith(`${current.root}/`) ? path.slice(current.root.length + 1) : path
165      const files = [...current.files.filter(one => one !== file), file].slice(-MAX_FILES)
166      current = { ...current, files }
167    }
168    return ran
169  })
170
171  on('turn.complete', async ($, e, next) => {
172    const done = await next(e)
173    if (e.agentId !== undefined) return done
174    await save($)
175    idle?.cancel()
176    idle = $.clock.after(IDLE_MS, () => void summarize($))
177    return done
178  })
179
180  on('session.end', async ($, e, next) => {
181    idle?.cancel()
182    await save($)
183    return next(e)
184  })
185
186  on('command.run', { command: 'recap' }, async ($, e) => {
187    const now = await $.clock.now()
188    if (e.args.trim() === 'last') {
189      const recap = await read($, previous)
190      if (recap === null) return { text: 'No recap from an earlier session here yet.' }
191      await update($, isShown, () => true)
192      return { text: describe(recap, now) }
193    }
194    if (current === undefined || !hasActivity(current)) {
195      return { text: 'Nothing to recap yet this session.' }
196    }
197    if (!(await summarize($))) await save($)
198    return { text: describe(current, now).replace(/^Last time in/, 'Recap for') }
199  })
200
201  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202    const recap = await read($, previous)
203    if (e.props.hasSurvey || recap === null || !(await read($, isShown))) return next(e)
204
205    const { Box, Button, Text } = $.ui.resolve(e)
206    const now = await $.clock.now()
207    const { done, next: then } = recap.done ? { done: recap.done, next: recap.next ?? '' } : fallback(recap)
208
209    return (
210      <Box flexDirection="column" width={e.props.bodyColumns}>
211        <Box>
212          <Text dimColor wrap="truncate-end">
213            ↩ Last time in {recap.project} · {when(recap.updatedAt, now)}{' '}
214          </Text>
215          <Button key="hide" label="Hide" onPress={() => update($, isShown, () => false)} />
216        </Box>
217        <Text wrap="truncate-end">
218          {'  Done: '}
219          {done}
220        </Text>
221        <Text wrap="truncate-end" bold>
222          {'  Next: '}
223          {then}
224        </Text>
225      </Box>
226    )
227  })
228}
229
types/index.d.ts 27 lines
1export type Recap = {
2  /** The session the recap is of. */
3  sessionId: string
4  /** The repo (or session folder) it was written for. */
5  root: string
6  /** The folder's name, shown as the band's label. */
7  project: string
8  /** Epoch ms of the session's start, and of its last saved activity. */
9  startedAt: number
10  updatedAt: number
11  /** The person's prompts, first line of each, oldest first. */
12  prompts: string[]
13  /** Files edited, relative to `root`. */
14  files: string[]
15  /** Subjects of the commits made since the session started. */
16  commits: string[]
17  /** The model's one-line summary and next step, once written. */
18  done?: string
19  next?: string
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'day-recap': { previous: Recap | null; isShown: boolean }
25  }
26}
27