SLOPSHOPPER

fleet

/fleet shows every live Claude Code session on this machine, and /send messages one.

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-02DominickGiordano/claude-mods/fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet
│ ┃ Fleet ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ * feat/auth-refresh idle 0s ctx 49% "f… │ fleet │ │ ⏺ Read(src/auth.ts) │ fleet: usage /send <session name or id │ │ ⎿ Read 6 lines │ prefix> <text> │ │ ⏺ 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 │ │ › /fleet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
* feat/auth-refresh idle 0s ctx 49% "fix the failing a…
README

fleet

Every live interactive Claude Code session sharing this Claude config dir, in one pane.

  • /fleet lists sessions grouped waiting, working, idle: repo, branch, context %, time in state, last prompt. * marks this session. Two sessions in the same checkout turn red.
  • The band shows sessions waiting on you (a permission prompt or a question), at most two then +N. Nothing shows when none wait.
  • /send <name or id prefix> <text> messages another session. An exact repo:branch wins over a prefix; an ambiguous match sends nothing. The pane's Send button does the same.

How it works

Each session writes its own $.store key every 15 s and on every state change; readers drop entries older than 60 s, show ones silent past 30 s as "last seen", and delete ones older than 10 minutes. A session that exits removes its key. Headless runs (-p, the SDK) write nothing but can still /send. If the store cannot be read, the pane says so and keeps the last rows as stale.

/send uses $.session.send: the message is queued at the other session as a cross-session message from the fleet plugin, and the model there reads it (mid-turn, inside the running turn). "Sent" means queued, not read.

"Waiting" starts when a tool call needs permission or AskUserQuestion opens, and ends when the call settles or the tool's run-in-background hint appears. In auto mode a classifier verdict can show as a brief wait.

