SLOPSHOPPER

session-board

Shows the running Claude sessions on this machine above the prompt

newbandtimer
A shopper browsing a rack in a slop shop
README

claude-mods

Mods for the Claude Code terminal: a band of rings above the prompt that shows your limits, this chat's usage and the prompt cache, plus a board of your running Claude sessions.

Quick install

Copy this prompt into Claude Code:

Install the Claude Code plugins from https://github.com/Oualid0/claude-mods:
add the repo as a plugin marketplace, then install every plugin listed in its
.claude-plugin/marketplace.json, and tell me to run /reload-plugins when done.

Manual install

claude plugin marketplace add Oualid0/claude-mods
claude plugin install usage-ring@claude-mods
claude plugin install session-board@claude-mods

Then run /reload-plugins in Claude Code, or start a new session. From a local clone, ./install.sh does the same (it needs python3) and is safe to run again.

Update

Auto-update is off by default. Update by hand with /plugin marketplace update claude-mods in a session, or claude plugin update usage-ring@claude-mods and claude plugin update session-board@claude-mods in the shell. You can also turn on Enable auto-update for the marketplace under Marketplaces in /plugin.

Mods

PluginWhat it does
usage-ringTwo chips right above the prompt: limits and chat, with a pixel Claude beside them that hammers while a turn runs and sleeps otherwise, and the model in grey next to it, e.g. Opus 5.5 (mid) (effort low, mid, high, xhigh or max, known from the first request on).
session-boardA sessions chip above that while other Claude sessions on this machine are running: one row each (● running, ✓ done for 60 s after it finished), plus your own row (○ ready or ● running) marked ←. With no other session running, the board is hidden.

What the labels mean

LabelChipMeaning
WklimitsWeekly limit used, in percent.
SelimitsSession limit (the 5-hour window) used, and the time until it resets: 4:50h, or 33m under an hour. Once the window is over it shows 0% 5:00h until the next reading.
CxchatContext window used, in percent.
TdchatTodos done out of all, e.g. 3/5. Only while the chat has a todo list.
TkchatTokens this chat used since the session started (input, output, cache reads and writes, subagents included). Grows after every model request.
CochatWhat the session cost so far, in US dollars.
CachatTime left on the prompt cache (33m), expired once it lapsed.

When the terminal is narrow, the least important goes first: the model label, the words limits and chat, Ca, Co, Tk, Td, Wk, the pixel Claude, then Cx. Se stays longest; if not even it fits, the band is hidden. Nothing is squeezed or wrapped.

Limits

  • Needs a Claude Code version with mods (function-hook plugins); tested with Claude Code 2.1.288. The mod API is early access and may change between versions.
  • Rings are pixel images in kitty and Ghostty; other terminals show a glyph instead.
  • Rings for the week, session and context turn red from 95%, and the cache time Ca turns red in its last 3 minutes.
  • Ca is an estimate. Claude Code does not tell plugins how long the cache lives (5 minutes or 1 hour), so the band assumes 1 hour and learns from what each request read from the cache: a hit after a pause of more than 5 minutes means 1 hour, a miss means 5 minutes. A miss can also come from a changed system prompt, which the band cannot see.
  • Tk starts at 0 when a session starts or resumes; earlier tokens of a resumed session are not available. Co starts with the session's cost.
  • Td counts TaskCreate/TaskUpdate/TaskList and TodoWrite. Newer models only have these tools with CLAUDE_CODE_ENABLE_TODO_TOOLS=1 (docs).
  • The board knows other sessions only as busy or idle; "needs input" is not available. Sessions without a name are hidden.
  • The board reads the session list from the ListAgents tool every 5 s. Its output is text for the model, not a fixed format: if a Claude Code update changes it, the board stays empty instead of showing an error.

Options

usage-ring has one option, off by default:

OptionMeaning
limitsFileWrite the session and weekly limits to $CLAUDE_CONFIG_DIR/usage-limits.json (default ~/.claude) for other tools to read.

Set it with /plugin configure usage-ring@claude-mods in Claude Code, or:

echo '{"limitsFile":"true"}' | claude plugin configure usage-ring@claude-mods --values-stdin

Development

  • Each plugin lives in plugins/<name>/: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx with the hooks, pure logic in files beside it, types/index.d.ts (the state contract) and tests/.
  • Check: claude plugin validate ., then claude plugin validate plugins/<name> and claude plugin test plugins/<name>.
  • Installed from a local clone, Claude Code reads the files in place: changes apply with /reload-plugins or the next session.
  • Both mods draw into the same band above the prompt; each render hook calls next(e) and keeps what is beneath (session-board on top, usage-ring next to the prompt).

