Shows the running Claude sessions on this machine above the prompt

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.
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.
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.
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.
| Plugin | What it does |
|---|---|
usage-ring | Two 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-board | A 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. |
| Label | Chip | Meaning |
|---|---|---|
Wk | limits | Weekly limit used, in percent. |
Se | limits | Session 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. |
Cx | chat | Context window used, in percent. |
Td | chat | Todos done out of all, e.g. 3/5. Only while the chat has a todo list. |
Tk | chat | Tokens this chat used since the session started (input, output, cache reads and writes, subagents included). Grows after every model request. |
Co | chat | What the session cost so far, in US dollars. |
Ca | chat | Time 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.
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).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.usage-ring has one option, off by default:
| Option | Meaning |
|---|---|
limitsFile | Write 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
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/.claude plugin validate ., then claude plugin validate plugins/<name> and claude plugin test plugins/<name>./reload-plugins or the next session.next(e) and keeps what is beneath (session-board on top, usage-ring next to the prompt).MIT, see LICENSE.
hooks/register.tsx 128 lines1import { 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}
128hooks/listing.ts 76 lines1// 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}
76hooks/color.ts 13 lines1// 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}
13types/index.d.ts 24 lines1export 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