SLOPSHOPPER

jr-fleet

Every Claude Code session on this machine at a glance: which ones are working and which ones wait for you, in a band above the prompt (/jrfleet hides or shows…

newpanebandcommandpromptprocess
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-fleet
│ ┃ jr-fleet ✕ › fix the failing auth test and add an audit log call │ ┃ jr fleet 0 sessions · 0 working · 0 │ ┃ waiting for you ⏺ 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 │ │ › /jrfleet │ ⎿ jr-fleet: jr fleet · hidden (/jrfleet to bring it back) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-fleet
jr fleet 0 sessions · 0 working · 0 waiting for you
README

jr-fleet

When several Claude Code sessions run at once, jr-fleet tells you which ones are working and which ones are waiting for you, without switching windows.

A quiet row on the right above the prompt names what each session works on, this one first (● working, ○ waiting or idle):

                                        tokenkarma ○ · acme ● · northwind ○

When a session finished and waits for your next prompt, the row moves to the left and says so:

▲ acme waits for you · 3 sessions

/jrfleet details lists them:

jr fleet   3 sessions · 1 working · 1 waiting for you
● acme                   waiting for you    4 min   ~/Code/clients/acme
● tokenkarma             working            now     ~/Code/brands/tokenkarma   (this one)
● northwind            idle              52 min   ~/Code/clients/northwind
  • Working: Claude is on a turn. Waiting for you: Claude finished and nobody has sent the next prompt. Idle: waiting for more than 30 minutes.
  • The row is always there, even with a single session, as a reminder of what you work on. Sessions that do not fit become +3.
  • Each session needs jr-fleet installed (it is a user-scope plugin, so every new session has it).

How it works

Every session with jr-fleet writes a small file to ~/.claude/jr-fleet/<session id>.json every 15 seconds: folder, project, state, since when. Each reads the others' files. A file not updated for a minute is a closed session; files older than a day are deleted, and a session removes its own file when it ends.

Commands

Command
/jrfleetHide the band above the prompt, or bring it back
/jrfleet hide · showHide it or show it explicitly (off and on work too)
/jrfleet detailsList every session on the machine

Install

/plugin install jr-fleet --marketplace jrlprost/claude-code-mods

Access

Reads and writes files in ~/.claude/jr-fleet/ only, and runs rm on that folder's stale files. Reads the session's id and folder. Sends nothing over the network.

Source 5 files
hooks/register.tsx 220 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrFleetBeat, JrFleetState } from '../types'
5import { CLEAN_AFTER_MS, fitSessions, liveSessions, selfFirst, shortName, shortPath, since, summary } from './fleet'
6import { gradientText } from './jr/draw'
7import { GRADIENTS } from './jr/gradient'
8
9const PANE = 'jr-fleet'
10const BEAT_MS = 15_000
11const fleet = atom({ plugin: 'jr-fleet', key: 'fleet' } as const, null)
12const bandHidden = atom({ plugin: 'jr-fleet', key: 'isBandHidden' } as const, false)
13const band = { isLoaded: false }
14
15/** `hide`/`off`, `show`/`on`, or a bare command, which toggles; anything else is not about the band. */
16const bandVerb = (args: string) => {
17  const arg = args.trim().toLowerCase()
18  if (arg === 'hide' || arg === 'off') return 'hide' as const
19  if (arg === 'show' || arg === 'on') return 'show' as const
20  return arg === '' ? ('toggle' as const) : null
21}
22
23/** Hidden or shown is one choice for every session, kept across restarts. */
24async function isBandHidden($: EngineInterface) {
25  if (!band.isLoaded) {
26    band.isLoaded = true
27    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
28  }
29  return read($, bandHidden)
30}
31
32async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
33  const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
34  await $.store.set('isBandHidden', hide)
35  await update($, bandHidden, () => hide)
36  return { text: hide ? 'jr fleet · hidden (/jrfleet to bring it back)' : 'jr fleet · shown' }
37}
38const live: { me: JrFleetBeat | null; dir: string; home: string; isBeating: boolean } = { me: null, dir: '', home: '', isBeating: false }
39
40async function ensureMe($: EngineInterface) {
41  if (live.me) return live.me
42  live.home = (await $.env.get('HOME')) ?? ''
43  live.dir = `${live.home}/.claude/jr-fleet`
44  const now = await $.clock.now()
45  const cwd = await $.session.cwd()
46  const root = await $.session.root()
47  live.me = { id: await $.session.id(), cwd, project: root.split('/').pop() || root, state: 'waiting', since: now, lastPrompt: null, beat: now }
48  return live.me
49}
50
51/** Reads every session's heartbeat, keeps the live ones, deletes day-old files. */
52async function scan($: EngineInterface) {
53  const now = await $.clock.now()
54  const beats: JrFleetBeat[] = []
55  const old: string[] = []
56  let entries: { name: string; kind: string; mtimeMs: number }[] = []
57  try {
58    entries = await $.fs.list(live.dir)
59  } catch {
60    entries = []
61  }
62  for (const entry of entries) {
63    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
64    if (now - entry.mtimeMs > CLEAN_AFTER_MS) {
65      old.push(`${live.dir}/${entry.name}`)
66      continue
67    }
68    try {
69      beats.push(JSON.parse(await $.fs.read(`${live.dir}/${entry.name}`)) as JrFleetBeat)
70    } catch {
71      // A file mid-write: skip it this round.
72    }
73  }
74  if (old.length) await $.process.run(['rm', '-f', ...old])
75  const sessions = liveSessions(beats, now)
76  await update($, fleet, () => ({ sessions, self: live.me?.id ?? '', home: live.home, at: now }))
77}
78
79async function beat($: EngineInterface) {
80  const me = await ensureMe($)
81  me.beat = await $.clock.now()
82  await $.fs.write(`${live.dir}/${me.id}.json`, JSON.stringify(me))
83  await scan($)
84}
85
86async function setState($: EngineInterface, state: JrFleetState) {
87  const me = await ensureMe($)
88  const now = await $.clock.now()
89  if (state === 'working') me.lastPrompt = now
90  if (me.state !== state) {
91    me.state = state
92    me.since = now
93  }
94  await beat($)
95}
96
97async function ensureBeating($: EngineInterface) {
98  if (live.isBeating) return
99  live.isBeating = true
100  await $.command.register({ name: 'jrfleet', description: 'Show or hide the fleet band; /jrfleet details lists every session' })
101  await beat($)
102  $.clock.every(BEAT_MS, () => {
103    void beat($)
104  })
105}
106
107const COLORS: Record<JrFleetState, string> = { working: '#7f5af0', waiting: '#ffb86b', idle: '#5c5f77' }
108const WORDS: Record<JrFleetState, string> = { working: 'working', waiting: 'waiting for you', idle: 'idle' }
109
110export const register: Register = on => {
111  on('session.start', async ($, e, next) => {
112    const started = await next(e)
113    await ensureBeating($)
114    return started
115  })
116
117  on('prompt.submit', async ($, e, next) => {
118    await ensureBeating($)
119    if (!e.command && !e.isMeta) await setState($, 'working')
120    return next(e)
121  })
122
123  on('turn.complete', async ($, e, next) => {
124    const done = await next(e)
125    if (e.agentId === undefined) await setState($, 'waiting')
126    return done
127  })
128
129  on('session.end', async ($, e, next) => {
130    if (live.me) await $.process.run(['rm', '-f', `${live.dir}/${live.me.id}.json`])
131    return next(e)
132  })
133
134  on('command.run', { command: 'jrfleet' }, async ($, e) => {
135    const verb = bandVerb(e.args)
136    if (verb) return setBand($, verb)
137    await ensureBeating($)
138    await scan($)
139    await $.ui.open({ id: PANE, title: 'jr fleet', rows: 14 })
140    return { text: 'jr fleet opened.' }
141  })
142
143  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144    const { Box, Text } = $.ui.resolve(e)
145    const f = await read($, fleet)
146    if (!f) return <Text dimColor>Looking for sessions…</Text>
147    const s = summary(f.sessions)
148    return (
149      <Box flexDirection="column">
150        <Text>
151          {gradientText(Text, 'jr fleet', GRADIENTS.brand)}
152          <Text dimColor>{`   ${s.total} session${s.total === 1 ? '' : 's'} · ${s.working} working · `}</Text>
153          <Text color={s.waiting ? '#ffb86b' : '#6b7089'} bold={s.waiting > 0}>{`${s.waiting} waiting for you`}</Text>
154        </Text>
155        {f.sessions.map(x => (
156          <Text wrap="truncate-end">
157            <Text color={COLORS[x.state]}>{'● '}</Text>
158            <Text bold>{x.project.slice(0, 22).padEnd(23)}</Text>
159            <Text color={COLORS[x.state]}>{WORDS[x.state].padEnd(16)}</Text>
160            <Text dimColor>{since(f.at - x.since).padStart(6)}</Text>
161            <Text dimColor>{`   ${shortPath(x.cwd, f.home)}`}</Text>
162            {x.id === f.self && <Text color="#ff5ca8">{'   (this one)'}</Text>}
163          </Text>
164        ))}
165      </Box>
166    )
167  })
168
169  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170    const below = await next(e)
171    // Never write state while drawing: the saved choice is loaded just after.
172    if (!band.isLoaded) {
173      $.clock.after(0, () => {
174        void isBandHidden($)
175      })
176    }
177    if (await read($, bandHidden)) return below
178    const f = await read($, fleet)
179    if (!f || f.sessions.length === 0 || e.props.hasSurvey) return below
180    const { Box, Text } = $.ui.resolve(e)
181    const waiting = f.sessions.filter(x => x.id !== f.self && x.state === 'waiting')
182    const count = ` · ${f.sessions.length} session${f.sessions.length === 1 ? '' : 's'}`
183    // A session waiting for you is the one thing worth the eye: on the left, in colour.
184    if (waiting.length > 0) {
185      return (
186        <Box flexDirection="column">
187          <Box>
188            <Text wrap="truncate-end">
189              <Text color="#ff8c42">{'▲ '}</Text>
190              <Text>{waiting.map(x => shortName(x.project)).join(', ')}</Text>
191              <Text color="#ffb86b">{waiting.length === 1 ? ' waits for you' : ' wait for you'}</Text>
192              <Text dimColor>{count}</Text>
193            </Text>
194          </Box>
195          {below}
196        </Box>
197      )
198    }
199    // Otherwise a quiet row on the right: what each session works on.
200    const { shown, more } = fitSessions(selfFirst(f.sessions, f.self), Math.max(20, e.props.bodyColumns - 2))
201    return (
202      <Box flexDirection="column">
203        <Box justifyContent="flex-end">
204          <Text wrap="truncate-end">
205            {shown.map((x, i) => (
206              <Text>
207                {i > 0 && <Text dimColor>{' · '}</Text>}
208                <Text color={x.id === f.self ? '#ff5ca8' : undefined}>{shortName(x.project)}</Text>
209                <Text color={x.state === 'working' ? '#7f5af0' : '#5c5f77'}>{x.state === 'working' ? ' ●' : ' ○'}</Text>
210              </Text>
211            ))}
212            {more > 0 && <Text dimColor>{` · +${more}`}</Text>}
213          </Text>
214        </Box>
215        {below}
216      </Box>
217    )
218  })
219}
220
hooks/fleet.ts 56 lines
1import type { JrFleetBeat, JrFleetState } from '../types'
2
3/** A heartbeat older than this means the session is gone. */
4export const STALE_MS = 60_000
5/** Waiting longer than this reads as idle. */
6export const IDLE_AFTER_MS = 30 * 60_000
7/** Heartbeat files older than this are deleted. */
8export const CLEAN_AFTER_MS = 24 * 3_600_000
9
10export const stateOf = (beat: JrFleetBeat, now: number): JrFleetState =>
11  beat.state === 'waiting' && now - beat.since > IDLE_AFTER_MS ? 'idle' : beat.state
12
13/** Sessions still beating: waiting first (oldest wait first), then working, then idle. */
14export const liveSessions = (beats: readonly JrFleetBeat[], now: number) => {
15  const order: Record<JrFleetState, number> = { waiting: 0, working: 1, idle: 2 }
16  return beats
17    .filter(b => now - b.beat < STALE_MS)
18    .map(b => ({ ...b, state: stateOf(b, now) }))
19    .sort((a, b) => order[a.state] - order[b.state] || a.since - b.since)
20}
21
22export const summary = (sessions: readonly { state: JrFleetState }[]) => ({
23  total: sessions.length,
24  waiting: sessions.filter(s => s.state === 'waiting').length,
25  working: sessions.filter(s => s.state === 'working').length,
26})
27
28export const since = (ms: number) => {
29  const m = Math.floor(ms / 60_000)
30  if (m < 1) return 'now'
31  if (m < 60) return `${m} min`
32  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
33}
34
35export const shortPath = (cwd: string, home: string) => (home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd)
36
37/** This session first, then the others in liveSessions' order. */
38export const selfFirst = <T extends { id: string }>(sessions: readonly T[], self: string) => [...sessions.filter(s => s.id === self), ...sessions.filter(s => s.id !== self)]
39
40const NAME_MAX = 20
41export const shortName = (project: string) => (project.length > NAME_MAX ? `${project.slice(0, NAME_MAX - 1)}…` : project)
42
43/** The sessions that fit in `columns` as `name ●` joined by ` · `, and how many are left for a `+N`. */
44export const fitSessions = <T extends { project: string }>(sessions: readonly T[], columns: number) => {
45  const shown: T[] = []
46  let width = 0
47  for (const [i, s] of sessions.entries()) {
48    const w = shortName(s.project).length + 2 + (shown.length ? 3 : 0)
49    const tail = i < sessions.length - 1 ? ` · +${sessions.length - i - 1}`.length : 0
50    if (shown.length && width + w + tail > columns) break
51    shown.push(s)
52    width += w
53  }
54  return { shown, more: sessions.length - shown.length }
55}
56
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
types/index.d.ts 22 lines
1/** working: Claude is on a turn. waiting: Claude finished and waits for your next prompt. idle: waiting for over 30 minutes. */
2export type JrFleetState = 'working' | 'waiting' | 'idle'
3
4/** What each session writes to ~/.claude/jr-fleet/<session id>.json every 15 seconds. */
5export type JrFleetBeat = {
6  id: string
7  cwd: string
8  project: string
9  state: JrFleetState
10  /** When the session entered its state. */
11  since: number
12  lastPrompt: number | null
13  /** When the file was last written. */
14  beat: number
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'jr-fleet': { fleet: { sessions: JrFleetBeat[]; self: string; home: string; at: number } | null; isBandHidden: boolean }
20  }
21}
22