License

MIT, see LICENSE.

Source 4 files
hooks/register.tsx 128 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board } from '../types'
5import { DONE_MS, parseListing, prune, reconcile } from './listing'
6import { hot } from './color'
7
8const board = atom({ plugin: 'session-board', key: 'board' } as const, { peers: [] } as Board)
9const selfStatus = atom(
10  { plugin: 'session-board', key: 'selfStatus' } as const,
11  'idle' as 'busy' | 'idle',
12)
13
14const POLL_MS = 5_000
15
16// The "hot" (whitened) tones of an ochre and a sage, as usage-ring lightens its numbers.
17const LABEL = {
18  busy: { glyph: '●', word: 'running', color: hot('#e0a458') },
19  done: { glyph: '✓', word: 'done', color: hot('#9cae86') },
20  idle: { glyph: '○', word: 'ready', color: hot('#9cae86') },
21} as const
22
23/** The status words share one column. */
24const WORD_WIDTH = Math.max(...Object.values(LABEL).map(l => l.word.length))
25
26/** Frame and label of the board's chip: Claude's terracotta. */
27const CHIP_COLOR = '#d97757'
28
29// When the poll still waiting on ListAgents started; the next tick skips it
30// instead of piling up, unless that call has hung for longer than POLL_STUCK_MS.
31let pollingSince: number | undefined
32const POLL_STUCK_MS = 30_000
33
34async function poll($: EngineInterface): Promise<void> {
35  const now = await $.clock.now()
36  if (pollingSince !== undefined && now - pollingSince < POLL_STUCK_MS) return
37  pollingSince = now
38  try {
39    await pollOnce($)
40  } finally {
41    pollingSince = undefined
42  }
43}
44
45async function pollOnce($: EngineInterface): Promise<void> {
46  let listing: string | undefined
47  try {
48    const r = await $.tool.call({ tool: 'ListAgents' })
49    listing = (r.result as { listing?: string } | undefined)?.listing ?? r.text
50  } catch {
51    return
52  }
53  if (listing === undefined) return
54  const { self, peers } = parseListing(listing)
55  const now = await $.clock.now()
56  const shown = (await read($, board)).peers
57  const next = reconcile(shown, peers, now)
58  await update($, board, () => ({ self, peers: next.peers }))
59  if (next.finished > 0) {
60    // Hide the finished rows on time rather than at the next poll.
61    $.clock.after(DONE_MS, () => {
62      void $.clock.now().then(t => update($, board, b => ({ ...b, peers: prune(b.peers, t) })))
63    })
64  }
65}
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    const result = await next(e)
70    await poll($)
71    $.clock.every(POLL_MS, () => {
72      void poll($)
73    })
74    return result
75  })
76
77  on('turn.start', async ($, e, next) => {
78    await update($, selfStatus, () => 'busy')
79    return next(e)
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    if (e.agentId === undefined) await update($, selfStatus, () => 'idle')
84    return next(e)
85  })
86
87  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
89
90    const { self, peers } = await read($, board)
91    if (peers.length === 0) return next(e)
92
93    const rows = [
94      { key: 'self', name: self ?? 'this session', label: LABEL[await read($, selfStatus)], isSelf: true },
95      ...peers.map(p => ({ key: p.ref, name: p.name, label: LABEL[p.status], isSelf: false })),
96    ]
97    const nameWidth = Math.min(40, Math.max(...rows.map(r => r.name.length)))
98
99    // The board sits on top; other mods' drawings (the rings) go below it.
100    const below = await next(e)
101    const { Box, Text } = $.ui.resolve(e)
102    // One chip as wide as its content, the sessions a row each beside the label.
103    return (
104      <Box flexDirection="column">
105        <Box alignSelf="flex-start" borderStyle="round" borderColor={CHIP_COLOR} paddingX={1} flexDirection="row" gap={1}>
106          <Text color={CHIP_COLOR}>sessions</Text>
107          <Box flexDirection="column">
108            {rows.map(r => (
109              <Box key={r.key} flexDirection="row" gap={2} alignItems="center">
110                <Text color={r.label.color} bold>
111                  {r.label.glyph} {r.label.word.padEnd(WORD_WIDTH)}
112                </Text>
113                <Box width={nameWidth}>
114                  <Text bold={r.isSelf} wrap="truncate-end">
115                    {r.name}
116                  </Text>
117                </Box>
118                <Text color={CHIP_COLOR}>{r.isSelf ? '←' : ' '}</Text>
119              </Box>
120            ))}
121          </Box>
122        </Box>
123        {below}
124      </Box>
125    )
126  })
127}
128
hooks/listing.ts 76 lines
1// Parses the text ListAgents answers and decides which peers the board shows.
2// The listing is written for the model, not a fixed format: anything the
3// parser does not recognise is skipped. ListAgents tells only busy from idle;
4// a closed session stays listed as idle, so idle counts as finished.
5
6import type { Peer } from '../types'
7
8/** How long a finished peer stays on the board. */
9export const DONE_MS = 60_000
10
11export type Listed = { ref: string; name: string; status: 'busy' | 'idle' | 'unknown' }
12
13const SELF = /^This session is (.+?) \[[0-9a-f]+\]/m
14const PEER = /^\s+(.+?) \[([0-9a-f]+)\]((?:\s+·\s+.*)?)$/
15/** A session nobody named yet is listed under a bare hex id (`e900c72c`). */
16const UNNAMED = /^[0-9a-f]{6,}$/
17
18export function parseListing(listing: string): { self?: string; peers: Listed[] } {
19  const self = SELF.exec(listing)?.[1]
20  const peers: Listed[] = []
21  let inPeers = false
22  for (const line of listing.split('\n')) {
23    if (/^Peer sessions \(\d+\):/.test(line)) {
24      inPeers = true
25      continue
26    }
27    if (!inPeers) continue
28    if (!/^\s/.test(line)) {
29      inPeers = false
30      continue
31    }
32    const m = PEER.exec(line)
33    if (!m) continue
34    const parts = (m[3] ?? '').split('·').map(p => p.trim())
35    peers.push({
36      name: m[1] ?? '',
37      ref: m[2] ?? '',
38      status: parts.includes('busy') ? 'busy' : parts.includes('idle') ? 'idle' : 'unknown',
39    })
40  }
41  return { self, peers }
42}
43
44/**
45 * The peers to show after a new listing: running ones, plus the ones that went
46 * from busy to idle within `DONE_MS`. Unnamed sessions never show.
47 */
48export function reconcile(
49  shown: readonly Peer[],
50  listed: readonly Listed[],
51  now: number,
52): { peers: Peer[]; finished: number } {
53  const before = new Map(shown.map(p => [p.ref, p]))
54  const peers: Peer[] = []
55  let finished = 0
56  for (const l of listed) {
57    if (UNNAMED.test(l.name)) continue
58    const was = before.get(l.ref)
59    if (l.status === 'busy') {
60      peers.push({ ref: l.ref, name: l.name, status: 'busy' })
61    } else if (was?.status === 'busy') {
62      peers.push({ ref: l.ref, name: l.name, status: 'done', finishedAt: now })
63      finished += 1
64    } else if (was?.status === 'done' && now - (was.finishedAt ?? 0) < DONE_MS) {
65      // Keep when it finished, but take the name as listed now: a /rename shows at once.
66      peers.push({ ...was, name: l.name })
67    }
68  }
69  return { peers, finished }
70}
71
72/** Drops finished peers whose `DONE_MS` ran out. */
73export function prune(peers: readonly Peer[], now: number): Peer[] {
74  return peers.filter(p => p.status === 'busy' || now - (p.finishedAt ?? 0) < DONE_MS)
75}
76
hooks/color.ts 13 lines
1// The hot (whitened) tone usage-ring gives its numbers, for the board's labels.
2
3/** How far toward white the hot tone goes. */
4const HOT = 0.35
5
6/** `hex` moved HOT of the way to white. */
7export function hot(hex: string): string {
8  const n = Number.parseInt(hex.replace('#', ''), 16)
9  return `#${[(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
10    .map(c => Math.round(c + (255 - c) * HOT).toString(16).padStart(2, '0'))
11    .join('')}`
12}
13
types/index.d.ts 24 lines
1export type Status = 'busy' | 'done'
2
3export type Peer = {
4  /** The short ref ListAgents prints in brackets. */
5  ref: string
6  name: string
7  status: Status
8  /** When it went from busy to idle, on `$.clock.now()`'s scale. */
9  finishedAt?: number
10}
11
12export type Board = {
13  /** This session's name as ListAgents reports it. */
14  self?: string
15  /** Only running peers and those that finished in the last few seconds. */
16  peers: Peer[]
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'session-board': { board: Board; selfStatus: 'busy' | 'idle' }
22  }
23}
24