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

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.+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.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.
hooks/register.tsx 374 lines1import 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