Source 1 files
hooks/register.tsx 374 lines
1import type { EngineInterface, On, Timer } from 'claude-code'
2
3export type State = 'waiting' | 'working' | 'idle'
4
5export type Entry = {
6  id: string
7  name: string
8  repo: string
9  checkout: string
10  branch: string
11  isWorktree: boolean
12  state: State
13  since: number
14  contextPercent: number | null
15  lastPrompt: string
16  updatedAt: number
17}
18
19type Place = Pick<Entry, 'repo' | 'checkout' | 'branch' | 'isWorktree'>
20
21type Self = {
22  isHeadless: boolean
23  place?: Place
24  isTurn: boolean
25  asks: Set<string>
26  lastPrompt: string
27  mark?: { state: State; since: number }
28  ended: Set<string>
29  peers: Entry[]
30  bad: number
31  isUnreadable: boolean
32  goodAt: number
33  target?: Entry
34  timers: Timer[]
35  tickAt: number
36}
37
38export const PREFIX = 'fleet:'
39export const BEAT_MS = 15_000
40export const REFRESH_MS = 5_000
41export const LIVE_MS = 60_000
42export const PRUNE_MS = 600_000
43const PANE = 'fleet'
44const ORDER: readonly State[] = ['waiting', 'working', 'idle']
45const COLOR: Record<State, string> = { waiting: 'yellow', working: 'green', idle: 'gray' }
46
47const basename = (path: string) => path.split('/').filter(Boolean).pop() ?? path
48// Entries come from other processes; a stray escape would repaint the terminal.
49const clean = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
50const isStale = (age: number) => age < 0 || age > 2 * BEAT_MS
51
52export function ageOf(ms: number) {
53  const s = Math.max(0, Math.round(ms / 1000))
54  if (s < 60) return `${s}s`
55  if (s < 3600) return `${Math.floor(s / 60)}m`
56  return `${Math.floor(s / 3600)}h`
57}
58
59function isEntry(value: unknown): value is Entry {
60  if (typeof value !== 'object' || value === null) return false
61  const e = value as Record<string, unknown>
62  return (
63    ['id', 'name', 'repo', 'checkout', 'branch', 'lastPrompt'].every(k => typeof e[k] === 'string') &&
64    ORDER.includes(e.state as State) &&
65    Number.isFinite(e.updatedAt) &&
66    Number.isFinite(e.since) &&
67    typeof e.isWorktree === 'boolean' &&
68    (e.contextPercent === null || Number.isFinite(e.contextPercent))
69  )
70}
71
72// Exact beats prefix; two sessions on one branch stay ambiguous rather than guess.
73export function pick(peers: readonly Entry[], query: string) {
74  const exact = peers.filter(p => p.name === query || p.id === query)
75  if (exact.length > 0) return exact
76  return peers.filter(p => p.id.startsWith(query) || p.name.startsWith(query))
77}
78
79async function placeOf($: EngineInterface, cwd: string): Promise<Place> {
80  const dirs = await $.process.run(
81    ['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'],
82    { cwd },
83  )
84  if (dirs.exitCode !== 0) return { repo: basename(cwd), checkout: cwd, branch: '', isWorktree: false }
85  const [checkout = cwd, gitDir = '', commonDir = ''] = dirs.stdout.trim().split('\n')
86  const branch = await $.process.run(['git', 'branch', '--show-current'], { cwd })
87  return {
88    // The common dir names the main checkout, so a worktree reports its repo, not its folder.
89    repo: basename(commonDir.replace(/\/?\.git$/, '')),
90    checkout,
91    branch: branch.stdout.trim(),
92    isWorktree: gitDir !== commonDir,
93  }
94}
95
96async function beat($: EngineInterface, self: Self) {
97  const id = await $.session.id()
98  // A beat queued before session.end would bring the deleted key back.
99  if (self.isHeadless || self.ended.has(id)) return
100  self.place ??= await placeOf($, await $.session.cwd())
101  const { context } = await $.session.usage()
102  const now = await $.clock.now()
103  arm($, self, now)
104  const state: State = self.asks.size > 0 ? 'waiting' : self.isTurn ? 'working' : 'idle'
105  if (self.mark?.state !== state) self.mark = { state, since: now }
106  const { repo, branch } = self.place
107  const entry: Entry = {
108    id,
109    name: branch ? `${repo}:${branch}` : repo,
110    ...self.place,
111    state,
112    since: self.mark.since,
113    contextPercent: context.percent ?? null,
114    lastPrompt: self.lastPrompt,
115    updatedAt: now,
116  }
117  await $.store.set(PREFIX + id, entry)
118}
119
120function pulse($: EngineInterface, self: Self) {
121  beat($, self).catch(err => $.ui.log(`fleet: heartbeat failed: ${err}`, { to: 'debug' }))
122}
123
124// clock.every ends quietly when a hook refuses a period, so a refresh gone silent means re-arm.
125function arm($: EngineInterface, self: Self, now: number) {
126  if (self.isHeadless || (self.timers.length > 0 && now - self.tickAt < 3 * REFRESH_MS)) return
127  if (self.timers.length > 0) $.ui.log('fleet: timers went silent, re-armed', { to: 'debug' })
128  for (const timer of self.timers) timer.cancel()
129  self.tickAt = now
130  self.timers = [
131    $.clock.every(BEAT_MS, () => pulse($, self)),
132    $.clock.every(REFRESH_MS, () => {
133      refresh($, self).catch(err => $.ui.log(`fleet: refresh failed: ${err}`, { to: 'debug' }))
134    }),
135  ]
136}
137
138// Throws only when the key list itself is unreadable; a bad entry is skipped and counted.
139export async function load($: EngineInterface) {
140  const now = await $.clock.now()
141  const live: Entry[] = []
142  let bad = 0
143  for (const key of await $.store.keys()) {
144    if (!key.startsWith(PREFIX)) continue
145    const entry = await $.store.get(key).catch(() => null)
146    if (entry === undefined) continue
147    if (!isEntry(entry)) {
148      bad += 1
149      continue
150    }
151    const age = now - entry.updatedAt
152    if (age > PRUNE_MS) await $.store.delete(key).catch(err => $.ui.log(`fleet: prune failed: ${err}`, { to: 'debug' }))
153    else if (age <= LIVE_MS) live.push(entry)
154  }
155  return { live, bad }
156}
157
158async function refresh($: EngineInterface, self: Self) {
159  self.tickAt = await $.clock.now()
160  const before = JSON.stringify([self.peers, self.bad, self.isUnreadable])
161  try {
162    const { live, bad } = await load($)
163    Object.assign(self, { peers: live, bad, isUnreadable: false, goodAt: await $.clock.now() })
164  } catch (err) {
165    if (!self.isUnreadable) $.ui.log(`fleet: store unreadable: ${err}`, { to: 'debug' })
166    self.isUnreadable = true
167  }
168  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
169  if (isOpen || JSON.stringify([self.peers, self.bad, self.isUnreadable]) !== before) $.ui.invalidate('ui.render')
170}
171
172async function sendTo($: EngineInterface, to: Entry, text: string) {
173  try {
174    const sent = await $.session.send({ to: { sessionId: to.id }, text })
175    $.ui.toast(sent.isDelivered ? `fleet: queued for ${to.name}` : `fleet: send to ${to.name} failed: ${sent.reason}`)
176  } catch (err) {
177    $.ui.toast(`fleet: send to ${to.name} failed: ${err}`)
178  }
179}
180
181export function register(on: On) {
182  const self: Self = {
183    isHeadless: false, isTurn: false, asks: new Set(), lastPrompt: '', ended: new Set(),
184    peers: [], bad: 0, isUnreadable: false, goodAt: 0, timers: [], tickAt: 0,
185  }
186
187  on('session.start', async ($, e, next) => {
188    await $.command.register({ name: 'fleet', description: 'List live Claude Code sessions', immediate: true })
189    const argumentHint = '<session name or id prefix> <text>'
190    await $.command.register({ name: 'send', description: 'Message another live session', argumentHint, immediate: true })
191    self.isHeadless = !e.isInteractive
192    arm($, self, await $.clock.now())
193    pulse($, self)
194    return next(e)
195  })
196
197  on('session.end', async ($, e, next) => {
198    self.ended.add(e.sessionId)
199    await $.store.delete(PREFIX + e.sessionId)
200    if (e.reason === 'clear') {
201      Object.assign(self, { lastPrompt: '', asks: new Set(), isTurn: false, mark: undefined })
202      // The new id is in place once the end step returns.
203      $.clock.after(0, () => pulse($, self))
204    }
205    return next(e)
206  })
207
208  // A peer's /send also starts a turn; its envelope is not what the person last asked.
209  on('prompt.submit', ($, e, next) => {
210    const isPerson = ['composer', 'bridge', 'sdk'].includes(e.origin.kind)
211    if (isPerson && e.text.trim()) self.lastPrompt = clean(e.text).replace(/\s+/g, ' ').trim().slice(0, 80)
212    return next(e)
213  })
214
215  on('turn.start', async ($, e, next) => {
216    const started = await next(e)
217    self.isTurn = true
218    self.place = undefined
219    pulse($, self)
220    return started
221  })
222
223  on('turn.complete', ($, e, next) => {
224    if (e.agentId !== undefined) return next(e)
225    self.isTurn = false
226    pulse($, self)
227    return next(e)
228  })
229
230  on('tool.check', async ($, e, next) => {
231    const verdict = await next(e)
232    if (verdict.decision === 'ask' && e.tool_use_id) {
233      self.asks.add(e.tool_use_id)
234      pulse($, self)
235    }
236    return verdict
237  })
238
239  // The pill only draws once an approved tool runs, so a long Bash stops reading as waiting.
240  on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
241    if (self.asks.delete(e.props.tool_use_id)) pulse($, self)
242    return next(e)
243  })
244
245  on('tool.call', async ($, e, next) => {
246    if (e.tool === 'AskUserQuestion') {
247      self.asks.add(e.tool_use_id)
248      pulse($, self)
249    }
250    try {
251      return await next(e)
252    } finally {
253      if (self.asks.delete(e.tool_use_id)) pulse($, self)
254    }
255  })
256
257  on('command.run', { command: 'fleet' }, async $ => {
258    await refresh($, self)
259    await $.ui.open({ id: PANE, title: 'Fleet' })
260    return {}
261  })
262
263  on('command.run', { command: 'send' }, async ($, e) => {
264    const [, query, text] = /^(\S+)\s+([\s\S]+)$/.exec(e.args.trim()) ?? []
265    if (!query || !text) {
266      $.ui.toast('fleet: usage /send <session name or id prefix> <text>')
267      return {}
268    }
269    const me = await $.session.id()
270    const now = await $.clock.now()
271    const loaded = await load($).catch(() => undefined)
272    if (!loaded) {
273      $.ui.toast('fleet: store unreadable, nothing sent')
274      return {}
275    }
276    const found = pick(loaded.live.filter(p => p.id !== me && !isStale(now - p.updatedAt)), query)
277    const [to, ...rest] = found
278    if (!to || rest.length > 0) {
279      const names = found.map(p => `${p.name} (${p.id.slice(0, 8)})`).join(', ')
280      $.ui.toast(to ? `fleet: "${query}" matches ${names}` : `fleet: no live session matches "${query}"`)
281      return {}
282    }
283    await sendTo($, to, text)
284    return {}
285  })
286
287  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288    const below = await next(e)
289    if (self.isUnreadable) return below
290    const me = await $.session.id()
291    const now = await $.clock.now()
292    const waiting = self.peers.filter(p => p.id !== me && p.state === 'waiting' && !isStale(now - p.updatedAt))
293    if (waiting.length === 0) return below
294    const { Box, Text } = $.ui.resolve(e)
295    const shown = waiting.slice(0, 2).map(p => `⏸ ${p.name} waiting ${ageOf(now - p.since)}`)
296    const more = waiting.length > 2 ? `  +${waiting.length - 2}` : ''
297    return (
298      <Box flexDirection="column">
299        <Text key="fleet-band" color="yellow" wrap="truncate-end">
300          {clean(`fleet  ${shown.join('  ')}${more}`)}
301        </Text>
302        {below}
303      </Box>
304    )
305  })
306
307  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
308    const els = $.ui.resolve(e)
309    const { Box, Text, Button } = els
310    const Input = 'Input' in els ? els.Input : undefined
311    const me = await $.session.id()
312    const now = await $.clock.now()
313    const checkouts = self.peers.map(p => p.checkout)
314    const aim = (to?: Entry) => {
315      self.target = to
316      $.ui.invalidate('ui.render')
317    }
318    const rows = ORDER.flatMap(state =>
319      self.peers
320        .filter(p => p.state === state)
321        .map(p => {
322          const isSelf = p.id === me
323          const isClash = checkouts.indexOf(p.checkout) !== checkouts.lastIndexOf(p.checkout)
324          const seen = now - p.updatedAt
325          const isOld = self.isUnreadable || isStale(seen)
326          const ctx = p.contextPercent === null ? '?' : `${Math.round(p.contextPercent)}%`
327          const line = [
328            isSelf ? '*' : ' ',
329            p.repo,
330            (p.branch || '-') + (p.isWorktree ? ' (wt)' : ''),
331            isOld ? `last seen ${seen < 0 ? '?' : ageOf(seen)} ago` : `${p.state}  ${ageOf(now - p.since)}`,
332            `ctx ${ctx}`,
333            p.lastPrompt && `"${p.lastPrompt}"`,
334            isClash && `same checkout: ${p.checkout}`,
335          ]
336          return (
337            <Box key={`row:${p.id}`} gap={1}>
338              <Text key={p.id} color={isClash ? 'red' : COLOR[state]} dimColor={isOld} bold={isSelf} wrap="truncate-end">
339                {clean(line.filter(Boolean).join('  '))}
340              </Text>
341              {Input && !isSelf && <Button key={`send:${p.id}`} label="Send" dimColor onPress={() => aim(p)} />}
342            </Box>
343          )
344        }),
345    )
346    const target = self.target
347    return (
348      <Box flexDirection="column">
349        {self.isUnreadable && (
350          <Text key="unreadable" color="red">{`store unreadable (last good ${ageOf(now - self.goodAt)} ago)`}</Text>
351        )}
352        {self.bad > 0 && <Text key="bad" dimColor>{`${self.bad} malformed entries skipped`}</Text>}
353        {rows.length === 0 && <Text dimColor>No sessions seen in the last 60s.</Text>}
354        {rows}
355        {target && Input && (
356          <Box gap={1}>
357            <Input
358              key="send-text"
359              label={clean(`to ${target.name}: `)}
360              submitLabel="send"
361              autoFocus
362              onSubmit={async text => {
363                if (text.trim()) await sendTo($, target, text.trim())
364                aim(undefined)
365              }}
366            />
367            <Button key="send-cancel" label="Cancel" onPress={() => aim(undefined)} />
368          </Box>
369        )}
370      </Box>
371    )
372  })
373}
374