SLOPSHOPPER

fleet

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

newpanebandcommandtoaststatus
★ 1v0.2.0MITupdated 2026-10-02Dubbus/claude-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 test and add an audit log call │ ┃ ⚠ No ~/.claude/sessions folder: this Claude │ ┃ Code version may keep its session list ⏺ Read(src/auth.ts) │ ┃ elsewhere. ⎿ Read 6 lines │ ┃ 0 sessions · 0 busy ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Handoff ] [ Save ] [ Close ] [ Copy resume ⎿ 3 pass, 1 fail │ ┃ ● in progress ◐ finished: hand off ○ ligh… │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ ⎿ fleet: Fleet pane opened. /fleet help lists save and kill. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ fleet: ⧉ fleet can't read sessions (/fleet)

Draws

Pane · Fleet
⚠ No ~/.claude/sessions folder: this Claude Code version may keep its session list elsewhere. 0 sessions · 0 busy [ Handoff ] [ Save ] [ Close ] [ Copy resume ] [ Refresh ] ● in progress ◐ finished: hand off ○ light · ctx = tokens…
README

claude-fleet

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.

What you get

  • Status line, always on: ⧉ 9 sessions · 2 busy · 1 need you.
  • /fleet opens a live pane, refreshed every 5s. For each session it shows:
  • a short title for what the session is about, instead of names like projects-3f
  • its status: busy in yellow, waiting on you in red
  • project folder and git branch
  • uncommitted files (✎) and commits ahead of or behind upstream (↑↓)
  • time since it was last active
  • its context size

Worktrees 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.

  • Keyboard controls in the pane to hand off, save, close or resume a session; see Keyboard shortcuts.
  • Context size and "cold": the right column is how many tokens the session's next message sends. Once the prompt cache has expired (marked 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.
  • Resume value, so you know whether a session is worth re-caching:
  • ● in progress: work stopped mid-flight. Worth resuming.
  • ◐ finished: real work, but the task was delivered. Write a handoff and close it.
  • ○ light: quick questions. Just close it.

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.
  • The first run only lists what would close. Repeat the command within 60s to confirm.
  • Busy sessions and sessions waiting on you are never closed.
  • Each process ID is re-checked to confirm it's still a Claude process before it's signalled.
  • With --handoff or --save, a session whose export fails is left running.
  • Conversations stay on disk, so 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.

While you work

These only work as a mod, since they react to the session you're in:

  • A handoff offer before your context gets expensive. When this session passes 300k tokens, a band above the prompt offers to write a handoff. You can pick:
  • Hand off and start fresh: writes the handoff, runs /clear, and opens the new conversation with "Read <handoff> and pick up where it leaves off".
  • Just write it.
  • Later: asks again after another 100k tokens.

Set CLAUDE_FLEET_HANDOFF_AT to change the threshold. /fleet handoff --fresh does the same thing as a command.

  • A pause on /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.)
  • A toast when another session needs you, for example when it's waiting on a permission prompt, and another when a busy session finishes. Set 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.

Keyboard shortcuts

/fleet opens the pane and gives it the keyboard.

KeyWhat it does
↑ / ↓ (or Tab)Select a session. The selected row is highlighted.
hWrite 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).
xClose 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.
sSave the full conversation as Markdown in the session's folder.
cCopy cd <folder> && claude --resume <id> to the clipboard.
rRefresh now (it also refreshes on its own every 5 seconds).
EscGive the keyboard back to the prompt. The pane stays open.
click the paneGive the pane the keyboard again.
✕ on the paneClose 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.

Install

Requirements:

  • Claude Code 2.1.286 or later. Tested on 2.1.286 and 2.1.287. The mod API is early access, so a later release may change it.
  • Mods enabled for your account. Mods are still rolling out; if Claude Code says "hooks modules are turned off … the rollout switch served off", your account doesn't have them yet.
  • macOS or Linux. fleet uses 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.

Try it safely

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.

How it works, and what leaves your machine

  • Sessions: Claude Code keeps one small JSON file per running session in ~/.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: fleet runs git status --porcelain=v2 --branch in each distinct folder, only while the pane is open.
  • Context size: fleet reads the token usage of the last reply from the end of each session's transcript. A transcript is only re-read when it has grown.
  • Titles and resume value: for each session, fleet sends its first few and latest requests, the end of its last reply, and how long it has been idle to Claude Haiku, through your own Claude Code login. It caches the result and only redoes it when the transcript has doubled in size. Sessions you named yourself with /rename keep their names.
  • Handoffs: only when you ask for one, fleet sends the conversation's text (your messages, Claude's replies, and one-line tool notes; at most about 100k tokens, with the middle trimmed on long sessions) to Claude Sonnet, through your login.

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.

Code layout

FileWhat's in it
hooks/register.tsxThe hooks, plus everything that touches $: reading sessions, git, model calls, saving, closing
hooks/lib/views.tsxWhat the pane and the band draw
hooks/lib/format.tsTurning rows into text: the table, summaries, the status line, help
hooks/lib/rules.tsWhat counts as stale, cold, needing you, or safe to close
hooks/lib/parse.tsParsing the registry, git output and transcripts
hooks/lib/prompts.tsWhat fleet asks Haiku and Sonnet, and how replies are read
hooks/lib/constants.ts, demo.tsThresholds and timings; demo-mode data
types/index.d.tsShared 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.

License

MIT

Source 9 files
hooks/register.tsx 708 lines
1// 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}
708
hooks/lib/constants.ts 41 lines
1export 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-'
41
hooks/lib/demo.ts 36 lines
1// 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}
36
hooks/lib/format.ts 145 lines
1import 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`
145
hooks/lib/parse.ts 176 lines
1// 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}
176
hooks/lib/prompts.ts 74 lines
1// 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.`
74
hooks/lib/rules.ts 34 lines
1import 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}
34
hooks/lib/views.tsx 161 lines
1// 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}
161
types/index.d.ts 70 lines
1export 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