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…

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
+3.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.
| Command | |
|---|---|
/jrfleet | Hide the band above the prompt, or bring it back |
/jrfleet hide · show | Hide it or show it explicitly (off and on work too) |
/jrfleet details | List every session on the machine |
/plugin install jr-fleet --marketplace jrlprost/claude-code-mods
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.
hooks/register.tsx 220 lines1import { 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}
220hooks/fleet.ts 56 lines1import 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}
56hooks/jr/draw.tsx 35 lines1// 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)
35hooks/jr/gradient.ts 75 lines1// 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}
75types/index.d.ts 22 lines1/** 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