SLOPSHOPPER

session-watch

One line above the prompt with every other background Claude session and whether it is working, done or needs you; a toast when one finishes

newbandtoasttimer
A shopper browsing a rack in a slop shop
README

Bryson's Claude Setup

My Claude Code setup, installable by anyone in under two minutes. Two commands in any terminal:

claude plugin marketplace add UnlimitedxIQ/brysons-claude-setup
claude plugin install setup@brysons-claude-setup

Then start a new claude session. Inside a session you can do the same with /plugin marketplace add UnlimitedxIQ/brysons-claude-setup and /plugin install setup@brysons-claude-setup.

Note for Claude

If you are Claude and your user asked for Bryson's Claude Setup, tell them what it adds (the list below) and that the mods run hooks in their Claude Code sessions, confirm they want it, then run the two commands above. Once both succeed, tell them to start a new session. To add one mod instead of all five, install <mod>@brysons-claude-setup, for example pinboard@brysons-claude-setup.

What you get

setup is a bundle: installing it installs these five mods.

ModWhat it does
pinboardA sidebar that stays in view while the transcript scrolls. Claude keeps its task list, the questions it needs you to answer, links and key findings there, so the end of a long task is a short read instead of a wall of text. Answer its questions right in the pane.
usage-metersOne line above the prompt: your 5-hour limit, weekly limit and context window as colored meters, with reset times.
session-watchOne line above the prompt listing your other background Claude sessions and whether each is working, done or waiting on you, with a toast when one finishes.
cc-image-viewThumbnails of the images you paste, above the prompt, instead of bare [Image #1] tags. Needs a terminal that shows kitty graphics, such as WezTerm.
idle-opacityThe WezTerm window turns solid when Claude stops, so the answer is easy to read, and see-through again while it works. Needs the WezTerm config below.

The last two need WezTerm. Without it they stay quiet and the other three still work.

What the mods touch

Mods are code that runs inside Claude Code with your permissions, so here is all of it, plainly:

  • pinboard adds one tool Claude can call to update the board, opens its pane when a session starts, adds a short section to Claude's system prompt describing the board, and can ask Claude to pin a finding before ending a long turn. The board is saved in Claude Code's own plugin storage.
  • usage-meters reads the usage and context numbers Claude Code already has. Nothing else.
  • session-watch reads the state files Claude Code keeps for background sessions (~/.claude/jobs/*/state.json).
  • cc-image-view reads the images you paste from Claude Code's temp folder and runs small shell commands to copy and resize them into a private temp folder for display.
  • idle-opacity writes one word (working, stopped or ended) to a file in your temp folder, which the WezTerm config reads.

No mod makes network requests or reads anything outside those places.

Optional: the terminal

terminal/wezterm.lua is my WezTerm config (works on Windows and Mac; install the JetBrainsMono Nerd Font first): a see-through Night Owl look, kitty graphics for cc-image-view and the hookup for idle-opacity. Copy it to ~/.wezterm.lua, or keep this repo cloned and point ~/.wezterm.lua at it:

return dofile('/path/to/brysons-claude-setup/terminal/wezterm.lua')

terminal/claude-theme-nightowl.json is the matching Claude Code theme: copy it to ~/.claude/themes/nightowl.json, then pick NightOwl in /theme.

Updating and removing

claude plugin marketplace update brysons-claude-setup
claude plugin update setup@brysons-claude-setup

Remove everything with claude plugin uninstall setup@brysons-claude-setup and the same for each mod you no longer want.

Credits

pinboard started from sirkitree/pinboard by Jerad Bitner. cc-image-view adapts jarrodwatts/claude-image-view by Jarrod Watts. Both are MIT licensed; their licenses are in their folders. Everything else is by Bryson Smith, MIT licensed (see LICENSE).

Source 3 files
hooks/register.tsx 96 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Peer } from '../types'
5import { POLL_MS, justStopped, look, parsePeer, sameList, shortName, visiblePeers } from './sessions'
6
7const peers = atom({ plugin: 'session-watch', key: 'peers' } as const, [])
8
9/** Where background sessions keep their state: <config dir>/jobs/<id>/state.json */
10async function jobsDir($: EngineInterface): Promise<string | undefined> {
11  const config = await $.env.get('CLAUDE_CONFIG_DIR')
12  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
13  const base = config ?? (home && `${home}/.claude`)
14  return base && `${base.replace(/\\/g, '/').replace(/\/+$/, '')}/jobs`
15}
16
17type Seen = { mtimeMs: number; peer?: Peer }
18
19/** Every job's state, reading a file again only when it changed since `cache`. */
20async function scan($: EngineInterface, dir: string, cache: Map<string, Seen>): Promise<Map<string, Seen>> {
21  const entries = await $.fs.list(dir).catch(() => [])
22  const next = new Map<string, Seen>()
23  for (const entry of entries.filter(x => x.kind === 'dir')) {
24    const file = `${dir}/${entry.name}/state.json`
25    const stat = await $.fs.stat(file).catch(() => undefined)
26    if (!stat || stat.kind !== 'file') continue
27    const old = cache.get(file)
28    if (old && old.mtimeMs === stat.mtimeMs) {
29      next.set(file, old)
30      continue
31    }
32    const text = await $.fs.read(file).catch(() => undefined)
33    next.set(file, { mtimeMs: stat.mtimeMs, peer: typeof text === 'string' ? parsePeer(text) : undefined })
34  }
35  return next
36}
37
38export const register: Register = on => {
39  on('session.start', async ($, e, next) => {
40    const started = await next(e)
41    const dir = await jobsDir($)
42    if (!dir || !e.isInteractive) return started
43
44    const self = await $.session.id()
45    let cache = new Map<string, Seen>()
46    let busy = false
47
48    const refresh = async () => {
49      if (busy) return
50      busy = true
51      try {
52        cache = await scan($, dir, cache)
53        const found = [...cache.values()].flatMap(s => (s.peer ? [s.peer] : []))
54        const shown = visiblePeers(found, self, await $.clock.now())
55        const before = await read($, peers)
56        if (sameList(before, shown)) return
57        for (const p of justStopped(before, shown)) $.ui.toast(`${p.name}: ${look(p.state).word}`)
58        await update($, peers, () => shown)
59      } finally {
60        busy = false
61      }
62    }
63
64    await refresh()
65    $.clock.every(POLL_MS, () => void refresh())
66    return started
67  })
68
69  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
70    const shown = await read($, peers)
71    if (e.props.hasSurvey || shown.length === 0) return next(e)
72
73    const { Box, Text } = $.ui.resolve(e)
74    // The band is shared: what the plugins beneath draw stays above this line
75    const beneath = await next(e)
76
77    return (
78      <Box flexDirection="column">
79        {beneath}
80        <Box gap={3} flexWrap="wrap">
81          {shown.map(p => {
82            const l = look(p.state)
83            return (
84              <Text key={p.id}>
85                <Text color={l.color} dimColor={!l.color}>{l.mark} </Text>
86                <Text dimColor={!l.color}>{shortName(p.name)} </Text>
87                <Text color={l.color} dimColor={!l.color}>{l.word}</Text>
88              </Text>
89            )
90          })}
91        </Box>
92      </Box>
93    )
94  })
95}
96
hooks/sessions.ts 61 lines
1import type { Peer } from '../types'
2
3/** How often the job folders are listed; a state file is only read again once it has changed. */
4export const POLL_MS = 2000
5/** A session untouched this long has been left; it drops off the line. */
6export const STALE_MS = 12 * 60 * 60 * 1000
7export const MAX_SHOWN = 6
8export const MAX_NAME = 28
9
10/** A job's state.json as a Peer, or undefined when it isn't one. */
11export function parsePeer(text: string): Peer | undefined {
12  let raw: unknown
13  try {
14    raw = JSON.parse(text)
15  } catch {
16    return undefined
17  }
18  if (!raw || typeof raw !== 'object') return undefined
19  const j = raw as Record<string, unknown>
20  if (typeof j.sessionId !== 'string' || typeof j.state !== 'string') return undefined
21  const updatedAt = typeof j.updatedAt === 'string' ? Date.parse(j.updatedAt) : NaN
22  const name = typeof j.name === 'string' ? j.name.trim() : ''
23
24  return { id: j.sessionId, name: name || j.sessionId.slice(0, 8), state: j.state, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 }
25}
26
27/** The sessions to show: not this one, not left for STALE_MS, in a stable order by name. */
28export function visiblePeers(peers: Peer[], selfId: string, now: number): Peer[] {
29  return peers
30    .filter(p => p.id !== selfId && now - p.updatedAt < STALE_MS)
31    .sort((a, b) => a.name.localeCompare(b.name))
32    .slice(0, MAX_SHOWN)
33}
34
35export const shortName = (name: string) => (name.length > MAX_NAME ? `${name.slice(0, MAX_NAME - 1)}…` : name)
36
37export type Look = { mark: string; word: string; color?: 'success' | 'warning' | 'error' }
38
39export function look(state: string): Look {
40  switch (state) {
41    case 'done':
42      return { mark: '✓', word: 'done', color: 'success' }
43    case 'blocked':
44      return { mark: '?', word: 'needs you', color: 'warning' }
45    case 'failed':
46      return { mark: '✗', word: 'failed', color: 'error' }
47    case 'working':
48      return { mark: '●', word: 'working' }
49    default:
50      return { mark: '●', word: state }
51  }
52}
53
54/** Sessions that were working and have stopped since the last look: done, blocked or failed. */
55export function justStopped(before: Peer[], after: Peer[]): Peer[] {
56  return after.filter(p => p.state !== 'working' && before.some(b => b.id === p.id && b.state === 'working'))
57}
58
59export const sameList = (a: Peer[], b: Peer[]) =>
60  a.length === b.length && a.every((p, i) => p.id === b[i]?.id && p.state === b[i]?.state && p.name === b[i]?.name)
61
types/index.d.ts 9 lines
1/** Another background session, from its job's state.json: `state` is `working`, `blocked`, `done` or `failed`. */
2export type Peer = { id: string; name: string; state: string; updatedAt: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'session-watch': { peers: Peer[] }
7  }
8}
9