See every running Claude Code session and worktree at a glance: status, branch, uncommitted work, staleness

A Claude Code mod that shows every Claude session running on your machine: what each one is working on, the state of its code, and how much context it holds.
10 sessions · 2 busy · 1 need you · 6 safe to close
841k tokens to re-cache · 4 idle > 7d · 1 open in two windows
● Payments webhook retry bug waiting api fix/webhooks ✎1 2m 141k
▸ ● Auth middleware refactor busy api feat/auth ✎4 ↑2 now 182k
● Flaky checkout test triage idle web main ✎2 25m 96k
◐ Release notes for v2.4 idle docs main 3d 44k cold
○ Regex for ISO dates idle web main 2d 14k cold
◐ Postgres migration dry run idle api master ↑2 12d 338k cold
◐ GraphQL schema review ×2 idle api main 30d 212k cold
(The example data above is fleet's demo mode; see Try it safely.)
If you keep several sessions open, or run parallel agents in worktrees, it's easy to lose track of which ones are working, which are waiting on you, and which have been sitting on uncommitted work for weeks.
⧉ 9 sessions · 2 busy · 1 need you./fleet opens a live pane, refreshed every 5s. For each session it shows:projects-3fWorktrees of the current repo with no session in them show up too. A conversation resumed in two terminals is marked ×2; closing the older window loses nothing.
cold), that whole context gets cached again at full price. A 2M-token session you only half need is often cheaper to save and close than to resume.The same Haiku call that writes the title makes this judgment, so it costs nothing extra.
/fleet handoff [pid|name] (or h in the pane) writes claude-handoff-<title>-<date>.txt in the session's folder. It's a short summary: the goal, status, decisions and why, next steps, key files, and gotchas. A fresh session can continue from about 3k tokens instead of re-caching the whole conversation, and the original session stays resumable./fleet save [pid|name] exports a conversation to claude-context-<title>-<date>.md in that session's folder: your messages, Claude's replies, and a one-line note for each tool call. With no argument it saves the current session. It warns you if the file isn't git-ignored, since conversations can contain secrets./fleet kill [--handoff|--save] closes sessions that are safe to close: cold and finished or light, or idle for over a week and not in progress. /fleet kill all [--handoff|--save] closes every idle session except yours.--handoff or --save, a session whose export fails is left running.claude --resume brings any of them back./fleet list, /fleet stale, /fleet retitle and /fleet help print the same information as text. These also work in claude -p.These only work as a mod, since they react to the session you're in:
/clear, and opens the new conversation with "Read <handoff> and pick up where it leaves off".Set CLAUDE_FLEET_HANDOFF_AT to change the threshold. /fleet handoff --fresh does the same thing as a command.
/exit when the session is in progress or holds over 100k tokens. You can run /fleet handoff --exit to hand off and then exit, or run /exit again within 30 seconds to quit anyway. Set CLAUDE_FLEET_EXIT_CHECK=0 to turn this off. (It catches /exit only, not ctrl+c.)CLAUDE_FLEET_NOTIFY=0 to turn these off.To use a band's buttons, give it the keyboard with ctrl+x then Tab, or click it.
/fleet opens the pane and gives it the keyboard.
| Key | What it does |
|---|---|
| ↑ / ↓ (or Tab) | Select a session. The selected row is highlighted. |
| h | Write a handoff for the selected session. Once it's written, the row turns red and one press of x closes the session (for 20 seconds). |
| x | Close the selected session. The row turns red; press x again within 10 seconds to confirm. Your own session and busy ones can't be closed. |
| s | Save the full conversation as Markdown in the session's folder. |
| c | Copy cd <folder> && claude --resume <id> to the clipboard. |
| r | Refresh now (it also refreshes on its own every 5 seconds). |
| Esc | Give the keyboard back to the prompt. The pane stays open. |
| click the pane | Give the pane the keyboard again. |
| ✕ on the pane | Close the pane. /fleet reopens it. |
Every action can also be run as a command: /fleet handoff, /fleet save, /fleet kill; /fleet help lists them all.
Requirements:
ps, kill, head and tail, so it doesn't run on Windows.git clone https://github.com/Dubbus/claude-fleet
claude --plugin-dir claude-fleet
Or set CLAUDE_CODE_PLUGIN_DIRS=/path/to/claude-fleet to load it in every session.
CLAUDE_FLEET_DEMO=1 claude --plugin-dir claude-fleet
This shows a fixed set of made-up sessions instead of your real ones, so you can try every key without touching anything: demo sessions have no transcripts and aren't real processes, so save, handoff and close all do nothing.
~/.claude/sessions/. fleet reads only the pid, cwd, name, nameSource, status, sessionId and updatedAt fields. It never reads the other fields, which include auth tokens. It checks each process ID with ps.git status --porcelain=v2 --branch in each distinct folder, only while the pane is open./rename keep their names.Those two are the only things sent anywhere. Everything else stays local, and there are no dependencies.
~/.claude/sessions/ is an internal Claude Code file, not a public API, so a future release may change it. If that happens, fleet tells you in the pane, the status line and /fleet list, rather than showing an empty list. If the registry has moved, set CLAUDE_FLEET_REGISTRY to the new folder.
| File | What's in it |
|---|---|
hooks/register.tsx | The hooks, plus everything that touches $: reading sessions, git, model calls, saving, closing |
hooks/lib/views.tsx | What the pane and the band draw |
hooks/lib/format.ts | Turning rows into text: the table, summaries, the status line, help |
hooks/lib/rules.ts | What counts as stale, cold, needing you, or safe to close |
hooks/lib/parse.ts | Parsing the registry, git output and transcripts |
hooks/lib/prompts.ts | What fleet asks Haiku and Sonnet, and how replies are read |
hooks/lib/constants.ts, demo.ts | Thresholds and timings; demo-mode data |
types/index.d.ts | Shared types and the plugin's state contract |
If you contribute, there's one rule to know: any function that uses $ has to live in register.tsx, because the mod validator only follows $ within the file that registers the hooks. Files in lib/ get plain data and callbacks instead, and the views get the surface's elements. Run claude plugin validate . before sending a change.
MIT
hooks/register.tsx 708 lines1// Everything here touches `$`, which the engine only follows within this one file.
2// Pure logic lives in ./lib: rules, formatting, parsing, prompts, demo data and the views.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Armed, Band, Git, Registered, Row, Snapshot, TitleEntry } from '../types'
7
8import {
9 AFTER_HANDOFF_MS, ARM_MS, CLOSE_CONFIRM_MS, EXIT_CHECK_TOKENS, EXIT_CONFIRM_MS, EXPORT_MAX_BYTES,
10 HANDOFF_AT_TOKENS, HANDOFF_SNOOZE_TOKENS, HEAD_BYTES, MISSING_RETRY_MS, PROJECTS_DIR, REGISTRY_DIR,
11 STATUS_EVERY_TICKS, TAIL_BYTES, TICK_MS, TITLES_KEY, TITLES_PER_SCAN,
12} from './lib/constants.ts'
13import { demoRows } from './lib/demo.ts'
14import {
15 ago, asTable, basename, findRow, HELP, label, pidLine, shellQuote, staleReport, statusLineText, tokensText,
16} from './lib/format.ts'
17import {
18 lastAssistantText, lastContextTokens, parseGitStatus, parsePids, parseRegistryEntry, parseWorktrees,
19 toMarkdown, transcriptPath, userPrompts,
20} from './lib/parse.ts'
21import { continuePrompt, HANDOFF_SYSTEM, handoffDoc, LABEL_SYSTEM, labelPrompt, parseLabel, trimForHandoff } from './lib/prompts.ts'
22import { isDemo, killTargets, needsYou, sortRows } from './lib/rules.ts'
23import { bandView, paneView } from './lib/views.tsx'
24
25// Kept here as a literal: hook matchers are read from this file, so an imported id would be opaque.
26const PANE = 'fleet'
27
28const snapshot = atom({ plugin: 'fleet', key: 'snapshot' } as const, null)
29const isOpen = atom({ plugin: 'fleet', key: 'isOpen' } as const, false)
30const selected = atom({ plugin: 'fleet', key: 'selected' } as const, null)
31const pendingClose = atom({ plugin: 'fleet', key: 'pendingClose' } as const, null)
32const band = atom({ plugin: 'fleet', key: 'band' } as const, null)
33
34// Module state: lost on a reload, which only means asking or caching again.
35// path -> { size, tokens }: a transcript is re-read only when it has grown.
36const tokenCache = new Map<string, { size: number; tokens: number | null }>()
37const transcriptCache = new Map<string, { path: string | null; at: number }>()
38// In flight across scans, so a slow title call isn't started twice.
39const titling = new Set<string>()
40// Set when the person chose to exit, so the next /exit isn't held again.
41let exitApproved = false
42let exitAskedAt = 0
43let snoozedAt = 0
44
45// --- Reading sessions ---
46
47// The registry is internal to Claude Code, so a release can move or reshape it.
48// Every way it can fail says why, rather than showing an empty fleet.
49const readRegistry = async ($: EngineInterface): Promise<{ sessions: Registered[]; problem: string | null }> => {
50 const home = await $.env.get('HOME')
51 // CLAUDE_FLEET_REGISTRY points fleet at another registry folder (a moved one, or a test fixture).
52 const override = await $.env.get('CLAUDE_FLEET_REGISTRY')
53 if (!home && !override) return { sessions: [], problem: "HOME isn't set, so fleet can't find ~/.claude/sessions." }
54 const dir = override || `${home}/${REGISTRY_DIR}`
55 const shown = override || `~/${REGISTRY_DIR}`
56 if (!(await $.fs.exists(dir))) {
57 return { sessions: [], problem: `No ${shown} folder: this Claude Code version may keep its session list elsewhere.` }
58 }
59
60 const found: Registered[] = []
61 let files = 0
62 for (const entry of await $.fs.list(dir)) {
63 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
64 files += 1
65 try {
66 const parsed = parseRegistryEntry(String(await $.fs.read(`${dir}/${entry.name}`)), entry.mtimeMs)
67 if (parsed) found.push(parsed)
68 } catch {
69 // A file mid-write or from another version: skip it this tick.
70 }
71 }
72 if (files === 0) return { sessions: [], problem: `${shown} is empty, though this session is running: the registry may have moved.` }
73 if (found.length === 0) {
74 return { sessions: [], problem: `Couldn't read any of the ${files} files in ${shown}: their format may have changed.` }
75 }
76
77 // Registry files outlive crashed processes; keep only the pids still running.
78 const ps = await $.process.run(['ps', '-o', 'pid=', '-p', found.map(r => r.pid).join(',')], { timeoutMs: 3000 })
79 const alive = parsePids(ps.stdout)
80 // This session is always running, so nothing alive means ps itself failed.
81 if (alive.size === 0) return { sessions: [], problem: `Couldn't check which sessions are running (ps: ${ps.stderr.trim() || `exit ${ps.exitCode}`}).` }
82 return { sessions: found.filter(r => alive.has(r.pid)), problem: null }
83}
84
85const gitStatus = async ($: EngineInterface, cwd: string): Promise<Git | null> => {
86 const r = await $.process.run(['git', '-C', cwd, 'status', '--porcelain=v2', '--branch'], { timeoutMs: 3000 })
87 return r.exitCode === 0 ? parseGitStatus(r.stdout) : null
88}
89
90// Worktrees of this session's repo that no session is sitting in.
91const idleWorktrees = async ($: EngineInterface, taken: Set<string>): Promise<string[]> => {
92 const r = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: await $.session.cwd(), timeoutMs: 3000 })
93 return r.exitCode === 0 ? parseWorktrees(r.stdout).filter(path => !taken.has(path)) : []
94}
95
96// Transcripts are filed under the folder a session started in, which can differ from where it is
97// now, so fall back to looking for the session's file in every project folder.
98const findTranscript = async ($: EngineInterface, home: string, row: Pick<Row, 'cwd' | 'sessionId'>) => {
99 if (!row.sessionId) return null
100 const direct = transcriptPath(home, row)
101 if (await $.fs.exists(direct)) return direct
102
103 const now = await $.clock.now()
104 const hit = transcriptCache.get(row.sessionId)
105 if (hit && (hit.path ? await $.fs.exists(hit.path) : now - hit.at < MISSING_RETRY_MS)) return hit.path
106
107 let found: string | null = null
108 for (const entry of await $.fs.list(`${home}/${PROJECTS_DIR}`)) {
109 if (entry.kind !== 'dir') continue
110 const path = `${home}/${PROJECTS_DIR}/${entry.name}/${row.sessionId}.jsonl`
111 if (await $.fs.exists(path)) {
112 found = path
113 break
114 }
115 }
116 transcriptCache.set(row.sessionId, { path: found, at: now })
117 return found
118}
119
120const contextTokens = async ($: EngineInterface, path: string): Promise<number | null> => {
121 if (!(await $.fs.exists(path))) return null
122 const { size } = await $.fs.stat(path)
123 const hit = tokenCache.get(path)
124 if (hit && hit.size === size) return hit.tokens
125
126 const r = await $.process.run(['tail', '-c', String(TAIL_BYTES), path], { timeoutMs: 3000 })
127 const tokens = lastContextTokens(r.stdout)
128 tokenCache.set(path, { size, tokens })
129 return tokens
130}
131
132const scan = async ($: EngineInterface, isFull: boolean) => {
133 if (await $.env.get('CLAUDE_FLEET_DEMO')) {
134 const now = await $.clock.now()
135 const demo: Snapshot = { rows: demoRows(now), scannedAt: now, warning: null }
136 await update($, snapshot, () => demo)
137 $.ui.status(statusLineText(demo))
138 return demo
139 }
140 const [registry, selfId, now, home, titles, previous] = await Promise.all([
141 readRegistry($), $.session.id(), $.clock.now(), $.env.get('HOME'), readTitles($), read($, snapshot),
142 ])
143
144 const rows: Row[] = registry.sessions.map(s => ({
145 key: `pid:${s.pid}`,
146 name: s.name,
147 isNamed: s.isNamed,
148 title: titles[s.sessionId]?.title ?? null,
149 value: titles[s.sessionId]?.value ?? null,
150 status: s.status,
151 cwd: s.cwd,
152 pid: s.pid,
153 sessionId: s.sessionId,
154 updatedAt: s.updatedAt,
155 isSelf: s.sessionId === selfId,
156 git: null,
157 tokens: null,
158 }))
159
160 if (isFull) {
161 const taken = new Set(rows.map(r => r.cwd))
162 for (const path of await idleWorktrees($, taken)) {
163 rows.push({
164 key: `wt:${path}`, name: '(no session)', isNamed: true, title: null, value: null, status: 'worktree', cwd: path,
165 pid: null, sessionId: '', updatedAt: 0, isSelf: false, git: null, tokens: null,
166 })
167 }
168 const byCwd = new Map<string, Promise<Git | null>>()
169 for (const row of rows) if (!byCwd.has(row.cwd)) byCwd.set(row.cwd, gitStatus($, row.cwd))
170 for (const row of rows) {
171 row.git = await byCwd.get(row.cwd)!
172 const path = home ? await findTranscript($, home, row) : null
173 if (path) row.tokens = await contextTokens($, path)
174 }
175 } else {
176 // Keep the last readings so the pane doesn't flicker between full scans.
177 const prev = new Map((previous?.rows ?? []).map(r => [r.key, r]))
178 for (const row of rows) {
179 row.git = prev.get(row.key)?.git ?? null
180 row.tokens = prev.get(row.key)?.tokens ?? null
181 }
182 }
183
184 const warning =
185 registry.problem ??
186 (rows.some(r => r.isSelf) ? null : "This session isn't in ~/.claude/sessions, so the list may be incomplete.")
187 const next: Snapshot = { rows: sortRows(rows), scannedAt: now, warning }
188 await update($, snapshot, () => next)
189 $.ui.status(statusLineText(next))
190 if (previous && !previous.warning) await notifyChanges($, previous, next)
191 if (isFull && home) void refreshTitles($, home, rows, titles).catch(() => undefined)
192 return next
193}
194
195// A toast when another session starts waiting on you, or finishes what it was doing.
196// CLAUDE_FLEET_NOTIFY=0 turns these off.
197const notifyChanges = async ($: EngineInterface, before: Snapshot, after: Snapshot) => {
198 if ((await $.env.get('CLAUDE_FLEET_NOTIFY')) === '0') return
199 const was = new Map(before.rows.map(r => [r.key, r.status]))
200 for (const row of after.rows) {
201 const prev = was.get(row.key)
202 if (row.isSelf || row.pid === null || prev === undefined || prev === row.status) continue
203 if (needsYou(row.status) && !needsYou(prev)) $.ui.toast(`⚠ ${label(row)} needs you (${row.status})`)
204 else if (prev === 'busy' && row.status === 'idle') $.ui.toast(`✓ ${label(row)} finished`)
205 }
206}
207
208// --- Titles and resume value ---
209
210const readTitles = async ($: EngineInterface) =>
211 ((await $.store.get(TITLES_KEY)) ?? {}) as Record<string, TitleEntry>
212
213const makeTitle = async ($: EngineInterface, path: string, idleText: string) => {
214 const [head, tail] = await Promise.all([
215 $.process.run(['head', '-c', String(HEAD_BYTES), path], { timeoutMs: 3000 }),
216 $.process.run(['tail', '-c', String(TAIL_BYTES), path], { timeoutMs: 3000 }),
217 ])
218 const first = userPrompts(head.stdout).slice(0, 3)
219 const latest = userPrompts(tail.stdout).slice(-2).filter(p => !first.includes(p))
220 if (first.length === 0) return null
221
222 const r = await $.model.complete({
223 model: 'haiku',
224 system: LABEL_SYSTEM,
225 prompt: labelPrompt(first, latest, lastAssistantText(tail.stdout).slice(-800), idleText),
226 maxTokens: 80,
227 effort: 'low',
228 })
229 return r.isAnswered ? parseLabel(r.text) : null
230}
231
232// Titles a few untitled sessions per scan, and retitles one whose transcript has doubled.
233const refreshTitles = async ($: EngineInterface, home: string, rows: Row[], titles: Record<string, TitleEntry>, limit = TITLES_PER_SCAN) => {
234 const now = await $.clock.now()
235 const due: { row: Row; path: string; size: number }[] = []
236 for (const row of rows) {
237 if (!row.sessionId || row.isNamed || titling.has(row.sessionId)) continue
238 const path = await findTranscript($, home, row)
239 if (!path) continue
240 const { size } = await $.fs.stat(path)
241 const known = titles[row.sessionId]
242 if (!known || !known.value || size > known.size * 2) due.push({ row, path, size })
243 if (due.length >= limit) break
244 }
245
246 for (const { row } of due) titling.add(row.sessionId)
247 const made = await Promise.all(
248 due.map(({ row, path, size }) =>
249 makeTitle($, path, ago(now - row.updatedAt).replace('now', 'under a minute'))
250 .then(made => (made ? { sessionId: row.sessionId, ...made, size } : null))
251 .catch(() => null),
252 ),
253 )
254 for (const { row } of due) titling.delete(row.sessionId)
255
256 const fresh = made.filter(m => m !== null)
257 if (fresh.length === 0) return 0
258 const all = await readTitles($)
259 for (const m of fresh) all[m.sessionId] = { title: m.title, size: m.size, value: m.value }
260 await $.store.set(TITLES_KEY, all)
261 const byId = new Map(fresh.map(m => [m.sessionId, m]))
262 await update($, snapshot, snap =>
263 snap && {
264 ...snap,
265 rows: snap.rows.map(r => {
266 const m = byId.get(r.sessionId)
267 return m ? { ...r, title: m.title, value: m.value ?? null } : r
268 }),
269 },
270 )
271 return fresh.length
272}
273
274// --- Saving and handing off ---
275
276const readTranscript = async ($: EngineInterface, row: Row) => {
277 const home = await $.env.get('HOME')
278 if (!home || !row.sessionId) throw new Error('no transcript for this session')
279 const source = await findTranscript($, home, row)
280 if (!source) throw new Error('transcript not found')
281 if ((await $.fs.stat(source)).size > EXPORT_MAX_BYTES) throw new Error('transcript is over 80 MB')
282 return String(await $.fs.read(source))
283}
284
285// `<folder>/<prefix>-<title>-<date>.<ext>`, numbered when that name is taken.
286const freshPath = async ($: EngineInterface, row: Row, prefix: string, now: Date, ext = 'md') => {
287 const slug = label(row).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60)
288 const stem = `${row.cwd}/${prefix}-${slug}-${now.toISOString().slice(0, 10)}`
289 let target = `${stem}.${ext}`
290 for (let n = 2; await $.fs.exists(target); n++) target = `${stem}-${n}.${ext}`
291 return target
292}
293
294// Conversations can hold secrets; say so when the file would show up in `git status`.
295const withGitNote = async ($: EngineInterface, row: Row, target: string) => {
296 const ignored = await $.process.run(['git', '-C', row.cwd, 'check-ignore', '-q', target], { timeoutMs: 3000 })
297 return ignored.exitCode === 1 ? `${target} (not git-ignored: check it before committing)` : target
298}
299
300const saveSession = async ($: EngineInterface, row: Row): Promise<string> => {
301 const jsonl = await readTranscript($, row)
302 const now = new Date(await $.clock.now())
303 const target = await freshPath($, row, 'claude-context', now)
304 await $.fs.write(target, toMarkdown(row, jsonl, now.toISOString()))
305 return withGitNote($, row, target)
306}
307
308const handoffSession = async ($: EngineInterface, row: Row): Promise<string> => {
309 const now = new Date(await $.clock.now())
310 const text = trimForHandoff(toMarkdown(row, await readTranscript($, row), now.toISOString()))
311 const r = await $.model.complete({ model: 'sonnet', system: HANDOFF_SYSTEM, prompt: text, maxTokens: 3000 })
312 if (!r.isAnswered) throw new Error(`the summary call failed (${r.reason})`)
313
314 const target = await freshPath($, row, 'claude-handoff', now, 'txt')
315 await $.fs.write(target, handoffDoc(row, target, now, r.text))
316 return withGitNote($, row, target)
317}
318
319// --- Closing sessions ---
320
321// Pids get reused: only signal a pid that is still a Claude process.
322const isClaudeProcess = async ($: EngineInterface, pid: number) => {
323 const r = await $.process.run(['ps', '-o', 'command=', '-p', String(pid)], { timeoutMs: 3000 })
324 return r.exitCode === 0 && /(^|\/)claude(\s|$)|\/claude\/versions\//.test(r.stdout.trim())
325}
326
327const runKill = async ($: EngineInterface, args: string, armed: Armed | null): Promise<[string, Armed | null]> => {
328 const words = args.split(/\s+/).filter(Boolean)
329 const isAll = words.includes('all')
330 const isSave = words.includes('--save')
331 const isHandoff = words.includes('--handoff')
332 const key = `${isAll ? 'all' : 'closable'}${isSave ? ' --save' : ''}${isHandoff ? ' --handoff' : ''}`
333 const snap = await scan($, true)
334 const targets = killTargets(snap, isAll)
335 const scope = isAll
336 ? 'idle sessions'
337 : 'sessions safe to close (cold and finished or light, or idle over a week and not in progress)'
338
339 if (targets.length === 0) return [`No ${scope} to close (busy sessions, ones waiting on you, and this one are never closed).`, null]
340
341 const isConfirmed =
342 armed !== null && armed.args === key && snap.scannedAt - armed.at < ARM_MS &&
343 targets.every(t => armed.pids.includes(t.pid!))
344
345 if (!isConfirmed) {
346 const tokens = targets.reduce((sum, r) => sum + (r.tokens ?? 0), 0)
347 return [
348 `This would close ${targets.length} ${scope}` + (tokens ? ` (${tokensText(tokens)} tokens of context):` : ':') + '\n\n' +
349 targets.map(r => pidLine(r, snap)).join('\n') + '\n\n' +
350 (isHandoff
351 ? 'A handoff summary is written in each folder first (a Sonnet call per session).\n'
352 : isSave
353 ? 'Each conversation is saved as Markdown in its folder first.\n'
354 : 'Add --handoff to write a summary first, or --save for the full conversation.\n') +
355 `Run /fleet kill${args ? ` ${args}` : ''} again within 60s to confirm. Conversations stay resumable with claude --resume.`,
356 { args: key, pids: targets.map(t => t.pid!), at: snap.scannedAt },
357 ]
358 }
359
360 const lines: string[] = []
361 for (const row of targets) {
362 if (!(await isClaudeProcess($, row.pid!))) {
363 lines.push(` skipped ${row.pid} ${label(row)}: no longer a Claude process`)
364 continue
365 }
366 try {
367 if (isSave) lines.push(` saved ${label(row)} → ${await saveSession($, row)}`)
368 if (isHandoff) lines.push(` handoff ${label(row)} → ${await handoffSession($, row)}`)
369 } catch (err) {
370 lines.push(` kept ${row.pid} ${label(row)}: ${(err as Error).message}, so not closed`)
371 continue
372 }
373 const r = await $.process.run(['kill', String(row.pid)], { timeoutMs: 3000 })
374 lines.push(r.exitCode === 0 ? ` closed ${row.pid} ${label(row)}` : ` failed ${row.pid} ${label(row)}: ${r.stderr.trim()}`)
375 }
376 void scan($, true)
377 return [lines.join('\n'), null]
378}
379
380// --- Pane actions on the selected row ---
381
382const selectedRow = async ($: EngineInterface) => {
383 const [snap, key] = await Promise.all([read($, snapshot), read($, selected)])
384 return snap?.rows.find(r => r.key === key) ?? null
385}
386
387// The selected live session, or a toast saying why there isn't one to act on.
388const actionableRow = async ($: EngineInterface) => {
389 const row = await selectedRow($)
390 if (!row || row.pid === null) {
391 $.ui.toast('Select a session first (↑/↓)')
392 return null
393 }
394 if (isDemo(row)) {
395 $.ui.toast('Demo mode: nothing to do for made-up sessions')
396 return null
397 }
398 return row
399}
400
401const copyResume = async ($: EngineInterface, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) => {
402 const row = await selectedRow($)
403 if (!row) return $.ui.toast('Select a session first (↑/↓)')
404 const command = row.sessionId
405 ? `cd ${shellQuote(row.cwd)} && claude --resume ${row.sessionId}`
406 : `cd ${shellQuote(row.cwd)} && claude`
407 const copied = await $.ui.copy({ text: command, surface })
408 $.ui.toast(copied.isCopied ? `Copied: ${command}` : command)
409}
410
411const saveSelected = async ($: EngineInterface) => {
412 const row = await actionableRow($)
413 if (!row) return
414 try {
415 $.ui.toast(`Saved → ${await saveSession($, row)}`)
416 } catch (err) {
417 $.ui.toast(`Couldn't save: ${(err as Error).message}`)
418 }
419}
420
421// Marks `row` so the next x closes it, until `ms` pass or another row is armed.
422const armClose = async ($: EngineInterface, row: Row, ms: number) => {
423 await update($, pendingClose, () => row.key)
424 $.clock.after(ms, () => {
425 void update($, pendingClose, key => (key === row.key ? null : key))
426 })
427}
428
429const handoffSelected = async ($: EngineInterface) => {
430 const row = await actionableRow($)
431 if (!row) return
432 $.ui.toast(`Writing a handoff for ${label(row)}…`)
433 let target: string
434 try {
435 target = await handoffSession($, row)
436 } catch (err) {
437 return $.ui.toast(`Couldn't write a handoff: ${(err as Error).message}`)
438 }
439
440 // The context now lives in the handoff, so offer to close the session with one press.
441 if (row.isSelf || row.status !== 'idle') return $.ui.toast(`Handoff → ${target}`)
442 await armClose($, row, AFTER_HANDOFF_MS)
443 $.ui.toast(`Handoff → ${target} · press x to close the session`)
444}
445
446const closeSelected = async ($: EngineInterface) => {
447 const row = await actionableRow($)
448 if (!row || row.pid === null) return
449 if (row.isSelf) return $.ui.toast("That's this session; quit it with /exit")
450 if (row.status !== 'idle') return $.ui.toast(`${label(row)} is ${row.status}; only idle sessions are closed`)
451
452 if ((await read($, pendingClose)) !== row.key) {
453 await armClose($, row, CLOSE_CONFIRM_MS)
454 return $.ui.toast(`Press x again to close ${label(row)} (h writes a handoff first)`)
455 }
456
457 await update($, pendingClose, () => null)
458 if (!(await isClaudeProcess($, row.pid))) return $.ui.toast(`${row.pid} is no longer a Claude process; left alone`)
459 const r = await $.process.run(['kill', String(row.pid)], { timeoutMs: 3000 })
460 $.ui.toast(r.exitCode === 0 ? `Closed ${label(row)}` : `Couldn't close ${label(row)}: ${r.stderr.trim()}`)
461 void scan($, true)
462}
463
464const openPane = async ($: EngineInterface) => {
465 await update($, isOpen, () => true)
466 await $.ui.open({ id: PANE, title: 'Fleet', focus: true })
467 await scan($, true)
468}
469
470// --- This session: offering a handoff before it gets expensive, and before /exit ---
471
472const selfRow = async ($: EngineInterface): Promise<Row> => {
473 const [snap, sessionId, cwd, titles] = await Promise.all([read($, snapshot), $.session.id(), $.session.cwd(), readTitles($)])
474 const known = snap?.rows.find(r => r.isSelf)
475 if (known) return known
476 return {
477 key: 'self', name: basename(cwd), isNamed: false, title: titles[sessionId]?.title ?? null,
478 value: titles[sessionId]?.value ?? null, status: 'busy', cwd, pid: null, sessionId,
479 updatedAt: 0, isSelf: true, git: null, tokens: null,
480 }
481}
482
483const selfTokens = async ($: EngineInterface) => {
484 const home = await $.env.get('HOME')
485 const row = await selfRow($)
486 const path = home ? await findTranscript($, home, row) : null
487 return path ? await contextTokens($, path) : null
488}
489
490const handoffThreshold = async ($: EngineInterface) => {
491 const raw = Number(await $.env.get('CLAUDE_FLEET_HANDOFF_AT'))
492 return Number.isFinite(raw) && raw > 0 ? raw : HANDOFF_AT_TOKENS
493}
494
495// Runs a built-in command on a timer: a command or press handler that awaits another
496// command (or a prompt) directly would wait on itself.
497const runLater = ($: EngineInterface, command: string) => {
498 $.clock.after(0, () => {
499 void $.command.run({ command }).catch(() => $.ui.toast(`fleet: couldn't run /${command}`))
500 })
501}
502
503const promptLater = ($: EngineInterface, text: string) => {
504 $.clock.after(0, () => {
505 void $.prompt.submit({ text }).catch(() => $.ui.toast("fleet: couldn't start the prompt"))
506 })
507}
508
509const startFresh = ($: EngineInterface, path: string) => {
510 void update($, band, () => null)
511 runLater($, 'clear')
512 // /clear first, then the handoff as the new conversation's first message.
513 $.clock.after(500, () => promptLater($, continuePrompt(path)))
514}
515
516// Writes a handoff for this session, then optionally starts fresh from it or exits.
517const handOffSelf = async ($: EngineInterface, then: 'fresh' | 'exit' | null) => {
518 // Whatever happens next, don't offer again until the context grows past this point.
519 snoozedAt = (await selfTokens($)) ?? snoozedAt
520 await update($, band, () => ({ kind: 'working', text: 'Writing a handoff for this session…' }) satisfies Band)
521 let path: string
522 try {
523 path = (await handoffSession($, await selfRow($))).split(' (')[0]
524 } catch (err) {
525 await update($, band, () => null)
526 $.ui.toast(`Couldn't write a handoff: ${(err as Error).message}`)
527 return
528 }
529 if (then === 'exit') {
530 exitApproved = true
531 runLater($, 'exit')
532 } else if (then === 'fresh') {
533 startFresh($, path)
534 } else {
535 await update($, band, () => ({ kind: 'handedOff', path, then: null }) satisfies Band)
536 }
537}
538
539// Shows the band's handoff offer when this session's context has passed the threshold.
540const checkContext = async ($: EngineInterface) => {
541 const [tokens, at, current] = await Promise.all([selfTokens($), handoffThreshold($), read($, band)])
542 if (tokens === null || tokens < at || tokens < snoozedAt + HANDOFF_SNOOZE_TOKENS) return
543 if (current && current.kind !== 'context') return
544 await update($, band, () => ({ kind: 'context', tokens }) satisfies Band)
545}
546
547// --- Hooks ---
548
549export const register: Register = on => {
550 let armed: Armed | null = null
551
552 on('session.start', async ($, e, next) => {
553 await $.command.register({
554 name: 'fleet',
555 description: 'Every running Claude session: status, branch, context size; save or close them',
556 argumentHint: '[list|stale|save [pid]|handoff [pid]|kill [all] [--handoff]|retitle|help]',
557 })
558
559 let tick = 0
560 $.clock.every(TICK_MS, () => {
561 tick += 1
562 void (async () => {
563 const open = await read($, isOpen)
564 if (open || tick % STATUS_EVERY_TICKS === 0) await scan($, open)
565 })().catch(() => undefined)
566 })
567 void scan($, false).catch(() => undefined)
568
569 return next(e)
570 })
571
572 on('turn.complete', async ($, e, next) => {
573 const result = await next(e)
574 if (e.agentId || (await $.env.get('CLAUDE_FLEET_DEMO'))) return result
575 $.clock.after(0, () => {
576 void checkContext($).catch(() => undefined)
577 })
578 return result
579 })
580
581 // /exit: when this session has work in progress or a big context, offer a handoff first.
582 on('command.run', { command: 'exit' }, async ($, e, next) => {
583 const now = await $.clock.now()
584 if (exitApproved || now - exitAskedAt < EXIT_CONFIRM_MS || (await $.env.get('CLAUDE_FLEET_EXIT_CHECK')) === '0') {
585 return next(e)
586 }
587 const [row, tokens] = await Promise.all([selfRow($), selfTokens($)])
588 const isWorthIt = row.value === 'active' || (tokens ?? 0) >= EXIT_CHECK_TOKENS
589 if (!isWorthIt) return next(e)
590
591 exitAskedAt = now
592 await update($, band, () => ({ kind: 'exit', tokens: tokens ?? 0, value: row.value }) satisfies Band)
593 const why = row.value === 'active' ? 'is still in progress' : `holds ${tokensText(tokens)} tokens of context`
594 return {
595 text:
596 `This session ${why}.\n` +
597 ` /fleet handoff --exit write a handoff, then exit\n` +
598 ` /exit exit anyway (within 30s)\n` +
599 `The band above the prompt has the same choices (ctrl+x then Tab, or click it).`,
600 }
601 })
602
603 on('command.run', { command: 'fleet' }, async ($, e) => {
604 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
605 const arg = rest.join(' ')
606
607 if (sub === 'help') return { text: HELP }
608 if (sub === 'list') return { text: asTable(await scan($, true)) }
609 if (sub === 'stale') return { text: staleReport(await scan($, true)) }
610
611 if (sub === 'retitle') {
612 await $.store.delete(TITLES_KEY)
613 const home = await $.env.get('HOME')
614 const snap = await scan($, true)
615 const made = home ? await refreshTitles($, home, snap.rows, {}, Infinity) : 0
616 return { text: `Titled ${made} sessions.\n\n${asTable(await scan($, true))}` }
617 }
618
619 if (sub === 'save') {
620 const row = findRow(await scan($, true), arg)
621 if (!row) return { text: `No session matches "${arg}". /fleet list shows pids and names.` }
622 try {
623 return { text: `Saved ${label(row)} → ${await saveSession($, row)}` }
624 } catch (err) {
625 return { text: `Couldn't save ${label(row)}: ${(err as Error).message}` }
626 }
627 }
628
629 if (sub === 'handoff' && (arg === '--fresh' || arg === '--exit')) {
630 // Deferred: handing off this session ends in /clear or /exit, which can't run inside this command.
631 $.clock.after(0, () => {
632 void handOffSelf($, arg === '--fresh' ? 'fresh' : 'exit')
633 })
634 return {
635 text: arg === '--fresh'
636 ? 'Writing a handoff, then clearing this session and continuing from it…'
637 : 'Writing a handoff, then exiting…',
638 }
639 }
640
641 if (sub === 'handoff') {
642 const row = findRow(await scan($, true), arg)
643 if (!row) return { text: `No session matches "${arg}". /fleet list shows pids and names.` }
644 try {
645 return { text: `Handoff for ${label(row)} → ${await handoffSession($, row)}` }
646 } catch (err) {
647 return { text: `Couldn't write a handoff for ${label(row)}: ${(err as Error).message}` }
648 }
649 }
650
651 if (sub === 'kill') {
652 const [text, nextArmed] = await runKill($, arg, armed)
653 armed = nextArmed
654 return { text }
655 }
656
657 await openPane($)
658 return { text: 'Fleet pane opened. /fleet help lists save and kill.' }
659 })
660
661 on('ui.close', async ($, e, next) => {
662 if (e.id === PANE) await update($, isOpen, () => false)
663 return next(e)
664 })
665
666 on('ui.focus', async ($, e, next) => {
667 if (e.requestId === PANE && e.element?.startsWith('row:')) {
668 const key = e.element.slice('row:'.length)
669 await update($, selected, () => key)
670 }
671 return next(e)
672 })
673
674 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
675 const offer = await read($, band)
676 if (!offer || e.props.hasSurvey) return next(e)
677 return bandView($.ui.resolve(e), offer, {
678 handOff: then => { void handOffSelf($, then) },
679 startFresh: path => startFresh($, path),
680 exitNow: () => {
681 exitApproved = true
682 runLater($, 'exit')
683 },
684 later: tokens => {
685 snoozedAt = tokens
686 void update($, band, () => null)
687 },
688 dismiss: () => { void update($, band, () => null) },
689 })
690 })
691
692 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
693 const [snap, picked, closing] = await Promise.all([read($, snapshot), read($, selected), read($, pendingClose)])
694 return paneView(
695 $.ui.resolve(e),
696 { snap, picked, closing, columns: e.props.bodyColumns, rows: e.viewport?.rows ?? 24 },
697 {
698 select: key => { void update($, selected, () => key) },
699 handoff: () => { void handoffSelected($) },
700 save: () => { void saveSelected($) },
701 close: () => { void closeSelected($) },
702 copy: press => { void copyResume($, press.surface) },
703 refresh: () => { void scan($, true) },
704 },
705 )
706 })
707}
708hooks/lib/constants.ts 41 lines1export const TICK_MS = 5000
2// The status line only needs the registry; git and token reads run only while the pane is open.
3export const STATUS_EVERY_TICKS = 3
4export const STALE_MS = 7 * 24 * 60 * 60 * 1000
5// Prompt-cache lifetime: 5 minutes on the API by default, 1 hour on some plans.
6// Past it, resuming a session pays to cache its whole context again.
7export const CACHE_TTL_MS = 60 * 60 * 1000
8// How long a `/fleet kill` stays armed for its confirming second run.
9export const ARM_MS = 60 * 1000
10// Context size comes from the last reply, so the tail of the transcript is enough.
11export const TAIL_BYTES = 1_000_000
12export const EXPORT_MAX_BYTES = 80 * 1024 * 1024
13// Session titles: the opening prompts come from the head, the latest from the tail.
14export const HEAD_BYTES = 300_000
15export const TITLES_KEY = 'titles'
16export const TITLES_PER_SCAN = 2
17// Handoffs summarize at most this much of a transcript (head kept for the goal, the rest from the end).
18export const HANDOFF_INPUT_CHARS = 400_000
19export const HANDOFF_HEAD_CHARS = 60_000
20// The band offers a handoff once this session's context passes this (CLAUDE_FLEET_HANDOFF_AT overrides),
21// and again each time it grows by HANDOFF_SNOOZE_TOKENS after "Later".
22export const HANDOFF_AT_TOKENS = 300_000
23export const HANDOFF_SNOOZE_TOKENS = 100_000
24// /exit pauses to offer a handoff when the session is in progress or at least this big.
25export const EXIT_CHECK_TOKENS = 100_000
26export const EXIT_CONFIRM_MS = 30_000
27// A second press of x within this window closes the selected session.
28export const CLOSE_CONFIRM_MS = 10_000
29// After a handoff, one press of x within this window closes that session.
30export const AFTER_HANDOFF_MS = 20_000
31// A transcript that couldn't be found is looked for again after this long.
32export const MISSING_RETRY_MS = 60_000
33
34// Claude Code writes one <pid>.json per running session here. It is an internal
35// file, not an API: read only the fields parseRegistryEntry names, never the rest (it holds tokens).
36export const REGISTRY_DIR = '.claude/sessions'
37export const PROJECTS_DIR = '.claude/projects'
38
39// Demo sessions' ids start with this; no action touches them.
40export const DEMO_PREFIX = 'demo-'
41hooks/lib/demo.ts 36 lines1// CLAUDE_FLEET_DEMO=1: a fixed, made-up fleet for screenshots and trying the pane safely.
2// Demo rows have no transcripts and their pids aren't Claude processes, so no action can touch anything.
3import type { Git, ResumeValue, Row } from '../../types'
4
5import { DEMO_PREFIX } from './constants.ts'
6
7export const demoRows = (now: number): Row[] => {
8 const m = 60_000
9 const d = 24 * 60 * m
10 const git = (branch: string, dirty = 0, ahead = 0): Git => ({ branch, dirty, ahead, behind: 0, hasUpstream: true })
11 const row = (
12 n: number, title: string, status: string, cwd: string, idleMs: number, tokens: number | null,
13 value: ResumeValue | null, g: Git | null, extra: Partial<Row> = {},
14 ): Row => ({
15 key: `pid:${90000 + n}`, name: `demo-${n}`, isNamed: false, title, value, status,
16 cwd: `/home/dev/${cwd}`, pid: 90000 + n, sessionId: `${DEMO_PREFIX}${n}`,
17 updatedAt: now - idleMs, isSelf: false, git: g, tokens, ...extra,
18 })
19 return [
20 row(1, 'Payments webhook retry bug', 'waiting', 'api', 2 * m, 141_000, 'active', git('fix/webhooks', 1)),
21 row(0, 'Auth middleware refactor', 'busy', 'api', 0, 182_000, 'active', git('feat/auth', 4, 2), { isSelf: true }),
22 row(2, 'Onboarding copy rewrite', 'busy', 'web', 20_000, 58_000, 'active', git('feat/onboarding', 6, 1)),
23 row(3, 'Flaky checkout test triage', 'idle', 'web', 25 * m, 96_000, 'active', git('main', 2)),
24 row(4, 'Release notes for v2.4', 'idle', 'docs', 3 * d, 44_000, 'reference', git('main')),
25 row(5, 'Regex for ISO dates', 'idle', 'web', 2 * d, 14_000, 'light', git('main')),
26 row(6, 'Docker build cache question', 'idle', 'infra', 9 * d, 21_000, 'light', git('main')),
27 row(7, 'Postgres migration dry run', 'idle', 'api', 12 * d, 338_000, 'reference', git('master', 0, 2)),
28 row(8, 'GraphQL schema review', 'idle', 'api', 30 * d, 212_000, 'reference', git('main')),
29 row(9, 'GraphQL schema review', 'idle', 'api', 31 * d, 212_000, 'reference', git('main'), { sessionId: `${DEMO_PREFIX}8` }),
30 {
31 key: 'wt:/home/dev/api-hotfix', name: '(no session)', isNamed: true, title: null, value: null, status: 'worktree',
32 cwd: '/home/dev/api-hotfix', pid: null, sessionId: '', updatedAt: 0, isSelf: false, git: git('hotfix/rate-limit'), tokens: null,
33 },
34 ]
35}
36hooks/lib/format.ts 145 lines1import type { Git, ResumeValue, Row, Snapshot } from '../../types'
2
3import { isClosable, isCold, isStale, needsYou, windowCounts } from './rules.ts'
4
5export const basename = (path: string) => path.split('/').filter(Boolean).pop() ?? path
6
7export const ago = (ms: number) => {
8 const s = Math.max(0, Math.round(ms / 1000))
9 if (s < 60) return 'now'
10 if (s < 3600) return `${Math.round(s / 60)}m`
11 if (s < 86400) return `${Math.round(s / 3600)}h`
12 return `${Math.round(s / 86400)}d`
13}
14
15export const tokensText = (n: number | null) =>
16 n === null ? '—' : n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
17
18export const fit = (text: string, width: number) =>
19 text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text.padEnd(width)
20
21export const shellQuote = (text: string) => `'${text.replace(/'/g, `'\\''`)}'`
22
23export const label = (row: Row) => (row.isNamed ? row.name : row.title ?? row.name)
24
25export const VALUE_GLYPH: Record<ResumeValue, string> = { active: '●', reference: '◐', light: '○' }
26export const VALUE_COLOR: Record<ResumeValue, string | undefined> = { active: 'green', reference: 'yellow', light: undefined }
27
28export const glyph = (row: Row) => (row.value ? VALUE_GLYPH[row.value] : ' ')
29
30// Fits the label to `width`, keeping a ×N marker visible by shortening the label instead.
31export const fitLabel = (row: Row, counts: Map<string, number>, width: number) => {
32 const n = counts.get(row.sessionId) ?? 1
33 if (n <= 1) return fit(label(row), width)
34 const mark = ` ×${n}`
35 return (fit(label(row), width - mark.length).trimEnd() + mark).padEnd(width)
36}
37
38export const gitText = (git: Git | null) => {
39 if (!git) return '—'
40 const marks = [git.dirty ? `✎${git.dirty}` : '', git.ahead ? `↑${git.ahead}` : '', git.behind ? `↓${git.behind}` : '']
41 return [git.branch, ...marks.filter(Boolean)].join(' ')
42}
43
44export const ctxText = (row: Row, now: number) =>
45 row.pid === null ? '' : `${tokensText(row.tokens)}${row.tokens !== null && isCold(row, now) ? ' cold' : ''}`
46
47// The status line: counts only, and nothing when this is the only session.
48export const statusLineText = (snap: Snapshot) => {
49 if (snap.warning) return "⧉ fleet can't read sessions (/fleet)"
50 const live = snap.rows.filter(r => r.pid !== null)
51 if (live.length <= 1) return undefined
52 const busy = live.filter(r => r.status === 'busy').length
53 const waiting = live.filter(r => needsYou(r.status)).length
54 const parts = [`⧉ ${live.length} sessions`]
55 if (busy) parts.push(`${busy} busy`)
56 if (waiting) parts.push(`${waiting} need you`)
57 return parts.join(' · ')
58}
59
60// Headline counts, then the details; the pane draws them as two lines.
61export const summaryParts = (snap: Snapshot): [string, string] => {
62 const live = snap.rows.filter(r => r.pid !== null)
63 const stale = live.filter(r => isStale(r, snap.scannedAt)).length
64 const busy = live.filter(r => r.status === 'busy').length
65 const cold = live.filter(r => !r.isSelf && isCold(r, snap.scannedAt)).reduce((sum, r) => sum + (r.tokens ?? 0), 0)
66 const twice = [...windowCounts(live).values()].filter(n => n > 1).length
67 const closable = live.filter(r => isClosable(r, snap.scannedAt)).length
68 const waiting = live.filter(r => needsYou(r.status)).length
69 const head = [
70 `${live.length} sessions`,
71 `${busy} busy`,
72 waiting ? `${waiting} need you` : '',
73 closable ? `${closable} safe to close` : '',
74 ]
75 const detail = [
76 cold ? `${tokensText(cold)} tokens to re-cache` : '',
77 stale ? `${stale} idle > 7d` : '',
78 twice ? `${twice} open in ${twice === 1 ? 'two windows' : 'several windows'}` : '',
79 ]
80 return [head.filter(Boolean).join(' · '), detail.filter(Boolean).join(' · ')]
81}
82
83export const summary = (snap: Snapshot) => summaryParts(snap).filter(Boolean).join(' · ')
84
85export const tableLine = (r: Row, now: number, hasGit = true, counts = new Map<string, number>()) =>
86 [
87 r.isSelf ? '▸' : ' ',
88 glyph(r),
89 fitLabel(r, counts, 34),
90 fit(r.status, 9),
91 fit(basename(r.cwd), 22),
92 hasGit ? fit(gitText(r.git), 22) : '',
93 (r.pid === null ? '' : ago(now - r.updatedAt)).padStart(4),
94 ctxText(r, now).padStart(11),
95 ].join(' ')
96
97// A row prefixed with its pid, for the lists that closing works from.
98export const pidLine = (r: Row, snap: Snapshot) =>
99 ` ${String(r.pid).padStart(6)} ${tableLine(r, snap.scannedAt, true, windowCounts(snap.rows))}`
100
101export const asTable = (snap: Snapshot) => {
102 const counts = windowCounts(snap.rows)
103 const hasGit = snap.rows.some(x => x.git)
104 return (
105 (snap.warning ? `⚠ ${snap.warning}\n\n` : '') +
106 `${summary(snap)}\n\n${snap.rows.map(r => tableLine(r, snap.scannedAt, hasGit, counts)).join('\n')}\n\n` +
107 `ctx = context the session re-caches on its next message; "cold" = cache expired, so that costs the full amount.` +
108 ([...counts.values()].some(n => n > 1)
109 ? `\n×2 = the same conversation open in two terminals; closing the older one loses nothing.`
110 : '') +
111 `\n● in progress: worth resuming ◐ finished: /fleet handoff, then close ○ light: just close`
112 )
113}
114
115export const staleReport = (snap: Snapshot) => {
116 const stale = snap.rows.filter(r => r.pid !== null && !r.isSelf && isStale(r, snap.scannedAt))
117 if (stale.length === 0) return 'No sessions idle for more than 7 days.'
118 return (
119 `${stale.length} sessions idle for more than 7 days:\n\n` +
120 `${stale.map(r => pidLine(r, snap)).join('\n')}\n\n` +
121 `/fleet kill closes these (add --handoff to write a summary of each first).`
122 )
123}
124
125// `/fleet save <query>`: no query is this session; otherwise a pid, a name or title, or part of a title.
126export const findRow = (snap: Snapshot, query: string) =>
127 query === ''
128 ? snap.rows.find(r => r.isSelf)
129 : snap.rows.find(r => r.pid !== null && (String(r.pid) === query || r.name === query || label(r) === query)) ??
130 snap.rows.find(r => r.pid !== null && label(r).toLowerCase().includes(query.toLowerCase()))
131
132export const HELP = `/fleet open the live pane
133/fleet list print the table
134/fleet stale sessions idle for more than 7 days
135/fleet save [pid|name] export a whole conversation as Markdown in its folder (default: this one)
136/fleet handoff [pid|name] write a short summary a fresh session can continue from
137/fleet handoff --fresh hand off this session, /clear, and continue from the handoff
138/fleet handoff --exit hand off this session, then exit
139/fleet kill [--handoff|--save] close sessions safe to close (repeat to confirm)
140/fleet kill all [--handoff|--save] close every idle session except this one
141/fleet retitle forget the generated session titles and make new ones
142
143● in progress: worth resuming · ◐ finished: hand off, then close · ○ light: just close
144In the pane: ↑/↓ select · h handoff · s save · x close (press twice) · c copy resume command · r refresh`
145hooks/lib/parse.ts 176 lines1// Parsers for what fleet reads from disk and from commands: the session registry, git, and transcripts.
2import type { Git, Registered, Row } from '../../types'
3
4import { PROJECTS_DIR } from './constants.ts'
5import { label } from './format.ts'
6
7// --- Registry ---
8
9// Reads only these fields of a registry file; the others hold tokens and are never touched.
10export const parseRegistryEntry = (text: string, mtimeMs: number): Registered | null => {
11 const raw = JSON.parse(text)
12 if (typeof raw.pid !== 'number' || typeof raw.cwd !== 'string') return null
13 return {
14 pid: raw.pid,
15 name: String(raw.name ?? raw.sessionId ?? raw.pid),
16 // 'derived' names are Claude Code's own (folder + suffix); anything else the person set.
17 isNamed: typeof raw.nameSource === 'string' && raw.nameSource !== 'derived',
18 status: String(raw.status ?? 'unknown'),
19 cwd: raw.cwd,
20 sessionId: String(raw.sessionId ?? ''),
21 updatedAt: Number(raw.updatedAt ?? mtimeMs),
22 }
23}
24
25export const parsePids = (psOutput: string) =>
26 new Set(psOutput.split('\n').map(line => Number(line.trim())).filter(Boolean))
27
28// --- Git ---
29
30// `git status --porcelain=v2 --branch` → branch, uncommitted files, ahead/behind.
31export const parseGitStatus = (stdout: string): Git => {
32 const git: Git = { branch: '?', dirty: 0, ahead: 0, behind: 0, hasUpstream: false }
33 for (const line of stdout.split('\n')) {
34 if (line.startsWith('# branch.head ')) git.branch = line.slice('# branch.head '.length)
35 else if (line.startsWith('# branch.ab ')) {
36 const [, a, b] = line.match(/\+(\d+) -(\d+)/) ?? []
37 git.ahead = Number(a ?? 0)
38 git.behind = Number(b ?? 0)
39 git.hasUpstream = true
40 } else if (line && !line.startsWith('#')) git.dirty += 1
41 }
42 return git
43}
44
45// `git worktree list --porcelain` → worktree paths.
46export const parseWorktrees = (stdout: string) =>
47 stdout
48 .split('\n')
49 .filter(line => line.startsWith('worktree '))
50 .map(line => line.slice('worktree '.length))
51
52// --- Transcripts ---
53
54// Claude Code's project folder name: every character outside [A-Za-z0-9] becomes '-'.
55export const transcriptPath = (home: string, row: Pick<Row, 'cwd' | 'sessionId'>) =>
56 `${home}/${PROJECTS_DIR}/${row.cwd.replace(/[^A-Za-z0-9]/g, '-')}/${row.sessionId}.jsonl`
57
58// Context size of the last main-thread reply in a transcript tail; what resuming re-sends.
59export const lastContextTokens = (tail: string): number | null => {
60 for (const line of tail.split('\n').reverse()) {
61 if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
62 try {
63 const entry = JSON.parse(line)
64 if (entry.type !== 'assistant' || entry.isSidechain) continue
65 const u = entry.message?.usage
66 if (!u) continue
67 return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
68 } catch {
69 // The first line of a tail is usually cut mid-entry.
70 }
71 }
72 return null
73}
74
75export const textOf = (content: unknown): string =>
76 typeof content === 'string'
77 ? content
78 : Array.isArray(content)
79 ? content.filter(b => b?.type === 'text').map(b => String(b.text)).join('\n')
80 : ''
81
82// A user turn as the person typed it: reminders dropped, slash commands shown as typed.
83export const cleanUserText = (text: string) =>
84 text
85 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
86 .replace(/<command-message>[\s\S]*?<\/command-message>/g, '')
87 .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, '')
88 .replace(/<command-name>(.*?)<\/command-name>/g, '$1')
89 .replace(/<command-args>(.*?)<\/command-args>/g, ' $1')
90 .replace(/<\/?local-command-stdout>/g, '')
91 .trim()
92
93export const userPrompts = (jsonl: string) => {
94 const prompts: string[] = []
95 for (const line of jsonl.split('\n')) {
96 if (!line.includes('"user"')) continue
97 try {
98 const entry = JSON.parse(line)
99 if (entry.type !== 'user' || entry.isSidechain || entry.isMeta) continue
100 const text = cleanUserText(textOf(entry.message?.content))
101 if (text) prompts.push(text.slice(0, 600))
102 } catch {
103 // A head or tail cut mid-entry.
104 }
105 }
106 return prompts
107}
108
109export const lastAssistantText = (jsonl: string) => {
110 const lines = jsonl.split('\n')
111 for (let i = lines.length - 1; i >= 0; i--) {
112 if (!lines[i].includes('"assistant"')) continue
113 try {
114 const entry = JSON.parse(lines[i])
115 if (entry.type !== 'assistant' || entry.isSidechain) continue
116 const text = textOf(entry.message?.content).trim()
117 if (text) return text
118 } catch {
119 // Cut mid-entry.
120 }
121 }
122 return ''
123}
124
125const toolLine = (block: { name?: string; input?: Record<string, unknown> }) => {
126 const input = block.input ?? {}
127 const detail = input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? ''
128 return `- \`${block.name}\`${detail ? `: ${String(detail).split('\n')[0].slice(0, 160)}` : ''}`
129}
130
131// The conversation as Markdown: your messages, Claude's replies, and one line per tool call.
132export const toMarkdown = (row: Row, jsonl: string, exportedAt: string) => {
133 const out = [
134 `# Claude session: ${label(row)}`,
135 '',
136 `- Folder: \`${row.cwd}\``,
137 `- Session: \`${row.sessionId}\` (resume with \`claude --resume ${row.sessionId}\`)`,
138 `- Exported: ${exportedAt}`,
139 '',
140 ]
141 let tools: string[] = []
142 const flushTools = () => {
143 if (tools.length) out.push(...tools, '')
144 tools = []
145 }
146
147 for (const line of jsonl.split('\n')) {
148 if (!line) continue
149 let entry
150 try {
151 entry = JSON.parse(line)
152 } catch {
153 continue
154 }
155 if (entry.isSidechain || entry.isMeta) continue
156
157 if (entry.type === 'user') {
158 const text = cleanUserText(textOf(entry.message?.content))
159 if (!text) continue
160 flushTools()
161 out.push('## You', '', text, '')
162 } else if (entry.type === 'assistant') {
163 const content = Array.isArray(entry.message?.content) ? entry.message.content : []
164 for (const block of content) {
165 if (block?.type === 'tool_use') tools.push(toolLine(block))
166 else if (block?.type === 'text' && String(block.text).trim()) {
167 flushTools()
168 out.push('## Claude', '', String(block.text).trim(), '')
169 }
170 }
171 }
172 }
173 flushTools()
174 return out.join('\n')
175}
176hooks/lib/prompts.ts 74 lines1// What fleet asks Claude: a title and resume value per session (Haiku), and handoffs (Sonnet).
2import type { ResumeValue, Row } from '../../types'
3
4import { HANDOFF_HEAD_CHARS, HANDOFF_INPUT_CHARS } from './constants.ts'
5import { basename, label } from './format.ts'
6
7export const LABEL_SYSTEM =
8 'You label coding sessions for a session list. Reply with JSON only: {"title": string, "value": string}.\n' +
9 'title: 3-6 words naming the task, leading with the concrete project and goal ("Canvas lab 2 grading drafts"); ' +
10 'no quotes, no trailing punctuation, never the words "session", "conversation" or "chat".\n' +
11 'value: whether resuming is worth re-sending the whole conversation. Pick exactly one:\n' +
12 '- "light": quick questions or one-off lookups; nothing a fresh session couldn\'t redo in a minute.\n' +
13 '- "reference": substantial work whose main task was delivered or answered. This is the DEFAULT for real work. ' +
14 'A last reply that offers more help, lists optional next steps, or asks "want me to...?" is still "reference".\n' +
15 '- "active": ONLY when work is clearly mid-flight: the last reply stops partway through an implementation, ' +
16 'a bug or failing test is still being chased, or the person must answer a question before anything can continue. ' +
17 'Sessions idle for weeks are rarely active.'
18
19export const labelPrompt = (first: string[], latest: string[], lastReply: string, idleText: string) =>
20 (
21 `Opening requests:\n${first.join('\n---\n')}\n\nLatest requests:\n${latest.join('\n---\n') || '(same)'}` +
22 `\n\nLast reply (end):\n${lastReply || '(none)'}\n\nIdle for: ${idleText}`
23 ).slice(0, 5000)
24
25const cleanTitle = (text: string) =>
26 text.split('\n')[0].replace(/^(title:\s*)/i, '').replace(/["'`*#]/g, '').replace(/[.\s]+$/, '').trim().slice(0, 48)
27
28// The label reply as JSON, or a bare title when the model skipped the JSON.
29export const parseLabel = (text: string): { title: string; value?: ResumeValue } | null => {
30 try {
31 const raw = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
32 const title = cleanTitle(String(raw.title ?? ''))
33 const value = ['active', 'reference', 'light'].includes(raw.value) ? (raw.value as ResumeValue) : undefined
34 return title ? { title, value } : null
35 } catch {
36 const title = cleanTitle(text)
37 return title && !title.includes('{') ? { title } : null
38 }
39}
40
41export const HANDOFF_SYSTEM = `You write handoff notes so a fresh Claude Code session can continue someone's work without the old transcript.
42From the transcript only, write Markdown with these sections, skipping any that would be empty:
43## Goal: what the person is trying to get done, in 1-3 sentences.
44## Status: what is done, and what was in progress when the transcript ends.
45## Decisions: choices made and WHY, including approaches tried and rejected.
46## Next steps: concrete open threads, in order.
47## Key files and commands: paths, commands and URLs that matter, as a short list.
48## Gotchas: preferences the person stated, constraints, and things that broke.
49Be specific and factual: names, paths and numbers over generalities. Never invent. At most 700 words.`
50
51// Keeps the start (where the goal is stated) and as much of the end as fits.
52export const trimForHandoff = (text: string) =>
53 text.length <= HANDOFF_INPUT_CHARS
54 ? text
55 : text.slice(0, HANDOFF_HEAD_CHARS) +
56 '\n\n[… middle of the conversation omitted …]\n\n' +
57 text.slice(-(HANDOFF_INPUT_CHARS - HANDOFF_HEAD_CHARS))
58
59export const handoffDoc = (row: Row, target: string, now: Date, summary: string) =>
60 [
61 `# Handoff: ${label(row)}`,
62 '',
63 `Written ${now.toISOString().slice(0, 16).replace('T', ' ')} from session \`${row.sessionId}\` in \`${row.cwd}\`.`,
64 '',
65 `To continue in a fresh session: run \`claude\` in this folder and say "Read ${basename(target)} and pick up where it leaves off."`,
66 `The full conversation is still there: \`claude --resume ${row.sessionId}\`.`,
67 '',
68 summary.trim(),
69 '',
70 ].join('\n')
71
72export const continuePrompt = (path: string) =>
73 `Read ${path} and pick up where it leaves off. It's a handoff from my previous session in this folder.`
74hooks/lib/rules.ts 34 lines1import type { Row, Snapshot } from '../../types'
2
3import { CACHE_TTL_MS, DEMO_PREFIX, STALE_MS } from './constants.ts'
4
5export const isStale = (row: Row, now: number) => row.status === 'idle' && now - row.updatedAt > STALE_MS
6
7export const isCold = (row: Row, now: number) => row.pid !== null && now - row.updatedAt > CACHE_TTL_MS
8
9// Anything but working, idle, or a worktree row is a session waiting on the person.
10export const needsYou = (status: string) => status !== 'idle' && status !== 'busy' && status !== 'worktree'
11
12// Safe to close: idle, and either cold with nothing unfinished, or idle for a week and not known to be in progress.
13export const isClosable = (row: Row, now: number) =>
14 row.pid !== null && !row.isSelf && row.status === 'idle' &&
15 ((isCold(row, now) && (row.value === 'light' || row.value === 'reference')) || (isStale(row, now) && row.value !== 'active'))
16
17export const killTargets = (snap: Snapshot, isAll: boolean) =>
18 snap.rows.filter(r => (isAll ? r.pid !== null && !r.isSelf && r.status === 'idle' : isClosable(r, snap.scannedAt)))
19
20export const isDemo = (row: Row) => row.sessionId.startsWith(DEMO_PREFIX)
21
22// The same conversation resumed in two terminals shows up as two processes with one sessionId.
23export const windowCounts = (rows: Row[]) => {
24 const counts = new Map<string, number>()
25 for (const r of rows) if (r.sessionId) counts.set(r.sessionId, (counts.get(r.sessionId) ?? 0) + 1)
26 return counts
27}
28
29// Needs-you first, then busy, then most recently active; worktrees last.
30export const sortRows = (rows: Row[]) => {
31 const rank = (r: Row) => (needsYou(r.status) ? 0 : r.status === 'busy' ? 1 : r.status === 'worktree' ? 3 : 2)
32 return rows.sort((a, b) => rank(a) - rank(b) || b.updatedAt - a.updatedAt)
33}
34hooks/lib/views.tsx 161 lines1// What the pane and the band draw. These get the surface's elements and plain callbacks, never `$`:
2// the engine only follows `$` within register.tsx, so everything that touches it stays there.
3import type { ElementTable, UiPressArgument } from 'claude-code'
4
5import type { Band, Snapshot } from '../../types'
6
7import {
8 ago, basename, ctxText, fit, fitLabel, gitText, glyph, label, summaryParts, tokensText, VALUE_COLOR,
9} from './format.ts'
10import { isCold, isStale, needsYou, windowCounts } from './rules.ts'
11
12export type PaneActions = {
13 select: (key: string) => void
14 handoff: () => void
15 save: () => void
16 close: () => void
17 copy: (press: UiPressArgument) => void
18 refresh: () => void
19}
20
21export type PaneView = {
22 snap: Snapshot | null
23 picked: string | null
24 closing: string | null
25 // The pane body's width and the viewport's height, in cells.
26 columns: number
27 rows: number
28}
29
30export const paneView = (el: ElementTable, view: PaneView, act: PaneActions) => {
31 const { Box, Button, Text } = el
32 const { snap, picked, closing } = view
33 if (!snap) return <Text dimColor>Scanning…</Text>
34
35 const now = snap.scannedAt
36 const counts = windowCounts(snap.rows)
37 const hasGit = snap.rows.some(r => r.git)
38 const [headline, details] = summaryParts(snap)
39 const ctxW = 10
40 const projW = Math.max(8, Math.min(20, Math.floor(view.columns * 0.16)))
41 const gitW = hasGit ? Math.max(6, Math.min(22, Math.floor(view.columns * 0.2))) : 0
42 const nameW = Math.max(10, view.columns - 4 - 9 - projW - gitW - 5 - ctxW - 5)
43 const room = Math.max(1, view.rows - 7)
44
45 return (
46 <Box flexDirection="column">
47 {snap.warning ? <Text color="red" wrap="wrap">⚠ {snap.warning}</Text> : null}
48 <Text bold wrap="truncate-end">{headline}</Text>
49 {details ? <Text dimColor wrap="truncate-end">{details}</Text> : null}
50 <Text> </Text>
51 {snap.rows.slice(0, room).map((r, i) => {
52 const isPicked = r.key === picked
53 const stale = r.pid !== null && isStale(r, now)
54 const color = r.status === 'busy' ? 'yellow' : needsYou(r.status) && r.pid !== null ? 'red' : undefined
55 const cold = r.tokens !== null && isCold(r, now)
56 return (
57 <Box key={r.key}>
58 <Button
59 key={`row:${r.key}`}
60 plain
61 label={isPicked ? '▸' : r.isSelf ? '•' : ' '}
62 autoFocus={i === 0 && picked === null ? true : undefined}
63 onPress={() => act.select(r.key)}
64 />
65 <Text color={r.value ? VALUE_COLOR[r.value] : undefined} dimColor={r.value === 'light'}>{` ${glyph(r)} `}</Text>
66 {r.key === closing ? (
67 <Text color="red" bold wrap="truncate-end">{fit(`x to close: ${label(r)}`, nameW)} </Text>
68 ) : (
69 <Text bold={r.isSelf || isPicked} inverse={isPicked} color={r.isSelf ? 'cyan' : undefined} dimColor={stale && !isPicked} wrap="truncate-end">
70 {fitLabel(r, counts, nameW)}
71 </Text>
72 )}
73 <Text> </Text>
74 <Text color={color} dimColor={!color}>{fit(r.status, 9)}</Text>
75 <Text dimColor={stale}>{fit(basename(r.cwd), projW)} </Text>
76 {hasGit ? (
77 <Text color={r.git?.dirty ? 'magenta' : undefined} dimColor={!r.git || stale}>{fit(gitText(r.git), gitW)}</Text>
78 ) : null}
79 <Text dimColor>{(r.pid === null ? '' : ago(now - r.updatedAt)).padStart(4)} </Text>
80 <Text color={cold && (r.tokens ?? 0) >= 500_000 ? 'red' : cold ? 'blue' : undefined} dimColor={!cold}>
81 {ctxText(r, now).padStart(ctxW)}
82 </Text>
83 </Box>
84 )
85 })}
86 {snap.rows.length > room ? <Text dimColor> +{snap.rows.length - room} more (/fleet list)</Text> : null}
87 <Text> </Text>
88 <Box>
89 <Button key="handoff" label="Handoff" hotkey="h" onPress={act.handoff} />
90 <Text> </Text>
91 <Button key="save" label="Save" hotkey="s" onPress={act.save} />
92 <Text> </Text>
93 <Button key="close" label="Close" hotkey="x" onPress={act.close} />
94 <Text> </Text>
95 <Button key="copy" label="Copy resume" hotkey="c" onPress={act.copy} />
96 <Text> </Text>
97 <Button key="refresh" label="Refresh" hotkey="r" onPress={act.refresh} />
98 </Box>
99 <Text dimColor wrap="truncate-end">● in progress ◐ finished: hand off ○ light · ctx = tokens a resume re-caches · cold = cache expired</Text>
100 </Box>
101 )
102}
103
104export type BandActions = {
105 handOff: (then: 'fresh' | 'exit' | null) => void
106 startFresh: (path: string) => void
107 exitNow: () => void
108 later: (tokens: number) => void
109 dismiss: () => void
110}
111
112export const bandView = (el: ElementTable, offer: Band, act: BandActions) => {
113 const { Box, Button, Text } = el
114
115 if (offer.kind === 'working') return <Text dimColor>⧉ {offer.text}</Text>
116
117 if (offer.kind === 'handedOff') {
118 return (
119 <Box flexDirection="column">
120 <Text color="green" wrap="truncate-end">⧉ Handoff written: {basename(offer.path)}</Text>
121 <Box>
122 <Button key="fresh" label="Start fresh from it" hotkey="f" variant="primary" onPress={() => act.startFresh(offer.path)} />
123 <Text> </Text>
124 <Button key="keep" label="Keep going here" hotkey="k" onPress={act.dismiss} />
125 </Box>
126 </Box>
127 )
128 }
129
130 if (offer.kind === 'exit') {
131 const why = offer.value === 'active' ? 'is still in progress' : `holds ${tokensText(offer.tokens)} tokens`
132 return (
133 <Box flexDirection="column">
134 <Text color="yellow" wrap="truncate-end">⧉ Before you go: this session {why}. A handoff keeps it for next time.</Text>
135 <Box>
136 <Button key="handoff-exit" label="Hand off and exit" hotkey="h" variant="primary" onPress={() => act.handOff('exit')} />
137 <Text> </Text>
138 <Button key="exit-now" label="Exit now" hotkey="e" onPress={act.exitNow} />
139 <Text> </Text>
140 <Button key="stay" label="Stay" hotkey="s" onPress={act.dismiss} />
141 </Box>
142 </Box>
143 )
144 }
145
146 return (
147 <Box flexDirection="column">
148 <Text color="yellow" wrap="truncate-end">
149 ⧉ This session is carrying {tokensText(offer.tokens)} tokens. A handoff lets a fresh session continue from about 3k.
150 </Text>
151 <Box>
152 <Button key="handoff-fresh" label="Hand off and start fresh" hotkey="h" variant="primary" onPress={() => act.handOff('fresh')} />
153 <Text> </Text>
154 <Button key="handoff-only" label="Just write it" hotkey="w" onPress={() => act.handOff(null)} />
155 <Text> </Text>
156 <Button key="later" label="Later" hotkey="l" onPress={() => act.later(offer.tokens)} />
157 </Box>
158 </Box>
159 )
160}
161types/index.d.ts 70 lines1export type Git = { branch: string; dirty: number; ahead: number; behind: number; hasUpstream: boolean }
2
3// Whether a session is worth re-caching: unfinished work (active), finished but substantial
4// (reference: save a handoff and close), or quick questions (light: close).
5export type ResumeValue = 'active' | 'reference' | 'light'
6
7export type Row = {
8 key: string
9 // Claude Code's name for the session; `isNamed` when the person chose it (then it beats `title`).
10 name: string
11 isNamed: boolean
12 // A short topic written by Haiku from the conversation, once fleet has made one.
13 title: string | null
14 value: ResumeValue | null
15 status: string
16 cwd: string
17 pid: number | null
18 sessionId: string
19 updatedAt: number
20 isSelf: boolean
21 git: Git | null
22 // Context size of the session's last reply: what resuming it re-caches once the cache is cold.
23 tokens: number | null
24}
25
26export type Snapshot = {
27 rows: Row[]
28 scannedAt: number
29 // Why the list may be wrong or empty (the registry moved or changed format), else null.
30 warning: string | null
31}
32
33// One session as Claude Code's registry describes it (only the fields fleet reads).
34export type Registered = {
35 pid: number
36 name: string
37 isNamed: boolean
38 status: string
39 cwd: string
40 sessionId: string
41 updatedAt: number
42}
43
44// A generated title and resume value, cached per session with the transcript size it was made at.
45export type TitleEntry = { title: string; size: number; value?: ResumeValue }
46
47// A `/fleet kill` waiting for its confirming second run.
48export type Armed = { args: string; pids: number[]; at: number }
49
50// What the band above the prompt is offering, for this session only.
51export type Band =
52 | { kind: 'context'; tokens: number }
53 | { kind: 'exit'; tokens: number; value: ResumeValue | null }
54 | { kind: 'handedOff'; path: string; then: 'fresh' | 'exit' | null }
55 | { kind: 'working'; text: string }
56
57declare module 'claude-code' {
58 interface PluginState {
59 fleet: {
60 snapshot: Snapshot | null
61 isOpen: boolean
62 // The row the pane's actions apply to, by Row.key.
63 selected: string | null
64 // A row whose close was asked once and waits for the second press.
65 pendingClose: string | null
66 band: Band | null
67 }
68 }
69}
70