SLOPSHOPPER

backlog-band

Shows the active project's BACKLOG.md Now items above the prompt; /later adds to Next

newbandguardcommandprocess
v0.1.0MITupdated 2026-10-03sivori/claude-mods/plugins/backlog-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog-band
› 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 › /later ⎿ backlog-band: Usage: /later <item> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 160 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Backlog } from '../types'
5
6const backlog = atom({ plugin: 'backlog-band', key: 'backlog' } as const, null)
7const isHidden = atom({ plugin: 'backlog-band', key: 'isHidden' } as const, false)
8
9const MAX_ITEMS = 5
10const OPEN_ITEM = /^-\s\[\s\]\s+(.*)$/
11const HEADING = /^##\s+(\S+)/
12
13/** The `## <name>` section's line range, or undefined when there is none. */
14function sectionOf(lines: string[], name: string) {
15  const start = lines.findIndex(line => HEADING.exec(line)?.[1]?.toLowerCase() === name)
16  if (start < 0) return undefined
17  const after = lines.slice(start + 1).findIndex(line => HEADING.test(line))
18  return { start, end: after < 0 ? lines.length : start + 1 + after }
19}
20
21export function parseNow(text: string): string[] {
22  const lines = text.split('\n')
23  const now = sectionOf(lines, 'now')
24  if (now === undefined) return []
25  return lines
26    .slice(now.start + 1, now.end)
27    .map(line => OPEN_ITEM.exec(line)?.[1]?.trim())
28    .filter((item): item is string => item !== undefined && item !== '')
29}
30
31/** Appends `- [ ] item` as the last entry of `## Next`, adding the section if missing. */
32export function addToNext(text: string, item: string): string {
33  const lines = text.split('\n')
34  const next = sectionOf(lines, 'next')
35  const entry = `- [ ] ${item}`
36  if (next === undefined) {
37    const someday = sectionOf(lines, 'someday')
38    const at = someday?.start ?? lines.length
39    lines.splice(at, 0, '## Next', entry, '')
40    return lines.join('\n')
41  }
42  let at = next.end
43  while (at > next.start + 1 && lines[at - 1]?.trim() === '') at -= 1
44  lines.splice(at, 0, entry)
45  return lines.join('\n')
46}
47
48async function gitRoot($: EngineInterface, dir: string) {
49  const ran = await $.process
50    .run(['git', '-C', dir, 'rev-parse', '--show-toplevel'])
51    .catch(() => undefined)
52  return ran?.exitCode === 0 ? ran.stdout.trim() : undefined
53}
54
55async function load($: EngineInterface, root: string) {
56  const path = `${root}/BACKLOG.md`
57  const exists = await $.fs.exists(path).catch(() => false)
58  const found: Backlog | null = exists
59    ? {
60        path,
61        project: root.split('/').pop() ?? root,
62        now: parseNow(String(await $.fs.read(path))),
63      }
64    : null
65  await update($, backlog, () => found)
66}
67
68// The project the band follows: the repo of the file last edited, else the session's.
69// A module variable: a reload re-seeds it in session.start from the kept state.
70let root: string | undefined
71
72async function follow($: EngineInterface, filePath: string) {
73  const cut = filePath.lastIndexOf('/')
74  const found = await gitRoot($, cut > 0 ? filePath.slice(0, cut) : '/')
75  if (found === undefined) return
76  const isNew = found !== root
77  root = found
78  if (isNew || filePath.endsWith('/BACKLOG.md')) await load($, found)
79}
80
81export const register: Register = on => {
82  on('session.start', async ($, e, next) => {
83    await $.command.register({
84      name: 'later',
85      description: "Add an item to the active project's BACKLOG.md Next section",
86      argumentHint: '<item>',
87    })
88    await $.command.register({
89      name: 'backlog-band',
90      description: 'Show or hide the backlog band above the prompt',
91    })
92    const kept = await read($, backlog)
93    const fallback = (await $.session.repo())?.root ?? (await $.session.root())
94    root = kept ? kept.path.replace(/\/BACKLOG\.md$/, '') : fallback
95    await load($, root)
96    return next(e)
97  })
98
99  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
100    const ran = await next(e)
101    await follow($, e.file_path)
102    return ran
103  })
104
105  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
106    const ran = await next(e)
107    await follow($, e.file_path)
108    return ran
109  })
110
111  // Bash, /backlog or another session may have changed the file: re-read once per turn.
112  on('turn.complete', async ($, e, next) => {
113    const done = await next(e)
114    if (e.agentId === undefined && root !== undefined) await load($, root)
115    return done
116  })
117
118  on('command.run', { command: 'later' }, async ($, e) => {
119    const item = e.args.trim()
120    if (item === '') return { text: 'Usage: /later <item>' }
121    const current = await read($, backlog)
122    if (current === null) {
123      return { text: `No BACKLOG.md at ${root ?? 'the session root'}; nothing added.` }
124    }
125    const text = String(await $.fs.read(current.path))
126    await $.fs.write(current.path, addToNext(text, item))
127    return { text: `Added to ${current.project} Next: ${item}` }
128  })
129
130  on('command.run', { command: 'backlog-band' }, async $ => {
131    const hidden = await update($, isHidden, was => !was)
132    return { text: hidden ? 'Backlog band hidden.' : 'Backlog band shown.' }
133  })
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const current = await read($, backlog)
137    if (e.props.hasSurvey || current === null || current.now.length === 0) return next(e)
138    if (await read($, isHidden)) return next(e)
139
140    const { Box, Text } = $.ui.resolve(e)
141    const room = Math.max(1, Math.min(MAX_ITEMS, e.props.maxRows - 2))
142    const shown = current.now.slice(0, room)
143    const more = current.now.length - shown.length
144
145    return (
146      <Box flexDirection="column" width={e.props.bodyColumns}>
147        <Text dimColor wrap="truncate-end">
148          {current.project} · Now{more > 0 ? ` (+${more} more)` : ''}
149        </Text>
150        {shown.map(item => (
151          <Text wrap="truncate-end">
152            {'  ▸ '}
153            {item}
154          </Text>
155        ))}
156      </Box>
157    )
158  })
159}
160
types/index.d.ts 15 lines
1export type Backlog = {
2  /** Absolute path of the BACKLOG.md this band reads. */
3  path: string
4  /** The project folder's name, shown as the band's label. */
5  project: string
6  /** Open items under `## Now`, text only. */
7  now: string[]
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'backlog-band': { backlog: Backlog | null; isHidden: boolean }
13  }
14}
15